模块 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 次,几十万块完全不现实。所以先用快的方法粗选出几十个候选,再用重排模型精排。