news 2026/5/1 8:12:23

RAG 检索召回优化的工程实践:从查询改写、混合检索与重排策略到召回评测集构建和线上漏召回溯

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
RAG 检索召回优化的工程实践:从查询改写、混合检索与重排策略到召回评测集构建和线上漏召回溯

RAG 检索召回优化的工程实践:从查询改写、混合检索与重排策略到召回评测集构建和线上漏召回溯的可复现方案

做 RAG 的同学,最后大多会卡在同一个位置:模型其实会回答,但就是没拿到该拿到的文档。表面看像生成问题,往下查通常是召回问题。

我这段时间在业务里反复做一件事:把“没检到”拆成可定位、可回放、可对比的工程问题。说实话,真正拉开效果差距的,往往不是换了多大的模型,而是检索层有没有把候选集喂对。

这篇文章我按一个可复现方案来写,覆盖四块内容:查询改写、混合检索、重排策略、评测与线上漏召回溯。重点放在工程细节和实测结果,不讲空话。


1. 先说问题定义:什么叫“召回优化”

在工程里,RAG 检索通常不是一步,而是一个候选集逐步筛选的过程:

  • 用户问题进入系统
  • 进行查询标准化或改写
  • 用 BM25、向量检索等方式取回候选文档
  • 用重排模型重新排序
  • 截断后交给生成模型回答

短句先说清楚。

召回优化的目标,不是单纯把top_k调大。top_k=50看起来保险,实际可能把无关片段也一起塞给后面的重排或生成,时延和成本一起上去,答案反而更飘。

我更常用的定义是:在给定时延和成本预算下,让包含正确证据的候选集尽量靠前出现。

所以后面所有动作,都围绕两个指标:

  • Recall@K:前 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
  • 比较 Recall@5、Recall@20、MRR

我在一个内部知识库数据集上做过对照,样本 1200 条:

方案Recall@5Recall@20MRR
原始 query0.610.780.49
规则改写0.660.810.54
LLM 改写0.710.860.60
原 query + 规则 + LLM 融合0.760.890.64

单看这个表,融合方案收益最稳。原因不玄乎,就是多保留了一些原始词面信号,能补回被改写误伤的样本。


5. 混合检索:别只押一个索引

检索召回里,BM25 和向量检索各有短板。实际业务中,把两者组合起来通常更稳。

5.1 BM25 擅长什么,向量检索擅长什么

BM25 的优点:

  • 产品名、错误码、版本号这类精确词匹配好
  • 术语明确时,前排结果很干净
  • 可解释性强,定位简单

向量检索的优点:

  • 同义表达、意图近义处理更自然
  • 用户问法口语化时更容易召回相关段落
  • 长句语义匹配更强

但两边都会翻车。

BM25 会卡在词不一致,向量检索会在短 query 或高频泛词里被带偏。所以我更推荐混合检索,而不是二选一。

5.2 一种简单有效的融合方式:RRF

RRF,Reciprocal Rank Fusion,工程实现很简单,但常常比手调加权分稳。公式如下:

RRF(d) = Σ 1 / (k + rank_i(d))

其中rank_i(d)表示文档d在某一路检索结果中的排名。

示例代码:

fromcollectionsimportdefaultdictdefrrf_fusion(result_lists,k=60):scores=defaultdict(float)doc_map={}forresultinresult_lists:forrank,iteminenumerate(result,start=1):doc_id=item["doc_id"]doc_map[doc_id]=item scores[doc_id]+=1.0/(k+rank)fused=sorted(scores.items(),key=lambdax:x[1],reverse=True)return[doc_map[doc_id]fordoc_id,_infused]

我一般会合并下面几路:

  • 原始 query 的 BM25
  • 原始 query 的向量检索
  • 改写 query 的 BM25
  • 改写 query 的向量检索

候选总量不用太大。很多场景10+10+10+10合并后取前 30,就够后面的重排用了。

5.3 检索实现示例

下面给一个 Python 伪代码,便于理解结构。

