没有评估,所有调优都是玄学

没有评估的 RAG 是玄学

前面四篇番外讲了 embedding 选型、切分、向量库、混合检索、reranker——任意一个环节都有十几种选择。换 chunk_size 从 400 到 600、加上 reranker、把 BM25 权重从 0.5 调到 0.7——你怎么知道这次改动让系统变好了还是变差了?

靠"试一两个 query 看看"是行不通的。RAG 系统的失败模式很微妙——可能在 90 个常见问题上都 OK,但在 10 个边角问题上召回完全错;可能召回都对了但 LLM 回答时编造了细节;可能在中文 query 上工作,遇到英文 query 就崩。这些都得靠系统化的评估才能发现。

这一篇就是把 RAG 评估这件事系统讲一遍——评什么、怎么评、用什么工具、怎么把它接进 CI 做回归测试。

RAG 评估的两个层面

RAG 系统是 检索 + 生成 两段式管线,评估也得分两层:

检索质量(Retrieval Quality)——你召回的 top-k 文档里,相关的有几篇?相关的排得够前吗?这一层不涉及 LLM,纯粹评检索栈。

回答质量(Generation Quality)——LLM 基于召回的文档,给出的最终答案对不对、有没有编造、是否真正回答了问题?这一层和 LLM 直接相关。

两层是独立的——检索全对、LLM 也可能编造;检索部分错、LLM 也可能靠常识答对。必须分开评,否则你 debug 时不知道问题出在哪。

第一层:检索质量怎么评

要评检索,得先有个评测集——一组(query,相关文档列表)的标注对。比如:

query: "如何重置密码"
relevant_docs: ["doc_023", "doc_087"]    # 这两篇真正包含答案

有了这种标注,跑一遍检索得到 top-k 列表,就能算各种指标。常用的有四个:

Hit Rate @ k:top-k 里至少有一篇相关文档,就算命中。简单粗暴,但忽略了"排在第几位"的信息。

def hit_rate_at_k(retrieved: list[str], relevant: set[str], k: int) -> int:
    return int(any(doc_id in relevant for doc_id in retrieved[:k]))

Recall @ k:top-k 里相关文档的占比(相对于全部相关文档)。多数 RAG 关心 Recall@10——你只塞 10 个 chunk 给 LLM,希望相关的尽量在这 10 个里。

def recall_at_k(retrieved: list[str], relevant: set[str], k: int) -> float:
    if not relevant:
        return 0.0
    hits = sum(1 for d in retrieved[:k] if d in relevant)
    return hits / len(relevant)

MRR(Mean Reciprocal Rank):相关文档第一次出现在第几位的倒数。第 1 位是 1.0、第 2 位是 0.5、第 5 位是 0.2。能反映第一篇相关文档排得多前——对那种"只要有一篇相关的就够了"的场景特别合适。

def reciprocal_rank(retrieved: list[str], relevant: set[str]) -> float:
    for rank, doc_id in enumerate(retrieved, start=1):
        if doc_id in relevant:
            return 1.0 / rank
    return 0.0

NDCG @ k(Normalized Discounted Cumulative Gain):考虑了相关性的分级(不是 0/1,可能有 0/1/2/3 表示无关 / 有点相关 / 很相关 / 极相关),并且按位置加权折减。学术 IR 评测的金标准,但需要更细的标注。

import numpy as np

def dcg(relevances: list[float]) -> float:
    return sum(rel / np.log2(i + 2) for i, rel in enumerate(relevances))

def ndcg_at_k(retrieved_ids: list[str], relevance_scores: dict[str, float], k: int) -> float:
    """relevance_scores: {doc_id -> 0/1/2/3 的相关性分级}"""
    actual = [relevance_scores.get(d, 0.0) for d in retrieved_ids[:k]]
    ideal = sorted(relevance_scores.values(), reverse=True)[:k]
    if not ideal or sum(ideal) == 0:
        return 0.0
    return dcg(actual) / dcg(ideal)

实战经验:先盯 Recall@10MRR——前者反映"该召回的有没有召回"、后者反映"召回得是不是靠前"。两者一起看就能覆盖 90% 的检索质量信号。NDCG 在你做学术对比或细粒度调优时再加。

评测集怎么造

评测集是 RAG 工程里最容易被忽视的部分——很多人觉得"造一份评测集太麻烦",结果调系统时全靠感觉。

有三条路造评测集:

人工标注:最准但最贵。从真实用户日志里抽 100~200 个有代表性的 query,让业务方/客服/标注员标出每个 query 真正应该召回的文档。这是金标准。

LLM 自动生成:让 LLM 反过来从你的文档生成 query。给 LLM 一段 chunk,让它生成"针对这段内容用户可能会问什么"——这样自动得到(query,相关 chunk)对。用 GPT-4 / Claude 来做,质量足够。生成 1000 对评测数据可能成本几美金。

