1. 为什么召回之后还需要一道“精排”工序
做过RAG(检索增强生成)的人大概都有过这种体验:向量库明明返回了Top-10文档,肉眼扫一遍却发现真正能回答问题的只有两三条,剩下的要么是语义相近但答非所问,要么是同一段话被切成了好几个chunk反复出现。把这一堆东西直接塞给大模型,结果就是答案里混进噪声、上下文被稀释,甚至模型被误导着编出一本正经的胡话。
这个问题的根源在于召回阶段和排序阶段的目标根本不一样。召回(Retrieval)追求的是“别漏”,要在海量文档里快速圈出一个候选集,所以它必须快、必须粗。而排序(Ranking)追求的是“别错”,要在小候选集里精确判断哪条最相关,所以它可以慢、可以细。用同一套机制同时干这两件事,本身就是拧巴的。
Ch09这一章要解决的,就是召回之后的两道关键工序:重排序(Rerank)和去冗余(MMR)。前者负责把候选文档按真实相关性重新打分排序,后者负责把内容高度重复的文档剔除掉,保证最终喂给大模型的上下文既精准又多样。这两个环节配合起来,能把RAG系统的答案质量往上抬一个明显的台阶。
这篇文章适合谁看?如果你已经搭过一个能跑通的RAG demo,但发现答案质量不稳定、经常答偏或者啰嗦重复,那这篇就是给你准备的。如果你还在纠结向量库怎么选、embedding怎么调,建议先把召回那部分打扎实再来看这里。全文会围绕Reranker的选型与部署、Cross-Encoder和Bi-Encoder的本质区别、MMR的数学直觉与参数调优、以及本地用llama.cpp跑reranker的实操细节展开,尽量把每个“为什么”都讲透。
2. Bi-Encoder和Cross-Encoder:两种打分范式的本质差异
要理解Reranker为什么有效,得先搞清楚它和召回阶段用的模型到底差在哪。这不是“模型大小”的差别,而是计算范式的差别。
2.1 Bi-Encoder:把句子压成向量再比距离
召回阶段主流用的是Bi-Encoder(双塔编码器)。它的工作方式是:query单独过一遍编码器得到一个向量,document也单独过一遍编码器得到一个向量,然后算这两个向量的余弦相似度或点积。
这种设计的核心优势是document向量可以离线预计算。你有100万篇文档,提前全部编码好存进向量库,查询时只需要编码query这一个向量,然后做一次近似最近邻搜索(ANN)就行。响应时间可以做到毫秒级,这是它能支撑大规模召回的根本原因。
但代价也很明显:query和document在编码过程中从未见过对方。模型只能各自把语义压缩进一个固定维度的向量里,这个压缩过程必然有信息损失。更麻烦的是,两个向量之间的相似度是一个很粗的信号,它衡量的是“整体语义接近程度”,而不是“这段文字是否精确回答了这个问题”。所以你会看到很多语义相近但实际无用的文档被召回上来。
2.2 Cross-Encoder:让query和document“面对面”交互
Cross-Encoder(交叉编码器)走的是完全不同的路子。它把query和document拼接成一个序列,一起送进模型,让注意力机制在两者之间充分交互,最后输出一个相关性分数。
输入格式:[CLS] query [SEP] document [SEP] 输出:一个标量分数,表示相关性因为query的每个token都能直接attend到document的每个token,模型可以捕捉到非常细粒度的匹配信号——某个关键词是否真的对应、否定词有没有搞反、时间条件是否满足等等。这就是为什么Cross-Encoder的打分精度远高于Bi-Encoder。
代价是无法预计算。每来一个query,你都得把它和每个候选document重新拼一遍、重新过一遍模型。100个候选文档就是100次前向推理。这就是为什么Cross-Encoder只能用在召回之后的小候选集上,而不能用来做全库检索。
2.3 两阶段架构的分工逻辑
把两者串起来,就形成了RAG系统的经典两阶段架构:
| 阶段 | 模型类型 | 候选规模 | 核心目标 | 延迟要求 |
|---|---|---|---|---|
| 召回 | Bi-Encoder | 百万级到千万级 | 高召回率,别漏 | 毫秒级 |
| 重排 | Cross-Encoder | 几十到几百 | 高精度,别错 | 百毫秒级 |
这个分工的本质是用召回阶段的“宽”换取重排阶段的“准”。召回先把范围从百万缩到几十,重排再在这几十条里精挑细选。如果跳过重排直接拿召回结果用,就等于放弃了精度;如果想让召回阶段就精确,那Bi-Encoder的范式决定了它做不到。
提示:不要试图用更大的Bi-Encoder模型来替代Reranker。模型再大,双塔范式下query和document不交互这个结构性缺陷依然存在,精度提升有天花板。
3. Reranker选型:从BGE到本地llama.cpp的落地路径
理解了原理,接下来就是选型。Reranker模型这两年更新很快,选错了要么效果不行,要么部署成本高得离谱。
3.1 主流Reranker模型横向对比
目前社区里用得比较多的Reranker大致分几类:
- BGE-Reranker系列(如bge-reranker-base、bge-reranker-large、bge-reranker-v2-m3):智源出品,中文场景表现扎实,v2-m3支持多语言,是很多中文RAG项目的默认选择。
- Cohere Rerank:商业API,效果稳定,但按调用量计费,数据要出本地,适合对成本不敏感、追求省事的场景。
- Jina Reranker:有开源版本也有API,多语言支持不错。
- Cross-Encoder类通用模型(如ms-marco-MiniLM系列):英文场景经典选择,轻量快速。
选型时我一般看三个维度:语言匹配度、延迟预算、部署约束。中文项目优先考虑BGE系列;如果对延迟极其敏感且候选集不大,MiniLM这类小模型够用;如果有数据不出本地的硬要求,那就得走本地部署路线。
3.2 用llama.cpp跑Reranker的可行性
这里要重点说一下llama.cpp这条路线。很多人以为llama.cpp只能跑生成式LLM,其实它通过GGUF格式也支持一部分编码器类模型,包括某些Reranker。它的价值在于:把模型量化后跑在CPU或消费级显卡上,不需要昂贵的GPU集群。
为什么这件事重要?因为很多中小团队根本没有独立的推理GPU,Reranker如果非要GPU部署,成本直接劝退。llama.cpp的量化能力(Q4、Q5、Q8等)能把模型体积压到原来的四分之一甚至更低,在普通机器上就能跑起来。
不过要泼一盆冷水:llama.cpp对Reranker的支持不如对生成模型那么成熟。不是所有Reranker都有现成的GGUF版本,你可能需要自己转换。而且Cross-Encoder的输出是一个分数而不是token序列,llama.cpp的接口需要确认是否支持这种输出模式。实操中更稳妥的做法是:用sentence-transformers或FlagEmbedding加载Reranker做推理,如果一定要本地化,再考虑ONNX Runtime或量化方案。
注意:网上有些教程把llama.cpp跑Reranker说得过于简单,实际转换GGUF时经常遇到算子不支持、输出层对不上的问题。建议先用Python原生方案跑通效果,确认Reranker对你的场景确实有提升,再考虑工程化部署。
3.3 部署形态的取舍
Reranker的部署形态直接决定了系统架构。常见的有三种:
- 同进程加载:Reranker和主服务跑在同一个Python进程里,用sentence-transformers直接加载。优点是简单,缺点是模型占内存、推理阻塞主线程。
- 独立推理服务:把Reranker封装成一个HTTP或gRPC服务,主服务通过接口调用。优点是解耦、可独立扩缩容,缺点是多一跳网络开销。
- 批处理推理:把候选文档攒成batch一次性送进模型,充分利用GPU并行能力。这是延迟和吞吐的最优解,但需要处理好batch size和显存的平衡。
我的经验是:候选集小于50条时,同进程加载最省事;超过50条或者QPS较高时,独立服务+批处理更稳。批处理的batch size不要贪大,Reranker的输入长度通常是query+document拼接,很容易撑爆显存,建议从8或16起步往上试。
4. MMR去冗余:让上下文既相关又不重复
Reranker解决了“排序准不准”的问题,但没解决“内容重不重复”的问题。文档切分时如果chunk有重叠,或者同一主题在多处出现,Reranker很可能把好几条高度相似的文档都排到前面。这时候就需要MMR出场了。
4.1 冗余是怎么产生的
先搞清楚冗余的来源,才能对症下药。常见的冗余场景有三种:
- chunk重叠:切分时为了保持语义完整,相邻chunk会保留一部分重叠文本,导致同一段话出现两次。
- 多文档同源:同一份内容被转载到多个文档,或者FAQ里同一个问题有多种问法。
- 主题聚集:某个主题在语料里被反复讨论,召回时自然会把一批相似文档都捞上来。
这些冗余如果直接喂给大模型,轻则浪费上下文窗口,重则让模型误以为某个信息特别重要而过度强调,或者在不同表述间反复横跳。
4.2 MMR的数学直觉
MMR全称Maximal Marginal Relevance(最大边际相关性),它的核心思想可以用一句话概括:在“与query相关”和“与已选文档不重复”之间找平衡。
公式长这样:
MMR = argmax [ λ · Sim(d_i, query) - (1-λ) · max Sim(d_i, d_j) ] d_i∈R\S d_j∈S拆开看:
Sim(d_i, query):候选文档和query的相关性,越高越好。max Sim(d_i, d_j):候选文档和已选集合S中任意文档的最大相似度,越高说明越冗余,要惩罚。λ:平衡系数,控制相关性和多样性的权重。
当λ=1时,MMR退化成纯相关性排序,和普通Reranker没区别;当λ=0时,完全追求多样性,可能选出和query不太相关的文档。实际调参一般在0.5到0.8之间。
4.3 λ参数的调优经验
λ怎么定?没有万能值,但有几个经验规律:
- 问答类任务(如客服、知识库问答):λ偏高,0.7-0.8。因为用户要的是精确答案,多样性是次要的。
- 综述类任务(如“帮我总结XX领域的进展”):λ偏低,0.5-0.6。需要覆盖不同角度,避免信息同质化。
- 多跳推理任务:λ中等,0.6-0.7。既要相关,又要保证不同跳的信息不重复。
调λ最靠谱的方法是准备一批标注好的query-文档对,用网格搜索看哪个λ在评测集上NDCG或MRR最高。凭感觉调容易翻车。
4.4 MMR的计算开销与优化
MMR的朴素实现是贪心算法:每次从剩余候选里选一个MMR分数最高的,选完更新已选集合,再选下一个。时间复杂度是O(n²·d),n是候选数,d是向量维度。候选集几十条时完全无感,上百条时就要注意了。
优化思路有两个:一是先用Reranker把候选集砍到20-30条再做MMR,减少n;二是预计算文档间的相似度矩阵,避免重复计算。如果候选集固定,相似度矩阵可以缓存复用。
提示:MMR用的是文档向量算相似度,这里的向量可以是Bi-Encoder的embedding,不需要再跑一遍Cross-Encoder。所以MMR的额外开销主要是向量相似度计算,比Reranker轻得多。
5. 把Reranker和MMR串进RAG流水线
原理和选型都清楚了,现在看怎么把它们组装进完整的RAG流程。这一节给出可落地的代码结构和参数配置。
5.1 完整流水线的四个阶段
一个带重排和去冗余的RAG检索流程长这样:
用户query ↓ [1] Bi-Encoder召回 → Top-K候选(K=50~100) ↓ [2] Cross-Encoder重排 → 按相关性重新打分排序 ↓ [3] 截断Top-N(N=20~30) ↓ [4] MMR去冗余 → 选出最终M条(M=5~10) ↓ 拼装上下文 → 送LLM生成注意第3步的截断很关键。Reranker虽然准,但候选太多时MMR的O(n²)开销会上去,而且低分文档参与MMR也没意义。先用Reranker砍一刀,再让MMR在高质量候选里挑,效率最高。
5.2 关键参数配置表
| 参数 | 含义 | 推荐值 | 调整依据 |
|---|---|---|---|
| recall_top_k | 召回候选数 | 50-100 | 召回率高就调小,低就调大 |
| rerank_top_n | 重排后保留数 | 20-30 | 看MMR开销和上下文预算 |
| mmr_lambda | 相关性/多样性平衡 | 0.6-0.8 | 任务类型决定 |
| final_top_m | 最终上下文条数 | 5-10 | 看LLM上下文窗口 |
| batch_size | Reranker批大小 | 8-32 | 看显存 |
5.3 代码骨架
用FlagEmbedding加载BGE-Reranker的典型写法:
from FlagEmbedding import FlagReranker from sklearn.metrics.pairwise import cosine_similarity import numpy as np # 加载Reranker reranker = FlagReranker('BAAI/bge-reranker-v2-m3', use_fp16=True) def rerank(query, candidates, top_n=30): pairs = [[query, doc] for doc in candidates] scores = reranker.compute_score(pairs, normalize=True) ranked = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True) return ranked[:top_n] def mmr(query_vec, doc_vecs, docs, lambda_=0.7, top_m=5): selected = [] candidates = list(range(len(docs))) while len(selected) < top_m and candidates: mmr_scores = [] for i in candidates: rel = cosine_similarity([query_vec], [doc_vecs[i]])[0][0] if selected: redundancy = max( cosine_similarity([doc_vecs[i]], [doc_vecs[j]])[0][0] for j in selected ) else: redundancy = 0 mmr_scores.append(lambda_ * rel - (1 - lambda_) * redundancy) best = candidates[int(np.argmax(mmr_scores))] selected.append(best) candidates.remove(best) return [docs[i] for i in selected]这段代码里有个细节值得说:compute_score的normalize=True会把分数归一化到0-1,方便后续和MMR的相似度做加权。如果不归一化,Reranker的原始logits范围可能很大,和余弦相似度量纲不一致,λ的调节就失去意义了。
5.4 批处理与显存控制
Reranker推理时,compute_score内部会自动做batch,但batch size默认可能偏大。如果显存吃紧,可以手动控制:
scores = reranker.compute_score(pairs, normalize=True, batch_size=16)实测下来,bge-reranker-v2-m3在16GB显存上,batch_size=32、输入长度512时基本能跑满。如果输入更长(比如document有1000+token),batch_size要降到8甚至4。显存溢出往往不是模型太大,而是输入序列太长,这一点在调参时容易被忽略。
6. 实测中的坑与调优心得
前面讲的都是“应该怎么做”,这一节讲“实际做的时候会撞上什么”。这些坑大多是我自己踩过或者看别人踩过的,文档里通常不会写。
6.1 Reranker不是万能药
最常见的误区是:以为加了Reranker效果就一定提升。实际上有两种情况Reranker帮不上忙:
一是召回阶段就没捞到正确文档。Reranker只能在候选集里排序,候选集里没有正确答案,它变不出来。这时候要回头优化召回,而不是怪Reranker。
二是query本身模糊。比如用户问“这个怎么弄”,没有上下文的话Reranker也判断不了哪条文档相关。这种情况要在query改写或对话历史补全上做文章。
判断Reranker是否有效,最直接的方法是对比加Reranker前后的NDCG@10。如果提升不明显,先检查召回率,再检查Reranker模型是否匹配你的语言和领域。
6.2 MMR的相似度用哪个向量
MMR计算文档间相似度时,用哪套向量?有人用Bi-Encoder的embedding,有人想用Cross-Encoder的分数。必须用Bi-Encoder的embedding,因为Cross-Encoder输出的是query-document的标量分数,不是document的向量表示,没法算文档间相似度。
还有一个细节:query和document的相似度,以及document之间的相似度,最好用同一套embedding模型算,保证量纲一致。混用不同模型的向量,余弦相似度的可比性会出问题。
6.3 λ调参的陷阱
调λ时容易犯两个错:
- 在训练集上调λ:过拟合,换一批query就失效。一定要留出独立的验证集。
- 只看相关性指标:λ调高相关性上去了,但多样性下来了,最终答案可能信息不全。要同时看相关性和覆盖度指标。
我的做法是画一条λ-NDCG曲线,找到拐点。通常λ从0.5往上调,NDCG先升后降,峰值附近就是较优值。
6.4 延迟预算的分配
Reranker和MMR都会增加延迟。一个典型的分配是:
| 阶段 | 延迟占比 | 说明 |
|---|---|---|
| 召回 | 10-20% | ANN搜索,很快 |
| 重排 | 50-60% | Cross-Encoder推理,大头 |
| MMR | 10-20% | 向量相似度计算 |
| 生成 | 20-30% | LLM生成 |
如果总延迟超标,优先优化重排阶段:减小候选集、换更小的Reranker、上批处理或量化。MMR的优化空间相对小,因为它本身就不重。
6.5 本地部署的现实考量
回到llama.cpp这条线。如果你的场景确实需要本地化部署Reranker,我的建议是:
- 先确认模型有GGUF版本。BGE-Reranker的部分版本社区有转换好的GGUF,直接下载省事。
- 没有现成GGUF就自己转,用llama.cpp的convert脚本,但要做好遇到算子不支持的心理准备。
- 量化等级选Q5或Q8。Reranker对精度敏感,Q4量化后分数排序可能失真,Q5是精度和体积的较好平衡。
- 实测对比量化前后的排序一致性。拿一批query跑一遍,看Top-5排序是否一致,不一致率高就说明量化过头了。
注意:llama.cpp跑Reranker在Windows 7这类老系统上兼容性是个大问题,很多新版本的依赖根本不支持。如果生产环境是老系统,建议老老实实用Python原生方案或者独立服务,别在这上面耗时间。
7. 一个可复现的调优案例
光讲理论不够,这里给一个具体的调优过程,你可以照着复现。
假设场景是企业内部知识库问答,语料约5000篇文档,切分成约3万个chunk。初始方案是纯向量召回Top-10直接送LLM,发现答案经常遗漏关键信息,且同一段话重复出现。
第一步:加Reranker。召回Top-50,用bge-reranker-v2-m3重排,取Top-10。结果:答案准确性明显提升,但重复问题依然存在,因为Top-10里有3-4条是同一段内容的不同chunk。
第二步:加MMR。在Reranker的Top-30上做MMR,λ=0.7,取Top-5。结果:重复消失,上下文覆盖了不同角度,答案更全面。
第三步:调参。λ从0.5试到0.9,发现0.7时NDCG@5最高。final_top_m从5试到10,发现5条已经够用,再多反而引入噪声。
第四步:压延迟。Reranker批大小从8调到32,延迟从800ms降到350ms。MMR的相似度矩阵预计算缓存,延迟从120ms降到40ms。总检索延迟控制在500ms以内。
这个案例的关键在于每一步只改一个变量,观察指标变化。一次性把Reranker、MMR、参数全改了,出了问题根本不知道是哪儿的锅。
8. 关于效果评估的一点个人做法
最后聊聊怎么判断这套东西到底有没有用。我一般看三个层次的指标:
检索层:NDCG@10、MRR、Recall@50。这些指标反映的是排序质量,加Reranker后NDCG应该有明显提升。
答案层:人工评估或LLM-as-judge,看答案的准确性、完整性、无重复性。这是最终用户能感知到的。
系统层:P99延迟、显存占用、QPS。效果再好,延迟爆炸也上不了线。
评估集怎么来?我的做法是从真实用户query里采样200-500条,人工标注每条query对应的正确文档。这个标注成本不低,但比拍脑袋调参靠谱得多。标注时注意覆盖不同类型的问题:事实型、推理型、对比型、开放型,保证评估集的代表性。
还有个小技巧:用A/B测试对比加Reranker+MMR前后的用户满意度。线上真实反馈比离线指标更有说服力,有时候离线指标涨了但用户无感,那就得反思指标选得对不对。
这套重排加去冗余的组合,我在几个项目里用下来,答案准确率的提升普遍在15%-30%之间,重复率下降更明显。但前提是召回阶段别太拉胯,召回率低于70%的话,后面再怎么精排也是巧妇难为无米之炊。所以如果你现在效果不理想,先回头看看召回,再考虑上Reranker和MMR。