RAG、检索与 Agent Eval

RAG 不是接上向量库就结束:从两张知识卡满分到 Agent A/B 的五次数据驱动迭代

以 64 条查询和 Agent A/B 为依据,展示检索、重排与 Query Rewrite 的迭代过程。

RAGQuery RewriteRetrieval Eval

最早给这套 RAG 做评测时,我拿到了一个看起来非常漂亮的结果:两张知识卡、四个 Chunk、六条查询,Recall@1、Recall@3、MRR 和 Citation Completeness 全是 1.0,Forbidden Top-1 是 0。第一反应当然是“链路已经跑通了”,但再看一眼数据规模,我很难把它解释成“检索质量已经足够好”。六道几乎可以从题面直接对应知识卡的题,更像连通性检查,不像一次能暴露排序问题的考试。

后来我把知识库扩到三十张卡、查询扩到六十条,原本的满分立即消失;继续保存阶段排名、修正 Citation 规则、把检索接进诊断 Agent,再对多轮追问加入 Query Rewrite 后,又出现了“离线检索达标,但 Agent 仍然失败”“召回提升明显,但平均时延多出十秒”这类更棘手的结果。回头看,真正让系统变得可信的并不是某个模型或某个百分比,而是这一串可复核的失败证据。

这篇文章要回答的核心问题是:一个小样本满分的 RAG,怎样逐步变成能够暴露问题、定位阶段、验证修改并阻止退化的工程系统?我的答案是,不要围着单个总分调参,而要把每次迭代都写成“数据观察—原因假设—单变量修改—对照验证—适用边界”的闭环。

一张图看懂当前 RAG 架构

先交代本文讨论的对象。用户查询会同时进入语义和关键词两条召回链路:语义侧先生成 Embedding,再到 Milvus 做向量召回;关键词侧分词后使用 BM25L,补足错误码、组件名、配置项等精确词信号。两路候选通过 RRF(Reciprocal Rank Fusion,倒数排名融合)合并,再交给 Rerank 模型重排,最终返回 Top-K 结果和 Citation。

混合检索架构:向量与关键词两路证据在排序阶段汇合

这里有三个容易被忽略的工程点。第一,RRF 使用名次而不是直接混合向量分和 BM25 分,因为两类原始分数不在同一个量纲。第二,Citation 不是给回答末尾附一个文档名,而是保存文档、Chunk、稳定标识,以及候选实际参与通道的 Rank 和 Score,后续才可能审计“这条答案为什么来自这里”。第三,所有召回都必须带范围约束,不能为了提高 Recall 跨越用户、租户、知识库或文档边界。

把主干压缩成伪代码,大致是这样:

vector_hits = vector_search(query, scope)
keyword_hits = bm25_search(query, scope)
candidates = reciprocal_rank_fusion(vector_hits, keyword_hits)
ranked = rerank(query, candidates)
return top_k_with_citations(ranked)

这段代码看起来只有五行,但五行分别对应五个可以独立失败的阶段:查询表达可能不适合检索,某一路可能漏召回,融合可能没有保留正确候选,重排可能把错误文档放到首位,引用也可能缺失必要证据。因此,后面的优化不会只看最终答案,而会尽可能保留每一阶段的中间结果。

五次迭代:每一次修改都由失败数据触发

下面这张图概括了迭代路线。需要特别说明,图中的不同 Retrieval 基线使用了不同规模的语料和查询,不能视为严格纵向单变量实验。它展示的是评测能力怎样逐步成熟,而不是一条“系统性能先降后升”的趋势线。

五次数据驱动迭代:从 Smoke 到按需 Query Rewrite

指标2卡/6查询30卡/60查询30卡/64查询
Recall@11.00000.92590.9310
Recall@31.00001.00001.0000
MRR1.00000.95990.9626
Forbidden Top-10.00000.01850.0172
Citation Completeness1.00000.98331.0000
文档数23030
Chunk 数4180180
查询数66064

这张表最重要的信息不是后两列是否高于前一列,而是评测终于从“只能证明链路工作”进化到“能够发现误排序、危险召回和引用缺陷”。指标变差有时是坏消息,但也可能意味着测试集第一次具备了区分度。

迭代一:从两张卡的满分,走到三十张卡的真实基线

Smoke 阶段的两张知识卡主题差异很大,查询中的关键短语也足够明确。向量召回和关键词召回几乎不需要在相似候选之间做选择。它证明了 Embedding、存储、融合、重排和 Citation 能串起来,却没有回答三个更关键的问题:相似故障会不会互相干扰?无答案查询会不会被强行匹配?正确知识进入候选后能不能稳定排在第一?

