AI 输出质量评估框架

AI 应用的离线质量评估框架:golden dataset 构建、自动化 eval pipeline、RAG 检索质量评估(precision@k、recall@k)、LLM 输出评估(faithfulness、relevance)。

#type / concept #status / growing #tech / ai

[!info] related notes

AI 输出质量评估框架

这篇解决什么问题

rag-faithfulness-checkerred-flag-detector运行时护栏——在生产环境中拦截错误输出。但它们不能回答:

  • RAG 检索出来的结果和用户问题相关吗?(检索质量)
  • 换一个 prompt 之后,回答质量变好了还是变差了?(回归测试)
  • 换一个 embedding 模型后,检索质量变好了还是变差了?(模型对比)

这些问题需要离线 eval pipeline 来回答。

Eval 的两个层次

层次评估对象指标时机
Retrieval EvalRAG 检索结果precision@k, recall@k, MRR换 embedding 模型、调检索参数
Generation EvalLLM 最终输出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 确保系统整体质量,运行时护栏兜底个案。

设计要点

  1. Golden dataset 要持续维护 — 知识库更新后同步更新 golden cases
  2. 先跑通最小方案 — 从知识库反向构造 cases,不需要一开始就人工标注
  3. Retrieval 和 Generation 分开评估 — 混在一起无法定位问题
  4. Eval 要集成到 CI — prompt 变更后自动跑 eval,防止质量退化(见 ai-quality-gates
  5. 保存每次 eval 的结果 — 用于对比不同版本的质量趋势

常见错误

用运行时护栏代替离线 eval

# ❌ "有 faithfulness checker 就够了"
# 运行时护栏只能拦截个别错误,无法发现系统性退化

# ✅ 离线 eval + 运行时护栏都要有

Golden dataset 太小

# ❌ 只有 5 个 case,结果没有统计意义
# 一个 case 的波动就影响 20% 的指标

# ✅ 至少 50 个 case,覆盖主要场景

只看平均值不看失败 case

# ❌ "faithfulness 平均 0.9,很好"
# 但可能有 2 个 case 是 0.0,需要具体分析

# ✅ 报告中列出所有低于阈值的 case
创建于 2026/6/26 更新于 2026/6/26