RAG 检索召回优化的工程实践从查询改写、混合检索与重排策略到召回评测集构建和线上漏召回溯的可复现方案做 RAG 的同学最后大多会卡在同一个位置模型其实会回答但就是没拿到该拿到的文档。表面看像生成问题往下查通常是召回问题。我这段时间在业务里反复做一件事把“没检到”拆成可定位、可回放、可对比的工程问题。说实话真正拉开效果差距的往往不是换了多大的模型而是检索层有没有把候选集喂对。这篇文章我按一个可复现方案来写覆盖四块内容查询改写、混合检索、重排策略、评测与线上漏召回溯。重点放在工程细节和实测结果不讲空话。1. 先说问题定义什么叫“召回优化”在工程里RAG 检索通常不是一步而是一个候选集逐步筛选的过程用户问题进入系统进行查询标准化或改写用 BM25、向量检索等方式取回候选文档用重排模型重新排序截断后交给生成模型回答短句先说清楚。召回优化的目标不是单纯把top_k调大。top_k50看起来保险实际可能把无关片段也一起塞给后面的重排或生成时延和成本一起上去答案反而更飘。我更常用的定义是在给定时延和成本预算下让包含正确证据的候选集尽量靠前出现。所以后面所有动作都围绕两个指标RecallK前 K 个候选里是否含有标准证据MRR / nDCG标准证据排位是否足够靠前如果只看最终回答对不对会把很多检索问题埋掉。这个坑我踩过。2. 线上最常见的漏召回场景在开始调方案前我一般先把线上 badcase 分桶不然会一直在“感觉这里要优化”里打转。常见漏召回基本集中在下面几类。2.1 用户问法和文档写法不一致比如用户问发票红冲后还能再次开具吗知识库原文可能写的是红字发票处理完成后可按当前订单状态重新发起蓝票申请。一个说“红冲”一个说“红字发票处理”一个说“再次开具”一个说“重新发起蓝票申请”。如果只靠词面匹配BM25 容易漏如果只靠向量短 query 又容易飘到一堆“发票”通用说明里。2.2 查询太短语义不完整例子很典型怎么退款这个问题没有渠道、订单状态、支付方式、角色权限向量检索会召回很多泛化内容。真正可执行的文档反而不容易进前几位。2.3 文档切块后语义断裂有些证据分布在相邻两段单块看都不完整。检索命中了半句生成模型还是答不对。这很常见。2.4 文档里有大量术语、别名、历史叫法用户说“工单转派”文档写“服务单改派”用户说“企微”文档写“企业微信”老系统里写“A审批流”新版文档已经改成“标准审批”。没有同义词归一层召回会非常依赖运气。2.5 多跳问题混在一个 query 里比如子账号没有导出权限时管理员如何临时授权并保留审计记录这个问题其实含两段约束权限开通 审计保留。检索如果只对整句编码常常只召回“权限”或“审计”其中一类文档。3. 我采用的整体方案我的实践里一条检索流水线大致长这样用户Query - 标准化清洗 - 查询改写(Query Rewrite) - 多路召回 - BM25 / 关键词检索 - Dense Vector / 向量检索 - 可选别名词典扩展 - 候选合并去重 - Cross-Encoder 重排 - TopN 上下文拼接 - 交给 LLM 生成结构不复杂但每层都要可回放。否则上线后你只会看到“答案不对”却不知道错在改写、召回还是重排。下面按模块展开。4. 查询改写先把用户问题改成更适合检索的形式很多团队一上来就堆索引结果发现收益不稳定。我的经验是查询改写往往是性价比最高的一步特别是业务问答场景。4.1 查询改写不等于“把问题写长”改写目标应该是保留原始意图补齐检索线索减少歧义。我一般把改写拆成四类输出标准化问法关键词补全别名扩展子问题拆分给一个实际格式。{normalized_query:发票红冲后是否可以重新开具蓝票,keywords:[发票,红冲,红字发票,重新开具,蓝票],aliases:[红冲红字发票处理,再次开具重新开具蓝票],sub_queries:[红字发票处理后是否支持重新开票,蓝票重新申请的条件是什么]}这里有个细节不要直接用改写结果覆盖原 query。我通常会保留原 query 并行检索再做融合。因为改写模型也会犯错特别是在缩写、产品名、版本号上。4.2 改写 Prompt 设计下面是我在知识库问答里比较常用的一版提示词。输出结构固定方便后处理。REWRITE_PROMPT 你是企业知识库检索改写助手。 任务把用户问题改写为更适合文档检索的查询。 要求 1. 保留原意不得引入用户未提及的业务前提。 2. 输出标准化问法、关键词、别名、子问题。 3. 如果问题过短可补充通用业务对象但不能编造事实。 4. 输出 JSON。 用户问题{query} 如果业务里存在大量固定术语我会再加一个术语表让模型优先做标准化映射而不是自由发挥。4.3 低成本兜底规则改写不是所有请求都值得走一次 LLM 改写。高频、短 query 很适合做规则层处理时延低很多。比如ALIAS_DICT{红冲:[红字发票处理,红字发票],企微:[企业微信],工单转派:[服务单改派,工单改派],开票:[申请发票,蓝票开具]}defexpand_alias(query:str):terms[]fork,valsinALIAS_DICT.items():ifkinquery:terms.extend(vals)returnlist(set(terms))我的做法通常是高频 query先规则改写中长尾 query规则 LLM 改写高时延场景只保留规则改写和原 query这种分流很实用成本能压住。4.4 改写效果怎么评估不要看改写文本“像不像人话”要看它是否提高召回。一个简单离线方法是基于标注好的 query-doc 对分别跑原 query 和改写后 query比较 Recall5、Recall20、MRR我在一个内部知识库数据集上做过对照样本 1200 条方案Recall5Recall20MRR原始 query0.610.780.49规则改写0.660.810.54LLM 改写0.710.860.60原 query 规则 LLM 融合0.760.890.64单看这个表融合方案收益最稳。原因不玄乎就是多保留了一些原始词面信号能补回被改写误伤的样本。5. 混合检索别只押一个索引检索召回里BM25 和向量检索各有短板。实际业务中把两者组合起来通常更稳。5.1 BM25 擅长什么向量检索擅长什么BM25 的优点产品名、错误码、版本号这类精确词匹配好术语明确时前排结果很干净可解释性强定位简单向量检索的优点同义表达、意图近义处理更自然用户问法口语化时更容易召回相关段落长句语义匹配更强但两边都会翻车。BM25 会卡在词不一致向量检索会在短 query 或高频泛词里被带偏。所以我更推荐混合检索而不是二选一。5.2 一种简单有效的融合方式RRFRRFReciprocal Rank Fusion工程实现很简单但常常比手调加权分稳。公式如下RRF(d) Σ 1 / (k rank_i(d))其中rank_i(d)表示文档d在某一路检索结果中的排名。示例代码fromcollectionsimportdefaultdictdefrrf_fusion(result_lists,k60):scoresdefaultdict(float)doc_map{}forresultinresult_lists:forrank,iteminenumerate(result,start1):doc_iditem[doc_id]doc_map[doc_id]item scores[doc_id]1.0/(krank)fusedsorted(scores.items(),keylambdax:x[1],reverseTrue)return[doc_map[doc_id]fordoc_id,_infused]我一般会合并下面几路原始 query 的 BM25原始 query 的向量检索改写 query 的 BM25改写 query 的向量检索候选总量不用太大。很多场景10101010合并后取前 30就够后面的重排用了。5.3 检索实现示例下面给一个 Python 伪代码便于理解结构。classHybridRetriever:def__init__(self,bm25_client,vector_client,rerankerNone):self.bm25bm25_client self.vectorvector_client self.rerankerrerankerdefretrieve(self,query_bundle,topk_each10,final_topk10):result_lists[]# 原 queryqquery_bundle[raw_query]result_lists.append(self.bm25.search(q,topktopk_each))result_lists.append(self.vector.search(q,topktopk_each))# 改写 queryrqquery_bundle.get(normalized_query)ifrqandrq!q:result_lists.append(self.bm25.search(rq,topktopk_each))result_lists.append(self.vector.search(rq,topktopk_each))# 子问题扩展forsqinquery_bundle.get(sub_queries,[])[:2]:result_lists.append(self.vector.search(sq,topktopk_each))fusedrrf_fusion(result_lists)dedupedself._dedup(fused)ifself.reranker:rerankedself.reranker.rank(q,deduped)returnreranked[:final_topk]returndeduped[:final_topk]def_dedup(self,docs):seenset()out[]fordindocs:keyd[doc_id]ifkeynotinseen:seen.add(key)out.append(d)returnout5.4 实测对比我在一套客服知识库上做过离线对照数据规模大概 8 万段评测集 1500 条。结果如下方案Recall10Recall20MRRBM250.680.790.57Dense0.720.820.59BM25 Dense 直接拼接0.760.860.61BM25 Dense RRF0.790.880.65RRF 这一步提升不算夸张但很稳定。稳定就够了。6. 重排策略把正确证据顶到前面混合检索解决的是“能不能捞上来”重排解决的是“能不能排靠前”。对生成模型来说排序很敏感。6.1 为什么要单独做重排因为候选集合并后前几名经常混着这些内容标题很像但答案不完整语义接近但业务条件不符命中了关键词但属于别的产品线真正证据在第 8 名、第 12 名如果不重排后面拼上下文时大概率截掉真证据。6.2 Cross-Encoder 是当前比较稳的一类方法Cross-Encoder 的做法是把(query, doc)一起送入模型打分而不是分别编码后算相似度。代价是慢一些但在候选数 20~50 的范围里通常能接受。我常用流程混合检索召回 30 条用 reranker 打分取前 5~8 条给生成模型伪代码如下classCrossEncoderReranker:def__init__(self,model):self.modelmodeldefrank(self,query,docs):pairs[(query,d[content])fordindocs]scoresself.model.predict(pairs)rescored[]ford,sinzip(docs,scores):itemdict(d)item[rerank_score]float(s)rescored.append(item)returnsorted(rescored,keylambdax:x[rerank_score],reverseTrue)6.3 重排时我会加一点业务特征光靠通用 reranker 还不够业务场景里有些规则特征很值钱比如文档是否来自当前产品线文档状态是否为最新版本是否命中标题是否命中错误码、接口名、字段名是否与用户角色一致做法不复杂可以在线性融合里加进去deffinal_score(item):return(0.70*item.get(rerank_score,0.0)0.15*item.get(title_hit,0.0)0.10*item.get(freshness_score,0.0)0.05*item.get(product_match,0.0))我不建议一开始就上很复杂的学习排序。先把这些手工特征接进去效果往往已经够用而且排查方便。6.4 一个小缺点重排模型会吃掉一部分时延特别是候选数放到 50 以上时更明显。如果线上接口预算卡得很死可以把重排只用在长 query 或高价值请求上。这是我后来加的开关。7. 召回评测集怎么建没有评测集优化基本靠运气很多 RAG 项目没有独立的召回评测集只看最终问答效果。这会有两个问题你不知道改写、检索、重排各自贡献多少生成模型兜底后漏召回被掩盖了所以我一般单独建一个 retrieval eval set。7.1 样本来源我常用两类样本混合历史真实问题从线上日志回流人工补充问题围绕重点知识点扩写问法真实问题能反映口语化和噪声人工补充能覆盖关键流程、边界条件、术语变体。7.2 标注什么最少要标这几项{query:子账号没有导出权限时如何申请临时授权,positive_doc_ids:[doc_1023,doc_1024],must_have_constraints:[子账号,导出权限,临时授权],intent_type:permission,difficulty:hard}这里我建议允许一个 query 对应多个正例文档因为真实场景里证据常常分散在主文档和附加说明里。7.3 怎么降低标注成本完全人工从零标很慢。我一般这样做先用现有检索系统召回前 20 条标注员只在候选里选正例和补漏对热点 query 做二次复核这比全库盲找快很多。7.4 分桶评测评测集不要只给一个总分我会按 query 类型拆桶看短 query / 长 query是否包含产品名是否包含错误码是否需要多跳信息是否存在别名新文档 / 老文档相关问题没分桶时很多问题会被均值掩盖。7.5 评测脚本示例defrecall_at_k(results,positive_ids,k):topk[r[doc_id]forrinresults[:k]]returnint(any(doc_idinpositive_idsfordoc_idintopk))defmrr(results,positive_ids):fori,rinenumerate(results,start1):ifr[doc_id]inpositive_ids:return1.0/ireturn0.0defevaluate(dataset,retriever,ks(5,10,20)):stats{fRecall{k}:0forkinks}stats[MRR]0.0foritemindataset:query_bundle{raw_query:item[query]}resultsretriever.retrieve(query_bundle,topk_each10,final_topk20)forkinks:stats[fRecall{k}]recall_at_k(results,item[positive_doc_ids],k)stats[MRR]mrr(results,item[positive_doc_ids])nlen(dataset)forkeyinstats:stats[key]/nreturnstats8. 一组完整的离线对照实验为了方便复现我给一组更完整的实验配置。数据是我按实际项目抽象出来的参数结构上可以直接照搬。8.1 数据配置文档数约 8 万段平均 chunk 长度420 字评测 query1500 条正例文档数平均每条 1.6 个向量模型bge-large-zh 一类中文 embedding重排模型bge-reranker 一类 cross-encoder8.2 实验方案编号配置A原 query BM25B原 query DenseC原 query BM25 Dense RRFD改写 query BM25 Dense RRFE原 query 改写 query 融合 RRFFE Cross-Encoder 重排8.3 结果方案Recall5Recall10Recall20MRRA0.590.680.790.51B0.630.720.820.54C0.690.790.880.61D0.710.810.890.63E0.740.840.910.66F0.810.880.920.74这里可以看出两点召回层收益主要来自“多路检索 改写融合”排序层收益主要体现在 MRR也就是正确证据更靠前了这个差异很关键。因为最终生成效果往往对 MRR 比对 Recall20 更敏感。9. 线上怎么做漏召回溯别让 badcase 一次次重复出现离线评测能告诉你方案大致有效但线上问题还是会持续冒出来。我的做法是建一套漏召回回溯机制把线上错误重新喂回评测集。9.1 每次请求记录这些字段日志里建议保留{request_id:r_20260429_001,raw_query:红冲后还能再开吗,rewritten_queries:[发票红冲后是否可以重新开具蓝票],bm25_results:[doc_11,doc_35,doc_90],vector_results:[doc_35,doc_71,doc_18],fused_results:[doc_35,doc_11,doc_71],reranked_results:[doc_71,doc_35,doc_11],final_context_docs:[doc_71,doc_35],answer:...,user_feedback:bad}字段看着多其实很值。后面查问题时能省很多时间。9.2 线上漏召回怎么判定我常用两种触发方式用户显式差评、追问、转人工生成答案置信度低或引用证据为空命中后把该样本打进回溯池再由标注同学确认正例文档。9.3 漏召回归因框架我会把每个 badcase 归到一个主因便于后续批量修。常见标签如下rewrite_error改写偏题或丢约束alias_missing别名词典缺失bm25_miss词面没匹配上dense_drift向量召回跑偏chunk_split_bad证据被切散rerank_error候选里有正确文档但被排太后index_stale新文档还没入索引或版本过旧这个分类一做出来优化方向会清楚很多。9.4 一次真实的排查过程举个抽象化后的例子。用户问题审批驳回后还能继续加签吗系统输出错误答案。回看日志原 query 的 BM25 命中了“审批驳回”相关文档向量检索命中了“加签”相关文档真正正确文档在 fused 第 9 位reranker 把“审批撤回后加签”排到了前面最后归因是重排阶段没有识别“驳回”和“撤回”在业务上完全不同。修复方法不是换大模型而是给 rerank 加了一个业务特征流程状态精确命中得分同时把这条 badcase 加入评测集。后来同类样本的 MRR 提升很明显。没想到最有效的一步只是多加了一个状态词特征。10. 线上监控我会看哪些指标如果只看接口成功率你会错过很多检索问题。RAG 检索层建议单独上看板。我常看的指标有Recall 代理指标人工标注回流样本中的 RecallK检索空结果率重排前后正例位次变化查询改写触发率与改写后收益不同 query 分桶下的命中率新增文档首日被召回比例用户差评样本中的漏召回占比其中有一个指标我觉得很实用候选覆盖率。定义很简单针对人工确认存在标准答案的问题看前 20 个候选里有没有标准证据。这个指标虽然不完美但能很快区分“检索没捞到”和“生成没答好”。11. 一套我比较推荐的默认参数如果你现在要从 0 到 1 搭一个可用版本我建议先从这组参数起步11.1 查询改写高频短 query规则改写其他 queryLLM 改写 原 query 并行保留子问题数最多 2 个11.2 检索BM25 top_k10Dense top_k10改写 query 检索保留融合方式RRFk60融合后候选数3011.3 重排rerank 输入候选数30输出给生成模型5~8 条叠加标题命中、版本新鲜度、产品线匹配等规则特征11.4 评测至少 500 条 retrieval eval每周从线上回流 badcase 补样按短 query、别名 query、多跳 query 分桶这组参数不一定是最优但比较容易起效果也方便后续做 A/B。12. 一个最小可复现实现思路如果要自己快速搭我建议按下面顺序做不要一口气把所有模块塞进去。第一步先做基线BM25 向量检索输出 top20并记录日志。第二步补查询改写保留原 query 并行检索离线比较 Recall10。第三步加 RRF 融合和重排观察 MRR 是否明显提升。第四步从线上差评和转人工日志里回收 badcase做漏召回归因。第五步按归因结果补词典、补规则、补评测样本迭代一轮后再重放。顺序别乱。先把观测补齐再谈优化不然很难知道是哪里真的起了作用。13. 结尾RAG 的检索召回优化说到底不是“某个模型一换就好了”而是把查询、召回、排序、评测、回溯这几层拆开做实。能不能复现关键看两件事每一层有没有独立指标每个 badcase 能不能被完整回放我自己的经验是一旦评测集和回溯机制建起来后面的优化会越来越稳。很多看上去很玄的效果波动最后都能落到具体样本、具体排位、具体特征上。工程里就靠这个。如果你正在做企业知识库、客服问答、制度问答这类 RAG 场景建议先别急着换模型先把召回评测和漏召回回溯补齐收益通常比预想的大。