from openai import OpenAI
client = OpenAI()

PROMPT = """以下是一段文档内容。请生成 3 个一个用户可能会问的、能从这段文档中找到答案的问题。
要求每个问题都是自然语言、避免太宽泛、避免直接复述原文。

文档:
{content}

返回 JSON 格式:{{"queries": ["问题1", "问题2", "问题3"]}}"""

def synthesize_queries(chunk_id: str, content: str) -> list[dict]:
    resp = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": PROMPT.format(content=content)}],
        response_format={"type": "json_object"},
    )
    queries = json.loads(resp.choices[0].message.content)["queries"]
    return [{"query": q, "relevant_chunk_ids": [chunk_id]} for q in queries]

用真实用户日志 + 弱标注:如果你的产品已经在线,把用户的真实 query + 用户最终点击 / 复制的回答关联到的文档作为弱信号。规模大、便宜、噪声多——和上面两种结合用。

起手用 LLM 合成 200~500 条评测做 baseline,等系统上线后逐步用真实日志替换。完全不要等"理想评测集"做完才开始评估——一个不完美的评测集也比没有强 100 倍。

第二层:回答质量怎么评

检索的对了之后,LLM 基于这些文档给出的回答质量怎么衡量?这一层有四个核心指标:

Faithfulness(忠实度):答案里的每个事实声明,是否都能在召回的上下文里找到依据?这个指标抓"幻觉"——LLM 编造文档里没有的事实。

Answer Relevance(答案相关性):答案是否真的回答了问题?比如问"密码怎么重置",答了一通"什么是密码"——相关性低。

Context Relevance(上下文相关性):召回的上下文里有多少和问题真正相关?这个指标和检索质量重叠,但它评的是"哪怕召回的 chunk 里有相关内容,也可能夹杂大量无关信息"。

Context Recall:标准答案里需要的信息,召回的上下文是否都覆盖了?这是从"参考答案"反推上下文是否足够。

这四个指标合起来叫 RAG Triad(前三个)+ Context Recall。Ragas 库把它们都实现了。

Ragas:RAG 评估的事实标准工具

Ragas 是当前 Python 生态里最成熟的 RAG 评估库,基本所有指标都自动算(用 LLM-as-judge):

pip install ragas datasets
from ragas import evaluate
from ragas.metrics import (
    faithfulness,
    answer_relevancy,
    context_precision,
    context_recall,
)
from datasets import Dataset

# 准备评测样本
samples = {
    "question": ["如何重置密码?", "支付失败怎么办?"],
    "contexts": [
        ["重置密码的步骤是登录后...", "..."],   # 召回的 chunk 列表
        ["支付失败的常见原因...", "..."],
    ],
    "answer": [
        "你可以登录后在设置里点击'重置密码'...",  # LLM 生成的回答
        "支付失败一般是网络问题或卡余额不足...",
    ],
    "ground_truth": [                            # 参考答案(可选,给 context_recall 用)
        "登录系统 → 设置 → 安全 → 重置密码",
        "检查网络连接 → 检查卡余额 → 联系银行",
    ],
}
dataset = Dataset.from_dict(samples)

result = evaluate(
    dataset,
    metrics=[faithfulness, answer_relevancy, context_precision, context_recall],
)
print(result)
# {'faithfulness': 0.85, 'answer_relevancy': 0.91, 'context_precision': 0.78, 'context_recall': 0.83}

每个指标都是 0~1 之间,越大越好。

Ragas 内部用 LLM 来判断这些事——比如算 faithfulness 时,它先把答案拆成一组"原子事实声明",再让 LLM 判断每个声明是否在 context 里找得到依据。整个评测过程会调几十次 LLM API,所以对评测集大小有上限——评 100 条样本可能花 0.5~1 美金(GPT-4o-mini)。

LLM-as-judge 的几个坑

Ragas 这种"用 LLM 评估 LLM"的做法叫 LLM-as-judge。它解决了"自动算指标"的问题,但有几个实战必须知道的坑

评判用的 LLM 要比被评估的 LLM 至少强一档。让 GPT-4 评 GPT-3.5 的输出靠谱;反过来不行。强弱差太多评判会带偏。

LLM 有 position bias:让 LLM 比较两个答案 A 和 B,A 在前面通常更容易被评高。规避办法是同一对答案评两次——一次 A 在前、一次 B 在前——结果一致才采信。

LLM 对细节敏感度低。涉及具体数字、代码细节的对错,LLM 经常会"觉得差不多"。这种场景必须用基于正则 / exact match 的程序化评估。

