没有评估,所有调优都是玄学
没有评估的 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@10 和 MRR——前者反映"该召回的有没有召回"、后者反映"召回得是不是靠前"。两者一起看就能覆盖 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 datasetsfrom 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 是最少需要的评估:
- 20 条 smoke test:人工写的最常见 query,断言答案里必须包含某些关键词,每次 commit 都跑
- 200 条合成评测集:用 GPT-4 从你的文档自动生成(query, gold chunk)对,跑 Recall@5 / MRR
- 50 条人工标注样本:每月人工抽检答案质量,关注 faithfulness 和 hallucination
- 真实日志监控:用户最终是否复制了答案、是否追问了同一问题(追问意味着第一次没答好)
这些跑起来之后,你换 chunk_size、换 embedding 模型、加 reranker,每次改动都能用具体数字说话——这就是评估的意义。
收尾
把这五篇番外(11~15)放在一起看,就是 RAG 工程的完整地图:
- 番外 11 讲清楚了 LLM 是怎么演化出来的,给整个 RAG 提供历史坐标
- 番外 12 讲 embedding 模型选型——RAG 第一道生死决定
- 番外 13 讲文本切分——RAG 第二道生死决定
- 番外 14 讲向量库 + 混合检索 + reranker——召回环节的工程栈
- 番外 15 讲评估——把所有调优变成可量化的工程
读完五篇你应该已经具备从零搭一个生产级 RAG 系统的所有知识。但真正的功夫是在自己的业务数据上反复跑评测、看 case、调参数——这些是看博客学不到的,只能在真实数据上磨出来。
下一篇我们回到主线——第 06 篇 RAG 实战,把这五篇番外里讲的所有零件用一段实际能跑的代码串起来,搭一个真正能回答关于一堆 markdown 文档的 RAG。
参考资料
- Ragas 官方文档 — RAG 评估的事实标准库
- Ragas Metrics 详解 — 每个指标的定义和原理
- TruLens — RAG 评估 + 可观测性平台
- DeepEval — pytest 风格的 LLM 测试框架
- LlamaIndex Evaluation 模块
- BEIR Benchmark — 检索任务标准评测套件,可以借它的 query 风格做合成
- LMSYS Chatbot Arena 论文 — LLM-as-judge 局限性的系统讨论
版权声明: 如无特别声明,本文版权归 sshipanoo 所有,转载请注明本文链接。
(采用 CC BY-NC-SA 4.0 许可协议进行授权)
本文标题:番外 15:RAG 评估方法,把感觉变成数字
本文链接:https://www.sshipanoo.com/blog/ai/ai-for-python/番外15-RAG评估方法/
本文最后一次更新为 天前,文章中的某些内容可能已过时!