最近在搞一个内部知识库问答系统,效果一直不温不火。明明向量检索、关键词召回都接上了,测试时问了句“超时时间在哪里改”,结果召回的列表里全是系统架构文档、参数命名规范,真正的配置说明文档倒是也出现了,但排在第八位。LLM一生成,直接被前面几条“结构性相似”的文档带偏,答得稀碎。查了几天数据,最后靠引入Rerank重排序才把这个问题压下去,整个过程挺有意思,写出来给做检索、RAG的同行参考。
先说结论:召回阶段本质上是在“大海捞针”,它负责从海量数据里快速挑出一批可能相关的候选,这个环节不追求精准,追求的是“别漏”。而Rerank重排序要做的是另一件事——对这批候选做精细复核,把真正对用户有用的排到前面。这两者配合好了,检索精度才能谈得上“拉满”。下面从问题根源、原理、落地到踩坑,一条条说清楚。
1. 召回一千条,精排死一片:先搞懂“噪音”从哪来
1.1 我遇到的真实场景:文档都在“沾边”,但没有一条真正回答问题
我那个知识库里有几百篇文档,覆盖配置指南、架构设计、常见问题、接口文档等等。测试期间我统计了一下,用户问“XX模块的缓存策略”,系统召回的前十篇里能出现三四篇都提到“缓存”这个词的文档,但真正讲了“XX模块当前缓存策略是什么、怎么调整”的那篇,往往才排在中游。
更迷惑的是,这些文档的向量相似度分数都不低,有0.75、0.78,看起来都挺相关的。但实际答案就是不对。问题出在哪?出在“语义相似”和“任务相关”是两回事。向量模型会认为“缓存”这个词在很多文档里都高频出现,于是把架构总览、历史演进、技术选型这些文档一并拉进来。它们确实“沾边”,但不解决问题。
1.2 召回阶段的半成品本质:粗粒度匹配的先天缺陷
检索系统的召回阶段,常用的方式无非几种:BM25这类稀疏检索按词频和逆文档频率打分,向量检索按语义向量之间的余弦相似度排序。它们的共性问题是:打分过程相对“粗糙”。
拿双塔向量模型来说,它把query编码成一个向量,把文档编码成另一个向量,然后算个夹角余弦。这个过程中,query和文档的每个词并没有真正“见面”互相审视,而是各自浓缩成一段语义向量后做整体的距离计算。这种方式擅长抓语义相近、主题一致的大方向,但很难判断“这个文档是否真的回答了query里的诉求”。
我打个比方:召回阶段像是相亲网站的初筛,看年龄、看地区、看学历,能筛出一批“条件接近”的人,但两个人都说要找“性格合适”,这个标签满足不了判断;Rerank重排序则像是”见面聊一场“,聊下来才知道眼缘、价值观、处世方式是否真的匹配。检索链路里,Rerank干的就是这个“聊一场”的活。
1.3 评估指标被“平均分”掩盖的灾难现场
在这个项目初期,我用Recall@10评估召回质量,数值很漂亮,能到90%以上,意思是正确答案基本都在这十条里。但这个指标完全掩盖了“正确答案排太靠后”的灾难。
举个例子,正确答案排在第九位,LLM对九篇文档做上下文拼接生成答案时,前几篇内容权重更高,答案最终很可能被不相关的高相似文档干扰。更严重的是,用户直接看结果列表的场景里,排在第九位的答案基本等于没被看见。这时候前面那些0.78相似度的噪音文档,就成了实实在在的垃圾信息。
所以单纯优化召回阶段,只会让“候选里包含正确项”的概率上升,却解决不了“正确项被埋没”的问题。这才是引入Rerank重排序的根本动机。
2. Rerank不是“重新排一遍”,而是换了一种打分方式
2.1 双塔向量检索为什么在精排上失灵
很多刚接触Rerank的人以为它就是“再跑一遍检索模型,按新分数重排”,这是误解。它的核心差异在于模型结构。
向量检索用的双塔模型,query侧塔和doc侧塔通常是分开编码的。为了检索效率,所有文档可以提前离线算好向量存进库里,query来的时候只算一个向量,然后做近邻检索。这种设计牺牲了query和doc之间的细粒度交互——两个塔互不干涉,最后才比一下。
而Rerank用的交叉编码器结构(Cross-Encoder),会把query和候选文档拼接成一段完整文本,一起送入模型做多层Transformer交互。模型可以逐词地盯着“查询里的‘在哪里改’”和“文档里的‘需要修改application.yaml中的timeout参数’”之间的对齐关系,这是一个真正“读题+对照”的过程。结果就是:它也许会把那些只是泛泛提到“超时”的架构文档打低分,而把直击提问点的那篇配置文档拉上来。
2.2 Cross-Encoder交互式建模:把“相关”两个字真正揉进模型
我再用相亲例子扩展一下。双塔模型给每个人的资料打分时,只看“学历、身高、城市”这种粗粒度标签;Cross-Encoder则把两边资料都摊在桌上,逐项比对,甚至能捕捉“这个人说的‘爱旅游’和另一个人说的‘自驾游’是不是同一种偏好”这种深层关联。放到检索场景,就是模型能理解“query里的意图”和“文档里的信息”是否构成答题关系。
Rerank的输出是一个分值,多数实现会输出0到1之间的相关分,或者一个可排序的logit。我们一般不拿这个分值做跨查询的比较,只用来对同一个query下的候选文档排序。这一步得到的顺序,才是真正提供给LLM或者用户列表的最终顺序。
2.3 Rerank模型怎么选择:通用型还是垂直微调
实操中选Rerank模型,我能给的实用建议是:
- 先用社区常见的开源通用重排模型跑通链路,看是否解决“正确答案被埋没”的问题。
- 如果领域词汇很强(比如医疗、法律、代码),要选在这些语料上微调过的版本,或干脆基于业务数据微调一个。
- 闭源Rerank API的优点是省事,但要注意数据出域合规和调用成本;开源模型则方便私有化部署。
我目前这个项目里先用通用开源模型试跑,领域属于企业软件配置,术语不算太偏,效果已经足够。如果哪天出现专业名词密集的文档,我会优先考虑收集一批业务查询-文档对做定向微调,而不是继续换更大的通用模型。
下面是我整理的一个简单选型思路表:
| 场景 | 推荐方向 | 理由 |
|---|---|---|
| 快速验证、通用领域 | 开源通用重排模型 | 零成本、部署快、效果提升明显 |
| 垂直术语密集 | 领域微调重排模型 | 通用模型对专有词理解不足 |
| 数据隐私敏感 | 私有化部署开源模型 | 避免数据外流 |
| 超低延迟场景 | 蒸馏版/小型化重排模型 | 在精度损失可接受范围内提速 |
3. 一通操作的完整落地过程:从召回结果到精排输出
3.1 整体链路改造:在哪里插入Rerank
我改造后的链路是这个样子的:
query进来以后,先同时走关键词召回和向量召回两条路,分别取回一批候选,合并去重,得到一个候选集合。这一步我通常会取50到100条,为了后续精排留足“货架空间”。然后把这些候选按Rerank的要求组装,批量传给重排接口,拿到每个候选的相关分,从高到低排好,真正取前5到10条交给LLM生成答案或直接展示给用户。
代码上大概长这样(示意,用的标准库和占位接口):
import requests import time def rerank(query: str, candidates: list[dict], top_n: int = 8): # candidates: [{"doc_id": "xxx", "text": "文档正文"}...] payload = { "query": query, "documents": [ {"doc_id": c["doc_id"], "text": c["text"]} for c in candidates ], "top_n": top_n } start = time.time() resp = requests.post( "http://your-rerank-service/api/rerank", json=payload, timeout=10 ) resp.raise_for_status() results = resp.json()["results"] # 已按相关分倒序 print(f"rerank cost: {time.time() - start:.3f}s") return results有几个点我得提醒:第一,候选条数别贪多,Rerank是Transformer推理,每条文档都要过一次模型,50条耗时通常已经到几百毫秒甚至秒级,100条以上服务端压力很大。第二,真正生产环境建议把Rerank服务独立部署,和主检索服务解耦,避免精排抖动拖垮主链路。
3.2 候选集规模与截断策略:太少没用,太多太慢
候选集数量直接决定Rerank能发挥多大作用。如果召回阶段本来只返回Top5,那Rerank只能在五条里调顺序,空间有限——万一正确的答案根本不在Top5,再怎么重排也白搭。我的经验是:召回阶段宁可多取,Rerank阶段再“裁”。
比较稳的做法是召回阶段取Top50,Rerank后保留Top5或Top10。如果你的检索库里文档条数特别多(百万级以上),可以考虑把候选集扩到100到200,但要评估耗时。凡是召回正确率很低的场景,先回头优化召回,而不是指望Rerank凭空造出正确答案。
截断策略上也要注意。很多文档一长就是几千字,Rerank模型输入长度有限,通常不能满足完整送入。我的做法是按“尽量保留关键段落”的方式做截断而不是硬取前512个字符。简单方案是先按段落切分,为每个段落生成短摘要,并用BM25粗算一下哪几个段落和query最相关,然后把这几段拼起来送Rerank。
3.3 接Rerank时的数据格式与批处理细节
Rerank接口一般要求传入query和一个候选文本数组。这里有个效率关键:不要写一个循环每条单独请求,要一次性把所有候选文本塞进一个请求里做batch推理。模型内部会把这一批候选都拼上query并行计算,比循环请求快很多。
同时要注意文本清洗:把文档里的HTML标签、多余换行、乱码字符去掉再送入模型,以免干扰打分。我实测过,一段含大量残缺HTML标签的文档,重排分数会被明显拉低,因为模型“看到”的序列被无关字符污染了。
组装数据时建议保留doc_id和原文本,Rerank之后不是直接丢给下游就完事,还要把命中的高亮片段、对应URL这类元信息一并传递,方便LLM输出时给出出处,也方便用户点击查看原文。
4. 效果验证:别只看“感觉准了”,要用数字说话
4.1 评测集怎么建:从历史日志里挖真样本
上Rerank之前,一定要有一套可复现的评测集,否则你没法确认到底是Rerank的功劳,还是数据清洗的功劳,还是心理作用。
我当时的做法是:从检索系统的历史日志里随机抽出近200条用户真实query,对每条query人工标注出“哪个文档是正确答案”,标注不了的(比如问题本身太模糊)直接剔除。这样得到了大约150条query-标准答案对。
这套评测集不追求多大,关键是真实、可复现。每次调整链路,我就在同一批query上跑,对比排序变化。否则今天觉得A方案好,明天觉得B方案好,没有基准就是在拍脑袋。
4.2 常用的几个指标和它们的含义
精排场景我主要看这几个指标:
- Recall@K:正确答案是否出现在前K条里。这个指标主要反映“召回有没有漏”,对Rerank来说通常不变化(候选集没变)。
- MRR(Mean Reciprocal Rank):第一个正确答案排在哪个位置,取倒数再平均。这个是核心指标,直接衡量“正确答案有没有被顶到前面”。
- nDCG:这个指标更进一步,适合标注有多级相关性的情况,能反映整个排序的质量,而不是只看第一个正确答案。
- 人工抽样反馈:真实用户点没点、满意不满意。这个最直接,但要有用户数据沉淀。
MRR是最贴合“精排优化”目标的指标。因为Rerank的终极目的就是让正确答案从第八位上升到第一位,MRR对“排序是否提前”非常敏感。顺便说一句,MRR的数值上升哪怕0.1,在真实体验里的差别都极其明显。
4.3 实测数据对比:一个真实的提升幅度案例
我拿手头这套知识库做了一轮对比。召回阶段都是“BM25 + 向量检索混合取Top50”,区别只在有没有Rerank重排序。
| 指标 | 未接Rerank(按召回分数排序) | 接入Rerank(重排后取Top10) |
|---|---|---|
| Recall@10 | 100% | 100% |
| MRR@10 | 0.183 | 0.862 |
| nDCG@10 | 0.314 | 0.906 |
| 第一个正确答案的平均排名 | 5.7 | 1.4 |
数据变化最明显的就是MRR,从0.183涨到0.862。这意味着以前正确答案平均排在第六位左右,现在平均排进前两名,不少query的正确答案直接排到了第一位。用户侧的直观感受是:结果列表第一条终于像“人话”了,LLM也更容易基于正确文档生成答案。
这个案例说明一件事:Rerank重排序解决的不是“有没有正确答案”的问题,而是“正确答案能不能被看见”的问题。绝大多数检索系统的候选库里其实是有正确答案的,只是被排序质量压得不见天日。
5. 比参数更蠢的坑:Rerank实战中的五个隐蔽问题
5.1 候选集太小,Rerank回天乏术
我最初为了省时间,把召回数量压到15条,然后直接Rerank。结果发现MRR提升有限,一查日志,不少query的正确答案压根没进这15条候选里,比如正确答案的表述方式和query差异较大,但意思相关,向量召回排在二十多位。
解决方案就是回去调召回:加大召回量,必要时同时开关键词和向量两条路。这一步说明Rerank不是万能药,它的上限是召回候选集的上限。别在前面省事,后面费事。
5.2 分块粒度不对,精排神仙难救
很多RAG系统会把长文档切成很多小块再建索引。如果块切得太碎,比如每块只有一两句话,Rerank模型拿到手的信息量太少,难以判断整体相关性;如果块切得太整,比如把整篇架构文档作为一条候选,相关的那句话被淹没在大量无关文字里,模型也容易打低分。
我调整了好几轮才找到平衡点:按章节为边界切块,每块确保语义相对完整,通常300到500字左右比较合适。对于长文,我会额外做“关键段落抽取”后拼接再送Rerank,这样既控制输入长度又不丢有效信息。
5.3 分数体系不一致,阈值设了等于没设
Rerank给出的分值看着像个概率,但它并不是所有模型、所有查询统一的“标准答案”。有的模型天然倾向打出0.9以上高分,有的模型普遍在0.3到0.6之间浮动。如果不理解这一点,你按某个模型设定的0.7阈值直接拿去过滤别的模型的输出,就会误伤大量有效结果。
我的建议是:别硬设全局阈值,先画出查询分数分布图再定。具体操作是收集一批线上query的重排分数,看召回正确文档时分数集中在哪个区间,再定阈值。如果分数分布跨度过大,干脆不用阈值,只按排序取TopN,阈值仅作为弱过滤条件。
5.4 速度与成本的权衡:每条都精排的账单你算过吗
Rerank的耗时和成本是跑不掉的。我实测,CPU上跑一个小型开源重排模型,单条候选可能要80到150毫秒,50条候选就是好几秒,完全不能用于线上实时检索。后来我把候选集控制在30到50条,并用GPU或带推理加速的部署方式,单批请求压缩到150毫秒左右才算是可以接受。
如果你用的是外部Rerank API,还要算成本,每千条候选的价格要仔细算账。我见过不少团队接入Rerank时拍脑袋全量精排所有候选,月底看到账单直接傻眼。务实的做法是:设定候选数上限、加缓存、对低优先级的query跳过Rerank,只对核心场景全量精排。
5.5 领域错配:开源模型跑在专业资料上的窘境
通用Rerank模型在通用语料上表现不错,但一旦遇到专业领域,比如代码、医药、法律条文的查询,它可能因为不认识领域词汇而打不出合理分数。我测试过一些开源重排模型,对一段满是函数名的代码文档打分时,明显不如对日常文本敏感。
当时队友建议直接换个更大的通用模型,我反而建议先做一个领域微调实验:用历史检索日志里被用户点击过的“query-文档对”当正样本,随机采一些不相关的文档当负样本,微调一个百来步的小模型。效果比直接换大模型划算得多。记着一条:领域错配时,用业务数据微调比盲目堆参数更管用。
6. 结合混合检索的动态权重:Rerank的进阶玩法
6.1 混合召回为什么和Rerank是天生一对
现在的主流方案里,召回阶段很少只用一种算法。BM25擅长精确词匹配,比如query里有“timeout”它就能精准捞起包含“timeout”的文档;向量检索擅长理解语义,比如query说“改超时”也能匹配到包含“timeout”的文档。把两者结合起来去重之后,候选集的多样性明显上升。
但混合召回也有个问题:两种算法的分数体系不一致,怎么合并排序?有人直接相加,有人加权,最终排序质量参差不齐。这时引入Rerank的优势就体现出来了——它不看召回分数体系,只看“query和文档真实相关度”,等于帮你重开一局,把前面的排序问题彻底抹掉了。
6.2 用Rerank分数反哺召回阶段的权重
Rerank打分还可以用来调优召回策略。做过混合检索的都知道,BM25结果和向量结果的权重很难调。常规做法是网格搜索,效率低还容易过拟合。我的做法是:
抽出一批线上query,先跑召回得到多路结果,再用Rerank对每条候选打分,把高分候选视为“真正相关”的正样本,低分视为负样本,然后统计BM25落在高分组和低分组的比例,根据这个比例迭代调整权重。
这一步本质上是用“精排的裁判视角”来指导“粗排的资源分配”。权重不再靠拍脑袋,而是有真实的相关性反馈做依据。我在项目里跑了几轮之后,混合召回的Top50命中正确文档的比例比之前高出一截,连Rerank自身的候选质量也水涨船高了。
6.3 我的收尾建议:小步快跑,先在低频场景试点
最后聊点个人体会。Rerank重排序抽象成一句话就是:用慢而准的方法,重新审视快而粗的结果。它听起来很简单,实际接入时牵扯到召回数量、分块策略、模型选型、延迟预算、评测方法等一系列决定,每一步都有各自的坑。
我的建议是别指望一次性上线全部吃掉,先挑一个低频但核心的场景慢慢跑,比如后台的相似文档推荐,或者用户主动触发的诊断式搜索。在这个场景里把评测集、链路、监控都跑顺了,再推广到主搜索、RAG问答这些高并发场景。我看到不少团队一上来就把所有流量都切到Rerank,结果延迟翻了好几倍,用户体验先崩了,再好的精度也白搭。
对我自己来说,这次改动的最大收获不是MRR涨了多少,而是建立了一个“召回负责全、精排负责准”的清晰认知。数据链路上每一层的目标都被拆得明明白白,后续任何一环出了问题,都知道该去查谁。这个思路,比某一版模型的具体分数值走得更远。