
昨天處理的是一段文字怎麼切、切多大。假設那一關過了:片段都在庫裡、長度也合理。今天換個問題——搜尋卻拿錯了型號。
假設使用者問的是 PROD-SKU-7842X,系統卻撈回 PROD-SKU-7842Y。內容很像,產品卻不是同一個。「懂意思」這件事在這裡救不了你——你還需要一條認字的路。
今天把 dense 跟 BM25 接在一起,再拿同一批查詢驗收:多加一路,到底有沒有比較好?
為什麼不能只靠一個索引?因為兩邊擅長的事不一樣。
向量檢索(dense)擅長找意思相近的內容,換個說法也有機會找到同一段;但遇到只差一個字的型號,它不保證分得清楚。關鍵字檢索(BM25)靠斷詞後的字詞比對,正好能補上型號、料號這類查詢。
一般搜尋裡兩種問法都會出現,這才是要接兩路的理由。
對地端還有個現實理由:BM25 那一路純 CPU、不吃顯卡,加它不用跟主 LLM 搶顯存。
建立索引時做一次:同一批 chunks,分別建 BM25 倒排索引與 dense 向量索引,兩路共用同一組 chunk ID——融合時才認得出是同一個片段。查詢進來之後是這樣走的:

圖 1:兩路各自撈候選,再按 chunk ID 把兩份名次合起來。
三個地方最容易混淆,先講清楚再往下:
融合不是 rerank。 融合只把兩份排名合成一份,它不看內容;reranker 是拿問題跟候選內容重新判斷相關度。兩件事可以疊,但不能互相取代。
檢索候選數不是送進 LLM 的塊數。 Day 17 把這兩本帳分開過:前者影響召回,後者影響 prefill 延遲。前面多撈幾塊不會膨脹 prompt,但送進去的塊數要另外控。
BM25 不是「型號相等」的硬保證。 它是斷詞後的字詞比對,不是精確比對——識別碼有沒有被完整保留在索引裡,決定了它幫不幫得上忙。這件事等一下單獨處理。
我拿本系列的 24 篇文章當語料(day01 到 day24,切成 451 塊),用程式挑出 116 個「只出現在 2 到 5 塊裡」的識別碼當查詢:型號、版號、build 號、環境變數名這一類。標為相關的就是含它的那幾塊。
同一份語料、同一批查詢,recall@10 是 dense(BGE-M3)49.7%、BM25(jieba)96.2%。116 題裡 dense 有 24 題完全撈不到。
我第一個反應是「query 寫太爛吧」。把單獨的識別碼改寫成完整問題再跑一次,recall 只動到 51.9%,24 題掛蛋只救回 1 題。這次改寫沒有解決主要問題。
看向量空間就懂了。同一顆 BGE-M3,PROD-SKU-7842X 與只差最後一個字元的 PROD-SKU-7842Y,cosine 是 0.9063,而兩個毫無關係的英數字串平均只有 0.4388。真實型號也一樣。
這不是挑出來的特例——語料裡天然存在的近似識別碼對有 63 組,最像的一組是 json.load 與 json.loads。對 dense 來說,那兩個幾乎是同一個東西。
⚠️ 這批查詢只能回答一件事。 query 就是識別碼、標為相關的是「含這個字串的片段」,所以它問的是「找不找得回含識別碼的片段」,不能拿來判定整體語意問答誰比較好。
那直接用 BM25 就好?不行,因為另一半的需求它接不住:使用者問「熱處理爐多久要保養一次」,文件裡寫的是「加熱設備的定期維護週期」——主要用詞不同,BM25 未必排得上來,而那正是 dense 在做的事(這是概念例子,不是本輪的量測)。
兩種問法都會進同一個搜尋框,要的能力卻不一樣。
認字那一路要能用,中文得先過兩關。這兩關沒過,後面的融合怎麼調都是白工。
一、文件與查詢要用同一套斷詞,而且別讓資料庫再切一次。 做法是兩端都先用 jieba 或 CKIP 斷好、以空白分隔,欄位再選一個只按空白切的 tokenization——Weaviate 是 whitespace(要不分大小寫就用 lowercase)。
不能選 word:它除了空白,還會按非英數字元再切一次,PROD-SKU-7842X 會變成 prod/sku/7842x。驗法是灌一筆只含 PROD-SKU-7842X 的測試資料,在那個欄位查 prod——命中就表示識別碼被拆開了。
另一半也別漏:Weaviate 用同一個 tokenization 切索引也切查詢,只斷一邊的話,一句沒空格的中文會變成一個 token,什麼都撈不到,而且不報錯。驗法是拿一句你確定文件裡有的中文,把 alpha 設成 0 只跑 BM25,看它回幾筆。
二、識別碼要完整進索引。 預設的斷詞器不會把 Q4_K_M 當成一個東西,它會切成 Q4 / _ / K / _ / M——那時 BM25 找的就不是這個型號了。先把你的型號丟給 tokenizer 看一眼,這一關花不到五分鐘。
修法是把型號、料號、內部縮寫加進斷詞器的 user dict,讓它們整串留下來——接著上一關那個「別讓資料庫再切一次」才守得住。這是我這輪試過最划算的一步:MRR@10 從 0.8872 升到 0.9619,116 題裡 17 勝 99 平 0 敗。
但要講精確:它主要是把正確答案往上推,不是把它撈進來。recall@10 只從 96.2% 動到 98.0%、5 題改變——召回只在少數題目上動,目前證據主要支持的是排名提升。
至於換詞典的效果,得看你怎麼量:我換上 dict.txt.big 之後,這次直接量到的是查詢與文件切法更一致,還不能把這個提升直接當成檢索品質的提升(數字在附錄 A)。而逐字切分也不是死刑——就算不斷詞,BM25 仍然可能找得到資料。要不要換斷詞方式,看你自己的檢索結果,不要看別人的語料。
融合最常見的是 RRF,內容就一行,rank 從 1 起算:
score(d) = Σ 1 / (k + rank(d))
它只吃名次、不看分數,所以不必先把 BM25 分數與 cosine 正規化到同一把尺上——這是它好用的地方。k = 60 是慣用值,不必精調(由來與敏感度見附錄 A)。
實作時也要調整兩路的權重。Weaviate 把它叫 alpha:1 是純向量、0 是純 BM25,server 預設 0.75、偏向量。我下面那張表刻意設成 0.5 的等權,因為要看的正是「不校準會怎樣」;真要上線,這個值得用自己的評估集測一輪。
但等權的 RRF 不會自己辨認哪一路比較可靠。同一批識別碼查詢,逐題配對之後:
| 比較(RRF k=60、兩路各取前 10 名) | 贏 | 平 | 輸 |
|---|---|---|---|
| hybrid vs dense | 82 | 34 | 0 |
| hybrid vs 單路 BM25 | 1 | 97 | 18 |

