:融合BM25與向量召回提升知識庫搜索效果)
上個月我在處理某知識庫系統(tǒng)的搜索需求時被兩個完全相反的問題夾擊用戶問“設備重啟后沒有聲音”向量檢索模型輕松召回了音頻故障相關文檔但同一套系統(tǒng)在面對“RT-2200 說明書”這種查詢時把型號切成了一段語義碎片結果排在第一頁的居然是某個型號介紹頁面。反過來看那套還在被業(yè)務方懷念的全文搜索引擎處理口語化問題時完全啞火——用戶說“屏幕一直閃”文檔里寫的是“顯示屏出現頻閃故障”BM25 根本匹配不到。兩邊都覺得自己沒做錯可用戶就是找不到東西。這就是混合檢索Hybrid Search真正要解決的問題。它不是一個花哨的模型也不是某種“銀彈算法”而是把兩種互補的召回機制拼在一起精確匹配靠關鍵詞語義理解靠向量。這篇我把這節(jié)內容完整展開——兩種檢索到底各有什么死角、分數怎么融合、項目里怎么落地、調參的時候真實踩過哪些坑以及為什么混合檢索在某些數據集上反而沒用。1. 單一通道的死角向量檢索會騙人關鍵詞檢索會漏人要理解混合檢索的價值不能只看它“多了一個召回通道”這一表象而要回到一個扎心的事實每次只用一個通道做召回必然漏掉另一類本應被命中的文檔。而這種漏不是調調參數就能解決的。1.1 向量檢索的“語義幻覺”先說稠密檢索也就是向量檢索。它的基本思路是把文本映射成一個幾百維的向量查詢時把用戶 query 也映射成向量然后找距離最近的文檔片段。問題在于嵌入模型擅長的是“語義相似”而不是“信息正確”。舉個體感很明顯的例子。我在某電商客服知識庫里做過測試收錄了兩篇文檔一篇標題是《筆記本電腦電池鼓包檢測與處理》另一篇是《移動電源鼓包風險警告》用戶輸入“筆記本電池鼓包怎么檢測”向量檢索給這兩篇文檔的相似度分數幾乎一樣。如果你用向量結果直接去生成回答大模型完全有可能拿移動電源那篇的內容拼進筆記本的回答里。關鍵詞檢索不會有這個問題因為“筆記本”“移動電源”這兩個詞面完全不同BM25 一眼就能區(qū)分開。向量檢索另一個天然弱點是精確匹配。型號、訂單號、日期、SKU 編號這類 token在嵌入模型眼里只是一團沒有語義的字符串經常被“平均化”甚至直接忽略。用戶輸入“單號 INV-2024-0917 的發(fā)票要紅沖”向量檢索會把重心放在“發(fā)票”“紅沖”上而“INV-2024-0917”這個最關鍵的約束反倒沒起作用。你搜“RT-2200 說明書”向量模型可能把“RT-2200”拆成“RT”和“2200”當成兩個互不相關的詞命中的結果自然亂七八糟。1.2 關鍵詞檢索的“字面潔癖”BM25 這類稀疏檢索走的是另一條極端只看詞面不管語義。它的眼神好到能捕捉“INV-2024-0917”這種精確串號但壞也壞在太死板用戶稍微換個說法它就六親不認。真實客服日志里“屏幕一直閃”和“顯示屏出現頻閃故障”在語義上指向同一個問題可 BM25 對這組詞的重疊度計算幾乎為零?!伴W”和“頻閃”確實有共同字但詞頻統(tǒng)計和逆文檔頻率并不會自動把它們當成同一個意圖。用戶說“重裝系統(tǒng)后電腦卡頓”文檔里寫的是“操作系統(tǒng)重新安裝之后出現性能下降”這是語義上的同一件事字面上沒有任何一個關鍵詞對齊。中文場景下還有分詞這道坎。同一段話用“重新啟動”和“重啟”兩種說法分詞器切出來的詞條完全不同BM25 的得分自然天差地別。你當然可以花大力氣維護同義詞表、近義詞庫但這是一條永遠追不完的路——新詞每天都有黑話和縮寫層出不窮靠人工詞典去補語義鴻溝本質上是拿慢動作追趕高速列車。1.3 先分清“召回”和“排序”再談混合很多人一聽混合檢索就覺得是“把兩個打分列表按某種權重相加”這其實是誤解。要理解混合檢索的真正價值必須先分清兩個概念召回Recall和排序Ranking。召回是從整個文檔庫里把“可能相關”的候選撈出來排序是對候選集精排。排序做得再好如果候選池里本身就沒有正確文檔一切都是零。傳統(tǒng)搜索里這兩個階段通常在同一個引擎里完成但在向量檢索興起之后很多人只關注排序階段的相似度分數卻忽略了向量召回本身可能從一開始就漏掉了精確匹配的那一篇?;旌蠙z索的核心價值不在于把兩個排序結果優(yōu)雅地合并而是讓兩個“召回通道”互補關鍵詞通道保證精確匹配不會漏向量通道保證語義改寫不會被丟。把候選先撈全后面的重排才有意義。2. 兩套不同的打分哲學BM25 與向量相似度在算什么既然要混合就得先搞清楚混的是什么東西。BM25 和向量相似度是兩套完全不同的打分體系各自的分數含義、分布、量綱都毫無可比性。先拆開講清楚再談融合。2.1 BM25 的老派公式詞頻、IDF 與文檔長度懲罰BM25 是很多年前從概率檢索模型演化出來的打分函數到今天依然霸榜全文檢索靠的不是新而是簡單可靠。它的公式長這樣score(D, Q) Σ IDF(qi) × [ f(qi, D) × (k1 1) ] / [ f(qi, D) k1 × (1 - b b × |D| / avgdl) ]這里的 f(qi, D) 是詞項 qi 在文檔 D 中的出現次數|D| 是文檔長度avgdl 是語料平均文檔長度k1 取 1.2 到 2.0 之間b 通常取 0.75。翻譯成人話就是這個詞在文檔里出現得越多文檔得分越高但這個詞如果在整個語料里遍地都是比如“的”“是”“問題”它的逆文檔頻率 IDF 就會被壓得很低起不到區(qū)分作用文檔越長詞頻帶來的收益越要打折免得長文檔靠堆字數取勝。舉個例子一個文檔里“發(fā)票”出現了 5 次另一個文檔出現 2 次如果“發(fā)票”在整個知識庫里出現在大量文檔中前者的優(yōu)勢沒那么大但如果“發(fā)票紅沖”這個詞組只出現在少數幾個文檔里那只要出現一次就基本鎖定目標。BM25 的“可解釋性”是它最大的優(yōu)點任何一個分數你都能說出是哪個詞貢獻的排查問題無比方便。而它的局限也很鮮明所有計算都建立在詞面精確相等之上換一種說法即失效。2.2 向量嵌入把一句話變成一個坐標點向量嵌入的思路完全不一樣。先用一個預訓練模型把每個文本片段編碼成一個固定維度的向量比如常見的 768 維。語義相近的文本在高維空間里的向量距離也相近。查詢時把用戶 query 也編碼成同維向量然后計算 query 向量與文檔向量的余弦相似度cosine_similarity(A, B) (A · B) / (|A| × |B|)這個分數越接近 1表示兩個文本在語義上越接近。它的優(yōu)勢在于能繞過“字面差異”用戶說“設備重啟后沒有聲音”文檔寫的是“開機后揚聲器無聲”它們的用詞完全不同但在語義空間里可能離得很近模型能識別出這是同一類問題。但要注意向量嵌入是在語義層面做壓縮必然會舍棄一些字面細節(jié)。一個 768 維的向量要承載整段話的含義它不可能把每一個型號、每一個編號都精確保留下來。這就是為什么純粹做語義檢索的系統(tǒng)在精確匹配類查詢上總有一種“模棱兩可”的難受感——它不是不會查而是天然不擅長。2.3 分數融合的三種主流算法當兩個通道各回一份候選列表后問題就來了BM25 的分數可能是 4.8向量相似度可能是 0.72這倆完全不是一個量綱怎么合并目前業(yè)界常見做法主要有三種特性對比如下融合方式計算邏輯優(yōu)點缺點適用場景RRFReciprocal Rank Fusionscore Σ 1 / (k rank)k 通常取 60對原始分數尺度完全免疫只依賴排名穩(wěn)定、可解釋忽略原始分數強度前兩名之間的差距被壓平快速上線、兩個通道分數量綱差異極大的情況歸一化加權和先對兩個通道的分數做 min-max 歸一化再按權重相加權重可調能精細控制兩個通道影響力依賴歸一化統(tǒng)計方式批次波動會帶來不穩(wěn)定已有評測集、追求最佳效果學習排序LTR把特征輸入排序模型訓練打分器上限最高需要標注數據、訓練成本高數據量大、長期迭代的搜索系統(tǒng)RRF 特別值得多說兩句。它的思想很樸素如果一個文檔在兩個通道里排名都靠前那它一定很相關。把排名的倒數加在一起排名第 1 貢獻 1/(160)≈0.016排名第 10 貢獻約 0.014雖然差距不大但多次取和就體現出排序穩(wěn)定的文檔優(yōu)勢了。實踐中我多數時候會先用 RRF 跑通流程效果不好再切換成歸一化加權和做精細調參。3. 落地方案在知識庫搜索里完成混合檢索改造理論講完上實操。我做知識庫搜索時常用兩套方案一是全部跑在同一個融合引擎里二是向量庫和關鍵詞引擎分開部署、在應用層合并。下面詳細展開。3.1 場景預設與數據準備為了講得具體我虛擬一個項目某公司產品知識庫規(guī)模約 2 萬篇文檔包含產品說明書、故障排查指南、售后政策、常見問題 FAQ。用戶的典型查詢風格非??谡Z化什么“我的設備老是重啟怎么辦”“屏幕閃得厲害”“RT-2200 說明書有嗎”兩類問題夾雜出現。數據準備階段最重要的一步是分塊chunk。我見過很多失敗案例都是直接用固定長度把文檔切碎結果把一個故障現象和它的解決方案切成兩半搜索時明明命中了片段片段里卻沒有答案。我采用的做法是先按段落切再把段落內超過 800 字的繼續(xù)拆分相鄰塊之間保留 80~120 字的重疊確??缍涡畔⒉粊G。同時把標題、文檔類型、更新時間放進元數據后續(xù)排序和過濾都靠它。每一條文檔片段最終需要兩份表達一份是原始文本喂給 BM25一份是嵌入向量喂給向量檢索。同一個文本片段兩個通道各自索引互不干擾。3.2 方案一在統(tǒng)一引擎里跑雙通道 RRF如果現有技術棧已經用了開源搜索引擎且版本支持 RRF可以直接在一個引擎里完成混合檢索。下面這個示例基于 8.8 以上版本的開源搜索服務的原生 RRF 能力索引中有文本字段和向量字段。先創(chuàng)建索引映射里同時聲明 text 類型和 dense_vector 類型PUT /knowledge_base { mappings: { properties: { title: { type: text, analyzer: ik_max_word }, content: { type: text, analyzer: ik_max_word }, content_vector: { type: dense_vector, dims: 768, index: true, similarity: cosine }, doc_type: { type: keyword }, updated_at: { type: date } } } }寫入文檔時需要同時塞入文本內容和向量。Python 偽代碼如下import requests def index_document(doc_id, title, content, embedding): payload { title: title, content: content, content_vector: embedding.tolist(), doc_type: doc_type, updated_at: updated_at } requests.put( f{ES_URL}/knowledge_base/_doc/{doc_id}, jsonpayload )查詢時同時執(zhí)行兩個子查詢一個走 BM25 的 match 查詢一個走向量的 knn 查詢再由 RRF 把結果融合。核心查詢體如下POST /knowledge_base/_search { size: 10, query: { rrf: { rank_window_size: 100, retriever: [ { standard: { query: { match: { content: 屏幕閃得厲害 } } } }, { knn: { field: content_vector, query_vector: [0.12, -0.35, ...], k: 100, num_candidates: 200 } } ] } } }rank_window_size 是 RRF 的候選窗口大小我一般設 100意思是每個通道各取前 100 條進入融合池。k 是向量通道要返回的近鄰數num_candidates 是圖搜索的候選數量。k 設太小會把質量差一點的候選直接排除在融合池外設太大又拖慢速度100 是個兼顧的起點。這套方案的好處是運維簡單一個引擎搞定全部。缺點是如果你還用著不支持 RRF 的舊版本就得自己在應用層實現融合邏輯。3.3 方案二向量數據庫與關鍵詞引擎分頭部署、應用層合并還有一種常見架構向量數據已經放在某個開源向量數據庫里關鍵詞搜索則跑在另外一套搜索引擎上。兩個系統(tǒng)各自召回應用層寫一個融合函數把結果合并。融合邏輯不復雜用 Python 實現 RRF 也就幾十行def rrf_fusion(results_a, results_b, k60, size10): score_map {} # results_a: list of (doc_id, rank) # results_b: list of (doc_id, rank) for rank, doc_id in enumerate(results_a): score_map[doc_id] score_map.get(doc_id, 0) 1.0 / (k rank 1) for rank, doc_id in enumerate(results_b): score_map[doc_id] score_map.get(doc_id, 0) 1.0 / (k rank 1) # 按融合分數從高到低取前 size 個 ranked sorted(score_map.items(), keylambda x: x[1], reverseTrue) return [doc_id for doc_id, _ in ranked[:size]]實際項目中這個函數會被包在一個服務接口里調用方傳入 query服務端并行請求兩個引擎等兩個結果都回來后做融合。注意這里兩個請求可以并發(fā)發(fā)起總耗時約等于較慢的那個通道而不是兩者之和。分頭部署的好處是架構靈活各系統(tǒng)可以獨立擴展。壞處是要自己維護兩套索引的同步文檔新增、更新、刪除都要雙寫。我踩過好幾次“文檔剛上線關鍵詞引擎里已經能搜到但向量庫里還沒有”的尷尬解決方案后面單開一節(jié)講。3.4 嵌入模型選型時我關注的三個點選嵌入模型這件事很多文檔里只是一句“選擇合適的模型”但實際選型時我一般盯三個點如果是中文為主、夾雜一些英文術語的文檔優(yōu)先選中文場景優(yōu)化過的開源嵌入模型它們在“發(fā)票紅沖”“頻閃故障”這類中文專業(yè)詞上表現更穩(wěn)如果文檔里經常出現代碼、型號、編號要注意模型分詞是否會被這類特殊符號干擾必要時可以做字段預處理給編號加保護向量的維度要和向量數據庫及索引聲明一致。常見的是 768 維或 1024 維有的模型輸出 1536 維改動成本不低最好在第一步就定下來。4. 權重怎么調、效果怎么驗一次從 0.42 到 0.55 的調整記錄混合檢索不是寫完代碼就完事融合權重怎么定效果到底好不好都得拿數據說話。這一節(jié)講我一套固定的調參流程。4.1 先造評測集50 條 Query 加上人工標注沒有評測集的調參全是拍腦袋。我的做法是從搜索日志里撈真實用戶 query去掉重復和亂碼挑出 50~100 條有代表性的覆蓋三類口語化描述、精確型號查詢、混合型查詢。然后給每條 query 標注相關文檔。標注很簡單三道層級相關、部分相關、不相關。如果這個文檔片段能夠直接回答用戶問題標相關只是打擦邊球標部分相關牛頭不對馬嘴標不相關。標注工作量不小但這一步省不掉后續(xù)所有權重選擇都建立在它的基礎上。評測指標我用三個各有側重RecallK正確文檔出現在前 K 個結果里的比例K 取 10。它是召回質量的直接度量MRRMean Reciprocal Rank第一個相關結果排在第幾位評價“用戶能不能第一眼看到答案”NDCG10綜合排序質量相關文檔越靠前分越高部分相關也算分。4.2 歸一化細節(jié)直接把分數相加是新手最容易踩的坑我在早先的一個項目里犯過這個錯把 BM25 原始分數和向量余弦相似度直接加權相加結果向量通道的貢獻幾乎被完全壓掉。原因很簡單BM25 的分數范圍通常在 0~10甚至更高而余弦相似度在 0~1 之間。權重設成 0.5 和 0.5一加之后 BM25 的分數把向量分數淹沒得徹徹底底。正確做法是先歸一化再相加。最常用的 min-max 歸一化是把當前查詢返回的 topK 分數線性變換到 [0, 1]normalized_score (raw_score - min_score) / (max_score - min_score)但這里有個隱蔽的坑。如果歸一化的 min 和 max 取自當前查詢的返回列表那每次查詢的基準都不一樣會出現“同一篇文檔在不同的查詢里歸一化分數不同”的現象融合結果不穩(wěn)定。更穩(wěn)的做法是在評測集上先離線統(tǒng)計每個通道分數的分位數用固定的經驗上下界做歸一化線上查詢時直接用同一套參數。我通常取 0.01 分位數和 0.99 分位數作為上下界遇到超出邊界的分數就截斷處理。4.3 網格搜索與一輪真實對比歸一化方案定下來之后我會上一個網格搜索遍歷權重組合。假設兩個通道權重分別為 alpha 和 1-alpha把 alpha 從 0.1 到 0.9 每隔 0.1 跑一遍評測集記錄每個組合的 MRR 和 NDCG10選最優(yōu)的。代碼邏輯大致長這樣best_score 0 best_alpha 0.5 for alpha in [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9]: final_rank merge_and_rank( bm25_results, vector_results, alphaalpha, beta1-alpha ) score evaluate(final_rank, ground_truth, metricmrr) if score best_score: best_score score best_alpha alpha在某個實際知識庫項目里純 BM25 的 MRR 是 0.42純向量是 0.44混合檢索用 RRF 跑到 0.51再用歸一化加權和加網格搜索調到 0.55。提升最大的是口語化查詢場景——用戶的描述方式和文檔里的用詞幾乎沒什么字面重合純 BM25 對這類查詢基本不可用向量通道把它們撈了回來而精確型號類查詢BM25 通道又托住了底讓向量不會把結果帶偏。但我也要潑一盆冷水如果你們的業(yè)務場景非常規(guī)范用戶 query 就是標準術語比如法律條文檢索、藥品說明書查詢純 BM25 可能已經做到 0.75 以上的 MRR混合檢索提升的幅度很有限甚至因為向量通道引入噪音而略微下降?;旌蠙z索不是萬能的它的收益來自兩種查詢類型的并存。5. 回歸實戰(zhàn)分塊、同步、延遲三個容易翻車的細節(jié)最后這部分是我在真實項目中反復踩過的坑每一個都讓效果白降一截。希望各位不用再走一遍。5.1 分塊不合理會讓混合檢索雪上加霜混合檢索里兩個通道共用同一套分塊結果分塊切得不好兩個通道會同時受害。最典型的錯誤是用固定 512 字硬切沒有考慮段落邊界把“問題描述”和“解決方案”切到兩個 block 里。用戶搜“重啟后沒聲音”召回了“重啟后沒聲音”這一段里面只有故障描述沒有答案而含答案的那一段因為關鍵詞較少排名被壓到很后面。我的建議是優(yōu)先考慮段落級分塊一個標題下的完整小節(jié)盡量保持完整。如果段落太長再切分時保留重疊區(qū)域。重疊的 text 會在 BM25 里重復計分但不影響大局換來的是語義完整性這筆賬劃算。5.2 兩套索引的數據一致性真不是小事分頭部署方案下向量庫和關鍵詞引擎的索引更新是兩個獨立的動作。新增文檔時一個通道已寫入、另一個通道還沒寫完就會產生“搜得到但點開是舊版本”或“新文檔完全搜不到”的問題。最穩(wěn)的做法是引入異步事件同步文檔變更先發(fā)消息隊列兩個系統(tǒng)各自消費更新完成的文檔再標記為可見。如果不想引入消息中間件至少要有一個定期對賬任務比對新舊文檔列表把差異補上。我見過某團隊上線混合檢索后業(yè)務反饋“前幾天上傳的新資料一直搜不到”排查發(fā)現是向量庫的批量導入任務每天晚上才跑一次新文檔要隔天才能進向量索引。這種問題搜索引擎不會報錯用戶只會在搜索框里一次次嘗試然后失望離開。5.3 延遲與成本向量維度、圖索引與并行查詢向量檢索是有成本的。一個 768 維的 float 向量單條文檔占 768 × 4 字節(jié)約 3KB。10 萬條文檔就是 300MB上百萬條就是 3GB這還沒算索引結構的額外開銷。生產環(huán)境里給向量字段建圖索引后內存占用還會再翻一些需要提前規(guī)劃。查詢延遲方面暴力掃描全部向量的方式在小規(guī)模數據上還能忍數據量一大就必須用近似最近鄰索引。開源的向量庫基本都支持 HNSW 這類圖索引關鍵字參數是 ef_search值越大召回質量越高、延遲越高。我會在可控延遲內把 ef_search 調到吞吐量允許的上限不要一味追求快否則混合檢索不如純關鍵詞檢索。另一個細節(jié)是并行查詢。兩個通道互相獨立完全可以在應用層并發(fā)請求分別等兩個結果回來后再融合。線程池或者協(xié)程都能做這個優(yōu)化很小但收益直接總耗時從兩通道之和降到了較慢通道的時間。最后分享一個我判斷是否該上混合檢索的方法。接到一個搜索系統(tǒng)我不會上來就搭混合檢索而是先拿純 BM25 和純向量各自跑一遍測試集看各自的失分點在哪。如果向量失分多在型號、編號這類精確匹配BM25 失分多在口語化、同義改寫那混合檢索的收益會很可觀如果兩個通道的出錯場景高度重疊或者其中一邊已經足夠好那混合的邊際收益就很小?;旌蠙z索本身不是魔法它只是給兩個互補的信號一個共存的機會。真正花時間的永遠是數據準備、評測集標注和分塊策略。把這三件事做好剩下的融合權重都是細枝末節(jié)。