AI 输出质量评估框架
AI 应用的离线质量评估框架:golden dataset 构建、自动化 eval pipeline、RAG 检索质量评估(precision@k、recall@k)、LLM 输出评估(faithfulness、relevance)。
#type / concept
#status / growing
#tech / ai
[!info] related notes
- 所属 MOC: BodySense MOC
- 运行时护栏: RAG Faithfulness 校验, Red Flag 检测器
- 质量门禁: [[ai-quality-gates|AI 质量门禁]]
- 知识库: RAG 知识库设计
AI 输出质量评估框架
这篇解决什么问题
rag-faithfulness-checker 和 red-flag-detector 是运行时护栏——在生产环境中拦截错误输出。但它们不能回答:
- RAG 检索出来的结果和用户问题相关吗?(检索质量)
- 换一个 prompt 之后,回答质量变好了还是变差了?(回归测试)
- 换一个 embedding 模型后,检索质量变好了还是变差了?(模型对比)
这些问题需要离线 eval pipeline 来回答。
Eval 的两个层次
| 层次 | 评估对象 | 指标 | 时机 |
|---|---|---|---|
| Retrieval Eval | RAG 检索结果 | precision@k, recall@k, MRR | 换 embedding 模型、调检索参数 |
| Generation Eval | LLM 最终输出 | faithfulness, relevance, safety | 换 prompt、换模型、调温度 |
两个层次独立评估:检索结果好不代表最终输出好,最终输出差不一定是检索的问题。
Golden Dataset 构建
Eval 的基础是一个标注好的 golden dataset:
@dataclass
class GoldenCase:
query: str # 用户问题
relevant_doc_ids: list[int] # 相关知识文档 ID(ground truth)
expected_answer: str | None # 期望回答(可选)
expected_intent: str | None # 期望意图(可选)
red_flag_expected: bool = False # 是否应触发 red flag
数据来源
| 来源 | 优点 | 缺点 |
|---|---|---|
| 人工标注 | 质量最高 | 成本高、数量少 |
| 历史对话筛选 | 真实场景 | 需要人工审核 |
| LLM 辅助生成 | 量大 | 需要人工校验 |
| 知识库反向构造 | 覆盖全面 | 可能不自然 |
最小可用方案
# 从知识库反向构造 golden cases
golden_cases = []
for unit in knowledge_units:
# 每个知识单元至少有一个查询应该命中它
case = GoldenCase(
query=f"{unit.title}怎么改善?", # 简单构造
relevant_doc_ids=[unit.id],
expected_answer=None,
expected_intent="exercise",
)
golden_cases.append(case)
这不是最完美的方案,但能在没有人工标注的情况下快速启动 eval。
Retrieval Eval 指标
Precision@k
检索出的前 k 个结果中,有多少是相关的:
def precision_at_k(retrieved_ids: list[int], relevant_ids: set[int], k: int) -> float:
top_k = retrieved_ids[:k]
hits = sum(1 for doc_id in top_k if doc_id in relevant_ids)
return hits / k
Recall@k
所有相关结果中,有多少被检索出来了:
def recall_at_k(retrieved_ids: list[int], relevant_ids: set[int], k: int) -> float:
top_k = retrieved_ids[:k]
hits = sum(1 for doc_id in top_k if doc_id in relevant_ids)
return hits / len(relevant_ids)
MRR (Mean Reciprocal Rank)
第一个相关结果排在第几位:
def reciprocal_rank(retrieved_ids: list[int], relevant_ids: set[int]) -> float:
for i, doc_id in enumerate(retrieved_ids):
if doc_id in relevant_ids:
return 1.0 / (i + 1)
return 0.0
评估流程
async def evaluate_retrieval(golden_cases: list[GoldenCase], retriever):
results = []
for case in golden_cases:
retrieved = await retriever.search(case.query, top_k=10)
retrieved_ids = [r.id for r in retrieved]
relevant_ids = set(case.relevant_doc_ids)
results.append({
"query": case.query,
"precision@5": precision_at_k(retrieved_ids, relevant_ids, 5),
"recall@5": recall_at_k(retrieved_ids, relevant_ids, 5),
"mrr": reciprocal_rank(retrieved_ids, relevant_ids),
})
# 汇总
avg_p5 = sum(r["precision@5"] for r in results) / len(results)
avg_r5 = sum(r["recall@5"] for r in results) / len(results)
avg_mrr = sum(r["mrr"] for r in results) / len(results)
return {"precision@5": avg_p5, "recall@5": avg_r5, "mrr": avg_mrr}
Generation Eval 指标
Faithfulness
LLM 输出中的每个声称是否都能在检索结果中找到依据:
async def eval_faithfulness(answer: str, retrieved_docs: list[str], llm) -> float:
# 让 LLM 判断每个声称是否有依据
prompt = f"""判断以下回答中的每个声称是否能在参考文档中找到依据。
回答:{answer}
参考文档:{chr(10).join(retrieved_docs)}
对每个声称,输出 grounded 或 ungrounded。
返回 JSON: {{"claims": [{{"text": "...", "verdict": "grounded|ungrounded"}}]}}"""
result = await llm.chat(prompt)
claims = parse_claims(result)
grounded = sum(1 for c in claims if c["verdict"] == "grounded")
return grounded / len(claims) if claims else 0.0
Relevance
LLM 输出是否回答了用户的问题:
async def eval_relevance(query: str, answer: str, llm) -> float:
prompt = f"""判断以下回答是否解决了用户的问题。打分 1-5。
1=完全无关,3=部分相关,5=完全解决问题。
用户问题:{query}
回答:{answer}
只返回数字。"""
score = int((await llm.chat(prompt)).strip())
return score / 5.0
Safety
是否触发了 red flag 检测:
def eval_safety(answer: str, red_flag_detector) -> bool:
result = red_flag_detector.detect(answer)
return not result.has_red_flags # True = 安全
完整 Eval Pipeline
async def run_eval(golden_cases, retriever, llm, red_flag_detector):
results = {
"retrieval": [],
"generation": [],
}
for case in golden_cases:
# 1. Retrieval eval
retrieved = await retriever.search(case.query, top_k=10)
retrieval_metrics = evaluate_single_retrieval(retrieved, case)
results["retrieval"].append(retrieval_metrics)
# 2. Generation eval
context = "\n".join([r.content for r in retrieved])
answer = await llm.chat(build_prompt(case.query, context))
gen_metrics = {
"faithfulness": await eval_faithfulness(answer, [r.content for r in retrieved], llm),
"relevance": await eval_relevance(case.query, answer, llm),
"safety": eval_safety(answer, red_flag_detector),
}
results["generation"].append(gen_metrics)
# 3. 汇总报告
return generate_report(results)
评估报告示例
=== AI Eval Report ===
Date: 2026-06-26
Cases: 50
Retrieval:
Precision@5: 0.72 (target: ≥0.7) ✅
Recall@5: 0.85 (target: ≥0.8) ✅
MRR: 0.68 (target: ≥0.6) ✅
Generation:
Faithfulness: 0.91 (target: ≥0.9) ✅
Relevance: 0.78 (target: ≥0.8) ⚠️ BELOW TARGET
Safety: 1.00 (target: =1.0) ✅
Failed cases:
- "肩膀疼怎么练" → relevance=0.4, retrieved wrong exercise
- "颈椎前伸的原因" → faithfulness=0.6, hallucinated cause
与运行时护栏的关系
| 维度 | 离线 Eval | 运行时护栏 |
|---|---|---|
| 时机 | 上线前、变更后 | 生产环境每次请求 |
| 目的 | 发现系统性退化 | 拦截个别错误输出 |
| 指标 | precision, recall, faithfulness 率 | 单次 faithfulness 通过/不通过 |
| 成本 | 需要 golden dataset | 无需标注数据 |
两者互补:离线 eval 确保系统整体质量,运行时护栏兜底个案。
设计要点
- Golden dataset 要持续维护 — 知识库更新后同步更新 golden cases
- 先跑通最小方案 — 从知识库反向构造 cases,不需要一开始就人工标注
- Retrieval 和 Generation 分开评估 — 混在一起无法定位问题
- Eval 要集成到 CI — prompt 变更后自动跑 eval,防止质量退化(见
ai-quality-gates) - 保存每次 eval 的结果 — 用于对比不同版本的质量趋势
常见错误
用运行时护栏代替离线 eval
# ❌ "有 faithfulness checker 就够了"
# 运行时护栏只能拦截个别错误,无法发现系统性退化
# ✅ 离线 eval + 运行时护栏都要有
Golden dataset 太小
# ❌ 只有 5 个 case,结果没有统计意义
# 一个 case 的波动就影响 20% 的指标
# ✅ 至少 50 个 case,覆盖主要场景
只看平均值不看失败 case
# ❌ "faithfulness 平均 0.9,很好"
# 但可能有 2 个 case 是 0.0,需要具体分析
# ✅ 报告中列出所有低于阈值的 case