我把数据扩大到三十张知识卡和一百八十个 Chunk,并建立六十条查询,其中五十四条有目标答案,六条是无答案探针。结果是 Recall@1 降到 0.9259,MRR 降到 0.9599,Forbidden Top-1 变成 0.0185,Citation Completeness 也降到 0.9833。Recall@3 仍为 1.0000,说明目标知识通常进入前三,但首位误排序已经出现。

这一步没有立刻换 Embedding 模型,也没有凭直觉增加 Top-K。我先保留原始候选和每阶段名次,因为总分只能说“错了”,不能说错在召回、融合还是重排。如果直接更换多个组件,即使总分回升,也无法判断是哪项修改生效,更无法解释是否引入新的副作用。

迭代二:Citation 失败,原来是评分规则误判了合法结果

六十条查询的 Citation 未满分,最初很像检索链路丢了字段。逐条审计后,我找到三个合法的 BM25-only Hit:它们具备 BM25 Rank/Score、RRF 证据和 Rerank 证据,但没有进入 Vector Top-K,所以 Vector Rank/Score 为 null。旧规则却要求每个最终结果同时拥有向量分与关键词分,于是把“没有参与该通道”误判成“证据丢失”。

这两种情况的处理完全不同。前者是混合检索的正常结果,甚至可能正是关键词召回存在的价值;后者才是需要阻止发布的数据完整性问题。如果为了让评分通过而给 Vector Score 填一个伪造的 0,后续审计会把“未参与”误读为“参与但相似度为零”;如果删除这些结果,又会损失错误码和专有名词的精确召回。

最终规则改成按实际参与通道检查:参与向量召回,就必须有对应 Rank 和 Score;参与 BM25,就必须有 BM25 Rank 和 Score;无论来自哪一路,只要进入最终结果,都必须有稳定标识、RRF 与 Rerank 证据。

if hit.vector_rank is not None:
    require(hit.vector_score is not None)
if hit.bm25_rank is not None:
    require(hit.bm25_score is not None)
require(hit.rrf_score is not None)
require(hit.rerank_rank is not None and hit.rerank_score is not None)
Hit 类型Vector 证据BM25 证据RRF 证据Rerank 证据是否完整
Vector-only
BM25-only
Hybrid
无召回证据任意任意
缺 Rerank 证据任意任意

修正后 Citation Completeness 回到 1.0000,而检索排名没有被改变。这个区别很重要:这轮优化修复的是评测口径,不是召回能力。Citation 完整也只代表来源可追踪,不代表知识内容一定正确,更不代表 Agent 使用了这条知识。

迭代三:Recall@3 满分,为什么还要保留四个失败样本

接下来我把正式基线补到六十四条查询:五十八条有目标答案,六条无答案探针。完整运行耗时约十一分十九秒。五项门禁全部通过,同时增加了三项通道覆盖率作为诊断指标。

六十四条查询的指标、门槛与通道覆盖率

评测维度结果门槛状态解释
Recall@10.9310≥0.80通过多数目标文档位于首位
Recall@31.0000≥0.95通过所有目标文档进入前三
MRR0.9626≥0.85通过首个相关结果整体靠前
Forbidden Top-10.0172≤0.05通过仍有一次危险首位误召回
Citation Completeness1.0000=1.00通过结果具有可审计来源
Vector / BM25 / Hybrid0.98440.70830.6927

通道覆盖率不是质量门禁。Vector Coverage 高,说明多数结果有语义侧信号;BM25 和 Hybrid 的比例则帮助观察关键词侧是否在真实参与,而不是判断“越高越好”。如果把 Coverage 当成目标,系统可能为了让两路都命中而扩大候选,反而引入更多噪声。

我会把这几类数字分成三层阅读。第一层是发布门槛:Recall、MRR、Forbidden 和 Citation 决定这一版本能否进入下一阶段。第二层是诊断指标:三种 Coverage 不直接判定通过与否,而是在门槛波动时帮助寻找候选生成的变化。第三层是样本证据:即使前两层都正常,也要检查具体错误落在哪个排名阶段。这样的分层可以避免一个常见误区——看到 BM25 Coverage 低就盲目调大关键词候选数,结果虽然覆盖率上升,错误候选也一起进入融合与重排。

同理,门槛值本身不是从这些结果反推出来的“及格线”。如果先跑出 0.9310,再把 Recall@1 门槛设成 0.93,测试只是在复述现状。更稳妥的做法是先依据业务容忍度固定最低要求,再用独立失败样本观察门槛之外的风险。Forbidden Top-1 尤其如此:总体比例可以低于阈值,但只要某个错误会诱导高风险动作,它仍应被单独保留并提高修复优先级。

