RAG 系统的自动化评估——不靠人工盲评,靠指标
RAG 系统的自动化评估——不靠人工盲评,靠指标
我在这件事上浪费了将近两个月。
我们的 RAG 知识库上线之后,老板问我:"效果怎么样?"我说"感觉还不错"。他说"感觉不是指标,我要数字"。
然后我就组织了人工评估。让五个业务同事,每人测一百个问题,用 1-5 分打分,最后取平均值。
这个过程耗时三周,花了大量人力,最终给了老板一个数字:4.2 分。
但这个数字完全没用——因为当我两周后改了 chunk size,想验证改动有没有效果时,我又得找五个人再打一遍分。评分标准也飘移了,上次给 4 分的答案,这次可能打 3 分,可能打 5 分,取决于评估人当天的心情。
人工评估不可复现、不可量化比较、成本高、周期长。这不是一个工程评估体系,这是一个行政活动。
RAGAS:让 LLM 评估 RAG
RAGAS(Retrieval Augmented Generation Assessment)是目前最成熟的 RAG 自动评估框架,它用 LLM 来评估 LLM 的输出,整个过程无需人工介入。
核心思路:既然 LLM 能生成答案,它也能判断答案的质量。
RAGAS 定义了 4 个核心指标,完全覆盖了 RAG 系统的关键质量维度。
四个核心指标详解
指标 1:Faithfulness(忠实度)
问题:LLM 的答案是否完全基于检索到的文档,没有凭空编造?
计算方式:
- 把答案拆分成多个原子性陈述
- 对每个陈述,判断它是否能在检索到的文档中找到支撑
Faithfulness = 有文档支撑的陈述数 / 陈述总数
这个指标测量的是幻觉程度。Faithfulness = 1.0 意味着答案里每句话都有原文支撑;0.5 意味着有一半是模型自己编的。
指标 2:Answer Relevancy(答案相关性)
问题:答案是否真正在回答用户的问题?
有点反直觉:这个指标不看答案对不对,只看答案有没有切题。
计算方式:
- 给定答案,让 LLM 反向生成 N 个可能的问题(这些问题会引出这个答案)
- 计算这 N 个生成问题和原始用户问题的语义相似度
- 取平均值作为 Answer Relevancy
如果答案答非所问(比如用户问 A,答案在说 B),那反向生成的问题就会偏向 B,和原始问题相似度低,分数就低。
指标 3:Context Precision(上下文精确度)
问题:检索出来的文档里,有多少是真正有用的?
这测量的是检索的信噪比。所有检索出来的文档块里,对回答这个问题有实质帮助的占多大比例。
Context Precision 低说明你检索出来了很多噪音,需要优化检索策略或加上下文压缩。
指标 4:Context Recall(上下文召回率)
问题:回答这个问题需要的信息,有没有都被检索到了?
这测量的是检索是否全面。如果答案里有些关键信息在检索到的文档里找不到,说明检索有遗漏。
需要注意:Context Recall 通常需要 Ground Truth 答案(标准答案)来计算,因为你得知道"应该有哪些信息"才能判断"有没有都检索到"。
没有 Ground Truth 怎么办
实际项目里,Ground Truth 非常难获取。给每个问题标注标准答案,这个工作量和人工评估差不多。
好消息是,Faithfulness 和 Answer Relevancy 这两个最重要的指标,完全不需要 Ground Truth。
Context Recall 需要 Ground Truth,但可以用"自动生成 Ground Truth"来绕开:
@Service
@Slf4j
public class GroundTruthGenerator {
private final ChatClient chatClient;
private static final String GENERATE_GT_PROMPT = """
基于以下文档内容,生成一个准确的参考答案,回答给定的问题。
问题:{question}
文档内容:
{context}
请生成简洁、准确、仅基于文档内容的参考答案:
""";
/**
* 用文档内容生成"伪 Ground Truth"
* 注意:这不是真正的 Ground Truth,是用来做无监督评估的近似值
*/
public String generateGroundTruth(String question, List<Document> documents) {
String context = documents.stream()
.map(Document::getContent)
.collect(Collectors.joining("\n\n"));
return chatClient.prompt()
.user(u -> u.text(GENERATE_GT_PROMPT)
.param("question", question)
.param("context", context))
.call()
.content();
}
}这个"伪 Ground Truth"方法有个已知偏差:用来生成它的文档就是检索到的文档,所以 Context Recall 分数会偏高。这是一个近似评估,不是精确评估,在没有真实标注数据的情况下,用来做相对比较(A 方案 vs B 方案)是可以的,但不能作为绝对质量指标。
RAGAS 评估框架的 Java 调用
RAGAS 官方是 Python 库,但我们团队用 Java,所以是通过 HTTP API 调用的方式来集成。我们部署了一个 Python 微服务来跑 RAGAS,Java 服务通过 REST 调用。
RAGAS Python 微服务(Flask):
# ragas_service.py
from flask import Flask, request, jsonify
from ragas import evaluate
from ragas.metrics import (
faithfulness,
answer_relevancy,
context_precision,
context_recall
)
from datasets import Dataset
app = Flask(__name__)
@app.route('/evaluate', methods=['POST'])
def evaluate_rag():
data = request.json
# 构建评估数据集
eval_data = {
'question': [data['question']],
'answer': [data['answer']],
'contexts': [data['contexts']], # list of retrieved doc contents
'ground_truth': [data.get('ground_truth', '')]
}
dataset = Dataset.from_dict(eval_data)
metrics = [faithfulness, answer_relevancy, context_precision]
if data.get('ground_truth'):
metrics.append(context_recall)
result = evaluate(dataset, metrics=metrics)
return jsonify({
'faithfulness': float(result['faithfulness']),
'answer_relevancy': float(result['answer_relevancy']),
'context_precision': float(result['context_precision']),
'context_recall': float(result.get('context_recall', -1))
})
if __name__ == '__main__':
app.run(host='0.0.0.0', port=9090)Java 调用方:
@Service
@Slf4j
public class RagasEvaluationService {
private final RestTemplate restTemplate;
@Value("${ragas.service.url:http://localhost:9090/evaluate}")
private String ragasServiceUrl;
public RagasEvaluationResult evaluate(RagasEvaluationRequest request) {
try {
ResponseEntity<Map<String, Object>> response = restTemplate.postForEntity(
ragasServiceUrl,
request.toMap(),
(Class<Map<String, Object>>) (Class<?>) Map.class
);
Map<String, Object> body = response.getBody();
if (body == null) {
throw new RuntimeException("Empty response from RAGAS service");
}
return RagasEvaluationResult.builder()
.faithfulness(toDouble(body.get("faithfulness")))
.answerRelevancy(toDouble(body.get("answer_relevancy")))
.contextPrecision(toDouble(body.get("context_precision")))
.contextRecall(toDouble(body.get("context_recall")))
.build();
} catch (Exception e) {
log.error("RAGAS evaluation failed", e);
return RagasEvaluationResult.failed();
}
}
private double toDouble(Object value) {
if (value == null) return -1.0;
return ((Number) value).doubleValue();
}
@Data
@Builder
public static class RagasEvaluationRequest {
private String question;
private String answer;
private List<String> contexts; // 检索到的文档内容列表
private String groundTruth; // 可选
public Map<String, Object> toMap() {
Map<String, Object> map = new HashMap<>();
map.put("question", question);
map.put("answer", answer);
map.put("contexts", contexts);
if (groundTruth != null) map.put("ground_truth", groundTruth);
return map;
}
}
@Data
@Builder
public static class RagasEvaluationResult {
private double faithfulness;
private double answerRelevancy;
private double contextPrecision;
private double contextRecall;
private boolean success;
public static RagasEvaluationResult failed() {
return RagasEvaluationResult.builder()
.faithfulness(-1)
.answerRelevancy(-1)
.contextPrecision(-1)
.contextRecall(-1)
.success(false)
.build();
}
/**
* 综合质量分(简单加权平均)
*/
public double overallScore() {
if (!success) return 0;
return (faithfulness * 0.35 + answerRelevancy * 0.35 + contextPrecision * 0.3);
}
}
}持续评估流程
评估不是一次性的,应该是持续的。每次系统变更(换了 Embedding 模型、调了 chunk size、换了 LLM),都跑一遍评估集,对比前后分数。
@Service
@Slf4j
public class ContinuousEvaluationService {
private final RagService ragService;
private final RagasEvaluationService ragasService;
private final EvaluationBaselineRepository baselineRepo;
private final AlertService alertService;
// 评估集路径
@Value("${evaluation.dataset.path}")
private String evaluationDatasetPath;
// 指标下降告警阈值
private static final double ALERT_THRESHOLD = 0.05;
@Scheduled(cron = "0 0 2 * * MON") // 每周一凌晨 2 点自动评估
public void scheduledEvaluation() {
runEvaluation("scheduled");
}
public EvaluationReport runEvaluation(String trigger) {
log.info("Starting evaluation, trigger: {}", trigger);
List<EvaluationCase> cases = loadEvaluationDataset();
List<RagasEvaluationService.RagasEvaluationResult> results = new ArrayList<>();
for (EvaluationCase evalCase : cases) {
try {
// 执行 RAG 查询
RagQueryResult ragResult = ragService.queryWithContext(evalCase.getQuestion());
// 构建 RAGAS 评估请求
RagasEvaluationService.RagasEvaluationRequest evalRequest =
RagasEvaluationService.RagasEvaluationRequest.builder()
.question(evalCase.getQuestion())
.answer(ragResult.getAnswer())
.contexts(ragResult.getRetrievedDocuments().stream()
.map(Document::getContent)
.collect(Collectors.toList()))
.groundTruth(evalCase.getGroundTruth()) // 可以为 null
.build();
results.add(ragasService.evaluate(evalRequest));
// 避免把评估服务打垮,限速
Thread.sleep(200);
} catch (Exception e) {
log.warn("Evaluation failed for case: {}", evalCase.getQuestion(), e);
}
}
// 计算平均分
EvaluationReport report = buildReport(results, trigger);
log.info("Evaluation complete: {}", report.getSummary());
// 与基准线对比
checkAgainstBaseline(report);
return report;
}
private void checkAgainstBaseline(EvaluationReport report) {
EvaluationBaseline baseline = baselineRepo.findLatest();
if (baseline == null) {
// 第一次评估,设为基准线
baselineRepo.save(EvaluationBaseline.fromReport(report));
log.info("Set initial baseline: {}", report.getSummary());
return;
}
double faithfulnessDropp = baseline.getFaithfulness() - report.getAvgFaithfulness();
double relevancyDrop = baseline.getAnswerRelevancy() - report.getAvgAnswerRelevancy();
if (faithfulnessDropp > ALERT_THRESHOLD || relevancyDrop > ALERT_THRESHOLD) {
log.error("Evaluation alert! Faithfulness drop: {}, Relevancy drop: {}",
faithfulnessDropp, relevancyDrop);
alertService.sendAlert(
"RAG 质量下降告警",
String.format("Faithfulness 下降 %.1f%%,Answer Relevancy 下降 %.1f%%",
faithfulnessDropp * 100, relevancyDrop * 100)
);
}
}
private EvaluationReport buildReport(
List<RagasEvaluationService.RagasEvaluationResult> results,
String trigger) {
List<RagasEvaluationService.RagasEvaluationResult> successful = results.stream()
.filter(r -> r.isSuccess())
.collect(Collectors.toList());
double avgFaithfulness = successful.stream()
.mapToDouble(RagasEvaluationService.RagasEvaluationResult::getFaithfulness)
.average().orElse(0);
double avgRelevancy = successful.stream()
.mapToDouble(RagasEvaluationService.RagasEvaluationResult::getAnswerRelevancy)
.average().orElse(0);
double avgPrecision = successful.stream()
.mapToDouble(RagasEvaluationService.RagasEvaluationResult::getContextPrecision)
.average().orElse(0);
return EvaluationReport.builder()
.trigger(trigger)
.totalCases(results.size())
.successfulCases(successful.size())
.avgFaithfulness(avgFaithfulness)
.avgAnswerRelevancy(avgRelevancy)
.avgContextPrecision(avgPrecision)
.timestamp(LocalDateTime.now())
.build();
}
}我们实际的评估数据
这是我们知识库从上线到现在的评估历史(每次重大更新后的数据):
| 版本 | 变更内容 | Faithfulness | Answer Relevancy | Context Precision | Overall |
|---|---|---|---|---|---|
| v1.0 | 初始版本 | 0.71 | 0.68 | 0.59 | 0.67 |
| v1.1 | 加 Query Rewriting | 0.73 | 0.74 | 0.67 | 0.71 |
| v1.2 | 加 Rerank | 0.74 | 0.75 | 0.78 | 0.75 |
| v1.3 | 换 Embedding 模型 | 0.76 | 0.77 | 0.81 | 0.78 |
| v1.4 | 加上下文压缩 | 0.82 | 0.79 | 0.83 | 0.81 |
| v1.5(回退) | 改了 chunk size(变差) | 0.74 | 0.76 | 0.75 | 0.75 |
v1.5 那次自动评估发现分数下降了,触发了告警,我们及时回滚了。如果没有自动评估,这个问题可能要等用户反馈才能发现。
几个实践中的教训
1. 评估集的质量比大小更重要
100 个精心设计、覆盖各类场景的评估问题,比 1000 个随机生成的问题有价值得多。我花了一周时间和业务同事一起整理了一个 150 个问题的评估集,这投资非常值。
2. 不同类型问题的指标权重要区分
简单的事实查询,Faithfulness 最重要;复杂分析问题,Answer Relevancy 更重要。最好按问题类型分组评估,而不是混在一起算平均值,混在一起平均会掩盖某类问题的问题。
3. 指标的绝对值不如趋势重要
Faithfulness 0.82 好不好?这个问题本身没意义。重要的是它在变好还是变差。建立基准线,跟踪趋势,才是评估的真正价值。
4. 评估本身也会有噪音
RAGAS 用 LLM 来评估,LLM 本身会有随机性。同一份数据跑两次,分数可能有 0.02-0.05 的波动。所以评估结果变化小于 0.03 时,不要轻易做结论,多跑几次确认。
总结
自动化评估体系建起来之后,我们的开发效率大幅提升——每次迭代的效果验证从三周缩短到了半天。更重要的是,有了数字支撑,向老板汇报有据可依,而不是"感觉还不错"。
如果你的 RAG 系统还在靠人工盲评,强烈建议把 RAGAS 框架集成进来,这是投入产出比非常高的工程改进。