classHybridRetriever:def__init__(self,bm25_client,vector_client,reranker=None):self.bm25=bm25_client self.vector=vector_client self.reranker=rerankerdefretrieve(self,query_bundle,topk_each=10,final_topk=10):result_lists=[]# 原 queryq=query_bundle["raw_query"]result_lists.append(self.bm25.search(q,topk=topk_each))result_lists.append(self.vector.search(q,topk=topk_each))# 改写 queryrq=query_bundle.get("normalized_query")ifrqandrq!=q:result_lists.append(self.bm25.search(rq,topk=topk_each))result_lists.append(self.vector.search(rq,topk=topk_each))# 子问题扩展forsqinquery_bundle.get("sub_queries",[])[:2]:result_lists.append(self.vector.search(sq,topk=topk_each))fused=rrf_fusion(result_lists)deduped=self._dedup(fused)ifself.reranker:reranked=self.reranker.rank(q,deduped)returnreranked[:final_topk]returndeduped[:final_topk]def_dedup(self,docs):seen=set()out=[]fordindocs:key=d["doc_id"]ifkeynotinseen:seen.add(key)out.append(d)returnout

5.4 实测对比

我在一套客服知识库上做过离线对照,数据规模大概 8 万段,评测集 1500 条。结果如下:

方案Recall@10Recall@20MRR
BM250.680.790.57
Dense0.720.820.59
BM25 + Dense 直接拼接0.760.860.61
BM25 + Dense + RRF0.790.880.65

RRF 这一步提升不算夸张,但很稳定。稳定就够了。


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.model=modeldefrank(self,query,docs):pairs=[(query,d["content"])fordindocs]scores=self.model.predict(pairs)rescored=[]ford,sinzip(docs,scores):item=dict(d)item["rerank_score"]=float(s)rescored.append(item)returnsorted(rescored,key=lambdax:x["rerank_score"],reverse=True)

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,start=1):ifr["doc_id"]inpositive_ids:return1.0/ireturn0.0defevaluate(dataset,retriever,ks=(5,10,20)):stats={f"Recall@{k}":0forkinks}stats["MRR"]=0.0foritemindataset:query_bundle={"raw_query":item["query"]}results=retriever.retrieve(query_bundle,topk_each=10,final_topk=20)forkinks:stats[f"Recall@{k}"]+=recall_at_k(results,item["positive_doc_ids"],k)stats["MRR"]+=mrr(results,item["positive_doc_ids"])n=len(dataset)forkeyinstats:stats[key]/=nreturnstats

8. 一组完整的离线对照实验

为了方便复现,我给一组更完整的实验配置。数据是我按实际项目抽象出来的参数,结构上可以直接照搬。

8.1 数据配置

  • 文档数:约 8 万段
  • 平均 chunk 长度:420 字
  • 评测 query:1500 条
  • 正例文档数:平均每条 1.6 个
  • 向量模型:bge-large-zh 一类中文 embedding
  • 重排模型:bge-reranker 一类 cross-encoder

8.2 实验方案

编号配置
A原 query + BM25
B原 query + Dense
C原 query + BM25 + Dense + RRF
D改写 query + BM25 + Dense + RRF
E原 query + 改写 query 融合 + RRF
FE + Cross-Encoder 重排

8.3 结果

方案Recall@5Recall@10Recall@20MRR
A0.590.680.790.51
B0.630.720.820.54
C0.690.790.880.61
D0.710.810.890.63
E0.740.840.910.66
F0.810.880.920.74

这里可以看出两点:

  • 召回层收益主要来自“多路检索 + 改写融合”
  • 排序层收益主要体现在 MRR,也就是正确证据更靠前了

这个差异很关键。因为最终生成效果,往往对 MRR 比对 Recall@20 更敏感。


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 代理指标:人工标注回流样本中的 Recall@K
  • 检索空结果率
  • 重排前后正例位次变化
  • 查询改写触发率与改写后收益
  • 不同 query 分桶下的命中率
  • 新增文档首日被召回比例
  • 用户差评样本中的漏召回占比

其中有一个指标我觉得很实用:候选覆盖率

定义很简单,针对人工确认存在标准答案的问题,看前 20 个候选里有没有标准证据。这个指标虽然不完美,但能很快区分“检索没捞到”和“生成没答好”。


11. 一套我比较推荐的默认参数

如果你现在要从 0 到 1 搭一个可用版本,我建议先从这组参数起步:

11.1 查询改写

  • 高频短 query:规则改写
  • 其他 query:LLM 改写 + 原 query 并行保留
  • 子问题数:最多 2 个

11.2 检索

  • BM25 top_k:10
  • Dense top_k:10
  • 改写 query 检索:保留
  • 融合方式:RRF,k=60
  • 融合后候选数:30