门禁通过后,我没有删除 Bad Case。相反,我把每个错误查询的目标最终名次,以及 Vector、BM25、Rerank 三个阶段的名次一起保存下来,用 V/B/R 表示。破折号表示该候选没有进入对应通道的保留范围。

查询目标最终排名目标 V/B/R 排名错误 Top-1 排名Forbidden归因
Q12V6 / B– / R3V4 / B– / R1Vector Recall
Q22V1 / B– / R2V4 / B– / R1Rerank Order
Q32V2 / B6 / R2V1 / B– / R1Rerank Order
Q43V19 / B7 / R3V9 / B– / R1Rerank Order

Q1 中目标在向量阶段只排第六,进入 Rerank 前先天位置不理想,因此首要怀疑是 Vector Recall。另三个案例里,目标在向量侧本来排第一或第二,经过 Rerank 后却落到第二或第三,更像 Rerank Order 问题。尤其 Q3 的错误首位属于 Forbidden:平均指标仍然很好,但这个单例具有更高安全优先级。

这也解释了为什么 Recall@3=1.0000 不是终点。它只说明目标进入前三,无法保证用户实际采用的 Top-1 正确。聚合指标适合门禁,失败样本适合定位;两者缺一不可。这里的“一召回、三排序”只描述这六十四条查询,不应外推成所有线上问题的固定比例。

迭代四:检索通过了,Agent 为什么仍然不会诊断

Retrieval Eval 回答的是“目标知识有没有被找到”,但业务 Agent 还要经历 Planner、Tool Calling、Evidence Gate 和 Decision。知识进入上下文,不等于模型会制定正确计划、调用必要工具、区分相关性与因果性,也不等于它会在证据不足时拒绝自动恢复。

为了观察收益能否传到下游,我固定模型、Prompt、Workflow、场景和知识库,只切换 RAG Off/On,在四个诊断场景上各运行一次。结果没有出现整齐的全面提升,而是一个上升、一个下降、两个持平。

四个诊断场景的 RAG Off/On 得分变化

场景模式总分过程分安全分工具数Citation耗时结果
AOff3881510167.8s失败
AOn3201503184.8s失败
BOff3881510172.2s失败
BOn3881513214.2s失败
COff3881510174.5s失败
COn3881513236.7s失败
DOff3201500104.5s失败
DOn3881513172.8s失败

RAG On 的四次运行一共生成十二条授权 Citation,说明检索确实进入了 Agent 上下文;然而 8次运行均未通过端到端门禁,失败原因都涉及根因结论或必要证据不足。因此,这组实验不能写成“开启 RAG 后诊断成功率提升”。它能支持的结论更克制:Citation 数量增加没有稳定转化为端到端能力,瓶颈不一定在 Retrieval。

场景 A 甚至从 38 降到 32,并出现过程分和工具数下降。这提示我,更多上下文可能改变 Planner 的路径选择,或让模型过早相信知识描述,减少现场取证。场景 D 的提升则说明,在另一些输入上,相关知识可能帮助 Agent 形成了更可执行的调查步骤。但由于两臂都没有过最终门禁,这些只能作为定位假设,不能被包装成因果结论。

这轮最有价值的产物不是一个平均分,而是评测边界的拆分:Retrieval Eval 负责召回与引用;Agent Eval 负责计划、工具、证据、根因和安全闭环。以后即便 Agent 失败,也可以先问“知识有没有找到”,再问“找到后在哪一步没有被正确使用”,而不是笼统地继续调 RAG。

迭代五:多轮追问需要改写,但不能每次都改写

单轮查询通常包含完整实体和症状,多轮追问却经常只有“那这个怎么处理”“它为什么还在等待”。如果直接检索,代词和省略信息会让 Embedding 与 BM25 都缺少主题线索。Query Rewrite 的任务,是根据最近对话把追问还原成可独立检索的查询,而不是替用户生成答案。

最初我把所有追问都送去改写,马上遇到三个问题:自然语言路由覆盖不足,结构化输出有时不满足 Schema,模型调用还会超时。更危险的是,如果改写改变了组件、症状或时间关系,检索会得到一条语言流畅但主题错误的查询。因此最终链路加入了上下文依赖判断、最多一次模型调用、二十五秒上限、语义保护和原查询回退。

按需 Query Rewrite、语义保护与原查询回退

