Day35|混合检索+重排序:RAG 召回率翻倍的正确姿势
苦猿的大模型日记 · Day35 · 混合检索与重排序实战-帮普通人把AI学进简历系列
前言:明明文档里有,怎么就是检索不到
先给你三个数字,感受一下差距。
同一个知识库,同一批问题。纯向量检索,能把正确答案捞进前十的比例,大概六成出头。加上一路BM25 混合检索,这个数字能窜到接近八成。再过一遍重排序(reranking),稳稳站上八成五以上。
从六成到八成五,中间没换模型,没扩数据,没加显卡。只是把检索这一层,从"一条腿走路"改成了"两条腿加一次精修"。
这就是今天要讲的事。
我见过太多人,RAG 上线后卡在同一个玄学问题上:"这内容明明白纸黑字写在文档里,模型怎么就是答不出来?"翻遍 prompt,换遍 embedding,最后发现——不是模型笨,是那段文档压根没被检索出来,根本没进模型的视野。
问题的根子,在很多人对 RAG 的理解上:RAG = Embedding + 向量库。这个等式,少了一半。
向量检索有它天生的盲区。它擅长"意思相近",却对"字面精确"很不敏感。你问"Qwen2.5-7B 支持多少上下文",它可能兴高采烈地给你捞回一堆讲"模型上下文窗口"的泛泛之谈,偏偏把那条精确写着"Qwen2.5-7B 支持 128K"的文档漏在了后面。
今天这一篇,我把生产级检索链路拆开揉碎讲清楚:向量检索为什么会漏,BM25 怎么补,RRF 怎么把两条路的结果融到一起,Cross-encoder 重排序为什么能再提一档,以及每一步怎么调参、怎么避坑。
PART 01:向量检索为什么会漏——语义相似 ≠ 字面匹配
向量检索的看家本领是语义泛化。你搜"怎么让模型跑得更快",它能帮你召回讲"推理加速""量化""KV Cache"的文档,哪怕这些词你一个都没提。这是它的强项。
但强项的背面就是弱项。当你的 query 里带着专有名词、产品型号、代码片段、日期、数字,向量检索容易把这些关键信息给"泛化"掉。
为什么?因为 embedding 模型的训练目标,是"把意思相近的句子在向量空间里拉到一起",而不是"把字面完全一致的词拉到一起"。在它眼里,"Qwen2.5-7B"和"Qwen2-7B"长得几乎一样,语义也几乎一样——都是"某个 70 亿参数的模型"。可对你来说,这俩差了一个版本,上下文长度从 128K 掉到 32K,答案天差地别。
几类场景,向量检索特别容易翻车:
- 专有名词:产品型号、人名、地名、术语缩写。字面差一点,语义看着都一样。
- 数字和日期:"2023 年 Q3"和"2024 年 Q3",在 embedding 眼里可能都约等于"某个季度"。
- 代码片段:函数名、变量名,这些是精确符号,最忌讳被泛化。
- 长尾低频词:训练集里没怎么见过的词,embedding 只能给它一个模糊的向量,谁跟它都不太像,也跟谁都有点像。
空口无凭,跑段代码看现象。同一个 query,我们对比向量检索和 BM25 各自捞回来什么:
from sentence_transformers import SentenceTransformer from rank_bm25 import BM25Okapi import numpy as np # 文档库:5 条关于模型上下文长度的文档 docs = [ "Qwen2.5-7B 支持 128K 上下文窗口", "Qwen2-7B 支持 32K 上下文窗口", "Llama3-8B 支持 8K 上下文窗口", "GPT-4 Turbo 支持 128K 上下文窗口", "模型的上下文窗口越长,处理长文档的能力越强", ] query = "Qwen2.5-7B 支持多少上下文" # —— 向量检索 —— model = SentenceTransformer("BAAI/bge-small-zh-v1.5") doc_embs = model.encode(docs, normalize_embeddings=True) q_emb = model.encode(query, normalize_embeddings=True) vec_scores = doc_embs @ q_emb print("向量检索 Top3:") for i in np.argsort(vec_scores)[::-1][:3]: print(f" {docs[i]} (cos={vec_scores[i]:.3f})") # —— BM25 检索 ——(这里先用字符级分词演示,中文正式场景要用 jieba,见 PART 02) tokenized = [list(d) for d in docs] bm25 = BM25Okapi(tokenized) bm25_scores = bm25.get_scores(list(query)) print("\nBM25 检索 Top3:") for i in np.argsort(bm25_scores)[::-1][:3]: print(f" {docs[i]} (bm25={bm25_scores[i]:.3f})")跑出来你多半会看到一个很典型的现象:向量检索容易把那条最泛的第 5 句"上下文窗口越长……"排到很靠前,因为它跟 query 的整体语义(讲上下文)最贴。而 BM25 会一巴掌拍在第 1 条上——因为"Qwen2.5-7B"这个精确字符串它认得。
一句话记住这一节:向量检索懂"你想问什么",但不一定咬得住"你到底问的是哪一个"。
PART 02:BM25 怎么补——字面匹配的"古典武器"
向量检索是深度学习时代的新贵,BM25 是搜索引擎年代传下来的老兵。别看它老,它专治向量检索那点毛病。
BM25 本质是TF-IDF 的升级版,靠词频统计打分。数学公式我不展开,讲直觉你就懂了,它就看三件事:
- TF(词频):query 里的词,在某篇文档里出现得越多,这篇分越高。
- IDF(逆文档频率):query 里的词,在整个文档库里越罕见,它的权重越高。
- 文档长度惩罚:怕长文档靠"字多"占便宜,按长度做个归一化。
第二条 IDF 是关键。为什么 BM25 对专有名词特别灵?因为"Qwen2.5-7B"这种词在文档库里出现频率极低,IDF 权重被拉得老高。一旦某篇文档里出现了它,分数立刻蹿上去。这恰好补上了向量检索"把低频专名泛化掉"的坑。
当然 BM25 也有它咬死的短板:它完全不懂同义词、不会泛化。在它眼里,"GPU"和"显卡"是两个毫不相干的词,"LLM"和"大语言模型"也八竿子打不着。你用"显卡"去搜一篇通篇写"GPU"的文档,BM25 直接给零分。
看出来了吗?向量检索的强项正好是 BM25 的弱项,BM25 的强项正好是向量检索的弱项。这种互补,才是要把它俩捏一起用的根本原因。
写一个能调参的 BM25 检索器,中文记得换 jieba 分词:
from rank_bm25 import BM25Okapi import jieba import numpy as np class BM25Retriever: def __init__(self, docs, k1=1.5, b=0.75): """ k1: 词频饱和度。越大越看重高频词(默认 1.5) b : 文档长度惩罚。0=不惩罚,1=完全惩罚(默认 0.75) """ self.docs = docs self.tokenized = [list(jieba.cut(d)) for d in docs] # 中文必须分词 self.bm25 = BM25Okapi(self.tokenized, k1=k1, b=b) def search(self, query, top_k=5): tok_q = list(jieba.cut(query)) scores = self.bm25.get_scores(tok_q) idx = np.argsort(scores)[::-1][:top_k] return [(i, scores[i]) for i in idx] retriever = BM25Retriever(docs, k1=1.2, b=0.75) for i, s in retriever.search("Qwen2.5-7B 支持多少上下文", top_k=3): print(f"{docs[i]} (bm25={s:.3f})")这里埋着一个新手最容易踩的坑:分词器。中文你要是图省事用list(doc)做字符级切分,BM25 的效果会差到怀疑人生——因为"上下文"被拆成"上""下""文"三个字,跟"文本上传"里的"文"也能匹配上,全是噪声。中文老老实实上 jieba 或别的分词器。
调参上,k1和b这两个旋钮:
- k1 太大(>2.0):高频词会主导排序,那种"关键词堆砌"的水文档反而排前面。
- b 太小(<0.5):长文档占便宜,因为它字多、词频天然高。
默认的k1=1.5, b=0.75适配绝大多数场景。除非你的文档长度方差特别大(有的三行、有的三千字),才需要动手调b。
PART 03:RRF 融合——把两条路的结果合到一起
现在手里有两条召回路:向量检索一份排名,BM25 一份排名。问题来了——怎么把这两份榜单合并成一份?
最直觉的做法是"分数相加"。但这里有个大坑:两条路的分数根本不在一个量纲上。向量检索是余弦相似度,规规矩矩落在 0 到 1 之间;BM25 分数可以飙到几十。你直接相加,等于让 BM25 一个人说了算,向量检索那点零点几的贡献直接被淹没。
RRF(Reciprocal Rank Fusion,倒数排名融合)就是来解决这个问题的。它的思路极简单,简单到有点反直觉:
不看分数,只看排名。
不管你这条路给的原始分是 0.9 还是 90,我都不管。我只看你把这篇文档排在了第几位,然后取排名的倒数来加权:
RRF_score(doc) = Σ 1 / (k + rank_i)rank_i是这篇文档在第 i 条路里的排名,k是个平滑常数,论文里默认取 60。因为只看排名不看绝对分,量纲问题天然就没了——排名本身就是归一化的。这也是 RRF 不用训练、几乎零调参就能上线的原因。
把它接进一个完整的混合检索器:
class HybridRetriever: def __init__(self, docs, embedding_model, k1=1.5, b=0.75, rrf_k=60): self.docs = docs self.emb_model = embedding_model self.doc_embs = embedding_model.encode(docs, normalize_embeddings=True) self.bm25 = BM25Retriever(docs, k1=k1, b=b) self.rrf_k = rrf_k def search(self, query, top_k=5): # 路 1:向量检索,拿到一份排名 q_emb = self.emb_model.encode(query, normalize_embeddings=True) vec_scores = self.doc_embs @ q_emb vec_ranks = np.argsort(vec_scores)[::-1] # 路 2:BM25,拿到另一份排名 bm25_ranks = [i for i, _ in self.bm25.search(query, top_k=len(self.docs))] # RRF 融合:只用排名,不用原始分 rrf = {} for rank, i in enumerate(vec_ranks): rrf[i] = rrf.get(i, 0) + 1 / (self.rrf_k + rank + 1) for rank, i in enumerate(bm25_ranks): rrf[i] = rrf.get(i, 0) + 1 / (self.rrf_k + rank + 1) ranked = sorted(rrf.items(), key=lambda x: x[1], reverse=True)[:top_k] return ranked # [(doc_idx, rrf_score), ...] hybrid = HybridRetriever(docs, model, rrf_k=60) for i, s in hybrid.search("Qwen2.5-7B 支持多少上下文", top_k=3): print(f"{docs[i]} (rrf={s:.4f})")RRF 好用,但它也不是万能的。它本质是一场"民主投票":只要两条路都把某篇文档排在前面,它就稳稳上榜。可万一两条路意见严重分歧——向量检索排第 1 的文档,BM25 把它排到了第 50——RRF 会各打五十大板,把它塞到中间位置。真正相关的文档,就有可能这么被"中庸"掉。
这个"投票会拉平尖子生"的问题,正是下一节重排序要收拾的烂摊子。
调参上就一个rrf_k:
- k 越小:越突出头部,排名靠前的文档权重被放大。
- k 越大:排名的影响被抹平,对尾部更友好。
默认 60 是论文推荐值,一般不用动。只有当你发现某条路的 top1 老是被莫名压下去时,才把 k 调小试试。
还有个实用小技巧:两条路的召回数量可以不对称。向量检索取 top20,BM25 反正快,取 top50,最后 RRF 融合完再截断到 top10。既保了召回广度,又不拖效率。
PART 04:Cross-encoder 重排序——把粗排结果再精修一遍
到这一步,混合检索给了我们一份"粗排"结果,比如 top20。但这 20 条里,仍然掺着沙子——RRF 投票投出来的中庸结果、两条路各自的噪声,都还在里面。
重排序(reranking)就是那道精修工序。它把 query 和每一条候选文档"喂进同一个模型",算出一个精确的相关度分数,再排一遍。
这里要理解一个关键区别:Bi-encoder 与 Cross-encoder。
- Bi-encoder(就是向量检索):query 和 doc分开各自编码成向量,再算余弦相似度。快,但两者在编码的那一刻互不知道对方存在,交互信息全丢了。
- Cross-encoder(重排序用的):把 query 和 doc拼成一句话——
[CLS] query [SEP] doc [SEP]——整个喂进 BERT,让它俩在每一层注意力里充分交互,最后吐出一个相关度分数。慢,但精确得多。
那问题来了,Cross-encoder 这么准,为什么不直接拿它做全库检索?
因为它慢得离谱。Bi-encoder 检索,query 只编码一次,剩下的就是跟库里向量做点积,10 万条文档也就是 10 万次点积,眨眼的事。Cross-encoder 不行,它必须把 (query, doc)成对喂进模型跑前向——10 万条文档就得跑 10 万次完整的 BERT 前向传播,等它算完黄花菜都凉了。
所以工程上是两阶段策略,各取所长:
第一阶段(粗排 / 召回):用混合检索,快速从十万条里筛出 top50。图的是快和全。
第二阶段(精排 / 重排):只对这 50 条上 Cross-encoder,精打细算重新排序。图的是准。
用一个大漏斗先把范围缩到几十条,再用精密仪器在这几十条里挑金子。代码接上去:
from sentence_transformers import CrossEncoder class HybridRetrieverWithRerank: def __init__(self, docs, embedding_model, rerank_model="BAAI/bge-reranker-base"): self.hybrid = HybridRetriever(docs, embedding_model) self.reranker = CrossEncoder(rerank_model, device="cuda") # 一定要上 GPU def search(self, query, top_k=5, rerank_top_n=20): # 第一阶段:混合检索粗排,取 top_n 送进精排 coarse = self.hybrid.search(query, top_k=rerank_top_n) cand_idx = [i for i, _ in coarse] cand_docs = [self.hybrid.docs[i] for i in cand_idx] # 第二阶段:Cross-encoder 精排 pairs = [[query, d] for d in cand_docs] rerank_scores = self.reranker.predict(pairs) reranked = sorted(zip(cand_idx, rerank_scores), key=lambda x: x[1], reverse=True)[:top_k] return reranked engine = HybridRetrieverWithRerank(docs, model) for i, s in engine.search("Qwen2.5-7B 支持多少上下文", top_k=3, rerank_top_n=5): print(f"{docs[i]} (rerank={s:.4f})")几个绕不开的坑,我按踩的频率排给你:
- rerank_top_n 别贪大。Cross-encoder 单条大概几到十几毫秒,
rerank_top_n=50就意味着每个 query 要跑 50 次前向。QPS 一高,它立马变成整条链路的瓶颈。工程上一般20 到 50,够用。 - 模型选型看语言。中文用
BAAI/bge-reranker-base或更强的bge-reranker-large;英文可以用cross-encoder/ms-marco-MiniLM-L6-v2。large 比 base 精度高个两三分,但速度慢一倍,按你的 QPS 和精度要求权衡。 - 显存要留够。Cross-encoder 是个完整的 BERT,几百 MB 起步。如果跟 embedding 模型挤在同一张卡上,上线前务必压测显存,别到高峰期 OOM。
PART 05:完整流程 + 调参清单 + 真实踩坑
前面四节像四个零件,这一节把它们拧成一台能跑的机器,再附上一张调参速查表和几个我真见过的坑。
先看端到端跑通的样子:
from sentence_transformers import SentenceTransformer docs = [ "Qwen2.5-7B 支持 128K 上下文窗口,适合长文档问答", "Qwen2-7B 支持 32K 上下文窗口", "Llama3-8B 支持 8K 上下文窗口", "GPT-4 Turbo 支持 128K 上下文窗口,但推理成本较高", "模型的上下文窗口越长,处理长文档能力越强,但推理速度会变慢", ] embedding_model = SentenceTransformer("BAAI/bge-small-zh-v1.5") engine = HybridRetrieverWithRerank(docs, embedding_model) query = "Qwen2.5-7B 支持多少上下文" results = engine.search(query, top_k=3, rerank_top_n=5) print(f"Query: {query}\n") for i, s in results: print(f"[{s:.4f}] {docs[i]}")一句话回顾整条链路:query 进来 → 向量检索 + BM25 双路召回 → RRF 融合出粗排 → Cross-encoder 精排 → 输出 top_k。前三步保召回率(该捞的都捞回来),后一步保精确率(把最对的顶到最前)。
调参速查表
| 参数 | 默认值 | 往哪调 | 什么时候调 |
|---|---|---|---|
BM25k1 | 1.5 | 短文档库调大(1.8-2.0);长文档库调小(1.0-1.2) | 文档长度方差大时 |
BM25b | 0.75 | 不想惩罚长文档调小(0.5);想严格惩罚调大(0.9) | 长文档占比高时调小 |
RRFk | 60 | 想突出头部调小(30);想照顾尾部调大(100) | 某条路 top1 老被压时调小 |
rerank_top_n | 20-50 | 求快调小;求全调大 | 按 QPS 和召回率权衡 |
这张表建议直接截图存下来,上手调参时对着看。
四个真实踩坑
坑 1:BM25 索引和检索的分词器没对齐,召回全是 0。
建索引时用 jieba,检索时手滑用了字符级切分,两边 token 对不上,BM25 分数齐刷刷归零。索引和查询必须同一个分词器,这是铁律。
坑 2:Cross-encoder 忘了指定 GPU,慢到怀疑人生。rerank_top_n=30,单个 query 硬生生跑了 2 秒多。一看,模型默默加载在 CPU 上了。显式写CrossEncoder(model_name, device="cuda"),瞬间提速几十倍。
坑 3:某条路返回空,RRF 悄悄退化。
query 是纯英文,BM25 却挂着中文分词器,分词出来匹配不上,返回空。RRF 一看只有一条路有结果,直接退化成纯向量检索——你以为在用混合检索,其实早就瘸了一条腿。融合前先检查两条路是否都有返回,空了要 fallback。
坑 4:文档库更新了,BM25 索引没重建。
向量库支持增量插入,很多人以为 BM25 也一样。不是。BM25Okapi是一次性根据全量语料算好 IDF 的,你新加的文档不重建索引,它压根不在计算范围里,新文档永远检索不到。文档库一变,记得重建 BM25。
结尾:召回率不是玄学,是工程
回到开头那个折磨无数人的问题——"内容明明在文档里,怎么就是检索不到"。
现在你知道答案了:大概率不是模型不行,是你只给了它一条腿走路。向量检索漏掉的字面精确匹配,BM25 补;两条路的分歧,RRF 融;融合后残留的噪声,Cross-encoder 精修。一层补一层,召回率就是这么从六成一路抬到八成五的。
这中间没有任何黑魔法。RAG 的召回率,从来不是靠祈祷模型变聪明得来的,是靠一层一层的工程手段抠出来的。
别再指望换个更大的 embedding 一键解决所有问题了。真正拉开差距的,是你愿不愿意在检索这一层,把该补的补上、该融的融好、该精修的精修到位。
互动时间:你的 RAG 系统现在是纯向量检索,还是已经上了混合检索?有没有踩过"内容明明在库里就是搜不到"的坑?评论区聊聊,我挑典型的下次拆。
下一篇,我们往更硬的方向走一步——聊聊 GraphRAG,当文档之间存在复杂的实体关系、需要多跳推理时,光靠向量和关键词就不够了,得请知识图谱出场。
— END —
苦猿 · 帮普通人把 AI 学进简历