圖 2:同一批查詢的三路 recall@10,並排看。
hybrid 把 dense 救回來了,而且這批查詢裡沒有一題的 recall@10 下降;但它同時顯著地輸給單路 BM25。這組設定下,融合後有些相關片段掉出了前十名,所以結果反而不如單跑 BM25。
兩路各取多少候選,也會影響結果。 改成各取 100 名,上面那張表就從 18 敗變成 45 敗。
我沒有逐題追出為什麼,所以只說觀察到的:「多取一些」不是保證改善。ES 那一路叫 rank_window_size,預設等於你要的筆數;要明確設定的不只是融合方式,還有這個視窗。
所以 hybrid 是要驗收的東西,不是開了就變好的開關。
同一組 query,量三行:dense-only、BM25-only、hybrid 的 recall@10。
# 三種模式跑同一份 golden set(Weaviate v4; alpha 1=純向量 0=純 BM25 0.5=等權)
# my_embed 是你自己的 embedding 服務; 沒掛 vectorizer 就一定要自己給向量
# tokenize_for_bm25 要跟建庫時同一套規則, 回傳空白分隔的字串
for name, alpha in [("dense-only", 1.0), ("bm25-only", 0.0), ("hybrid", 0.5)]:
hit = 0.0
for g in golden:
q = g["question"]
r = docs.query.hybrid(query=tokenize_for_bm25(q), # BM25 那一路吃斷好的
vector=my_embed(q), # dense 那一路吃原句
query_properties=["search_text"], # 預先斷詞的欄位
alpha=alpha,
fusion_type=HybridFusion.RANKED, # = RRF, 顯式 pin
limit=10)
ids = {o.properties["doc_id"] for o in r.objects}
gold = set(g["gold_doc_ids"])
hit += len(ids & gold) / len(gold) # 與 D22 同一把尺
print(name, "recall@10 =", round(hit / len(golden), 3))
(骨架而已,不是貼上就能跑:collection、search_text 欄位、語料與 golden.jsonl 都要先備好,建庫設定在附錄 B。)
前面那組離線實驗跑出來是 dense-only 49.7%、BM25-only 96.2%、hybrid 90.7%——hybrid 贏了 dense,卻沒有贏過最好的那條單路。上面這段 Weaviate 骨架是示範怎麼對你自己的庫做同一件事,數字不必跟我一樣。
判讀照這張表走:
| 看到什麼 | 下一步 |
|---|---|
| 已知該命中的題,BM25 卻回空 | 先查斷詞、索引欄位與查詢設定 |
| hybrid 低於最好的單路 | 分題型看兩路各自敗在哪,再決定調融合權重還是候選視窗 |
| 三路都低 | 沿文件處理、索引、檢索、標註,先找出資料在哪一層漏掉 |
| 答案已在候選池、只是排不上前面 | 這時才輪到 reranker |