if not depends_on_conversation_context(query, recent_turns):
    return retrieve(query)

rewritten = rewrite_once(query, recent_turns, timeout=25)
if rewritten.failed or not preserves_topic(rewritten, recent_turns):
    return retrieve(query)
return retrieve(rewritten.text)

三轮有效实验记录了从失败到达标的过程。期间还有基础设施无效运行,但它们没有进入质量比较,因为无效密钥或运行中断不能说明模型质量。

运行Recall@1Recall@3MRRForbidden Top-1应用/调用平均时延P95
第1轮0.500.500.500.102/52,466.4ms3,263ms
第2轮0.900.900.900.107/1013,331.7ms18,035ms
最终调优0.901.000.950.009/1011,508.3ms15,777ms

第一轮只有五次调用、两次应用,说明路由本身没有覆盖足够的上下文依赖追问;第二轮结构化输出恢复,质量明显提高,但仍有超时回退和一次 Forbidden Top-1;最终轮十次调用没有超时,九次改写被采用,一次因语义保护未通过而回退。这里的回退不是失败被掩盖,而是明确的安全行为:不确定改写是否保留主题时,宁可用原查询检索。

跨轮数据能展示修复过程,却不适合直接宣称每次变化都由单个改动造成,因为前三轮没有保存等价的语料指纹。真正可以做严格 A/B 的,是最终同一范围内的 Baseline 与 Rewrite:两臂使用42个文档、224个Chunk,只切换是否改写。

指标BaselineRewrite变化
Recall@10.300.90+0.60
Recall@30.301.00+0.70
MRR0.300.95+0.65
Forbidden Top-10.200.00-0.20
Citation Completeness1.001.00持平
Vector Coverage0.801.00+0.20
BM25 Coverage0.63330.40-0.2333
Hybrid Coverage0.43330.40-0.0333
平均时延758ms11,508.3ms+10,750.3ms
P95868ms15,777ms+14,909ms

质量收益很明确:Recall@3 从 0.30 到 1.00,Forbidden Top-1 从 0.20 到 0.00。但成本同样明确:平均从 758ms 增至 11,508.3ms,也就是平均增加约10.75秒,P95 增加十四秒以上。十条追问的小样本也不足以证明所有对话都该改写。更合理的上线策略是只对上下文依赖查询启用,并继续优化轻量模型、缓存和超时预算。

可复用方法:把一次优化变成持续闭环

如果把这次过程压缩成一条可复用的工程路径,我会写成:

建立基线 → 扩大难度 → 保存失败样本 → 定位阶段 → 单变量修改 → A/B验证 → 防止退化

建立基线时,先区分 Smoke 和正式 Benchmark。Smoke 要快,用来发现服务、索引、模型或字段契约是否断裂;正式 Benchmark 要有相似候选、无答案探针和安全负例,用来观察系统是否真的会犯错。两者承担不同职责,不要因为 Smoke 满分就停止扩充数据。

扩大难度时,不只增加数量,还要增加决策边界。加入同组件不同根因、同现象不同组件、术语精确匹配、语义改写、过期文档和禁止首位的干扰项。好的负例不是随机无关文本,而是“看起来相关、实际上不应成为首选”的候选。

保存失败样本时,要记录阶段信息而不是只截最终回答。目标在 Vector、BM25 和 Rerank 的名次,哪些通道实际参与,Citation 缺了什么,Agent 调了哪些工具、收到了哪些观察,这些字段决定错误能否复现。没有中间证据的失败日志,往往只能支持下一次猜测。

定位阶段后再做单变量修改。评分规则错误就修评分规则,不伪造检索分数;Rerank 顺序错误就保留候选集,针对重排做对照;查询缺上下文才进入 Rewrite,不要让所有请求共同承担模型时延。每次修改都保留旧失败样本作为回归用例,避免修好一个指标却破坏另一个边界。

最后,A/B 不只比较质量,也比较成本和安全。确认相同语料、相同查询、相同模型配置和同一评分器,再报告 Recall、MRR、Forbidden、Citation、平均时延和 P95。对于 Agent,还要检查必要证据和恢复权限,而不能用引用数量或回答流畅度代替闭环成功。

这套方法并不会保证下一次评测得到更高分,反而可能让更多问题浮出水面。但这正是它的价值:工程系统最怕的不是出现失败,而是失败被小样本、错误口径或聚合平均数藏起来。与其强迫每个指标都回到 100%,我更愿意让每个结论都有证据、每次修改都可复现、每个危险退化都能被下一次测试拦住。

陈涛 · Agent Application Developer

杭州 · 2026