Day35|混合检索+重排序:RAG 召回率翻倍的正确姿势

发布时间:2026/7/23 21:48:53
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_embeddingsTrue) q_emb model.encode(query, normalize_embeddingsTrue) 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 02BM25 怎么补——字面匹配的古典武器向量检索是深度学习时代的新贵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, k11.5, b0.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, k1k1, bb) def search(self, query, top_k5): 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, k11.2, b0.75) for i, s in retriever.search(Qwen2.5-7B 支持多少上下文, top_k3): print(f{docs[i]} (bm25{s:.3f}))这里埋着一个新手最容易踩的坑分词器。中文你要是图省事用list(doc)做字符级切分BM25 的效果会差到怀疑人生——因为上下文被拆成上下文三个字跟文本上传里的文也能匹配上全是噪声。中文老老实实上 jieba 或别的分词器。调参上k1和b这两个旋钮k1 太大2.0高频词会主导排序那种关键词堆砌的水文档反而排前面。b 太小0.5长文档占便宜因为它字多、词频天然高。默认的k11.5, b0.75适配绝大多数场景。除非你的文档长度方差特别大有的三行、有的三千字才需要动手调b。PART 03RRF 融合——把两条路的结果合到一起现在手里有两条召回路向量检索一份排名BM25 一份排名。问题来了——怎么把这两份榜单合并成一份最直觉的做法是分数相加。但这里有个大坑两条路的分数根本不在一个量纲上。向量检索是余弦相似度规规矩矩落在 0 到 1 之间BM25 分数可以飙到几十。你直接相加等于让 BM25 一个人说了算向量检索那点零点几的贡献直接被淹没。RRFReciprocal 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, k11.5, b0.75, rrf_k60): self.docs docs self.emb_model embedding_model self.doc_embs embedding_model.encode(docs, normalize_embeddingsTrue) self.bm25 BM25Retriever(docs, k1k1, bb) self.rrf_k rrf_k def search(self, query, top_k5): # 路 1向量检索拿到一份排名 q_emb self.emb_model.encode(query, normalize_embeddingsTrue) vec_scores self.doc_embs q_emb vec_ranks np.argsort(vec_scores)[::-1] # 路 2BM25拿到另一份排名 bm25_ranks [i for i, _ in self.bm25.search(query, top_klen(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(), keylambda x: x[1], reverseTrue)[:top_k] return ranked # [(doc_idx, rrf_score), ...] hybrid HybridRetriever(docs, model, rrf_k60) for i, s in hybrid.search(Qwen2.5-7B 支持多少上下文, top_k3): print(f{docs[i]} (rrf{s:.4f}))RRF 好用但它也不是万能的。它本质是一场民主投票只要两条路都把某篇文档排在前面它就稳稳上榜。可万一两条路意见严重分歧——向量检索排第 1 的文档BM25 把它排到了第 50——RRF 会各打五十大板把它塞到中间位置。真正相关的文档就有可能这么被中庸掉。这个投票会拉平尖子生的问题正是下一节重排序要收拾的烂摊子。调参上就一个rrf_kk 越小越突出头部排名靠前的文档权重被放大。k 越大排名的影响被抹平对尾部更友好。默认 60 是论文推荐值一般不用动。只有当你发现某条路的 top1 老是被莫名压下去时才把 k 调小试试。还有个实用小技巧两条路的召回数量可以不对称。向量检索取 top20BM25 反正快取 top50最后 RRF 融合完再截断到 top10。既保了召回广度又不拖效率。PART 04Cross-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_modelBAAI/bge-reranker-base): self.hybrid HybridRetriever(docs, embedding_model) self.reranker CrossEncoder(rerank_model, devicecuda) # 一定要上 GPU def search(self, query, top_k5, rerank_top_n20): # 第一阶段混合检索粗排取 top_n 送进精排 coarse self.hybrid.search(query, top_krerank_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), keylambda x: x[1], reverseTrue)[:top_k] return reranked engine HybridRetrieverWithRerank(docs, model) for i, s in engine.search(Qwen2.5-7B 支持多少上下文, top_k3, rerank_top_n5): print(f{docs[i]} (rerank{s:.4f}))几个绕不开的坑我按踩的频率排给你rerank_top_n 别贪大。Cross-encoder 单条大概几到十几毫秒rerank_top_n50就意味着每个 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_k3, rerank_top_n5) print(fQuery: {query}\n) for i, s in results: print(f[{s:.4f}] {docs[i]})一句话回顾整条链路query 进来 → 向量检索 BM25 双路召回 → RRF 融合出粗排 → Cross-encoder 精排 → 输出 top_k。前三步保召回率该捞的都捞回来后一步保精确率把最对的顶到最前。调参速查表参数默认值往哪调什么时候调BM25k11.5短文档库调大1.8-2.0长文档库调小1.0-1.2文档长度方差大时BM25b0.75不想惩罚长文档调小0.5想严格惩罚调大0.9长文档占比高时调小RRFk60想突出头部调小30想照顾尾部调大100某条路 top1 老被压时调小rerank_top_n20-50求快调小求全调大按 QPS 和召回率权衡这张表建议直接截图存下来上手调参时对着看。四个真实踩坑坑 1BM25 索引和检索的分词器没对齐召回全是 0。建索引时用 jieba检索时手滑用了字符级切分两边 token 对不上BM25 分数齐刷刷归零。索引和查询必须同一个分词器这是铁律。坑 2Cross-encoder 忘了指定 GPU慢到怀疑人生。rerank_top_n30单个 query 硬生生跑了 2 秒多。一看模型默默加载在 CPU 上了。显式写CrossEncoder(model_name, devicecuda)瞬间提速几十倍。坑 3某条路返回空RRF 悄悄退化。query 是纯英文BM25 却挂着中文分词器分词出来匹配不上返回空。RRF 一看只有一条路有结果直接退化成纯向量检索——你以为在用混合检索其实早就瘸了一条腿。融合前先检查两条路是否都有返回空了要 fallback。坑 4文档库更新了BM25 索引没重建。向量库支持增量插入很多人以为 BM25 也一样。不是。BM25Okapi是一次性根据全量语料算好 IDF 的你新加的文档不重建索引它压根不在计算范围里新文档永远检索不到。文档库一变记得重建 BM25。结尾召回率不是玄学是工程回到开头那个折磨无数人的问题——内容明明在文档里怎么就是检索不到。现在你知道答案了大概率不是模型不行是你只给了它一条腿走路。向量检索漏掉的字面精确匹配BM25 补两条路的分歧RRF 融融合后残留的噪声Cross-encoder 精修。一层补一层召回率就是这么从六成一路抬到八成五的。这中间没有任何黑魔法。RAG 的召回率从来不是靠祈祷模型变聪明得来的是靠一层一层的工程手段抠出来的。别再指望换个更大的 embedding 一键解决所有问题了。真正拉开差距的是你愿不愿意在检索这一层把该补的补上、该融的融好、该精修的精修到位。互动时间你的 RAG 系统现在是纯向量检索还是已经上了混合检索有没有踩过内容明明在库里就是搜不到的坑评论区聊聊我挑典型的下次拆。下一篇我们往更硬的方向走一步——聊聊 GraphRAG当文档之间存在复杂的实体关系、需要多跳推理时光靠向量和关键词就不够了得请知识图谱出场。— END —苦猿 · 帮普通人把 AI 学进简历