AI评估框架:指标不够用了之后

去年在做一个 RAG(检索增强生成)系统时,这个问题特别明显——本地评估分数看着不错,上线后用户反馈「检索不准」「答非所问」。

AI 这东西有个很反直觉的特点:训练集表现好,真实环境里可能烂得要命。

评估框架到底在做什么

先澄清一个容易被混淆的点:评估框架和测试框架不是一回事。测试框架通常回答「代码有没有bug」,评估框架回答「模型在任务上表现如何」。

一个完整的评估框架至少要包含这几块:

flowchart LR data["数据集构建"] --> metric["指标体系设计"] metric --> pipeline["评估流程"] pipeline --> report["结果分析"] report --> data

数据集构建负责收集和组织评估样本,指标体系设计决定从哪些维度评估模型表现,评估流程负责执行并收集结果,结果分析则把原始分数变成可行动的洞察。

它们之间的关系是循环的:分析结果通常会反过来影响数据集或指标体系的选择。

从单指标开始,但别停在那

最早的时候我也贪图省事,只选几个常见的指标:BLEU-4、ROUGE-L、EM(精确匹配)。代码大概是这样:

from nltk.translate.bleu_score import corpus_bleu
from rouge import Rouge
import numpy as np

def evaluate_metrics(predictions, references):
    """基础的文本生成评估指标"""
    # BLEU-4
    bleu = corpus_bleu(
        [[ref.split()] for ref in references],
        [pred.split() for pred in predictions],
        weights=(0.25, 0.25, 0.25, 0.25)
    )

    # ROUGE-L
    rouge = Rouge()
    rouge_scores = rouge.get_scores(
        predictions,
        references,
        avg=True
    )

    # EM(Exact Match)
    em = np.mean([
        1 if pred.strip() == ref.strip() else 0
        for pred, ref in zip(predictions, references)
    ])

    return {
        'bleu_4': bleu,
        'rouge_l': rouge_scores['rouge-l']['f'],
        'em': em
    }

看着挺正常,但实际问题很快就暴露了。

RAG 场景里,答案往往很长,用户更关心的是关键信息有没有被覆盖。但 BLEU 对词序和常见词敏感,ROUGE 又过度依赖 n-gram 重叠。两者都没法准确反映「核心信息准确性」。

举个例子,模型回答「北京在北方」,标准答案「北京是中国北部城市」,BLEU 分数会很低(词序和短语不匹配),但信息其实是准确的。反过来,模型乱说一通但恰巧词序匹配上了,分数可能还很高。

这就是「指标-真实感受」之间的gap。

重新设计指标体系

后来我重新思考了评估目标:RAG 系统的核心是「检索+生成」,评估也应该拆成两部分。检索部分看有没有找到相关文档,生成部分看答案质量。

from typing import List, Dict, Any
from dataclasses import dataclass

@dataclass
class EvaluationSample:
    """单个评估样本"""
    query: str
    retrieved_docs: List[str]
    generated_answer: str
    reference_answer: str
    categories: List[str]  # 样本类别,如"事实型"、"观点型"

@dataclass
class MetricResult:
    """单个指标结果"""
    name: str
    value: float
    confidence: float  # 置信区间
    details: Dict[str, Any]  # 额外细节

class EvaluationFramework:
    """完整的评估框架"""

    def __init__(self, config: Dict[str, Any]):
        self.config = config
        self.retrieval_metrics = []
        self.generation_metrics = []

    def add_metric(self, metric_type: str, metric_func):
        """添加指标"""
        if metric_type == 'retrieval':
            self.retrieval_metrics.append(metric_func)
        elif metric_type == 'generation':
            self.generation_metrics.append(metric_func)

    def evaluate(self, samples: List[EvaluationSample]) -> Dict[str, List[MetricResult]]:
        """执行完整评估"""
        results = {
            'retrieval': [],
            'generation': []
        }

        # 评估检索质量
        for metric in self.retrieval_metrics:
            result = metric(samples)
            results['retrieval'].append(result)

        # 评估生成质量
        for metric in self.generation_metrics:
            result = metric(samples)
            results['generation'].append(result)

        return results

这样拆分后,检索评估可以单独看 Recall、MRR、NDCG 这些信息检索指标,生成评估则可以更关注语义和结构。

检索评估的一个例子:

