混合檢索和重排
從零寫一個 BM25 關鍵詞檢索,用 RRF 把它和向量檢索融合,先把中文問題改寫成英文檢索詞,最後加一個重排模型。每一步都用同一套 20 道題量化效果。
- 約 50 分鐘
- 難度:進階
- 實測:2026-09-14 deepseek-flash,multilingual-e5-small,bge-reranker-base
程式碼和執行結果保留原樣(簡體中文),與實際執行時完全一致。
上一課的向量檢索,20 道題只找對了一半多。問題歸結起來有兩個:它對"Digest"、"NO_PROXY"這種精確的名字不敏感;中文問題匹配英文文件本身就難。
這一課依次加上三樣東西:關鍵詞檢索、查詢改寫、重排。每加一樣,都用上一課的 evaluate 跑同一套 20 道題,看數字到底變了多少。結論先說:最大的提升來自一個你可能想不到的地方。
關鍵詞檢索:BM25
搜尋引擎在向量檢索流行之前,用的一直是關鍵詞檢索:問題裡的詞在文件裡出現得越多、越集中,文件的得分越高。其中最常用的演算法叫 BM25。它比"數一數出現了幾次"聰明在兩個地方:
- 稀有的詞更重要。"the"幾乎每一塊都有,出現了也說明不了什麼;"DigestAuth"只在一兩塊裡出現,一旦匹配上,就是很強的訊號。這個權重叫 IDF(逆文件頻率)。
- 詞頻的作用有上限。一個詞在一塊裡出現 10 次,不代表比出現 2 次相關 5 倍。BM25 讓得分隨詞頻增長得越來越慢。同時,它會懲罰特別長的塊,因為長塊裡本來就更容易出現各種詞。
從零寫一個:
import math
import re
from collections import Counter
def tokenize(text):
# 英文单词和代码标识符按单词切(转成小写),中文按单个汉字切
return re.findall(r"[a-z0-9_]+|[一-鿿]", text.lower())
class BM25:
def __init__(self, chunks, k1=1.5, b=0.75):
self.chunks = chunks
self.docs = [tokenize(text) for _, text in chunks]
self.avg_len = sum(len(d) for d in self.docs) / len(self.docs)
self.tf = [Counter(d) for d in self.docs]
df = Counter(word for d in self.docs for word in set(d))
n = len(self.docs)
# 越少的块里出现的词,越能说明问题,权重越高
self.idf = {w: math.log(1 + (n - c + 0.5) / (c + 0.5)) for w, c in df.items()}
self.k1, self.b = k1, b
def score(self, query_words, i):
tf, length = self.tf[i], len(self.docs[i])
s = 0.0
for w in query_words:
if w in tf:
# 词频越高分越高,但增长越来越慢;块越长,同样的词频得分越低
s += self.idf[w] * tf[w] * (self.k1 + 1) / (tf[w] + self.k1 * (1 - self.b + self.b * length / self.avg_len))
return s
def search(self, query, k=5):
words = tokenize(query)
scores = [(self.score(words, i), i) for i in range(len(self.docs))]
scores.sort(reverse=True)
return [(s, *self.chunks[i]) for s, i in scores[:k]]
k1 控制詞頻的作用多快飽和,b 控制對長塊的懲罰力度,1.5 和 0.75 是常用的預設值。這個實現每次都要把所有塊算一遍分,196 塊完全沒問題;塊多到幾十萬時,要用倒排索引只計算包含查詢詞的塊,Elasticsearch 這類搜尋引擎做的就是這件事。現成的 Python 實現也有,比如 rank_bm25 包。
直接用中文問題搜:很差
BM25(原问题) 第 1 名 15% 前 3 名 30% 前 5 名 35% MRR 0.229
比向量檢索(前 5 名 65%)還差得多。原因很簡單:問題是中文的,文件是英文的,詞根本對不上。"伺服器要求 Digest 認證怎麼辦?"裡只有"digest"一個英文詞能匹配上,其他漢字在英文文件裡一個都沒有。
查詢改寫
既然詞對不上,那就讓它們對上:先讓大模型把中文問題改寫成英文的檢索詞,再拿去檢索。
def rewrite(question):
"""把中文问题改写成英文检索词。httpx 的文档是英文的,这样关键词才能对上。"""
if question not in rewrites:
response = client.chat.completions.create(
model=MODEL,
messages=[{"role": "user", "content": (
"把下面这个关于 Python 库 httpx 的问题,改写成用于搜索 httpx 英文文档的检索词。"
"输出一行英文,包含问题的英文翻译,以及文档里可能出现的参数名、类名、术语。不要解释。\n\n" + question)}],
extra_body={"thinking": {"type": "disabled"}},
)
rewrites[question] = response.choices[0].message.content.strip()
CACHE.write_text(json.dumps(rewrites, ensure_ascii=False, indent=2))
return rewrites[question]
提示詞裡除了"翻譯成英文",還要求"文件裡可能出現的參數名、類名、術語"。一個改寫的例子:
服务器要求 Digest 认证怎么办? → httpx Digest authentication server requires digest auth how to use DigestAuth parameter class terms
模型不僅翻譯了,還猜出了 httpx 裡對應的類名 DigestAuth。這正是模型擅長的事:它知道 httpx 大致長什麼樣,能把使用者的口語化問題,翻譯成文件裡會用的專業詞彙。
改寫結果快取在 rewrites.json 裡,同一個問題只花一次錢。實際應用中,每個使用者問題都要多一次模型呼叫,大約多幾百毫秒和不到 0.0001 美元。
融合:RRF
向量檢索和關鍵詞檢索各有所長:一個抓大意,一個抓精確的詞。能不能兩個一起用?
難點在於兩者的分數沒法直接相加:向量檢索的分數是 0 到 1 之間的相似度,BM25 的分數可能是十幾、幾十。常用的辦法是不看分數,只看排名,叫 RRF(Reciprocal Rank Fusion,倒數排名融合):
def rrf(result_lists, k=5, c=60):
"""倒数排名融合:一个块在每个列表里排第 r 名,就得 1/(c+r) 分,把各列表的分数加起来。"""
scores, items = Counter(), {}
for results in result_lists:
for rank, (_, file, text) in enumerate(results, 1):
scores[(file, text)] += 1 / (c + rank)
items[(file, text)] = (file, text)
return [(s, *items[key]) for key, s in scores.most_common(k)]
一個塊在兩個列表裡都排得靠前,得分就高;只在一個列表裡出現,得分就低一些。c=60 是原始論文裡的常用值,它讓排名第 1 和第 2 的差距不至於太大。用的時候,每個檢索方法各取前 20 個,融合後取前 5 個。
結果
code/04-rag/hybrid.py 把這些組合全部跑了一遍,向量檢索用的是上一課效果更好的 multilingual-e5-small:
向量(原问题) 第 1 名 30% 前 3 名 50% 前 5 名 65% MRR 0.418
BM25(原问题) 第 1 名 15% 前 3 名 30% 前 5 名 35% MRR 0.229
向量(改写后) 第 1 名 55% 前 3 名 90% 前 5 名 95% MRR 0.718
BM25(改写后) 第 1 名 70% 前 3 名 95% 前 5 名 100% MRR 0.804
混合 RRF(改写后) 第 1 名 60% 前 3 名 90% 前 5 名 100% MRR 0.772
查詢改寫是最大的功臣。同樣是 BM25,前 5 名命中率從 35% 跳到 100%;同樣是向量檢索,從 65% 升到 95%。一次便宜的模型呼叫,比換什麼檢索演算法都管用。在我們這個"中文問題、英文文件"的場景下,這一步幾乎是必需的。即使問題和文件是同一種語言,把口語化的問題改寫成文件的用詞,通常也有幫助。
改寫之後,BM25 比向量檢索更好。技術文件裡充滿了參數名、類名,改寫後的檢索詞裡恰好有這些詞,關鍵詞檢索一下就能對上。這和很多人"向量檢索更先進"的印象不同。
混合檢索在這組題上沒有更好。RRF 融合後前 5 名命中率 100%,和 BM25 一樣,但 MRR 反而從 0.804 降到了 0.772。向量檢索排得不好的那些結果,把一些 BM25 排在第 1 的正確答案擠到了後面。
這不是說混合檢索沒用。在別的資料上,比如使用者的問題很口語、文件裡沒有對應的關鍵詞時,向量檢索的作用會大得多。我想說的是:不要因為某種方法聽起來更先進就用它,要用評估資料來決定。20 道題太少,這個差異可能只是隨機波動;換成你自己的資料,結論可能完全不同。
重排
向量檢索是先把問題和文件分別變成向量,再比較向量。問題和文件在計算時互相"看不見",這很快,可以提前算好所有文件的向量,但比較粗糙。
重排模型(reranker)換了一種做法:把問題和一個文件塊拼在一起交給模型,讓模型同時讀兩者,直接給出一個相關性的分數。這樣判斷得準得多,但每一對都要算一次,沒法提前算好,慢得多。
所以通常分兩步:先用快的方法(向量、BM25、混合)粗選出 20 塊,再用重排模型給這 20 塊精確打分,重新排序,取前 5 塊。
我用的是 BAAI 開源的 bge-reranker-base,支援中英文,下載下來 1.1GB,CPU 就能跑:
from sentence_transformers import CrossEncoder
reranker = CrossEncoder("BAAI/bge-reranker-base", max_length=512)
def reranked(question, k=5, use_rewrite=False):
pool = candidates(question, 20)
query = rewrite(question) if use_rewrite else question
scores = reranker.predict([(query, text) for _, _, text in pool])
order = sorted(range(len(pool)), key=lambda i: -scores[i])
return [(float(scores[i]), pool[i][1], pool[i][2]) for i in order[:k]]
candidates 就是上面的"改寫 + 混合 RRF",取前 20 塊。結果(code/04-rag/rerank.py):
混合 RRF(改写后) 第 1 名 60% 前 3 名 90% 前 5 名 100% MRR 0.772 (20 题用时 0.2 秒)
混合 + 重排(用原中文问题) 第 1 名 45% 前 3 名 85% 前 5 名 90% MRR 0.643 (20 题用时 20.1 秒)
没找到:怎么让请求走 HTTP 代理?(应在 advanced/proxies.md)→ 第 1 名 advanced/proxies.md
没找到:有些域名不想走代理,环境变量怎么设置?(应在 environment_variables.md)→ 第 1 名 environment_variables.md
混合 + 重排(用改写后的问题) 第 1 名 85% 前 3 名 95% 前 5 名 100% MRR 0.912 (20 题用时 20.0 秒)
用改寫後的英文問題重排,第 1 名命中率從 60% 升到 85%,MRR 從 0.772 升到 0.912,這是所有組合裡最好的。對 RAG 來說,第 1 名對不對很重要:模型對放在最前面的資料往往最重視,而且你可以因此少放幾塊,省錢也減少干擾。
用原來的中文問題重排反而變差了。bge-reranker-base 雖然支援中英文,但中文問題配英文文件,它的判斷沒那麼準。有意思的是兩道"沒找到"的題,第 1 名其實就在正確的檔案裡,只是不包含那個關鍵詞,說明它找到了大致正確的位置,但沒挑中最準確的那一塊。
代價是時間:20 道題用了 20 秒,平均每題 1 秒,都花在 CPU 上給 20 個候選塊打分。有 GPU 會快很多,也可以減少候選塊的數量,或者使用重排的 API 服務。要不要加重排,看你的應用能不能接受這多出來的一秒。
總結一下各步的作用
| 做法 | 前 5 名命中 | MRR | 額外代價 |
|---|---|---|---|
| 向量檢索 | 65% | 0.418 | 無 |
| 加上查詢改寫 | 95% | 0.718 | 每個問題多一次模型呼叫 |
| 改寫 + BM25 | 100% | 0.804 | 無 |
| 改寫 + 混合 RRF | 100% | 0.772 | 無 |
| 改寫 + 混合 + 重排 | 100% | 0.912 | 每個問題多約 1 秒(CPU) |
在你的專案裡,建議按這個順序嘗試:先做最簡單的一種檢索,建立評估集;然後試查詢改寫;再試混合;最後考慮重排。每一步都看數字,沒有提升就不加。
練習
- 把 BM25 的
k1改成 0.5 和 3,b改成 0 和 1,看看"BM25(改寫後)"的結果怎麼變。 - 修改
rewrite的提示詞,只要求翻譯、不要求補充參數名和類名,重新執行(記得先刪掉rewrites.json)。前 5 名命中率降了多少? - 在
rrf裡給兩個檢索方法不同的權重,比如 BM25 的分數乘以 2,看看能不能讓混合檢索超過單獨的 BM25。 - 把重排的候選數量從 20 改成 10,看看準確率和用時怎麼變。
自測
1. BM25 為什麼要給稀有的詞更高的權重?
幾乎每塊都有的詞(比如 "the"、"httpx")匹配上了也區分不出哪塊更相關;只在少數塊裡出現的詞(比如 "DigestAuth"),一旦匹配就能強烈說明這塊和問題相關。IDF 就是用來衡量一個詞有多稀有的。
2. 為什麼不能直接把向量檢索和 BM25 的分數加起來?RRF 是怎麼解決的?
兩者的分數範圍完全不同,向量相似度在 0 到 1 之間,BM25 分數可能是幾十,直接相加時 BM25 會佔絕對主導。RRF 不用原始分數,只看每個結果在各自列表裡的排名,按 1/(c+排名) 計分再相加,兩種方法的排名就能公平地合在一起。
3. 重排模型比向量檢索準,為什麼不直接用重排模型檢索所有的塊?
重排模型要把問題和每一個塊拼在一起計算一次,沒法提前算好。塊多的時候太慢了,196 塊就要算 196 次,幾十萬塊完全不現實。所以先用快的方法粗選出幾十個候選,再用重排模型精排。