评判结果有方差。同一对答案 LLM 评 5 次可能给出 0.6、0.8、0.7、0.7、0.9 不同的分。生产评估要么固定 seed、要么取多次平均。

实战建议:LLM-as-judge 适合做大规模快速筛查、不适合做最终决策。重要的发布前评估,最后还是得人工抽检几十条样本拍板。

别忘了"传统"指标:延迟、成本、稳定性

RAG 不是只有质量指标。生产系统还要看

  • P50 / P99 延迟:检索 + LLM 总耗时。Reranker 会让延迟翻倍,要权衡
  • 每次 query 成本:embedding API + LLM API + 向量库查询的总和
  • 缓存命中率:相同 query 是否命中缓存(embedding 缓存 + 答案缓存都重要)
  • Out-of-Distribution 拒答率:当 query 完全不在你文档覆盖范围时,系统是说"我不知道"还是硬编一个答案?

这些不需要 LLM-as-judge,用传统监控就行——但生产 RAG 这些指标和质量指标同等重要。延迟从 2 秒涨到 10 秒,质量提升 5% 也许根本不值。

把评估接进 CI:回归测试

最后一个最容易被忽略的实战环节——把评估自动化跑起来,每次系统改动都跑一遍。

至少做到这三件事:

最小评测集(smoke test):20~30 条最常见的 query,必须每次 commit 都跑一次。Recall@5、Faithfulness 等核心指标如果回退超过阈值(比如下降 5%),CI 就 fail。

# tests/test_rag_smoke.py
import pytest
from your_rag import answer

@pytest.mark.parametrize("query,must_contain", load_smoke_set())
def test_smoke(query: str, must_contain: list[str]):
    response = answer(query)
    for keyword in must_contain:
        assert keyword in response, f"'{keyword}' missing in answer to '{query}'"

完整评测集(nightly):500~1000 条,每天定时跑,结果存表,可以画质量随时间的趋势图。

A/B 评测脚本:当你想测"加 reranker 是否值得"时,跑一次新旧两版的对比,输出每个指标的 delta。

def compare_configs(eval_set, config_a: dict, config_b: dict):
    metrics_a = run_evaluation(eval_set, **config_a)
    metrics_b = run_evaluation(eval_set, **config_b)
    for metric in ["recall@10", "mrr", "faithfulness"]:
        delta = metrics_b[metric] - metrics_a[metric]
        print(f"{metric}: {metrics_a[metric]:.3f}{metrics_b[metric]:.3f}  ({delta:+.3f})")

把评估接进 CI 之后,你每次改动是有数据支撑的决策——而不是"感觉好像变好了"。这是 RAG 工程从手工艺到工程的本质区别。

最少跑通的评估脚手架

如果你正在搭一个新的 RAG,下面这套 starter 是最少需要的评估:

  1. 20 条 smoke test:人工写的最常见 query,断言答案里必须包含某些关键词,每次 commit 都跑
  2. 200 条合成评测集:用 GPT-4 从你的文档自动生成(query, gold chunk)对,跑 Recall@5 / MRR
  3. 50 条人工标注样本:每月人工抽检答案质量,关注 faithfulness 和 hallucination
  4. 真实日志监控:用户最终是否复制了答案、是否追问了同一问题(追问意味着第一次没答好)

这些跑起来之后,你换 chunk_size、换 embedding 模型、加 reranker,每次改动都能用具体数字说话——这就是评估的意义。

收尾

把这五篇番外(11~15)放在一起看,就是 RAG 工程的完整地图:

  • 番外 11 讲清楚了 LLM 是怎么演化出来的,给整个 RAG 提供历史坐标
  • 番外 12 讲 embedding 模型选型——RAG 第一道生死决定
  • 番外 13 讲文本切分——RAG 第二道生死决定
  • 番外 14 讲向量库 + 混合检索 + reranker——召回环节的工程栈
  • 番外 15 讲评估——把所有调优变成可量化的工程

读完五篇你应该已经具备从零搭一个生产级 RAG 系统的所有知识。但真正的功夫是在自己的业务数据上反复跑评测、看 case、调参数——这些是看博客学不到的,只能在真实数据上磨出来。

下一篇我们回到主线——第 06 篇 RAG 实战,把这五篇番外里讲的所有零件用一段实际能跑的代码串起来,搭一个真正能回答关于一堆 markdown 文档的 RAG。

参考资料

版权声明: 如无特别声明,本文版权归 sshipanoo 所有,转载请注明本文链接。

(采用 CC BY-NC-SA 4.0 许可协议进行授权)

本文标题:番外 15:RAG 评估方法,把感觉变成数字

本文链接:https://www.sshipanoo.com/blog/ai/ai-for-python/番外15-RAG评估方法/

本文最后一次更新为 天前,文章中的某些内容可能已过时!