模組 04 · 第 4 課

混合檢索和重排

從零寫一個 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)

在你的專案裡,建議按這個順序嘗試:先做最簡單的一種檢索,建立評估集;然後試查詢改寫;再試混合;最後考慮重排。每一步都看數字,沒有提升就不加。

練習

  1. 把 BM25 的 k1 改成 0.5 和 3,b 改成 0 和 1,看看"BM25(改寫後)"的結果怎麼變。
  2. 修改 rewrite 的提示詞,只要求翻譯、不要求補充參數名和類名,重新執行(記得先刪掉 rewrites.json)。前 5 名命中率降了多少?
  3. rrf 裡給兩個檢索方法不同的權重,比如 BM25 的分數乘以 2,看看能不能讓混合檢索超過單獨的 BM25。
  4. 把重排的候選數量從 20 改成 10,看看準確率和用時怎麼變。

自測

1. BM25 為什麼要給稀有的詞更高的權重?

幾乎每塊都有的詞(比如 "the"、"httpx")匹配上了也區分不出哪塊更相關;只在少數塊裡出現的詞(比如 "DigestAuth"),一旦匹配就能強烈說明這塊和問題相關。IDF 就是用來衡量一個詞有多稀有的。

2. 為什麼不能直接把向量檢索和 BM25 的分數加起來?RRF 是怎麼解決的?

兩者的分數範圍完全不同,向量相似度在 0 到 1 之間,BM25 分數可能是幾十,直接相加時 BM25 會佔絕對主導。RRF 不用原始分數,只看每個結果在各自列表裡的排名,按 1/(c+排名) 計分再相加,兩種方法的排名就能公平地合在一起。

3. 重排模型比向量檢索準,為什麼不直接用重排模型檢索所有的塊?

重排模型要把問題和每一個塊拼在一起計算一次,沒法提前算好。塊多的時候太慢了,196 塊就要算 196 次,幾十萬塊完全不現實。所以先用快的方法粗選出幾十個候選,再用重排模型精排。