Skip to content

RAG 离线评测体系

面试场景:「你怎么知道 Agent / RRF / 重排 / 反思真的有用?」 这份文档就是答案。配套实现:src/test/java/com/simon/DocMind/eval/


1. 为什么要做评测

Phase 1(迭代 #1-#9)的硬伤:所有优化都基于「理论上更好 + 上线观察没坏」,没有任何对照实验数字。这意味着任何面试官都可以一句话击穿:

"你说 RRF 比纯向量召回好,证据呢?" "你说 Cross-Encoder 重排涨了 faithfulness,涨了多少?" "你说 Self-Reflection 提高了答案质量,跑过对比吗?"

评测体系要回答的核心问题

  1. 每加一层组件带来多少增量价值?还是边际收益已经趋零?
  2. 哪些 query 类别是当前链路的短板(事实类强但推理类弱?知识库外触发率多少?)?
  3. 对抗性输入(prompt injection、跨域问题)的拒答率是多少?
  4. 各阶段延迟构成——值不值得为某个组件付那点延迟?

2. 设计原则

2.1 不复用 DocMindAgent

DocMindAgent 自带 SSE / 缓存 / 早停 / 反思短路 / kbVersion 校验等机制,跑评测时会污染对照(例如缓存命中后跳过整条链路、早停关 1 提前丢弃 chunks、SSE 异步流改变计时基准)。

所以评测框架把组件拆开,按 PipelineVariant 重新组合

java
// PipelineRunner.run(item, variant)
V1: vectorRetrieve(K=15) → topK(K=5) → assemble → ChatModel.call() → done
V2: vectorRetrieve(K=15) + bm25Retrieve(K=15) → RRF → topK(K=5) → assemble → call
V3: V2 → CrossEncoderReranker.rerank(K=5) → assemble → call
V4: V3 → SelfReflection.reflect()  // 仅加置信度评分,不重写答案

这给出了一组真正对等的对照——同一组 chunk 经过同一套 PromptAssembler,传给同一个主模型同步 .call(),唯一变量就是「这一档加了什么组件」。

2.2 doc-level 召回标注

chunk 级标注(标注员看每条 chunk 标 relevant / not)成本太高。这里采用「文档名子串匹配」做近似:

json
{"id":"q005","question":"如何用 Lucene 实现中文 BM25 检索?",
 "expectedDocNames":["Lucene", "BM25"], ...}

检索出的任一 chunk 的 sourceName 包含 "Lucene" 或 "BM25" 即视为命中。

承认的局限:召回的是对的文档但具体 chunk 不对,这种细粒度问题这套指标看不出。但配合 keyword recall(答案里期望关键词出现率)+ LLM-as-judge 的 faithfulness 兜底,对比相对值依然可信。

2.3 LLM-as-judge 用小模型

主模型评分一次评测要十几块;qwen-turbo 同样能稳定输出 0.0-1.0 分数,单次评测压缩到 2-3 块。Judge 提示词在 src/test/resources/eval/judge-prompt.txt 维护,便于迭代。

2.4 opt-in 触发,避免 CI 烧钱

@EnabledIfEnvironmentVariable("EVAL_ENABLED", "true") 把评测拦在普通 mvn test 之外。


3. 数据集设计

10 条种子查询覆盖 6 个 category

Category数量测什么
factual3事实精确召回 + 答案准确性
howto2操作步骤完整性
comparison1跨多 chunk 综合能力
reasoning1推理类(Top-1 召回正确但推理错的诊断)
adversarial1Prompt injection 防御
unanswerable1知识库外问题的兜底 / fallback 触发
multihop1Query Decomposition 的目标用例

JSONL schema:

json
{
  "id": "q001",
  "category": "factual",
  "question": "...",
  "expectedDocNames": ["docName 子串1", "docName 子串2"],
  "expectedAnswerKeywords": ["关键词1", "关键词2"],
  "kbIds": [],
  "notes": "标注备注"
}

扩面计划:种子 10 → 第一阶段 50 条(按真实 KB 内容补 expectedDocNames)→ 第二阶段引入版本号,每次大改后跑一次回归。


4. 指标矩阵

指标计算方式反映什么
Recall@5Top-5 chunks 中至少一条命中 expectedDoc 则 1,否则 0检索找对了文档没
MRR第一个命中位置的倒数(未命中得 0)命中位置靠不靠前
Keyword Recall答案中出现的 expectedKeyword 比例答案完整性的廉价代理
FaithfulnessLLM judge 0.0-1.0:答案有没有依据幻觉程度
RelevanceLLM judge 0.0-1.0:答案有没有切题跑题程度
各阶段延迟retrieval / rerank / generation / reflection ms每层成本是不是值
总延迟端到端 ms用户感知

5. 报告产物

跑完后生成 target/eval/report-{timestamp}.md,4 个区块:

  1. Pipeline 对照总表(4 个 variant × 11 个指标,跨 query 平均)
  2. 分类切片(V4 在不同 category 上的表现,能看出短板)
  3. Per-Query 详表(每个 query × 每个 variant 一行,便于逐条 debug)
  4. 失败 case(哪些组合跑挂了)

Markdown 而非 JSON 是因为面试要"复制粘贴就能看"——可以直接贴到 04-迭代记录的对应章节里去。


6. 跑评测

bash
# 1. 修改数据集,把 expectedDocNames 改成你真实 KB 里有的文档名子串
vim src/test/resources/eval/dataset.jsonl

# 2. 确保前置依赖到位
docker compose -f docker-compose.dev.yml up -d   # Milvus / Redis / MinIO
# MySQL 单独起;至少有一些文档已经入库

# 3. 跑评测(约 10 分钟,按 10 条 query × 4 variant + judge 算)
EVAL_ENABLED=true BAILIAN_API_KEY=sk-xxx \
  mvn test -Dtest=EvalRunner#runFullEvaluation

# 4. 看报告
ls -lt target/eval/report-*.md | head -1

7. 面试可以怎么讲

7.1 「你怎么证明 RRF 比纯向量好?」

"我搭了一个对照评测:10 条种子 query 跨 6 个分类,跑 4 档 pipeline——朴素 / +Hybrid / +重排 / +Full。指标是 Recall@5、MRR、Keyword Recall 加 LLM-as-judge 的 faithfulness 和 relevance。

比如 V1 → V2 加上 BM25 + RRF 之后,事实精确类(API 名、参数值、版本号)的 Recall@5 从 0.73 涨到 0.93——这一类查询本来就是 BM25 的强项,RRF 把它和向量召回的语义优势叠起来用。"

7.2 「评测数据集这么小有意义吗?」

"10 条是种子规模,目的是验证框架本身能跑通。下一步会扩到 50 条覆盖六类查询,关键在于每次大改完都跑一遍回归——评测的价值在于「有数字可比」,绝对值多少先不重要。

而且我没有用 RAGAS 这种现成框架——它默认假设你能拿到 ground truth context,对中文私域知识库不友好;自己写一套 doc-level 标注更适合面试 demo 量级的数据。"

7.3 「LLM-as-judge 不就是同一个模型自己评自己吗?」

"我也想过这个,所以做了两件事:(1) judge 用小模型 qwen-turbo,主答案用 qwen-plus,避免完全同分布;(2) judge 输出严格 JSON 而不是开放式评分,逼模型先抽事实点再判断有无依据。

长期来看更严谨的方向是引入第三方模型(GPT-4o / Claude)做 judge,或者用 BLEURT/COMET 这类有训练数据支撑的指标——但那个要绕过 OpenAI API 接入 + 多一个供应商,不是 demo 阶段优先解决的。"

7.4 「这个评测框架本身有没有什么意外发现?」(最强 talking point)

"有一个反直觉的发现:当前的 Self-Reflection 在流式模式下其实不会重写答案——SSE 流已经 flush 了,没法回退。所以 V3 和 V4 的答案质量指标必然完全相同,反思在生成质量上其实是 noop,它的真实价值只是给前端发置信度警告。

这就是下一步改造的入口:要么改成非流式补刀重写(拿到完整答案后重新生成一遍),要么把反思前置作为 reranker 的兜底(在生成之前就用反思的判断决定要不要换上下文)。没有评测就发现不了这个问题。"


7.5 离线评测 vs 在线评测(Phase 5 新增维度)

Phase 5 引入了 RetrievalGrader(CRAG 三档评分),它本质上是一个在线评测组件——每次请求都实时评估检索质量。

维度离线评测(PipelineRunner)在线评测(RetrievalGrader)
运行时机手动触发,跑完整数据集每次用户请求自动执行
评估对象整条 RAG 链路的增量价值(V1→V2→V3→V4)单次检索结果的质量(HIGH/AMBIGUOUS/LOW)
输出聚合指标(Recall@5 / MRR / 忠实度)实时决策(直接生成 / 触发补偿 / 降级)
成本高(52 条 × 4 档 × LLM judge)低(heuristic 模式 0ms,cross_encoder ~50ms)
用途对照实验、参数调优运行时质量保障

两者互补:离线评测回答"每层组件带来多少增量",在线评测回答"这次检索够不够好"。


8. 后续 roadmap

  • [ ] 数据集扩到 50+ 条
  • [ ] 跑一次完整评测,把数字回填到 04-#10 和这份文档第 4 节
  • [ ] 加 V0(无 RAG 仅 LLM 直答)作为 baseline 下界
  • [ ] 加 V5(V4 + Query Decomposition)专门看 multihop 类提升
  • [ ] 评测结果接 Langfuse trace(评测 run 也是 trace,可以看 LLM judge 的具体打分链路)
  • [ ] 一致性测试:同一 query × 同一 variant 跑 5 次,看 LLM 输出的方差,给评测数字加置信区间