圖 3:最後一步是明天的題目,前面五步今天做得完。
圖 1 上那個選配的 reranker:先確認答案進得了候選池,再看重排能不能把它往前推。
還有一件事比三行數字更重要:評估集裡要同時有識別碼題與語意題。 兩種題型分開比較,也要按實際流量的比例看整體——不然你量到的只是自己挑了哪一種題。
word 會拆掉識別碼),型號整串留進索引。明天 Day 26〈Routing 的四個階梯:從 metadata filter 到 agentic 迴圈〉,處理另一種找錯:型號對了,版本卻不對。先用版本、生效日期與廠區縮小搜尋範圍,再看什麼時候需要 router 或多步查詢。
明天見。
語料 ironman/day01.md–day24.md(24 篇、451 塊,chunk 上限 300 個 bge-m3 token);dense BAAI/bge-m3 @ 5617a9f6、CPU;BM25 自寫 Okapi(k1=1.5、b=0.75);RRF k=60、各取前 10 名。golden set:識別碼 116 題、繁中詞 158 題。
# 第一次要抓 bge-m3 權重與 dict.txt.big, 之後全離線
python3 ironman/research/day25/run.py # 第二次起約 60 秒, 其中 dense encode 41 秒
絕對值只屬於這 24 篇——同一位作者、同一個系列,識別碼密度跟你的不一樣。要帶走的是量法。
適用範圍:
cjk bigram 對兩字詞只吐一個 token 就是那個詞本身;其餘 21 題是三到四字,會切出兩個以上 bigram(文件可含「分隔」「隔符」卻不含「分隔符」)。raw.json 有 corpus_sha256 護欄。省略的數字:近似識別碼對 63 組、平均 cosine 0.805,最像的 json.load ↔ json.loads 0.9649;Qwen3.6-27B ↔ Qwen3.8-27B 0.9392。k = 60 出自 Cormack 等人 2009 年那篇,k 從 10 到 100 的 MAP 只差 1.1%。識別碼進索引的 recall 只改變 5 題,n=5 下最小可能 p 就是 0.0625;顯著的是 MRR。
換 dict.txt.big:158 題配對後 50 勝 98 平 10 敗,變好的主要是查詢與文件切法一致的比例(87.1% → 94.4%)。模擬逐字切分時量到 recall@10 仍有 80.5%、零題掛蛋,「的」在 451 塊裡佔 416 塊、IDF 0.082 —— 高頻字 IDF 低本來就是它在壓低那些字對排名的影響。⚠️ 這幾格是 Python 模擬的 analyzer,不是實際跑 Elasticsearch 的結果。
語料稀釋實驗、lost in the middle 的複現分歧、中文 IR 的 bigram 對分詞文獻與分域預印本的指標辨析,都在 NOTES。
| 平台 | BM25 那一路 | 中文條件 | 融合 |
|---|---|---|---|
| Weaviate | 內建,hybrid() 一個 API 打完 |
gse_ch 實測不通;兩端自己斷詞 + 欄位 whitespace |
rankedFusion(RRF)/ relativeScoreFusion;v1.24 起預設換後者,要明確設定 |
| Milvus 2.5+ | 原生 BM25 sparse 欄位 | 內建 chinese analyzer;繁中把 dict 設 _extend_default_ |
RRFRanker / WeightedRanker |
| Elasticsearch | 老牌 BM25 | standard 對中文逐字;內建 cjk 是零外掛 bigram |
RRF 要 Enterprise;rank_window_size 預設等於筆數 |
| Qdrant | v1.15.2 起 server 端能生(qdrant/bm25);payload 的 full-text index 只 filter 不排名 |
tokenizer 設 multilingual、關掉英文 stemmer/stopwords |
server-side RRF / DBSF |
| pgvector | ts_rank 不是 BM25(官方明說 ranking 不用全域資訊) |
要 zhparser / pg_jieba | 自己寫 CTE 湊 RRF;真 BM25 找 ParadeDB pg_search 或 VectorChord-bm25 |
兩個查了才知道的:
內建的中文 tokenizer 我沒能用起來:gse 載的其實是日文詞典(init_gse() 呼叫 gse.New("ja")),中文那顆叫 gse_ch;但起 docker 跑不通——1.33 建欄位回 422,1.34 建得起來、查詢回 200 卻一筆都查不到(詞典改用相對路徑,image 工作目錄是 /,要加 -w /go/pkg/mod)。官方文件還打架:collections 頁只列 gse、寫著「Chinese text → gse」。這也是本篇改走自行斷詞的原因。
Elasticsearch 的 RRF 要 Enterprise 授權:RRFRankPlugin.java 標的是 License.OperationMode.ENTERPRISE,Basic 送 rrf retriever 會在解析階段拋 non-compliant。網路上「要買 Platinum」是舊資訊,方向卻相反——2024-10 那個 PR 把它往上收緊。30 天 trial 會解鎖,「本機跑得起來」不能當反證。
建庫與前置:
import json, weaviate
from weaviate.classes.config import Configure, Property, DataType, Tokenization
from weaviate.classes.query import HybridFusion # 正文那段迴圈要用
client = weaviate.connect_to_local() # docker run ... weaviate:1.34.0, 不必開 gse
client.collections.create(
"Docs",
vector_config=Configure.Vectors.self_provided(), # 向量自己算, 自己給
properties=[
# 存的是 chunk ID(片段級), 不是整份文件的 ID —— 評估也要用同一個粒度
Property(name="doc_id", data_type=DataType.TEXT),
# 已經斷好、空白分隔; whitespace 只按空白切, 識別碼不會再被拆
Property(name="search_text", data_type=DataType.TEXT,
tokenization=Tokenization.WHITESPACE),
],
)
# 灌資料時 search_text 放 tokenize_for_bm25(原文), 向量放 my_embed(原文)
golden = [json.loads(l) for l in open("golden.jsonl", encoding="utf-8")]
docs = client.collections.get("Docs")
assert len(docs), "Docs 是空的或還沒建: 三行都 0.0 不是 hybrid 壞掉"
# 接正文那段三行迴圈
| 用在哪 | 出處 |
|---|---|
| 三路 recall@10、cosine、斷詞一致率、檢定 | 一手實測,research/day25/ |
gse = 日文詞典、gse_ch 建不起來或查不到、word 會拆識別碼 |
weaviate 原始碼;1.33/1.34 docker 實測 |
| ES 的 RRF 需 Enterprise(8.16 起) | RRFRankPlugin.java 與 Elastic subscriptions 矩陣 |
| 各家平台條件與融合流程 | 官方文件、release note |
RRF 公式、k = 60 的由來與敏感度 |
Cormack, Clarke & Büttcher, SIGIR 2009, Table 1 |
| 其餘外部引用 | 逐條列在 NOTES |