RAG 的效果争论通常这样结束:一方说"我这版明显更好",另一方说"我试了不行",谁都拿不出证据。可评估的东西才能被改进——RAG 尤其如此,因为它的效果由四个可独立变动的环节决定:切分、embedding、融合、生成。
评估要做三件事:分层定位问题、有一份能重复跑的评测集、把评测挂进回归流程。
1. 三层评估:别用端到端指标定位问题
| 层级 | 评什么 | 指标 | 发现问题的能力 |
|---|---|---|---|
| 检索层 | 相关文档有没有被召回、排得对不对 | recall@k、precision@k、MRR | 定位到具体召回失败 |
| 生成层 | 答得有没有依据、对不对题 | faithfulness、answer relevance | 定位到 prompt 与上下文问题 |
| 端到端 | 用户拿到的答案是否可用 | 人工抽检通过率、追问率 | 只能告诉你"变差了" |
端到端指标下降 5 个点,可能是切分改了、模型换了、也可能是评测集分布变了。只有分层评测才能把"变差了"变成"检索层 recall 从 0.82 掉到 0.61"。
def recall_at_k(ranked_ids, gold_ids, k=10):
got = set(ranked_ids[:k])
return len(got & set(gold_ids)) / len(gold_ids) if gold_ids else 0.0
def precision_at_k(ranked_ids, gold_ids, k=10):
got = ranked_ids[:k]
return len(set(got) & set(gold_ids)) / max(len(got), 1)
def mrr(ranked_ids, gold_ids):
for i, d in enumerate(ranked_ids, 1):
if d in set(gold_ids):
return 1.0 / i
return 0.0
三个指标回答不同问题:recall 关心"有没有"、precision 关心"噪声多不多"、MRR 关心"第一条对不对"。只报 recall 会掩盖排序问题——把正确答案从第 1 位挪到第 9 位,recall@10 完全不变。
2. 构建评测集的三种方式
| 方式 | 做法 | 成本 | 覆盖真实分布 | 常见缺陷 |
|---|---|---|---|---|
| 真实问题日志 | 采样线上 query + 标注 gold | 高(标注贵) | 最好 | 长尾问题被采样掉 |
| LLM 合成问题 | 从 chunk 反向生成问题 | 低 | 差 | 问题与原文用词过于相似 |
| 专家标注 | 领域专家出题并给答案 | 最高 | 中 | 数量少,更新慢 |
推荐组合:合成问题铺量 + 真实日志采样校准 + 专家标注守住关键业务。 合成问题的最大价值不是质量,是能覆盖到日志里没有的场景,比如新上线的文档。
SYNTH_PROMPT = """阅读下面的文档片段,生成 3 个用户可能会问的问题。
要求:不出现片段中的原句连续 8 个字以上;至少一问涉及具体数值或版本号。
输出 JSON 数组,每项含 question 与 must_include(答案必须包含的字面串)。
片段:
{chunk}"""
def synth_questions(chunk, llm):
out = llm(SYNTH_PROMPT.format(chunk=chunk["text"]))
return [{"question": q["question"], "gold_chunk_ids": [chunk["id"]],
"must_include": q["must_include"]} for q in json.loads(out)]
注意"不出现原句连续 8 字"这个约束:不加它,合成问题会变成原文的复述,向量检索几乎必然命中,评测虚高。
3. RAGAS 类指标的定义与局限
| 指标 | 大致定义 | 依赖 | 局限 |
|---|---|---|---|
| faithfulness | 答案中的陈述有多少能由上下文支持 | LLM 判定 | 判定器本身会出错,且偏好"话少"的答案 |
| answer relevance | 答案是否回应了问题 | LLM 判定 | 对"答非所问但正确"不敏感 |
| context precision | 上下文中相关内容的占比 | gold 标注 | 依赖标注质量 |
| context recall | 必要信息是否都在上下文里 | gold 标注 | 跨块信息难以判定 |
关键认识:这些指标大多是"用模型评模型",它们的绝对值不可跨项目比较,只能在同一套判定器下比较相对变化。 所以开工前要固定两件事:判定模型与版本、判定 prompt。
| 固定项 | 不固定的后果 |
|---|---|
| 判定模型的版本 | 换版本后分数整体位移,难以判断是系统变好还是评委变松 |
| 判定 prompt | 同上 |
| 采样条数与随机种子 | 小样本波动被当成趋势 |
| 温度参数 | 同一输入分数抖动 |
4. k 值选择与召回的边际收益
k 不是越大越好:k 增大提高 recall,但把噪声一起送进上下文,反而压低生成层指标。
| k | recall@k | 上下文噪声 | 生成层表现 |
|---|---|---|---|
| 3 | 低 | 低 | 常常信息不足 |
| 5 | 中 | 低 | 多数场景够用 |
| 10 | 高 | 中 | 需要 rerank 配合 |
| 20 | 接近饱和 | 高 | 必须重排 + 截断 |
| 50+ | 饱和 | 极高 | 只适合作为 rerank 的候选池 |
def sweep_k(retriever, evalset, ks=(3, 5, 10, 20, 50)):
for k in ks:
rs = [recall_at_k(retriever.search(e["q"], k=k), e["gold"], k) for e in evalset]
print(f"k={k:<3} recall@{k}={sum(rs)/len(rs):.3f}")
典型输出(示意):
k=3 recall@3=0.581
k=5 recall@5=0.712
k=10 recall@10=0.834
k=20 recall@20=0.871
k=50 recall@50=0.889
从 10 到 50 只涨 5 个点——这 5 个点值不值那 40 条噪声,取决于是否有 rerank。有重排就取大 k,没有就取小 k。
5. 离线评测与 A/B 的差异
| 维度 | 离线评测 | 在线 A/B |
|---|---|---|
| 关注 | recall / faithfulness | 点击、追问、停留、转人工 |
| 样本 | 固定评测集 | 真实流量分流 |
| 周期 | 分钟级 | 天级(要等样本量) |
| 能发现 | 明确退化 | 用户实际感知变化 |
| 会漏掉 | 用户问法的真实分布 | 小流量下噪声大 |
离线评测负责"不许退化",A/B 负责"是否值得上线"。 顺序不可反:先离线卡住召回底线,再 A/B 看业务指标。
分流时要注意查询类型分层——如果实验组恰好分到更多"查型号"的请求,任何改动都会显得更差。
def stratify(queries, buckets=("natural", "exact_term", "multi_turn")):
def kind(q):
if q["turns"] > 1:
return "multi_turn"
return "exact_term" if any(c.isdigit() for c in q["text"]) else "natural"
return {b: [q for q in queries if kind(q) == b] for b in buckets}
6. 把评测做成回归测试
改 chunk 策略、换模型、调融合参数——每一次都需要跑同一套评测。所以它必须是命令,而不是一次性的 notebook。
# eval/regression.yaml
baseline:
recall@10: 0.80
mrr: 0.66
faithfulness: 0.91
gate: # 低于阈值即失败
recall@10: -0.03 # 允许的最大退化(百分点)
mrr: -0.02
faithfulness: -0.02
dataset:
path: eval/sets/core_v4.jsonl
size: 400
report:
out: eval/reports/
keep_last: 30
import sys, yaml
def gate(current, baseline, limits):
fails = []
for key, max_drop in limits.items():
if current[key] - baseline[key] < max_drop:
fails.append(f"{key}: {baseline[key]:.3f} -> {current[key]:.3f}")
return fails
cfg = yaml.safe_load(open("eval/regression.yaml", encoding="utf-8"))
fails = gate(metrics, cfg["baseline"], cfg["gate"])
print("\n".join(fails) or "all gates passed")
sys.exit(1 if fails else 0)
回归的关键是报告留档且可对比:keep_last: 30 让"最近三十次改动的曲线"随时可取,退化往往不是一次造成的。
7. 标注成本控制:分层抽样
全量标注不现实,随机抽样又容易漏掉稀有但重要的查询类型。
| 抽样策略 | 做法 | 何时用 |
|---|---|---|
| 随机抽样 | 均匀采 | 先看整体分布 |
| 分层抽样 | 按查询类型配额 | 有明确类型划分时 |
| 不确定性抽样 | 优先标模型最犹豫的 | 提升标注的信息量 |
| 异常抽样 | 拒答/追问的样本 | 找失败模式 |
def stratified_sample(logs, quota_per_type=50, key="query_type"):
out, seen = [], {}
for row in reversed(logs): # 从新到旧
t = row.get(key, "unknown")
if seen.get(t, 0) < quota_per_type:
out.append(row)
seen[t] = seen.get(t, 0) + 1
return out, seen
reversed 不是为了顺序好看:新数据更能代表当前分布,而评测集的价值随语料变化快速衰减。
8. 排错:五种"评了但没用"
| 现象 | 原因 | 处理 |
|---|---|---|
| 分数一直很高但用户不满意 | 评测集来自合成、与原句过于相似 | 混入真实日志采样 |
| 每次指标都在抖 | 样本太小 / 判定温度不为 0 | 扩到 300+,判定固定温度 |
| recall 很好但答案不全 | 跨块信息未进上下文 | 评测加"完整性"项 |
| 换了模型分数整体位移 | 判定器也换了 | 固定判定模型与 prompt |
| 改完没人知道好不好 | 没有基线快照 | 每次改动留报告并可对比 |
第一条最常见,也最容易被误认为"系统已经不错了"。
9. 边界
| 场景 | 建议 |
|---|---|
| 语料每天更新 | 评测集按月补充新分布样本 |
| 只有几十条 query | 先人工全量过一遍,别急着上指标 |
| 内部知识库、用户少 | 端到端人工抽检比 LLM 判定更可靠 |
| 强合规场景 | faithfulness 必须人工复核关键样本 |
| 多租户 | 评测集要覆盖每个租户的代表样本 |
10. 自测
- 端到端指标下降,为什么不适合直接定位到某个环节?
- 把正确答案从第 1 位挪到第 9 位,recall@10 和 MRR 各有什么变化?
- 合成问题时为什么要限制"不出现原文连续 8 字"?
- k 从 10 涨到 50,recall 只涨 5 个点,这个收益该不该拿?
- 离线评测与 A/B 各自的职责边界是什么?
- 分层抽样相比随机抽样解决了什么问题?