11.3 重排

  • rerank 输入候选数:30
  • 输出给生成模型:5~8 条
  • 叠加标题命中、版本新鲜度、产品线匹配等规则特征

11.4 评测

  • 至少 500 条 retrieval eval
  • 每周从线上回流 badcase 补样
  • 按短 query、别名 query、多跳 query 分桶

这组参数不一定是最优,但比较容易起效果,也方便后续做 A/B。


12. 一个最小可复现实现思路

如果要自己快速搭,我建议按下面顺序做,不要一口气把所有模块塞进去。

第一步

先做基线:BM25 + 向量检索,输出 top20,并记录日志。

第二步

补查询改写,保留原 query 并行检索,离线比较 Recall@10。

第三步

加 RRF 融合和重排,观察 MRR 是否明显提升。

第四步

从线上差评和转人工日志里回收 badcase,做漏召回归因。

第五步

按归因结果补词典、补规则、补评测样本,迭代一轮后再重放。

顺序别乱。先把观测补齐,再谈优化,不然很难知道是哪里真的起了作用。


13. 结尾

RAG 的检索召回优化,说到底不是“某个模型一换就好了”,而是把查询、召回、排序、评测、回溯这几层拆开做实。能不能复现,关键看两件事:

  • 每一层有没有独立指标
  • 每个 badcase 能不能被完整回放

我自己的经验是,一旦评测集和回溯机制建起来,后面的优化会越来越稳。很多看上去很玄的效果波动,最后都能落到具体样本、具体排位、具体特征上。

工程里就靠这个。

如果你正在做企业知识库、客服问答、制度问答这类 RAG 场景,建议先别急着换模型,先把召回评测和漏召回回溯补齐,收益通常比预想的大。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/5/1 8:10:33

房价预测:从线性回想到决策树

在房地产市场分析中,预测房价是一个常见但充满挑战的任务。本文将探讨如何通过机器学习技术,特别是从线性回归到决策树模型的转变,来提高房价预测的准确性。 问题描述 假设我们有一份包含房屋特征数据的CSV文件,其中包括房屋面积、地址、是否有停车位、仓库和电梯等信息。…

作者头像 李华
网站建设 2026/5/1 8:04:26

ZenTimings完整指南:免费解锁AMD Ryzen内存性能监控与调试工具

ZenTimings完整指南:免费解锁AMD Ryzen内存性能监控与调试工具 【免费下载链接】ZenTimings 项目地址: https://gitcode.com/gh_mirrors/ze/ZenTimings 想要深入了解你的AMD Ryzen处理器内存性能吗?ZenTimings是一款专为AMD Ryzen平台设计的免费…

作者头像 李华
网站建设 2026/5/1 8:01:32

AI写论文必备!4款AI论文生成工具,让你的毕业论文脱颖而出!

AI论文写作工具推荐 在撰写期刊论文、毕业论文或职称论文时,许多学术工作者往往会遇到各种困难。人工写作论文,面对成千上万的文献,寻找相关资料就像在大海中捕风;而繁琐的格式要求时常让人感觉压力山大;不断的内容修…

作者头像 李华
网站建设 2026/5/1 8:00:43

bp的使用

BP 在 CTF 中的使用BP(Binary Patch)在 CTF(Capture The Flag)竞赛中常用于修改二进制文件的行为,绕过保护机制或直接获取 flag。以下是常见的使用场景和方法:修改关键跳转或条件通过工具如 IDA Pro、Ghidr…

作者头像 李华
网站建设 2026/5/1 8:00:12

G-Helper终极指南:3分钟掌握华硕笔记本性能优化技巧

G-Helper终极指南:3分钟掌握华硕笔记本性能优化技巧 【免费下载链接】g-helper G-Helper is a fast, native tool for tuning performance, fans, GPU, battery, and RGB on any Asus laptop or handheld - ROG Zephyrus, Flow, Strix, TUF, Vivobook, Zenbook, Pro…

作者头像 李华
网站建设 2026/5/1 7:54:49

Chapter 3:实战章 - AI IDE 工具深度实战

Chapter 3:实战章 - AI IDE 工具深度实战 学习目标 掌握 Cursor Rules 的完整配置和使用方法 掌握 MCP 协议的实战配置 掌握 Trae IDE 的 Builder 模式配置 理解不同工具的 Harness 设计思路 一、Cursor Rules 实战 1.1 Cursor Rules 概述 Cursor Rules 是 Cursor IDE 中实…

作者头像 李华