「RAG 實戰筆記」第三篇。第一篇把混合檢索一句話帶過:「dense 撈 20、sparse 撈 20、RRF 融合、rerank 精排」。這篇把這句話完全展開——為什麼單一檢索必然有盲區、RRF 為什麼用名次而不用分數、雙塔與交叉編碼在架構上的根本差異,以及每一步在 Qdrant 上的實際程式碼。
一、為什麼需要「混合」:兩種檢索的互補盲區
Dense(稠密向量):懂語意、瞎於字面
Embedding 模型(我用 bge-m3)把整段文字壓成一個 1024 維向量,語意相近的文字在向量空間中距離相近。它的強項是同義與改寫:
查詢「員工請假要跑什麼流程」能命中寫著「休假申請辦法」的切塊——字面幾乎沒有重疊,語意卻是同一件事。
但它有個致命盲區:精確字串。「115年偵字第7615號」這種案號,對 dense 向量來說只是一串沒有語意結構的符號,壓進 1024 維後跟其他案號幾乎難以區分。查案號、料號、法條編號、產品型號——dense 檢索會給你「語意上都是案號」的一堆錯誤結果。
Sparse(稀疏向量):認字精準、不懂改寫
bge-m3 的特別之處是同一次推理順便產出 sparse 向量——一張「token → 權重」的詞彙表,本質上是神經網路加權版的關鍵字索引(可以理解為學習出來的 BM25)。它的強項正好補上 dense 的盲區:「7615」就是「7615」,一字不差才有分。
但反過來,使用者查「休假」而文件寫「請假」,sparse 就完全接不上——它不懂同義詞。
| Dense | Sparse | |
|---|---|---|
| 擅長 | 同義改寫、模糊語意 | 專有名詞、代號、精確字串 |
| 盲區 | 案號、料號等精確匹配 | 同義詞、換句話說 |
| 本質 | 語意壓縮成幾何距離 | 加權關鍵字比對 |
兩者的失敗模式互斥——這就是混合檢索的理論基礎:不是「兩個都用比較保險」的迷信,而是盲區互補的必然。
二、RRF:為什麼融合名次,而不是分數
兩路檢索各自回傳一份帶分數的排名,怎麼合併?直覺是把分數加起來——這是錯的。dense 的分數是餘弦相似度(0~1 之間、分佈密集),sparse 的分數是詞彙權重內積(範圍完全不同)。兩種量綱不同的分數相加,等於拿體重加身高。
RRF(Reciprocal Rank Fusion)的解法:丟掉分數,只看名次。
1 | RRF_score(文件d) = Σ 每一路檢索 1 / (k + rank_d) |
rank_d 是文件在該路檢索的名次,k 是平滑常數(通常 60)。一個切塊如果在 dense 排第 2、sparse 排第 5,它的 RRF 分數就是 1/62 + 1/65。只在其中一路出現的,就只拿那一路的分。
這個設計的精妙之處:
- 免疫於量綱問題——名次永遠可比,不管各路的分數怎麼算
- 兩路都認可的結果自然上浮——雙料入榜的切塊分數幾乎翻倍
k壓制了「第一名獨大」——1/61和1/62差距很小,避免任何一路的榜首直接輾壓全場,讓融合真的是「合議」而不是「一言堂」
三、Qdrant 實作:一次請求做完兩路檢索與融合
理論講完,看真實程式碼。Qdrant 的 query_points + prefetch 讓整個混合檢索在資料庫端一次完成,不用自己撈兩份結果回來手工融合:
1 | result = client.query_points( |
幾個實作細節:
- collection 建立時就要宣告雙向量欄位:
vectors_config放 dense(COSINE 距離)、sparse_vectors_config放 sparse。一個切塊一個 point,同時攜帶兩種向量。 - 查詢端與索引端用同一顆 bge-m3——
embed_query同樣一次產出 dense + sparse,一次模型推理餵飽兩路檢索。 - 撈的數量由設定檔控制(
dense_top_k/sparse_top_k各 20),改參數不動程式碼,方便跑評測調參。
四、Reranker:雙塔的極限,交叉編碼來補
混合檢索解決了「兩種盲區」,但還有一個更深層的架構限制。
雙塔(Bi-encoder)為什麼天生粗糙
Dense 檢索是「雙塔」架構:問題和切塊各自獨立編碼成向量,事後只比距離。切塊在建索引時被壓縮成 1024 個數字——那時它根本不知道未來會被什麼問題查詢。一段 700 字的內容硬塞進固定維度,細節必然丟失;問題與切塊之間詞與詞的精細對應關係(誰修飾誰、否定詞在哪),距離計算完全看不見。
換來的是速度:所有切塊向量預先算好,查詢時只算一次問題向量,再做 ANN 近鄰搜尋——百萬級資料毫秒回應。
交叉編碼(Cross-encoder):慢工出細活
Reranker(我用 bge-reranker-v2-m3)走完全不同的路:把**「問題+切塊」串接成一段輸入**,整段送進模型,讓注意力機制直接看見兩者之間每個詞的互動,輸出一個相關性分數。
代價是每個候選都要跑一次完整的模型推理——不能預先計算(輸入包含查詢時才知道的問題),沒有索引可以加速。30 個候選就是 30 次推理。
兩段式:讓兩種架構各做擅長的事
1 | 百萬切塊 ──混合檢索(雙塔·快·粗)──▶ 30 個候選 ──Reranker(交叉編碼·慢·準)──▶ top-15 進 prompt |
粗篩的職責是召回:寧可多撈,別漏掉正確答案——所以廣撈 30。精排的職責是排序:把真正相關的排上來、把「語意相似但答非所問」的踢下去。慢的模型只處理 30 筆,成本可控。
實作上有兩個值得記的細節:
- 用開關控制(
rerank_enabled),方便 A/B 對照 rerank 到底有沒有幫助——回到第一篇說的:每個改動都要能被黃金問答集量測。 - 裝置選擇的實戰陷阱:bge-m3 embedding 在 Apple MPS 上會卡死(所以 embedding 預設避開 MPS),但同系列的 reranker 在 MPS 上不僅正常、還比 CPU 快約一倍(30 個候選 24 秒 → 12.3 秒,分數完全相同)。同一家的模型、同一台機器、完全相反的結論——加速後端的相容性只能實測,不能推論。
五、整條管線的最終形貌
1 | 問題 |
每一層都在補上一層的不足:sparse 補 dense 的字面盲區,RRF 公平合議兩路結果,reranker 補雙塔架構的精度極限。**沒有一個環節是銀彈,疊起來才是。**第一篇提過的實測結果——檢索命中率 100%——就是這條管線加上表格感知切塊共同達成的。
下一篇處理企業場景最硬的需求:權限怎麼在檢索層強制執行——讓沒權限的文件連被檢索到的機會都沒有。
留言