def retrieval_recall(samples: List[EvaluationSample], k: int = 5) -> MetricResult:
    """Top-k 召回率"""
    hits = 0
    total = len(samples)

    for sample in samples:
        # 简单的判断:如果任一检索结果包含参考答案的关键词,就算命中
        # 实际应用中可以用更复杂的相关性判断
        keywords = extract_keywords(sample.reference_answer)
        for doc in sample.retrieved_docs[:k]:
            if any(kw in doc for kw in keywords):
                hits += 1
                break

    # 计算置信区间(Wilson区间)
    p = hits / total
    z = 1.96  # 95%置信
    margin = z * ((p * (1 - p)) / total) ** 0.5

    return MetricResult(
        name=f'recall@{k}',
        value=p,
        confidence=margin,
        details={'hits': hits, 'total': total}
    )

def extract_keywords(text: str) -> List[str]:
    """简单的关键词提取,实际项目可用更复杂的方法"""
    # 这里只是示意,可以用jieba、TF-IDF等
    import re
    words = re.findall(r'[一-龥]{2,}', text)
    return list(set(words))

生成评估则可以引入语义相似度、事实一致性等指标:

from sentence_transformers import SentenceTransformer
from sklearn.metrics.pairwise import cosine_similarity

class SemanticSimilarity:
    """语义相似度评估"""

    def __init__(self, model_name: str = 'paraphrase-multilingual-MiniLM-L12-v2'):
        self.model = SentenceTransformer(model_name)

    def __call__(self, samples: List[EvaluationSample]) -> MetricResult:
        """计算语义相似度"""
        similarities = []

        for sample in samples:
            # 计算生成答案和参考答案的语义相似度
            answer_emb = self.model.encode([sample.generated_answer])
            ref_emb = self.model.encode([sample.reference_answer])
            sim = cosine_similarity(answer_emb, ref_emb)[0][0]
            similarities.append(sim)

        avg_sim = np.mean(similarities)
        std_sim = np.std(similarities)

        return MetricResult(
            name='semantic_similarity',
            value=avg_sim,
            confidence=1.96 * std_sim / np.sqrt(len(samples)),
            details={
                'similarities': similarities,
                'std': std_sim
            }
        )

数据集构建的关键问题

指标再好,数据集有问题也会白搭。在实践中踩过几个明显的坑。

第一个是「样本不均衡」。最早的数据集里,「简单问答」占了 80%,模型在这些样本上表现很好,拉高了整体分数。但复杂场景的样本太少,模型表现差但被淹没了。

后来改用分层采样,强制保证各类别数量平衡:

import random
from collections import defaultdict

def balanced_sample(
    samples: List[EvaluationSample],
    target_per_category: int = 50
) -> List[EvaluationSample]:
    """按类别平衡采样"""
    category_dict = defaultdict(list)

    # 按类别分组
    for sample in samples:
        for category in sample.categories:
            category_dict[category].append(sample)

    balanced = []
    # 从每个类别随机抽取目标数量
    for category, category_samples in category_dict.items():
        if len(category_samples) >= target_per_category:
            selected = random.sample(category_samples, target_per_category)
        else:
            selected = category_samples  # 样本不足就全用
        balanced.extend(selected)

    return balanced

第二个是「参考答案质量参差不齐」。有的参考答案本身就很不完整或存在事实错误,导致模型回答其实不错,但分数很低。解决方案是用多个人工标注者的答案,取一致性较高的作为「黄金标准」,或用模型生成的答案作为候选,再由人工审核。

第三个是「样本过期」。领域知识变化很快,几个月前还是准确的答案,现在可能已经错了。需要建立数据集版本管理和更新机制,定期标注并替换过期样本。

评估流程的自动化

手动跑评估很麻烦,尤其是模型迭代快的时候。后来搭了一套自动化流程,每次模型更新后自动触发评估:

import subprocess
from datetime import datetime
import json

def run_evaluation_pipeline(
    model_path: str,
    dataset_path: str,
    output_dir: str
) -> Dict[str, Any]:
    """运行完整评估流程"""
    timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
    output_file = f"{output_dir}/eval_{timestamp}.json"

    # 1. 加载数据集
    samples = load_dataset(dataset_path)

    # 2. 运行模型生成答案
    print("Running inference...")
    generated_answers = run_inference(model_path, [s.query for s in samples])

    # 3. 更新样本
    for sample, answer in zip(samples, generated_answers):
        sample.generated_answer = answer

    # 4. 运行评估
    print("Running evaluation...")
    framework = EvaluationFramework({'model_path': model_path})

    # 添加指标
    framework.add_metric('retrieval', lambda s: retrieval_recall(s, k=5))
    framework.add_metric('retrieval', lambda s: retrieval_recall(s, k=10))
    framework.add_metric('generation', SemanticSimilarity())
    framework.add_metric('generation', lambda s: factual_consistency(s))

    results = framework.evaluate(samples)

    # 5. 保存结果
    report = {
        'timestamp': timestamp,
        'model_path': model_path,
        'dataset_path': dataset_path,
        'results': {
            'retrieval': [
                {'name': r.name, 'value': r.value, 'confidence': r.confidence}
                for r in results['retrieval']
            ],
            'generation': [
                {'name': r.name, 'value': r.value, 'confidence': r.confidence}
                for r in results['generation']
            ]
        },
        'sample_count': len(samples)
    }

    with open(output_file, 'w') as f:
        json.dump(report, f, indent=2)

    # 6. 可选:触发可视化或告警
    if results['generation'][0].value < 0.7:  # 假设阈值
        send_alert(f"Semantic similarity dropped: {results['generation'][0].value}")

    return report

