2456 字
约 8 分钟
13
S20-X08 · 检索质量怎么评估:recall@k、MRR 与 RAGAS

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. 端到端指标下降,为什么不适合直接定位到某个环节?
  2. 把正确答案从第 1 位挪到第 9 位,recall@10 和 MRR 各有什么变化?
  3. 合成问题时为什么要限制"不出现原文连续 8 字"?
  4. k 从 10 涨到 50,recall 只涨 5 个点,这个收益该不该拿?
  5. 离线评测与 A/B 各自的职责边界是什么?
  6. 分层抽样相比随机抽样解决了什么问题?
S20-X08 · 检索质量怎么评估:recall@k、MRR 与 RAGAS
https://www.clxhxhhr.top/posts/5683/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。