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