主题
RAG 评估:从"我相信我的 RAG 好"到"我证明我的 RAG 好"
一个 8 年 Java 后端,用 20 条简历问答做语料,花四天把 RAG 从"跑通了"升级到"能量化了"。
核心结论:没有评估的 RAG 优化就是瞎调参数。Rerank、HyDE、Query 改写各管一段,Ragas 让每个改动的 ROI 可量化。
为什么"跑通了"不等于"做好了"
Week 1 手撕 Mini RAG,Week 2 用 Spring AI + Qdrant 做了简历问答机器人,Week 4 在 Dify 上搭了知识库。四次实践,每次都"跑通了"--输入问题,返回答案,看起来像那么回事。
但面试官问"你的 RAG 好不好",你总不能说"我觉得挺好"。更尴尬的是,每次改一个参数(换分词器、调 chunk size、加 Rerank),你根本不知道是变好了还是变差了,因为没有一个量化指标在跟踪。
Week 5 就解决这一个问题:让 RAG 效果可量化、可归因、可对比。
一、分三层量化:检索层、Context 质量层、生成层
RAG 是个流水线,每一步都可能出错。只看最终答案对不对,无法定位问题出在哪一步。我用了三组指标,分别测不同层级:
用户问题 ──► 检索 ──► context ──► LLM ──► 答案
│ │ │
│ ├─ ContextPrecision:context 干不干净
│ └─ ContextRecall :该找的都找到了吗
├─ Hit@K / P@K / MRR │
└────────────────────────────► Faithfulness:答案有没有瞎编- 检索层(Day 1-2):Hit@K 看召回天花板,P@K 看 context 纯净度,MRR 看重排效果
- Context 质量层(Day 3):Ragas 的 ContextPrecision / ContextRecall,让 LLM 判断 context 和标准答案的相关性
- 生成层(Day 3):Faithfulness 测答案有没有超出 context 瞎编
为什么要分三层? 因为改哪一步,指标就动哪一步。加了 Rerank,检索层指标涨但生成层不动--说明 Rerank 改的是 context 排序不是 LLM 行为。加了拒答 prompt,Faithfulness 涨但检索指标不动--说明 prompt 只影响生成不影响检索。如果三个指标混在一起看,你永远不知道改动到底值不值。
二、Rerank:不是"多加一层",是引入正交信息
实验设计
三链路消融:
- A:BM25 Top-3 直出(baseline)
- B:BM25 Top-K → LLM 精排 → Top-3(两阶段)
- C:BM25 Top-K 全部(天花板对照组)
三组打分器:无打分(baseline)、lexical 词覆盖率(同源退化)、LLM 语义打分(正交判据)。
最硬的结论:Rerank 生效的结构前提
很多人抱怨"加了 Rerank 没效果"。我在 recall_k=3, final_k=3 时跑出来的数据:
| recall_k | Hit@3 增益 | P@3 增益 | MRR 增益 |
|---|---|---|---|
| 3 | +0.0pt | +0.0pt | +0.050 |
| 5 | −5.0pt | +0.0pt | +0.035 |
| 8 | −5.0pt | +0.0pt | +0.037 |
| 12 | +10.0pt | +5.0pt | +0.162 |
k=3 时增益为零不是巧合,是数学必然:重排不改变集合成员,Hit@K 和 P@K 只关心"集合里有什么",不关心"谁排第几"。
Rerank 生效的结构前提是
recall_k > final_k。 不满足这个前提,跑出来的零增益是数学上必然的,不是 Rerank 没用。
判据正交性实验
最关键的发现:Rerank 的增益不来自"多加一层",来自引入召回阶段看不见的信息。
| 打分器 | 判据 | 和 BM25 的关系 | MRR 变化 |
|---|---|---|---|
| lexical(词覆盖率) | 字面(去掉 IDF 加权) | 同源退化 | −0.050 ❌ |
| LLM(语义) | 语义匹配 | 正交 | +0.077 ✅ |
lexical 打分器把 BM25 的三层信息(IDF 加权、词频饱和、长度归一化)全丢了,只保留字面重合计数。这不是"第二意见",是"同一个人说话但含糊了"。LLM 引入了 BM25 看不见的语义信息,才是真正的"第二意见"。
生产级教训
- LLM pointwise 打分有结构缺陷:每次只看一条 query + 一篇文档,无法做相对判断。我的实验里 D02(毕业院校)单独看得 4 分、D20(薪资构成)得 3 分,但摆一起任何模型都会选 D20
- 延迟差 1-2 个数量级:LLM 12 个候选 = 12 次 HTTP 请求(12-24 秒),Cross-Encoder = 1 次(30-50ms)
- 生产用 Cross-Encoder 不用 LLM 打分:靠训练数据校准绝对分数,不是让 LLM 临时理解评分标准
三、HyDE + Query 改写:治召回的病根
HyDE:让检索端看到 LLM 在找什么
Rerank 只能救"召回进来但排得靠后"的文档。如果正确文档压根没被召回,Rerank 无能为力。HyDE 解决的就是这上一层问题:让 LLM 先根据 query 生成一段假想答案,再用这段假想答案去检索。
实测数据(focus 6 条 BM25 翻车的 query):
链路 Hit@3 P@3 MRR
原始 query 0.333 0.111 0.310
HyDE 假想文档 0.500 0.167 0.546 (+76% MRR)
query + 假想文档拼接 0.500 0.167 0.546最干净的证据:query「这人写代码几年了」,BM25 给 gold 文档 D03("8年Java后端开发经验")打 0.0000 分--字面零重合。Day 1 用 LLM Rerank 从第 6 名救回第 1 名。Day 2 用 HyDE 让它在召回阶段就排第 1,BM25 得分 16.34。两个技术作用在不同层,同一道题各自解法不同。
关键判据:HyDE 补「词类」有效,补「实体」有害。
- 有效:query「写代码几年」→ 假想文档「8年Java后端开发经验」→ 词类匹配,BM25 从 0 跳到 16.34
- 有害:query「他念到什么学历了」→ 假想文档编造「上海交通大学、GPA 3.8」→ 真实语料只有「某211高校」→ 高 IDF 噪音把检索带跑
Query 改写:多轮对话的救命稻草
10 条多轮对话,裸 last_query 真命中 0/10。改写后:
链路 Hit@3 P@3 MRR
裸 last_query 0.200 0.067 0.248
改写后 query 0.700 0.233 0.729
改写增益: Hit@3 +50.0pt | MRR +0.481改写增益远大于 HyDE 增益,原因:多轮对话的 baseline 实在太差("效果呢"、"框架呢"这种 query 单独检索一条都命不中),有巨大的提升空间。
三层分工
多轮对话 → Query 改写 → HyDE 生成 → BM25 召回(k=12) → Rerank 精排(Top-3) → LLM 生成
│ │ │ │
│ │ │ └─ 窗口内重排
│ │ └─ 让原来找不到的文档被命中
│ └─ 跨过字面鸿沟
└─ 补全省略和代词三层各有各的靶子,互不重叠,能而且应该叠加。
四、Ragas:端到端评估的三个指标和它的盲区
三管道对比
| 管道 | Faithfulness | ContextPrecision | ContextRecall |
|---|---|---|---|
| baseline(BM25 Top-3,无拒答约束) | 0.859 | 0.683 | 0.858 |
| baseline + 拒答约束 | 0.952 | 0.683 | 0.858 |
| rerank + 拒答约束 | 0.935 | 0.800 | 0.925 |
两个结论:
- 拒答约束把 Faithfulness 从 0.859 拉到 0.952(+9.3pt),ContextPrecision/Recall 纹丝不动--prompt 工程只影响生成,不影响检索
- Rerank 把 ContextPrecision 从 0.683 拉到 0.800(+11.7pt),Faithfulness 基本不动--Rerank 改的是 context 质量,主要影响 context 类指标
改动哪一段,指标就动哪一段。这说明三个指标确实在测不同的东西。
Faithfulness 的盲区
最有意思的发现:三条语料答不了的拒答题,即使不加拒答约束,模型也会说"资料没有"然后罗列一堆来自 context 的正确废话。每句话都忠实于 context,Faithfulness 高达 0.88-1.0,但实际是答非所问。
Faithfulness 是"没说谎"的下限指标,不是"答得好"的质量指标。 生产里必须配 Answer Relevancy 一起看,单看 Faithfulness 会被"正确的废话"骗。
LLM-as-judge 的偏差
- self-preference bias:judge 和被评模型用同一个模型会系统性偏高
- 推理模型做 judge 容易截断:reasoning_tokens 吃光 max_tokens 预算 → 正文没输出 → NaN。生产用非推理模型做 judge,或者给足 token 预算
- 必须人工抽检对齐:LLM 裁判只能筛大部分,不能当终裁
五、RAG v2 综合优化:各改动的 ROI
把四天的优化集成到一条流水线,对比 baseline:
| 优化措施 | 作用层 | 最大增益 | 代价 | ROI |
|---|---|---|---|---|
| 拒答 prompt | 生成 | Faithfulness +9.3pt | 0(改 prompt) | ⭐⭐⭐⭐⭐ |
| Rerank (k=12) | 精排 | Hit@3 +10pt, MRR +0.162 | 12 次 LLM 调用 | ⭐⭐⭐⭐ |
| Query 改写 | 召回 | Hit@3 +50pt(多轮) | 1 次 LLM 调用 | ⭐⭐⭐⭐⭐ |
| HyDE | 召回 | MRR +0.236(focus) | 1 次 LLM 调用 | ⭐⭐⭐ |
先做 ROI 最高的。 拒答 prompt 零成本提 9.3pt,Query 改写一次调用提 50pt(多轮场景)。Rerank 效果好但代价最大。HyDE 在特定 query 上有效但有实体污染风险。
不是加越多越好,是知道哪个改动值多少分。
六、完整 RAG v2 流水线
用户输入
│
├─ 多轮对话?─→ Query 改写(补全省略和代词)
│
├─ query 太短/口语化?─→ HyDE 生成假想文档
│
▼
BM25 召回 (recall_k=12)
│
▼
LLM Rerank 精排 → Top-3
│
▼
拒答约束 prompt + context → LLM 生成
│
▼
答案生产级改进方向:
- Rerank 换 Cross-Encoder(BGE-Reranker),延迟从 12-24 秒降到 30-50ms
- judge 换独立模型消除 self-preference bias
- 补 Answer Relevancy 指标(需要 embedding 权限)
- 评测集扩大到 100+ 条,降低单条抖动影响
七、踩坑 Top 5
- 分词器翻转结论符号:virtualenv 看不见系统 jieba → 降级成 char-bigram → Rerank 从 +0.032 变成 −0.050。每次实验必须打环境指纹
- 推理模型 max_tokens 有偏截断:越需要仔细判断的文档思考越久 → 被截断 → 系统性把最难判断的文档排在最后。不是随机噪音,是有偏偏差
- BM25 全 0 分稳定排序返回伪结果:9/12 文档全 0 分,sort 返回入库顺序,看起来"有结果"其实完全随机
- system role 让模型静默返回空串:finish_reason='stop',reasoning_tokens=0,max_tokens 加到 800 也一样。改成单条 user 消息才正常
- temperature=0 不保证确定性:同一份代码同一 prompt,D15 一次 0 分一次 2 分。重要结论跑 2-3 次看稳定性
总结
四天实验,20 条 query + 10 条多轮,从"跑通了"到"量化了"。
三个最值钱的结论:
- Rerank 生效需要 recall_k > final_k,否则数学上必然零增益。很多人说"Rerank 没用",真实原因是召回窗口和最终窗口一样大
- HyDE 补词类有效补实体有害,判据是看 LLM 生成的是领域通用词还是具体实体
- Faithfulness 是下限不是质量,"正确的废话"也能高分,必须配 Answer Relevancy 一起看
如果面试官问"你怎么证明你的 RAG 好",我的回答是:三层量化(Hit@K / ContextPrecision / Faithfulness),三管道消融(baseline / +拒答 / +Rerank),每个改动对应哪个指标变动可量化、可归因。不是"我觉得变好了",是"我证明变好了多少,而且知道为什么"。
参考:Day 1 Rerank 实验 (
rerank_experiment.py) / Day 2 HyDE 实验 (hyde_experiment.py) / Day 3 Ragas 评估 (ragas_eval.py) / 评测集 (qa_dataset.py,corpus.py)