RAG、检索与 Agent Eval
RAG 不是接上向量库就结束:从两张知识卡满分到 Agent A/B 的五次数据驱动迭代
以 64 条查询和 Agent A/B 为依据,展示检索、重排与 Query Rewrite 的迭代过程。
最早给这套 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 基线使用了不同规模的语料和查询,不能视为严格纵向单变量实验。它展示的是评测能力怎样逐步成熟,而不是一条“系统性能先降后升”的趋势线。

| 指标 | 2卡/6查询 | 30卡/60查询 | 30卡/64查询 |
|---|---|---|---|
| Recall@1 | 1.0000 | 0.9259 | 0.9310 |
| Recall@3 | 1.0000 | 1.0000 | 1.0000 |
| MRR | 1.0000 | 0.9599 | 0.9626 |
| Forbidden Top-1 | 0.0000 | 0.0185 | 0.0172 |
| Citation Completeness | 1.0000 | 0.9833 | 1.0000 |
| 文档数 | 2 | 30 | 30 |
| Chunk 数 | 4 | 180 | 180 |
| 查询数 | 6 | 60 | 64 |
这张表最重要的信息不是后两列是否高于前一列,而是评测终于从“只能证明链路工作”进化到“能够发现误排序、危险召回和引用缺陷”。指标变差有时是坏消息,但也可能意味着测试集第一次具备了区分度。
迭代一:从两张卡的满分,走到三十张卡的真实基线
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@1 | 0.9310 | ≥0.80 | 通过 | 多数目标文档位于首位 |
| Recall@3 | 1.0000 | ≥0.95 | 通过 | 所有目标文档进入前三 |
| MRR | 0.9626 | ≥0.85 | 通过 | 首个相关结果整体靠前 |
| Forbidden Top-1 | 0.0172 | ≤0.05 | 通过 | 仍有一次危险首位误召回 |
| Citation Completeness | 1.0000 | =1.00 | 通过 | 结果具有可审计来源 |
| Vector / BM25 / Hybrid | 0.9844 | 0.7083 | 0.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 | 归因 |
|---|---|---|---|---|---|
| Q1 | 2 | V6 / B– / R3 | V4 / B– / R1 | 否 | Vector Recall |
| Q2 | 2 | V1 / B– / R2 | V4 / B– / R1 | 否 | Rerank Order |
| Q3 | 2 | V2 / B6 / R2 | V1 / B– / R1 | 是 | Rerank Order |
| Q4 | 3 | V19 / B7 / R3 | V9 / B– / R1 | 否 | Rerank 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,在四个诊断场景上各运行一次。结果没有出现整齐的全面提升,而是一个上升、一个下降、两个持平。

| 场景 | 模式 | 总分 | 过程分 | 安全分 | 工具数 | Citation | 耗时 | 结果 |
|---|---|---|---|---|---|---|---|---|
| A | Off | 38 | 8 | 15 | 1 | 0 | 167.8s | 失败 |
| A | On | 32 | 0 | 15 | 0 | 3 | 184.8s | 失败 |
| B | Off | 38 | 8 | 15 | 1 | 0 | 172.2s | 失败 |
| B | On | 38 | 8 | 15 | 1 | 3 | 214.2s | 失败 |
| C | Off | 38 | 8 | 15 | 1 | 0 | 174.5s | 失败 |
| C | On | 38 | 8 | 15 | 1 | 3 | 236.7s | 失败 |
| D | Off | 32 | 0 | 15 | 0 | 0 | 104.5s | 失败 |
| D | On | 38 | 8 | 15 | 1 | 3 | 172.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,模型调用还会超时。更危险的是,如果改写改变了组件、症状或时间关系,检索会得到一条语言流畅但主题错误的查询。因此最终链路加入了上下文依赖判断、最多一次模型调用、二十五秒上限、语义保护和原查询回退。

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@1 | Recall@3 | MRR | Forbidden Top-1 | 应用/调用 | 平均时延 | P95 |
|---|---|---|---|---|---|---|---|
| 第1轮 | 0.50 | 0.50 | 0.50 | 0.10 | 2/5 | 2,466.4ms | 3,263ms |
| 第2轮 | 0.90 | 0.90 | 0.90 | 0.10 | 7/10 | 13,331.7ms | 18,035ms |
| 最终调优 | 0.90 | 1.00 | 0.95 | 0.00 | 9/10 | 11,508.3ms | 15,777ms |
第一轮只有五次调用、两次应用,说明路由本身没有覆盖足够的上下文依赖追问;第二轮结构化输出恢复,质量明显提高,但仍有超时回退和一次 Forbidden Top-1;最终轮十次调用没有超时,九次改写被采用,一次因语义保护未通过而回退。这里的回退不是失败被掩盖,而是明确的安全行为:不确定改写是否保留主题时,宁可用原查询检索。
跨轮数据能展示修复过程,却不适合直接宣称每次变化都由单个改动造成,因为前三轮没有保存等价的语料指纹。真正可以做严格 A/B 的,是最终同一范围内的 Baseline 与 Rewrite:两臂使用42个文档、224个Chunk,只切换是否改写。
| 指标 | Baseline | Rewrite | 变化 |
|---|---|---|---|
| Recall@1 | 0.30 | 0.90 | +0.60 |
| Recall@3 | 0.30 | 1.00 | +0.70 |
| MRR | 0.30 | 0.95 | +0.65 |
| Forbidden Top-1 | 0.20 | 0.00 | -0.20 |
| Citation Completeness | 1.00 | 1.00 | 持平 |
| Vector Coverage | 0.80 | 1.00 | +0.20 |
| BM25 Coverage | 0.6333 | 0.40 | -0.2333 |
| Hybrid Coverage | 0.4333 | 0.40 | -0.0333 |
| 平均时延 | 758ms | 11,508.3ms | +10,750.3ms |
| P95 | 868ms | 15,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%,我更愿意让每个结论都有证据、每次修改都可复现、每个危险退化都能被下一次测试拦住。