这套流程可以集成到 CI/CD 里,每次模型训练完成自动跑一遍,分数不达标就阻止发布。

分析结果比跑出分数更重要

评估报告不应该只是一堆数字。真正有价值的是从数字里读出问题。

比如某次评估里,semantic_similarity 看着不错,但 factual_consistency(事实一致性)很低。进一步分析发现,模型在「观点型」问题上表现很好(语义相关性强),但在「事实型」问题上经常编造信息。

这说明模型在风格和语气上模仿到位,但知识获取能力有问题。解决方案是加强检索模块,或者引入外部知识库验证。

def detailed_analysis(
    samples: List[EvaluationSample],
    metric_results: Dict[str, List[MetricResult]]
) -> Dict[str, Any]:
    """细粒度分析"""
    analysis = {
        'by_category': {},
        'by_difficulty': {},
        'failure_cases': []
    }

    # 按类别分析
    by_category = defaultdict(list)
    for sample in samples:
        for category in sample.categories:
            by_category[category].append(sample)

    for category, cat_samples in by_category.items():
        # 只计算当前类别的指标
        cat_results = framework.evaluate(cat_samples)
        analysis['by_category'][category] = cat_results

    # 找出表现最差的样本(failure cases)
    for sample in samples:
        # 这里可以自定义判定「差」的标准
        if sample.generated_answer == "" or "抱歉" in sample.generated_answer:
            analysis['failure_cases'].append({
                'query': sample.query,
                'generated': sample.generated_answer,
                'reference': sample.reference_answer,
                'categories': sample.categories
            })

    return analysis

踩过的几个大坑

这套体系不是一开始就这么完善的,中间踩了不少坑。

坑一:过度依赖单指标。 早期只看 BLEU 分数,模型学会「投机取巧」——输出一些高概率词但语义空洞。后来引入语义相似度和事实一致性才缓解。

坑二:置信区间被忽略。 评估报告只给平均值,没有置信区间。某次发现模型分数「提升」了 2%,但数据量太少,置信区间就有 ±3%,实际上是噪声。

坑三:评估集泄露到训练集。 为了提高分数,有人把评估集的样本悄悄混进了训练集。这是典型的数据泄露,需要严格的权限和流程控制。

坑四:指标和业务目标不一致。 业务关心「用户满意度」,但评估系统只看文本相似度。后来引入用户反馈数据,和评估指标做相关性分析,发现某些指标和真实满意度关联很强,有些几乎无关。

一些实用建议

基于这些经验,整理几个实用建议:

  1. 指标要和业务目标对齐。 不是指标越多越好,而是要回答「用户真正关心什么」。

  2. 持续监控和迭代。 评估体系本身也需要评估——它的分数变化和真实业务指标是否一致。

  3. 保留历史结果。 建立评估结果的时序数据库,可以看模型长期表现趋势,避免某次偶然提升被误判为「突破」。

  4. 人工抽样必不可少。 自动化再完善,也要定期人工抽查样本,看评估系统有没有「幻觉」或系统性偏差。

  5. 简单可复现优先。 指标选常见的、开源的、社区认可度高的,除非有特殊需求,否则不要自己发明新指标。

收住

评估框架不是银弹,它更像一面镜子——照出模型的问题,也照出我们对问题理解不足的地方。从单指标到体系化的过程,本质上是「从想当然到有证据」的转变。

现在回头看,这套体系还在不断演进。新模型出来要有新指标,新场景要有新样本,甚至「什么是好」的标准也在变。唯一不变的是:如果你不肯花功夫认真评估,模型上线后一定会让你花更多功夫去擦屁股。

AI 这东西,账面成绩再漂亮,也得看考场表现。

版权声明: 本文首发于 指尖魔法屋-AI评估框架:指标不够用了之后https://blog.thinkmoon.cn/post/215-practice-ai-evaluation-metrics-framework/) 转载或引用必须申明原指尖魔法屋来源及源地址!