RAGFlow Intro Part 3 — Encode 與 Save — 向量化、索引 Schema 與雙引擎抽象

大多數人處理 RAG 的儲存,是 collection.add(documents=chunks, embeddings=vecs),然後就不再想這件事。 真正的答案是:索引的 schema 決定了你三個月後能做什麼查詢;相似度函式決定了你的關鍵字檢索是有效還是裝飾;欄位命名決定了你換 embedding 模型要不要重建整個索引。 這些決定在寫入的那一刻就凍結了。 這篇文章拆的是 RAGFlow 在那一刻做的每一個選擇。


前言

Part 2 結束時,我們手上有一組 chunk:純文字、可能帶版面座標、可能帶人工或 LLM 產生的關鍵字與問句。

本篇處理接下來兩步:

chunks ──▶ ① Encode(向量化)  ──▶ ② Save(寫進 doc engine + 物件儲存)
           rag/svr/task_executor.py    conf/mapping.json
           rag/llm/embedding_model.py  rag/utils/*_conn.py

這兩步看起來機械,實際上藏了 RAGFlow 最有辨識度的幾個設計。我們從最反直覺的一個開始。


一、Encode:為什麼向量是「檔名 × 0.1 + 內容 × 0.9」

task_executor.pyembedding() 函式,核心只有幾行:

 1async def embedding(docs, mdl, parser_config=None, callback=None):
 2    tts, cnts = [], []
 3    for d in docs:
 4        tts.append(d.get("docnm_kwd", "Title"))          # ① 檔名
 5        c = "\n".join(d.get("question_kwd", []))          # ② 有問句就用問句
 6        if not c:
 7            c = d["content_with_weight"]                  #    沒有才用內容
 8        c = re.sub(r"</?(table|td|caption|tr|th)( [^<>]{0,12})?>", " ", c)
 9        cnts.append(c)
10
11    # 檔名只編碼一次,然後複製到所有 chunk
12    vts, c = await thread_pool_exec(mdl.encode, tts[0:1])
13    tts = np.tile(vts[0], (len(cnts), 1))
14
15    # 內容分批編碼
16    for i in range(0, len(cnts), settings.EMBEDDING_BATCH_SIZE):
17        vts, c = await thread_pool_exec(batch_encode, cnts[i:i + settings.EMBEDDING_BATCH_SIZE])
18        ...
19
20    title_w = float(parser_config.get("filename_embd_weight", 0.1) or 0.1)
21    vects = title_w * tts + (1 - title_w) * cnts          # ③ 加權混合
22
23    for i, d in enumerate(docs):
24        v = vects[i].tolist()
25        d["q_%d_vec" % len(v)] = v                        # ④ 欄位名帶維度
26    return tk_count, vector_size

四個決定,每一個都值得單獨講。

1.1 檔名混進向量:解決「文件層級語意遺失」

考慮一個真實情境:一個知識庫裡有 200 份文件,其中一份叫 2025-Q3-財報-台灣區.pdf。裡面有一個 chunk 寫著:

「本季營收較上季成長 12%,主要來自通路擴張。」

使用者查詢:「台灣區 Q3 的營收成長」

這個 chunk 的內容裡完全沒有「台灣」和「Q3」——它們在檔名裡。純內容向量會讓這個 chunk 排在後面,因為它跟「台灣區 Q3」的語意距離不近。

filename_embd_weight = 0.1 的作用就是:把文件層級的語意,以 10% 的權重摻進每一個 chunk 的向量裡。 同一份文件的所有 chunk 因此共享一個微小的「文件身分」偏移。

為什麼是 0.1 而不是 0.3? 因為權重太高會讓同一份文件的所有 chunk 彼此變得過於相似——你查任何東西,都會召回同一份文件的一堆 chunk,多樣性崩掉。0.1 是「足以在文件間區分,不足以壓過內容差異」的量級。

實作上還有一個小優化:檔名只 encode 一次,然後 np.tile 複製到所有 chunk。 一份 200 chunk 的文件,檔名向量只算一次,省下 199 次呼叫。

1.2 有 questions 時,編碼問句而不是內容

1c = "\n".join(d.get("question_kwd", []))
2if not c:
3    c = d["content_with_weight"]

這行的行為很激烈:如果 chunk 有 question_kwd(來自 qa 模板或 question_proposal 加值),就完全用問句取代內容去做 embedding。

理由是 embedding 的對稱性問題。使用者輸入的是問句,chunk 存的是陳述句。「Q3 毛利率是多少?」和「本季毛利率為 39.8%」在向量空間裡的距離,通常大於「Q3 毛利率是多少?」和「本季毛利率是多少?」之間的距離。

把 chunk 的向量換成「這個 chunk 能回答的問題」,就把問題—答案的非對稱匹配,轉成問題—問題的對稱匹配。 這就是 HyDE 的鏡像版本:HyDE 是在查詢端生成假答案,這裡是在文件端生成假問題。

代價很明確:如果生成的問句品質差,整個 chunk 的向量就毀了。 這也是 question_proposal 預設關閉的原因之一。內容本身還是完整保留在 content_with_weight 欄位,全文檢索仍然打得到,所以最壞情況是向量路失效、關鍵字路還在——混合檢索在這裡提供了容錯。

1.3 移除表格標籤:一個小但重要的清理

1c = re.sub(r"</?(table|td|caption|tr|th)( [^<>]{0,12})?>", " ", c)

Part 2 提到 TSR 會把表格轉成 HTML 保留結構。但HTML 標籤不該進 embedding<td> 對語意沒有貢獻,卻會佔掉 token 額度、稀釋真實內容的訊號。

所以寫入 doc engine 的 content_with_weight 保留 HTML(給 LLM 看,結構有用),送去 embedding 的版本剝掉標籤(給向量模型看,標籤是雜訊)。同一份內容,兩個消費者,兩種預處理。

1.4 欄位名帶維度:q_768_vec

1d["q_%d_vec" % len(v)] = v

768 維就寫成 q_768_vec,1024 維就是 q_1024_vec。這不是隨手命名——它跟下一節的動態 mapping 是一組設計。先記住這個欄位名,第三節會揭曉。


二、批次、截斷與限流:三個保護機制

embedding() 裡有三層保護,都是被生產環境教出來的。

┌─────────────────────────────────────────────────────────────────┐
│  chunks(可能有 5000 個)                                        │
└───────────────────────────┬─────────────────────────────────────┘
                            ▼
        ┌───────────────────────────────────────────┐
        │ ① 分批:EMBEDDING_BATCH_SIZE               │
        │    for i in range(0, len(cnts), BATCH):   │
        │    避免單次請求體過大 / provider 拒收      │
        └───────────────────┬───────────────────────┘
                            ▼
        ┌───────────────────────────────────────────┐
        │ ② 截斷:truncate(c, mdl.max_length - 10)  │
        │    留 10 token 餘裕,避免邊界 off-by-one   │
        │    (這是 Part 2「不做原子切分」的下游承接)│
        └───────────────────┬───────────────────────┘
                            ▼
        ┌───────────────────────────────────────────┐
        │ ③ 限流:async with embed_limiter          │
        │    + @timeout(60) 裝在 batch_encode 上    │
        │    避免打爆 provider rate limit / 卡死    │
        └───────────────────┬───────────────────────┘
                            ▼
        ┌───────────────────────────────────────────┐
        │ ④ 進度回寫:                              │
        │    prog = 0.7 + 0.2 × (i+1)/len(cnts)    │
        └───────────────────────────────────────────┘

幾個細節:

mdl.max_length - 10 的那個 10。 不同 provider 的 tokenizer 與 RAGFlow 本地的 num_tokens_from_string() 不會完全一致,留 10 token 餘裕比每次都撞 400 錯誤便宜得多。這是一行沒有註解但意圖明確的防禦性程式碼。

進度公式寫死了 0.7 → 0.9。 也就是 UI 上 70%~90% 這段必然是在做 embedding。這對排錯很有用:卡在 72% 不動,就是 embedding provider 有問題,不是解析有問題。

@timeout(60) 在單一批次上。 一批(通常 16~64 個 chunk)給 60 秒。超時就整個任務失敗重試。這裡的隱含假設是「embedding 應該很快」——如果你用的是自架的慢速模型,這個值需要調。

2.1 一個常見誤解:向量維度不是你選的

RAGFlow 的知識庫在建立時就綁定 embedding 模型(list datasets 的輸出裡有 embedding_model 欄位),而且綁定之後不能換。原因很簡單:索引裡已經有 768 維的 q_768_vec,你換成 1024 維的模型,新 chunk 會寫進 q_1024_vec,兩批向量根本不在同一個欄位,檢索時只會看到其中一半。

要換模型 = 重建知識庫。 這不是 RAGFlow 的限制,是所有向量檢索系統的共同物理約束。RAGFlow 只是把它顯式化了:欄位名裡就寫著維度,你一看就知道換模型代表什麼。


三、Save:欄位名稱本身就是 Schema

打開 conf/mapping.json,你會看到一個很反常的 ES mapping:properties 只有一個欄位lat_lon),但 dynamic_templates19 條

 1{
 2  "settings": {
 3    "index": { "number_of_shards": 2, "number_of_replicas": 0, "refresh_interval": "1000ms" },
 4    "similarity": { "scripted_sim": { ... } }
 5  },
 6  "mappings": {
 7    "dynamic_templates": [
 8      { "int":  { "match": "*_int",  "mapping": { "type": "integer", "store": "true" } } },
 9      { "tks":  { "match": "*_tks",  "mapping": { "type": "text", "similarity": "scripted_sim",
10                                                   "analyzer": "whitespace", "store": true } } },
11      ...
12    ],
13    "properties": { "lat_lon": { "type": "geo_point" } }
14  }
15}

沒有任何 chunk 欄位是預先宣告的。 欄位的型別完全由它的名稱後綴決定。

3.1 19 條動態模板全表

後綴 / 模式ES 型別關鍵設定實際欄位範例
*_intintegerstorepage_num_inttop_intchunk_order_int
*_ulongunsigned_longstore
*_longlongstore
*_shortshortstore
*_fltfloatstorecreate_timestamp_fltweight_flt
*_tkstextsimilarity: scripted_sim, analyzer: whitespacetitle_tksimportant_tksquestion_tks
*_ltkstextanalyzer: whitespacecontent_ltkscontent_sm_ltks
^(.*_(kwd|id|ids|uid|uids)|uid|id)$keywordsimilarity: booleandocnm_kwdimportant_kwddoc_idkb_id
^.*(_dt|_time|_at)$date三種格式
*_nstnested
*_objobject(dynamic)
^.*_(with_weight|list)$text, index: false只存不索引content_with_weightn_hop_with_weight
*_fearank_featurepagerank_fea
*_feasrank_featurestag_feas
*_512_vecdense_vector(512), cosineindex: trueq_512_vec
*_768_vecdense_vector(768), cosineindex: trueq_768_vec
*_1024_vecdense_vector(1024), cosineindex: trueq_1024_vec
*_1536_vecdense_vector(1536), cosineindex: trueq_1536_vec
*_binbinary

現在回頭看 Part 1 提過的那行:

1d["q_%d_vec" % len(v)] = v

768 維向量自動命中 *_768_vec 模板,自動成為 768 維 cosine 向量欄位。零 schema 變更。 這是整個設計最漂亮的地方:換 embedding 模型不需要改 mapping、不需要 migration、不需要重啟。

3.2 這個設計的代價

好處清楚,代價也清楚:

好處代價
新增欄位零 schema 變更欄位名打錯不會報錯,只會靜默變成 ES 預設型別
換 embedding 模型不用 migration只支援 512/768/1024/1536 四種維度,用 384 維模型(如 all-MiniLM)向量欄位不會被索引
一份 mapping 支撐所有文件型別讀程式碼時要先知道命名規則才看得懂欄位含義
欄位語意自我文件化重新命名欄位 = 破壞性變更

那個 384 維的坑值得記住。 bge-smallall-MiniLM-L6-v2 這類輕量模型是 384 維,寫進去會是 q_384_vec沒有任何模板匹配它,ES 會用預設行為處理(float 陣列,不建 KNN 索引)。結果是「寫入成功,向量檢索永遠回傳空」——一個非常難查的靜默失敗。實務上請用 512/768/1024/1536 維的模型。

3.3 Infinity 的對應:顯式 schema,相同命名

有趣的是,conf/infinity_mapping.json 用的是完全顯式的 schema(因為 Infinity 是強型別的),但欄位名跟 ES 完全一致

 1{
 2  "id":       {"type": "varchar", "default": ""},
 3  "doc_id":   {"type": "varchar", "default": ""},
 4  "kb_id":    {"type": "varchar", "default": "",
 5               "index_type": {"type": "secondary", "cardinality": "low"}},
 6  "content":  {"type": "varchar", "default": "", "analyzer": ["rag-coarse", "rag-fine"],
 7               "comment": "content_with_weight, content_ltks, content_sm_ltks"},
 8  "docnm":    {"type": "varchar", "default": "", "analyzer": ["rag-coarse", "rag-fine"],
 9               "comment": "docnm_kwd, title_tks, title_sm_tks"},
10  "questions":{"type": "varchar", "default": "", "analyzer": ["rag-coarse", "rag-fine"],
11               "comment": "question_kwd, question_tks"},
12  "tag_feas": {"type": "varchar", "default": "", "analyzer": "rankfeatures"},
13  "pagerank_fea": {"type": "integer", "default": 0},
14  "available_int": {"type": "integer", "default": 1,
15                    "index_type": {"type": "secondary", "cardinality": "low"}},
16  ...
17}

注意那些 comment 欄位:Infinity 用一個欄位(content)加多個 analyzer(rag-coarse 粗分詞、rag-fine 細分詞),取代 ES 裡的三個欄位(content_with_weight + content_ltks + content_sm_ltks)。 這是 Infinity 為 RAG 場景做的原生設計:一份原文,多種分詞,在引擎內部處理,不需要應用層存三份。

這也解釋了 rag_tokenizer.py 那段看起來很奇怪的程式碼:

1class RagTokenizer(infinity.rag_tokenizer.RagTokenizer):
2    def tokenize(self, line: str) -> str:
3        if settings.DOC_ENGINE_INFINITY:
4            return line          # Infinity 自己分詞,應用層原樣傳
5        else:
6            return super().tokenize(line)   # ES 需要應用層預先分好詞

用 ES 時,應用層負責分詞(因為 analyzer 是 whitespace,ES 不做中文分詞);用 Infinity 時,引擎自己分詞。 同一個介面,兩種責任劃分。

而且 RAGFlow 的分詞器本身就來自 Infinity 套件(import infinity.rag_tokenizer)——這兩個專案是同一個團隊(InfiniFlow)的產品,分詞邏輯共用一份實作。

3.4 Chunk 的完整欄位地圖

infinity_mapping.json 按用途分類,就是一張 chunk 的解剖圖:

┌─────────────────────────────────────────────────────────────────────┐
│  身分與歸屬                                                         │
│  id / doc_id / kb_id / mom_id(父 chunk)/ create_time              │
├─────────────────────────────────────────────────────────────────────┤
│  可檢索內容(多欄位 boost 的來源,Part 4 會用到)                    │
│  content(content_with_weight + content_ltks + content_sm_ltks)    │
│  docnm(docnm_kwd + title_tks + title_sm_tks)                      │
│  important_keywords(important_kwd + important_tks)  ← 人工介入 ^30 │
│  questions(question_kwd + question_tks)             ← 假問句   ^20 │
│  authors(authors_tks + authors_sm_tks)                            │
├─────────────────────────────────────────────────────────────────────┤
│  向量                                                               │
│  q_{512|768|1024|1536}_vec                                          │
├─────────────────────────────────────────────────────────────────────┤
│  排序特徵(加法項,不是乘法項)                                      │
│  pagerank_fea(文件級權重)/ tag_feas(標籤特徵,cosine × 10)       │
│  weight_int / weight_flt / rank_int / rank_flt                      │
├─────────────────────────────────────────────────────────────────────┤
│  版面定位(給 UI 高亮與 PDF 跳頁用)                                 │
│  page_num_int / top_int / position_int / img_id                     │
├─────────────────────────────────────────────────────────────────────┤
│  知識圖(GraphRAG 寫入,Part 4 會用到)                              │
│  knowledge_graph_kwd / entity_kwd / entity_type_kwd                 │
│  from_entity_kwd / to_entity_kwd / n_hop_with_weight / source_id    │
├─────────────────────────────────────────────────────────────────────┤
│  RAPTOR 摘要樹                                                      │
│  raptor_kwd / raptor_layer_int                                      │
├─────────────────────────────────────────────────────────────────────┤
│  結構與導航                                                          │
│  toc_kwd / children_kwd / parent_kwd / depth_int / doc_type_kwd     │
├─────────────────────────────────────────────────────────────────────┤
│  生命週期                                                            │
│  available_int(0 = 人工停用)/ removed_kwd / deleted_doc_id        │
│  chunk_hash_kwd / input_hash_kwd(冪等性,避免重複寫入)             │
└─────────────────────────────────────────────────────────────────────┘

這張表就是 RAGFlow 所有功能的物理基礎。 之後 Part 4 講的每一個檢索特性,都對應到這裡的某幾個欄位。


四、scripted_sim:把 TF 壓成 min(freq, 1) 的自訂相似度

mapping.jsonsettings 裡藏了整個索引設計最有主張的一段:

 1"similarity": {
 2  "scripted_sim": {
 3    "type": "scripted",
 4    "script": {
 5      "source": "double idf = Math.log(1+(field.docCount-term.docFreq+0.5)/(term.docFreq + 0.5))
 6                              / Math.log(1+((field.docCount-0.5)/1.5));
 7                 return query.boost * idf * Math.min(doc.freq, 1);"
 8    }
 9  }
10}

翻譯成人話:

score = query.boost × 正規化 IDF × min(詞頻, 1)
                                    └──────┬──────┘
                                    詞出現 1 次和 50 次,得分完全一樣

這是一個刻意閹割 BM25 的相似度。標準 BM25 的公式是:

BM25 = IDF × (tf × (k1+1)) / (tf + k1 × (1 - b + b × dl/avgdl))
              └────────────┬───────────┘   └────────┬────────┘
                    詞頻飽和曲線                文件長度正規化

RAGFlow 把 tf 直接壓成 0 或 1,同時也就消掉了文件長度正規化(因為長度正規化存在的意義就是修正 tf 的長度偏差)。

4.1 為什麼要這樣做

三個理由,都跟「chunk 不是文件」有關:

理由一:chunk 太短,詞頻沒有訊息量。 一個 128 token 的 chunk 裡,「毛利率」出現 1 次和 3 次,在語意上幾乎沒有差別——它就是在講毛利率。標準 BM25 的 tf 曲線是為「幾千字的網頁」設計的,套在 128 token 的片段上,放大的是噪音。

理由二:避免關鍵字堆疊。 表格類 chunk 特別容易出現同一個詞重複幾十次(例如每一列都有「營收」)。用標準 BM25,這種 chunk 會在任何含「營收」的查詢裡衝到第一,即使它只是一張欄位標題重複的表。壓成 min(freq,1) 之後,「有沒有提到」比「提到幾次」重要。

理由三:讓分數可加。 因為 tf 被壓成二元,*_tks 欄位的得分基本上退化成「命中詞的 IDF 加權和」。這讓多欄位 boost(important_kwd^30)的行為變得可預測——boost 30 就真的是 30 倍,不會被某個欄位裡異常的詞頻扭曲。這對 Part 4 的融合公式很關鍵。

4.2 這個決定的代價

好處代價
chunk 尺度下更穩定的排序失去「這篇文章大量討論某主題」的訊號
對關鍵字堆疊有免疫力長 chunk 與短 chunk 得分不再區分(無長度正規化)
分數可加、boost 可預測與標準 ES 行為不同,你不能拿 BM25 的直覺來調參

翻轉點:如果你把 RAGFlow 拿來索引長文件(例如 one 模板,整份文件一個 chunk),這個相似度就不合適了——此時詞頻是真訊號。標準的做法是改用預設的 BM25 similarity,但這需要改 mapping.json 並重建索引。

還要注意:這個 similarity 只掛在 *_tks 欄位上*_ltks(如 content_ltks)用的是 ES 預設 BM25,而 *_kwd 用的是 boolean(命中就是 1,不命中就是 0)。三種欄位,三種評分哲學:

*_kwd  → boolean similarity     「有或沒有」          精確匹配欄位
*_tks  → scripted_sim(IDF-only)「有沒有,看詞多罕見」 標題/關鍵字/問句
*_ltks → 預設 BM25               「標準全文檢索」      正文

五、雙引擎抽象:一個介面,六種後端

RAGFlow 支援六種 doc engine。抽象層是 DocStoreConnection,各後端的實作在 rag/utils/

檔案後端定位
es_conn.pyElasticsearch 8.11.3預設,生態成熟
infinity_conn.pyInfinity v0.7.3同團隊自研,RAG 原生
opensearch_conn.pyOpenSearch 2.19.1ES 分支,含 hybrid pipeline
ob_conn.pyOceanBase 4.4分散式 HTAP
serenedb_conn.pySereneDB 26.07
gaussdb_conn.pyGaussDB華為,含 text-to-SQL

切換完全靠環境變數:

1DOC_ENGINE=elasticsearch        # 或 infinity / opensearch / oceanbase / serenedb
2COMPOSE_PROFILES=${DOC_ENGINE},${DEVICE},metadata-${METADATA_DB_PROFILE}

同一份 compose 檔,profile 決定起哪些容器。 換引擎不改 YAML。

5.1 索引命名與隔離策略

1def index_name(uid):
2    return f"ragflow_{uid}"       # uid = tenant_id
┌────────────────────────────────────────────────────────────────┐
│  索引 ragflow_{tenant_A}                                        │
│  ┌──────────────────────────────────────────────────────────┐  │
│  │ kb_id = kb_1  │ kb_id = kb_2  │ kb_id = kb_3  │  ...      │  │
│  │ (合約庫)     │ (產品手冊)   │ (FAQ)       │           │  │
│  └──────────────────────────────────────────────────────────┘  │
│  2 shards / 0 replicas / refresh_interval 1000ms               │
└────────────────────────────────────────────────────────────────┘
┌────────────────────────────────────────────────────────────────┐
│  索引 ragflow_{tenant_B}   ← 完全獨立的索引                     │
└────────────────────────────────────────────────────────────────┘

租戶級隔離(索引),知識庫級過濾(欄位)。

Infinity 的 schema 把這個意圖寫得更明白:kb_id 標記為 {"type": "secondary", "cardinality": "low"}——低基數的二級索引,正是「用來過濾、不是用來查找」的欄位。available_int 也是同樣的標記(0/1 兩種值)。

三個預設值的含義:

  • number_of_shards: 2 — 不是 1,因為單 shard 無法平行;不是 5,因為多數租戶的資料量不需要。這是「小租戶不浪費、中租戶夠用」的折衷。超大租戶需要手動調。
  • number_of_replicas: 0 — 預設沒有副本。這是單機部署的預設值,意思是ES 節點掛了資料就沒了。上生產必須改。
  • refresh_interval: 1000ms — 比 ES 預設的 1s 一樣(1000ms = 1s),寫入後 1 秒內可搜。這是「使用者上傳完馬上想搜到」與「寫入吞吐」之間的取捨;批量匯入時可以調大到 30s 換吞吐。

5.2 額外的索引:metadata 與 skill

除了主 chunk 索引,conf/ 裡還有幾份小 schema:

doc_meta_es_mapping.json / doc_meta_infinity_mapping.json
  → {id, kb_id, meta_fields: json}
  → 文件層級的 metadata store,支撐檢索時的 metadata 過濾

skill_es_mapping.json / skill_infinity_mapping.json
  → agent skill 的索引

message_infinity_mapping.json
  → 對話訊息(memory 功能用)

meta_fields 是一個 JSON 欄位,存使用者自訂的 metadata({"author": ["John","Tom"], "category": "tech"})。檢索時可以下條件過濾:

RETRIEVE 'AI' ON DATASETS 'test'
  WITH meta_data_filter '{"method":"manual",
                          "conditions":[{"key":"author","op":"eq","value":"Luo"}]}';

還有 "method":"auto" 模式——讓 LLM 自己從問句推斷該套哪些 metadata 條件。 這是 Part 4 的內容。


六、物件儲存:原始檔與圖片切片

向量與文字進 doc engine,二進位進物件儲存。rag/utils/ 裡有一整排 driver:

minio_conn.py       MinIO(預設,compose 裡是 pgsty/silo 這個 S3 相容映像)
s3_conn.py          AWS S3
oss_conn.py         阿里雲 OSS
gcs_conn.py         Google Cloud Storage
azure_sas_conn.py   Azure(SAS token)
azure_spn_conn.py   Azure(Service Principal)
opendal_conn.py     OpenDAL(一個介面接數十種後端)
encrypted_storage.py 加密層(包在任一後端外面)

透過 storage_factory.py 選型,統一以 settings.STORAGE_IMPL 提供給上層。

存兩類東西:

① 原始檔案。 使用者上傳的 PDF/DOCX 原檔。用途是:重新解析(改了 chunk 參數要重跑)、UI 上的原文預覽與高亮定位。

② 圖片切片。 DeepDoc 在解析 PDF 時會把圖片區塊、表格區塊切出來存成圖片。image2id() 負責這件事:

1async def image2id(d: dict, storage_put_func: partial, objname: str,
2                   bucket: str = "imagetemps"):

chunk 上只留一個 img_id 欄位(指向物件儲存的 key),圖片本身不進 doc engine。這個分離很重要:把 base64 圖片塞進 ES 文件會讓索引體積爆炸、highlight 變慢、備份變貴。

┌──────────────┐        ┌─────────────────┐        ┌──────────────┐
│  Doc Engine  │        │  Object Storage │        │    MySQL     │
│              │        │                 │        │              │
│ chunk 文字    │        │ 原始 PDF        │        │ document 列  │
│ 向量          │──img_id▶│ 圖片切片        │        │ task 列      │
│ 排序特徵      │        │ (bucket per kb) │        │ 進度 / 狀態  │
│ img_id 參照   │        │                 │        │              │
└──────────────┘        └─────────────────┘        └──────────────┘
      可重建                   真實來源                  真實來源

一個實用的心智模型:doc engine 裡的資料全部可以從物件儲存 + MySQL 重建。 這就是為什麼 number_of_replicas: 0 的預設值不算太瘋狂——最壞情況是重新解析一次,不是資料永久遺失(前提是你確實備份了 MinIO 和 MySQL)。


七、三個演進階段:儲存層

╔══ Phase 1:ES 單節點(< 100 萬 chunk) ══╗

┌────────────────────────────────────────────┐
│ ES 單節點  MEM_LIMIT=8GB                    │
│  ragflow_{tenant}  2 shards / 0 replicas   │
└────────────────────────────────────────────┘
┌──────────┐  ┌───────────┐
│ MySQL 8  │  │ MinIO 單盤│
└──────────┘  └───────────┘
  • 可接受的捷徑:0 replica、單盤 MinIO、refresh_interval 用預設
  • 解決:功能完整可用
  • 剩下:任一元件掉線即全掉;1000 萬 chunk 時單節點記憶體不足

╔══ Phase 2:ES 叢集 + 分離儲存(百萬~千萬) ══╗

┌─────────────────────────────────────────────────────────┐
│ ES 3 節點   replicas=1   每租戶索引獨立                  │
│ 批量匯入時 refresh_interval 調到 30s,完成後調回          │
└─────────────────────────────────────────────────────────┘
┌────────────────┐  ┌─────────────────┐  ┌──────────────┐
│ MySQL 主從     │  │ MinIO 分散式    │  │ Valkey 叢集  │
│ max_conn 900   │  │ 或直接用 S3     │  │              │
└────────────────┘  └─────────────────┘  └──────────────┘
  • 新增元件:ES replica、MySQL 從庫、S3/分散式 MinIO
  • 關鍵設定:service_conf.yamlmysql.max_connections: 900stale_timeout: 300
  • 解決:單點故障、寫入吞吐
  • 剩下:ES 的混合檢索融合只能做「近似」(Part 4 詳談的 "0.001,1" 權重與第二次 KNN 回查)

╔══ Phase 3:Infinity 或分散式 HTAP(千萬以上) ══╗

┌──────────────────────────────────────────────────────────────┐
│ Infinity:原生 weighted_sum 融合                              │
│   ┌──────────────┐  ┌──────────────┐  ┌──────────────────┐  │
│   │ 全文(rag-   │  │ 向量          │  │ FusionExpr        │  │
│   │ coarse/fine)│  │              │  │ weighted_sum      │  │
│   └──────┬───────┘  └──────┬───────┘  │ weights "0.7,0.3" │  │
│          └─────────────────┴──────────▶└──────────────────┘  │
│   一次請求完成融合,不需要把向量傳回應用層                      │
└──────────────────────────────────────────────────────────────┘
    或 OceanBase / GaussDB(把 metadata 與 chunk 放同一套 HTAP)
  • 新增元件:Infinity(或 HTAP 資料庫)、doc metadata store 分離
  • 解決:融合精度(真正的加權融合,不是近似)、延遲、應用層不再搬向量
  • 剩下:Infinity 生態新、除錯資料少;HTAP 方案的運維門檻高

三階段對照

維度Phase 1Phase 2Phase 3
chunk 量級< 100 萬100 萬~1000 萬1000 萬+
Doc engineES 單節點ES 3 節點 replica=1Infinity / OceanBase
replica01依引擎
融合方式ES 近似 + 本地重排同左引擎原生加權融合
向量是否回傳應用層否(第二次 KNN 取分數)否(引擎內完成)
物件儲存MinIO 單盤MinIO 分散式 / S3S3 / OSS + 加密層
每 100 萬 chunk 索引體積≈ 3~6 GB(768 維)同左 × replica依引擎壓縮策略

索引體積的估算方式:768 維 float32 = 3 KB/chunk,加上文字與倒排索引大約再 1~3 KB,所以 100 萬 chunk ≈ 4~6 GB。乘上 replica 數。這是規劃磁碟的基準線。


八、為什麼選 X 不選 Y

決定                    選 X 的理由                        不選 Y 的理由 / Y 更好的時機
──────────────────────────────────────────────────────────────────────────────────────
後綴驅動動態 mapping     換 embedding 模型零 schema 變更;   固定 mapping:型別安全、
vs 固定 mapping         新功能加欄位不用 migration;        欄位打錯會報錯,但每次加欄位
                       欄位名自我文件化                     都要 migration
                                                          ▶ 翻轉點:團隊需要強型別保證、
                                                            或維度不在 512/768/1024/1536

IDF-only(min(tf,1))   chunk 太短,詞頻是噪音;            標準 BM25:長文件的正確選擇;
vs 標準 BM25            對關鍵字堆疊免疫;                  詞頻確實有訊息量
                       分數可加,多欄位 boost 可預測        ▶ 翻轉點:用 `one` 模板索引
                                                            長文件時,改回 BM25

0.1 檔名 + 0.9 內容     文件層級語意不遺失;                純內容向量:chunk 之間差異最大化,
vs 純內容向量           檔名只 encode 一次,成本可忽略       但檔名裡的關鍵資訊查不到
                                                          ▶ 翻轉點:檔名是無意義亂碼
                                                            (如 UUID)時設為 0

有問句就編碼問句        把非對稱匹配(問句 vs 陳述句)       一律編碼內容:穩定、不依賴生成品質
vs 一律編碼內容         轉成對稱匹配;FAQ 場景提升最大       ▶ 翻轉點:問句生成品質不穩時
                                                            關掉 question_proposal

租戶級索引 + kb_id 過濾  避免 shard 爆炸(500 個 KB          KB 級索引:隔離最強、刪除最快,
vs 知識庫級索引         = 1000 個 shard);跨 KB 查詢便宜    但 shard 數失控
                                                          ▶ 翻轉點:單一 KB 資料量極大
                                                            (> 1 億 chunk)時該獨立索引

圖片存物件儲存 + img_id  索引體積不爆炸;highlight 不變慢;   base64 塞進文件:查詢時一次拿到,
vs 內嵌 base64          備份便宜                            但索引大 10 倍、備份與快照極慢
                                                          ▶ 翻轉點:幾乎沒有

replicas 預設 0         單機開發不浪費資源;資料可從         預設 1:開箱即高可用,
vs 預設 1               MinIO + MySQL 重建                  但 16 GB 單機跑不動
                                                          ▶ 翻轉點:上生產第一件事就是改成 1

六種 doc engine 抽象     不同客戶有不同基礎設施約束          只支援一種:程式碼簡單、
vs 只支援 ES            (國企要信創、雲廠要自家服務);     優化到底,但市場被鎖死
                       profile 切換零改檔                   ▶ 翻轉點:自用系統只需要一種

九、系統效應:儲存層設計帶來什麼

能力靠哪個設計實現沒有它會怎樣
換 embedding 模型不改程式後綴驅動 mapping + q_{dim}_vec每次換模型要改 mapping + migration
換 doc engine 不改程式DocStoreConnection 抽象 + profile綁死一家,客戶談不下來
人工加關鍵字有 30 倍效果*_kwd = boolean similarity + ^30 boost人工修正被詞頻噪音淹沒
表格類 chunk 不會霸榜min(doc.freq, 1)欄位標題重複的表格衝到第一
標籤先驗可注入排序tag_feas = rank_features + cosine × 10只能靠微調 embedding 模型
文件級權重可調pagerank_fea = rank_feature所有文件一律平等
UI 能在 PDF 上高亮定位page_num_int / top_int / position_int引用只能給文字,不能跳頁
索引可從備份重建原檔在物件儲存、狀態在 MySQLdoc engine 掉了就永久遺失
人工停用雜訊 chunkavailable_int + low-cardinality 二級索引只能刪除,無法可逆停用
metadata 條件過濾獨立的 doc_meta_* 索引過濾條件要跟 chunk 一起重建

十、系列導航

資料現在躺在索引裡了:文字、向量、排序特徵、版面座標、知識圖節點,全部按名稱後綴各就各位。

下一篇是把它們取回來——也是整個系列技術密度最高的一篇:查詢編譯器如何把一句人話變成加權布林查詢、ES 與 Infinity 兩種融合路徑為什麼不一樣、三層重排的公式、GraphRAG 與 RAPTOR 什麼時候值得開、以及那個不依賴 LLM 服從度的引用對齊演算法。

  • Part 4 — Decode 與檢索:混合搜尋、Rerank、GraphRAG / RAPTOR 與引用

Part 2 — 資料進場 — DeepDoc 解析、Chunking 策略與 14 種模板 | Part 4 — Decode 與檢索 — 混合搜尋、Rerank、GraphRAG/RAPTOR 與引用 →


本文基於 RAGFlow main 分支(2026 年 9 月)原始碼撰寫。所有 mapping、相似度腳本、欄位命名與預設值皆從 conf/mapping.jsonconf/infinity_mapping.jsonrag/svr/task_executor.py 實際核對。索引體積為量級估算。

Yen

Yen

Yen