RAG 實戰筆記

混合檢索深入拆解:dense、sparse、RRF 與 Reranker 的理論與實作

2026-08-09 #RAG#混合檢索#Qdrant#Reranker#bge-m3

「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/611/62 差距很小,避免任何一路的榜首直接輾壓全場,讓融合真的是「合議」而不是「一言堂」

三、Qdrant 實作:一次請求做完兩路檢索與融合

理論講完,看真實程式碼。Qdrant 的 query_points + prefetch 讓整個混合檢索在資料庫端一次完成,不用自己撈兩份結果回來手工融合:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
result = client.query_points(
collection_name=settings.collection_name,
prefetch=[
models.Prefetch( # 第一路:dense 語意檢索
query=q["dense"],
using="dense",
limit=settings.dense_top_k, # 各撈 20
),
models.Prefetch( # 第二路:sparse 關鍵字檢索
query=models.SparseVector(
indices=q["sparse_indices"],
values=q["sparse_values"],
),
using="sparse",
limit=settings.sparse_top_k,
),
],
query=models.FusionQuery(fusion=models.Fusion.RRF), # 資料庫端 RRF 融合
limit=limit, # 開 rerank 時廣撈 30 筆候選
with_payload=True,
)

幾個實作細節:

  • 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
2
3
4
5
6
7
問題
└─ bge-m3 編碼(一次推理,dense + sparse 雙輸出)
├─ dense 檢索 top-20 ─┐
└─ sparse 檢索 top-20 ─┤
├─ RRF 融合(名次合議)→ 候選 30
└─ bge-reranker-v2-m3 交叉編碼精排 → top-15
└─ 進 prompt,強制標來源生成

每一層都在補上一層的不足:sparse 補 dense 的字面盲區,RRF 公平合議兩路結果,reranker 補雙塔架構的精度極限。**沒有一個環節是銀彈,疊起來才是。**第一篇提過的實測結果——檢索命中率 100%——就是這條管線加上表格感知切塊共同達成的。

下一篇處理企業場景最硬的需求:權限怎麼在檢索層強制執行——讓沒權限的文件連被檢索到的機會都沒有

Creative Commons 姓名標示 非商業性

本文採用 CC BY-NC 4.0 授權

歡迎轉載與引用,請標明出處(混合檢索深入拆解:dense、sparse、RRF 與 Reranker 的理論與實作 — EmptyWu);禁止用於商業用途。

商業使用或合作提案,歡迎來信洽談:[email protected]

留言
分享

留言