RAG召回与重排实战:从向量检索到混合排序提升问答质量 最近在帮一个问答系统收敛线上badcase折腾了一个多月后发现真正导致回答质量上不去的大部分不是生成模型而是前面那两步召回没捞到该捞的文档重排没把正确的排到前面。这篇是RAG技术深度解析的第四篇前几篇聊了分块策略、向量化、向量库选型这次集中讲讲召回和重排的实战。文章适合两类人一类是RAG管道刚跑通、想提效果的新手另一类是线上效果不稳定、正在排查问题的开发者。我会从选型、参数、工程落地一直讲到踩坑和评估方法全部是实操里验证过的思路没有套话。1. 召回层决定效果上限的第一道关口1.1 为什么召回比生成更值得花时间很多团队做RAG一开始的精力全放在生成侧又是调prompt又是换大模型结果badcase一查根因往往在检索侧该出现的文档压根没被召回生成模型只能对着空荡荡的上下文硬答。你让模型“不知道就说不知道”它可能真说了不知道但用户实际上需要的是一个本来存在却被漏掉的答案。这个道理说起来简单但真要定位到具体问题就需要数据。我在一个项目里粗统计过一段时间内的badcase百分之六十左右是召回环节漏了正确文档百分之二十五是重排没把正确的排到前面真正属于生成模型本身胡说的不到百分之十五。这个比例不一定具有普遍性但它解释了一个事实生成模型的能力上限再高也要看检索侧喂进去什么。RAG整个链路的逻辑是“先找再答”召回决定了答案可能性的上界重排决定这个上界能兑现多少生成只负责把选中的证据整理成自然语言。所以我的建议是先把召回当成一个独立问题来对待。评估模型选得如何、候选数量够不够、多路召回的互补性有没有做起来这些都需要单独测。不要一上来就盯着生成结果调prompt那样只会掩盖检索侧的问题甚至让你误判成是prompt写不好。前几篇已经聊过文档分块和向量化这里得接着往下说一个容易忽略的事实无论你选哪种向量模型把一篇文档编码成向量时语义信息总会有压缩损失。分块粒度、块与块之间的重叠、embedding模型对领域术语的覆盖度都会影响召回效果。所以召回层不是“选一个向量库就完事”而是一套需要反复实验的子系统。1.2 召回方案选型向量召回、BM25和混合检索怎么选召回层最常见的三个方案是向量召回、BM25词法召回、以及两者混合的混合检索。先分别说说它们的适用场景和局限再讲为什么混合往往是更稳的选择。向量召回的基本思路是把query和候选文档都编码成向量再算相似度通常是余弦相似度。它的优势是能处理同义词、口语化表达和语义相近但词面完全不同的情况。比如用户问“怎么重置密码”文档里写的是“修改登录凭证”向量召回能建立这种链接。它的问题也很明显依赖向量模型的语义理解能力对专有名词、产品代号、代码片段这些“词面即答案”的场景反而容易翻车。因为模型更习惯理解自然语义看到“ABC-1024”这种型号很可能把它当作普通文本的组成部分而不是一个必须精确命中的标识符。BM25是经典词法检索它不看语义只看词频和文档词频的加权匹配。好处在于精确、稳定、不需要GPU、服务器上随便跑就能有结果。专有名词、错误码、函数名、配置键这种带明确标识符的query交给BM25非常靠谱。缺点是它理解不了语义改写用户说“登录不上去”文档里全写的是“认证失败”BM25大概率匹配不上。混合检索就是把两者结果合并。常用的合并方式有两种一种是分数加权final_score α * 向量分数 (1 - α) * BM25分数α需要根据实际数据调。另一种是RRFReciprocal Rank Fusion思路不看具体分数只把每条结果在不同召回列表里的排名取倒数再加和公式是score(d) Σ 1/(k rank_i(d))k通常取60。RRF这种做法的好处是绕开了“两类分数量纲不一致”的问题对异常值更鲁棒。我把三种方式的特性整理成一张表方便对照方案核心原理优点缺点典型场景向量召回语义向量相似度理解同义改写、口语化对专名/代码/型号容易失准开放域问答、长尾语义表达BM25词频与逆文档频率加权精确命中词面、零成本无法处理语义改写错误码、函数名、型号检索混合检索向量BM25结果融合两类互补整体召回率更高需要调融合公式和参数线上生产环境主流选择我个人的经验是如果只让我做一个方案我必然选混合检索。向量召回的语义泛化能力和BM25的精确匹配能力是互补的单用任何一路都会在特定query上吃亏。比如一个只问了几个字“一直转圈”的用户向量召回可能找到一堆和loading状态相关的页面BM25却能精准命中包含“白屏”或“转圈”的故障文档反过来用户问“忘了密码咋整”向量召回又明显好过BM25。1.3 召回参数实操TopK、阈值和分块粒度怎么配合确定了方案后接下来就是参数。召回阶段最重要的三个参数是TopK、相似度阈值和分块粒度它们互相影响得放在一起看。TopK决定从检索引擎里捞多少候选给到下游。设太小正确文档可能根本没进候选池设太大候选噪音多重排阶段的计算压力激增。从我实测的经验看向量召回阶段的TopK建议设在50到200之间。如果你用的是混合检索就让向量路和BM25路各返回100条左右再合并去重。这个数量级对下游重排来说是可接受的同时能保证大多数相关文档不掉出候选池。有一些系统会为了追求速度把TopK压到20我觉得在文档库不大时可以接受但如果文档库到了几十万上百万的量级20的候选量很容易让召回率崩掉。相似度阈值是另一个需要认真调的点。很多新手会给向量检索设一个固定score阈值比如0.7低于这个值就不返回。但实际上不同向量模型的分数分布差异极大有的模型大部分合法结果都集中在0.5到0.8之间阈值设0.7会误杀一堆正确答案有的模型分数整体偏高低于0.8的基本都不能用。比较靠谱的做法是抽几百条真实query把正确命中文档的分数分布跑出来再决定阈值。如果你用的是混合检索我甚至不建议在召回阶段设太严格的向量分数阈值而是把过滤压力放到重排之后。分块粒度对召回的影响往往被低估。块切得太小每个向量承载的语义信息太少向量召回时很难匹配到完整意图块切得太大一个块里可能包含多个主题向量表达变得模模糊糊相似度计算也不精准。目前业界常用做法是结合段落结构来切优先按Markdown标题、段落、列表等结构切分再配合固定窗口截断超长部分用滑动窗口控制重叠。每个分块的文本长度中文场景下粗略控制在300到500字左右比较常见但要结合你的文档类型微调。代码文档可以切得更碎政策条款类长文档则更依赖结构切分而不是固定字数。参数调优不是一次完成的我通常的做法是先固定分块策略把TopK调到候选不遗漏再看分数分布定阈值最后调混合检索的融合权重。顺序不能反否则前面步骤引入的不确定因素会污染你对参数的判断。2. 重排层把“差不多”变成“准”2.1 为什么向量检索阶段之后还需要重排一个常见疑问是向量召回本来就是在算相关性为什么还要再重排一次答案是第一阶段检索为了保住效率牺牲了query和文档之间细粒度的交互信号。向量召回本质上是把query编码成一个向量、把文档编码成另一个向量然后算相似度。query和文档之间的词级交互在这个过程里几乎不发生。哪怕你用的是再好的双塔模型它在编码时也是各自独立编码最后只拿两个向量做内积等于把复杂的匹配关系压缩成一个点。这种压缩丢掉的正是类似“文档里哪一句话在直接回应query的哪个点”这种细粒度信息。重排阶段通常采用跨编码器结构Cross-Encoder它会把query和文档拼接成一段文本一起送进模型做全量token交互最后输出一个相关性分数。这等于让模型“读完整段对话再打分”。用生活里的类比来说向量召回像是HR拿关键词筛简历先确保候选人大概在这个池子里重排则像面试官把简历逐行看完判断这人到底合不合适。一个负责“广撒网”一个负责“精挑细选”。所以重排不是可选项而是把检索质量从“大概相关”推到“准确相关”的关键环节。尤其当TopK可能达到几十甚至上百时如果不重排直接把一堆候选拼接进上下文生成模型会被大量无关信息干扰回答质量自然下降。2.2 重排模型选型与工程落地batch处理、分数归一化和降级方案重排模型的选择主要有两个考量效果和速度。跨编码器模型因为要做query和文档的全量交互耗时通常比向量编码高一个数量级。选型时不要只看公开榜单的分数要拿你自己领域的query和文档做小批量验证。重点看三件事一是在你的badcase上是否能纠正召回阶段的错误排序二是模型能处理的最大序列长度够不够文档超长时是截断还是分段处理三是单条推理时延在CPU/GPU环境下能不能满足线上要求。工程上有一个细节特别容易踩坑batch推理。重排模型一次处理一条query和一条文档如果候选有100条就要跑100次前向。如果你每个query只传一条文本去推理GPU利用率上不去时延会非常难看。正确做法是按batch组织输入比如每次把query和8条、16条文档拼成batch一起推理吞吐能快很多。但batch不能无限加大因为序列长、显存会爆尤其候选文档很长时batch_size要相应调小。再一个重要问题是分数处理。重排模型输出的分数分布五花八门有的在0到1之间有的可能跑到十几甚至几十光看绝对数值没有意义。所以不要用一个固定阈值去卡重排分数。我的建议是重排结果只用来排序然后把排序位置作为最终筛选依据比如直接取重排后的top_n作为上下文。如果确实需要阈值过滤先把分数做归一化处理比如用percentile或者z-score再设定一个相对位置而不是一个拍脑袋的绝对数值。线上系统还要准备降级策略。重排服务是典型的耗时大户如果它超时或者挂了不应该让整个RAG链路也挂掉。一个务实的降级方案是给重排设置严格超时时间比如100毫秒到300毫秒超时就直接使用召回阶段的排序结果返回。虽然效果上会有损失但至少用户还能拿到答案。这个降级分支在日志里要有明确标记方便后续排查到底有多少请求走了降级路径。2.3 粗排与精排的分层设计当文档库规模变大候选集从一两百涨到上千时直接让重排模型处理所有候选会慢到无法接受。这时候需要在召回和精排之间插一个粗排层。粗排的目的是用极低的计算成本从上千条候选中筛出最有可能相关的几十条。粗排用的模型比精排轻量得多可以用精简版的跨编码器也可以用双塔模型加上更复杂的分数融合公式甚至可以用规则过滤比如必须出现在分块标题里、必须包含某个实体词、文档更新时间必须在一个范围内。规则过滤在代码库检索场景里效果意外地好因为代码检索对版本有强要求。我在一个实际项目里用过“召回1000 - 粗排过滤到100 - 精排取top5”的结构。粗排阶段用轻量跨编码器模型单条推理耗时大约在1到3毫秒100条也就几百毫秒精排阶段再用高精度重排模型跑100条由于候选质量已经过滤过一遍模型打分更集中排序也更有区分度。整体算下来时延从直接精排1000条的几秒钟降到了1秒以内而最终效果几乎没有下降。粗排层真正难的是设计过滤条件或轻量模型的阈值因为粗排本身也会有误伤。如果粗排阶段把正确文档误杀了精排再强也救不回来。所以粗排后的候选集不能收太紧宁可多留一些噪音给精排处理也不要为了提高速度把召回率砍掉一半。3. 召回与重排的工程落地从脚本到服务化管道3.1 一套可复用的召回-重排管道设计前面讲了原理和选型现在落地到管道结构。一个典型的召回-重排管道至少包含这些环节query预处理、多路召回、候选合并与去重、粗排可选、精排、阈值过滤、构造上下文、喂给生成模型。query预处理这一步容易被忽略但影响很大。常见操作包括去除无意义字符、对产品名/型号做归一化、把缩写展开成全称、判断是否需要query改写。以单元测试场景为例用户输入可能是一串报错代码预处理时需要把代码和自然语言分开处理代码走BM25精确匹配自然语言走向量召回。“登录失败”这种短query如果能用大模型改写成“登录失败的可能原因以及解决方案”召回效果会明显提升。但在线改写会增加一次模型调用要做好耗时预算。多路召回之后是合并和去重。混合检索返回的结果既可能来自向量路也可能来自BM25路甚至同一篇文档的不同分块都被召回。去重不能只看文档ID更合理的做法是看“文档ID 分块序号”同时可以把内容过于相似的相邻分块合并成一个更大的上下文窗口。这一步做好之后重排的输入质量才会有保证。融合后的候选进入重排后还需一个环节是过滤。过滤规则要结合业务场景定制比如过期文档不展示、指定类型的文档优先、某些状态下的文档不允许作为答案来源等等。这些规则看着不起眼但线上很多badcase都是在这里解决的。重排负责“准”规则过滤负责“合不合规”两者配合才完整。3.2 候选上下文的选择top_n怎么定窗口怎么拼重排结束之后最终要选多少文档进入生成模型的上下文这是个需要权衡的问题。top_n设太小正确文档可能被挤掉设太大上下文变得冗长大模型注意力被稀释还容易产生幻觉。我一般把最终进入上下文的文档数量控制在3到5个分块左右具体取决于生成模型的上下文长度和处理风格。如果模型上下文很长可以放宽到5到8个但前提是重排质量足够高否则噪音带来的负面影响会超过信息量的收益。对短上下文模型宁可只取1到2个高质量分块也不要硬塞一堆原文进去。还有一个被忽视的点上下文拼接顺序。把重排分数最高的文档放在离问题最近的位置然后依次排列。我在实验中观察到大模型对靠后的内容利用程度可能弱于靠前内容所以重排后的顺序不要随手打乱也不要简单按文档类型排序。曾经有个团队把检索结果按文档来源分组再输出看起来整齐了结果正确文档排在最后答非所问的概率明显上升。另外多个分块如果来自同一篇文档应该按它们在原文档中的相对顺序拼接而不是按重排分数单独排列。文档被打散后块与块之间的衔接信息对生成非常重要。比如某个答案分散在两个相邻分块里把它们倒着拼进上下文模型很容易理解错逻辑。3.3 缓存设计哪些结果值得缓存哪些不能缓存RAG链路最贵的是检索和重排所以缓存设计对成本和时延影响很大。缓存做得好的系统重复用户query可以直接返回结果QPS能扛得高很多缓存设计不当则会引入数据不一致和过期内容问题。缓存的核心原则是按query粒度缓存最终结果比如把预处理后的query作为key缓存召回候选、重排排序和最终答案。这样可以绕开中间层各自计算的成本。但需要注意query改写后的问题原文需要保持一致否则同一语义的不同表述会产生多条缓存命中率反而下降。哪些场景不适合缓存要特别留意。第一文档库频繁更新的场景索引刷新后缓存里的旧答案必须同步失效第二用户个性化上下文参与重排的场景不同用户即使query相同重排结果也可能不同不能共用缓存第三对时间敏感的内容比如实时行情、事件动态类缓存时间要设置得很短。缓存虽然能显著降本增效但千万不能在RAG调试阶段开太强。调试时需要看新鲜数据如果缓存命中率过高你会误以为线上效果很好实际上只是旧结果在反复命中。我通常在开发和评测环境里关掉缓存只有上线后才开启并监控缓存命中率保持在一个合理区间。3.4 性能监控每一层都要有可观测性管道一旦服务化光有功能是不够的必须能看清每一层发生了什么。我在生产环境里最常看的几个指标是召回阶段耗时、每路召回返回数量、去重后候选数量、重排耗时、重排后top_n的分数分布、最终无结果率、超时降级比例。这些指标要分层观察。比如系统突然变慢你得能快速判断是向量检索变慢了还是重排batch被大文档拖慢了还是GC问题在生成阶段出现。每一层都埋好trace定位问题时按trace把流程串起来比事后翻日志反复猜测高效得多。还有一个细节重排质量本身如何监控。可以通过记录每条query重排后的第一个文档分数统计其分布变化。如果某次模型更新后top1分数普遍下降可能说明新模型对数据分布不太适应这个信号能早于用户投诉被捕获到。日志数据是RAG持续迭代的原材料后面第五节会专门聊怎么用它来评估效果。4. 实战踩坑记录最容易翻车的几个场景4.1 短query的语义漂移短query是召回环节的重灾区。用户输入“登录失败”向量召回会把“登录”和“失败”两个语义点放大召回一堆登录页面设计、登录测试用例、登录历史记录就是没有报错解决方案。这类问题本质上是query信息量不足向量模型很难从两个词里推断出用户的真实意图。我自己常用的处理手段是query扩展。可以在召回的query侧把用户输入的短句交给一个轻量大模型扩写成完整的问句比如“用户登录时提示登录失败可能的原因和解决方案”然后再拿扩展后的文本去做向量召回。这个操作会多一次模型调用但对短query的效果提升非常明显。另一种手段是给query补充场景上下文。很多业务系统本身是带场景的比如用户是从订单页面发起的咨询那么“查询失败”就应该优先召回与订单查询相关的文档。把场景ID或者当前页面路径拼进query再编码是一个成本极低但效果很稳的技巧。4.2 重排高分但内容不相关的“表面匹配”有一种badcase很气人重排模型给某篇文档打了高分内容却只是在句式上和query长得像语义根本不相关。比如用户问“密码重置失败的提示”某文档里写满了“密码重置的注意事项”句式相关、内容偏题重排模型却可能误判。这种问题在跨编码器模型上一样会出现因为模型训练语料里的相关性定义和自己的业务场景未必一致。处理思路有几个方向。一是多样性控制用MMR最大边际相关来约束重排结果避免返回的top_n全是一篇文档的相似段落公式大致是MMR argmax(λ * 相关性分数 - (1-λ) * 与已选结果的相似度)λ通常取0.7左右。二是做领域微调用自己业务里标注过的高质量数据对重排模型做二次训练。三是给重排结果叠加规则约束比如要求文档标题或首段必须包含query中的核心实体词。具体选哪种要看你badcase的集中度如果全是同一类问题规则最直接如果问题五花八门MMR和微调更通用。4.3 固定阈值误杀正确结果重排模型输出的分数在0到1之间看似方便但实际上不同query下正确文档的分数分布可能完全不同。某个query下正确文档最高分是0.88另一个query下正确文档最高分只有0.61。如果你设置了一个全局阈值0.7第二个query就会被无脑过滤掉。这种“一刀切”的阈值方案我没见过真正可靠的。更稳妥的做法是放弃绝对阈值改用相对位置决策重排后取top_n不再额外设置阈值。如果非要做过滤可以用动态阈值策略比如针对当前query取重排分数的80分位以下结果全部过滤这样对每个query都相对公平。也可以从召回阶段就控制好输入质量候选集本身噪音不高的话重排结果直接取top_n基本够用。4.4 重排耗时把整体时延拖垮跨编码器重排模型在CPU上跑单条耗时经常要数百毫秒甚至更久。如果候选集有100条串行推理可能要几十秒这在线上是不可接受的。这不是模型性能差而是你没有为它设计好工程上的使用方式。解决手段按优先级排序首先削减候选量用粗排层把候选从500压到50到100其次用GPU部署重排模型batch推理可以显著提速然后考虑模型量化常见的INT8量化能几乎无损地把时延砍掉一半最后是并行和异步把重排和后续环节分成异步任务不要让主请求等待完整的全链路串行。如果这些做完还是超时就要考虑降级方案。比如对head query高频query直接走缓存对长尾query才走完整重排。许多系统把高频query的重排结果提前算好用户命中时就近取用只在cache miss时走完整链路效果和性能都能兼顾。4.5 索引更新后缓存与向量不一致RAG系统上线后文档库会持续更新。向量索引需要重建或增量更新缓存里却可能还残留着旧索引对应的答案。用户再次问到同一个问题时系统直接命中旧缓存给出的答案可能已经过时。这属于“正确性”问题比效果问题更严重。我踩过这个坑后总结经验是文档更新事件必须显式驱动缓存失效而不是依赖缓存自身的过期时间。每次文档库有变更就把受影响文档关联的query列表捞出来主动删除对应缓存。如果做不到精细到query就只能给缓存设置较短的TTL比如几分钟到几小时换取一致性。另一个间接方案是把文档版本号拼进缓存key索引更新时版本号变了旧key自然失效。4.6 只看到最终答案、无法定位哪一层出错RAG系统是链条式的一个错误结果可能是任何一层造成的。但很多团队只记录最终返回的答案没有记录中间环节的数据出了问题根本没法回溯只能反复改生成模型的prompt碰运气。我现在做日志记录时有一个习惯把每个请求的关键中间数据落库。包括原始query、改写后的query、每路召回的候选列表、去重后的候选、重排后的top_n及分数、最终进上下文的文档顺序。一旦用户投诉某条回答不靠谱直接按trace翻出这些数据通常三分钟内就能判断是哪一层的问题。日志积累多了还能沉淀成后续评估用的badcase集合形成迭代闭环。5. 怎么量化召回与重排的好坏评估与迭代5.1 离线评估手工标注一份可信的测试集评估召回和重排最基础的是有一份可信的测试集。做法是从线上真实query里随机抽样一批为每个query标注出文档库里有哪些文档是相关的。标注可以分成三档相关、部分相关、不相关。最好独立标注两个人然后合并讨论分歧避免单一标注者主观偏差。有了标注就能算常见指标。RecallK衡量召回的覆盖能力比如有5个相关文档召回TopK50的列表里包含其中3个那Recall50就是0.6。MRRMean Reciprocal Rank看第一个正确结果在排序中的位置如果正确结果排在第3位那这个query的RR就是1/3。NDCG则综合考察排序质量排序越靠前、正确文档越多得分越高。这三个指标各有用处。如果你优先保证“别漏”看RecallK如果你更关心“第一个就给对的”看MRR如果你想评估重排整体排序质量NDCG更全面。我在实际评估里通常会同时看Recall50和MRR10前者保证候选池不丢后者保证重排后正确结果能进前10。如果Recall50只有0.4说明一半以上的正确文档压根没被召回到这时候先别调重排回头去优化召回。5.2 在线日志分析快速判断是检索问题还是生成问题离线评估做好了线上问题仍然会存在。我建议在线上日志里记录一个用于判断的证据标记检索出的top_n文档里是否存在能支撑最终答案的内容。这个标记在代码里就是一道判断逻辑实现成本不高但价值很大。当用户反馈差时按日志快速分类用户反馈场景日志表现大概率问题层级回答偏离问题top_n文档里没有任何相关文档召回层漏召相关资料存在但回答用错了相关文档排在5名开外未进入上下文重排层排序不佳相关文档在上下文中答案仍然乱说相关文档排在top3但生成内容偏离生成层幻觉或prompt不清晰回答太泛泛而谈上下文中相关文档太少或太短分块粒度不合理这个方法虽然粗糙但能迅速把排查范围缩小到某一层而不是每次都从零开始。把日志分类结果累积起来你就能知道当前系统最严重的短板在哪一层按优先级投入优化资源。5.3 调优顺序与badcase回流闭环优化RAG链路我一直主张一个顺序先调召回再调重排最后才碰生成prompt。理由很朴素如果正确文档根本没进候选集重排和生成做得再好也无济于事。先确认召回覆盖率足够再确认重排排序足够准最后才轮到生成阶段做表达层的优化。badcase回流是持续改进的关键。每周定期从线上收集一批badcase人工标好问题出在哪一层然后根据问题分布安排优化任务。如果这周的大量badcase集中在召回层就把资源投到query改写和召回路线上如果集中在重排层就收集数据去微调重排模型或调MMR参数。这些badcase本身也可以沉淀成回归测试集每次模型或代码改动后先跑一遍确保修一个bug不会炸掉一片功能。我见过不少团队在评估上走弯路有的只看最终回答好不好不拆中间层指标结果无法归因有的建了大型测试集但样本和线上分布差距过大评估结果好看、上线露馅。评估这件事不需要一步到位用最低成本先把线上日志分类机制跑起来比搭建一个看起来华丽但没人维护的评测平台要实用得多。做RAG这一年多我最大的体会是检索质量永远是第一位的。很多生成侧的花式技巧比如复杂prompt、知识编辑、思维链提示都无法弥补召回漏文档这个根本性缺陷。把召回和重排当成一套需要精心调校的系统来对待效果提升的确定性远比反复优化生成输出要高。最后再分享一个小建议每次改动检索或重排方案时坚持做A/B对比在同一批query集合上跑老方案和新方案用同样的指标评估而不是靠感觉判断“好像变好了”。原因是检索侧的改动经常是此消彼长一个场景变好、另一个场景变差只有量化对比才能真正看清方向对不对。