RAG质量评估体系:如何科学衡量你的检索系统好不好
RAG质量评估体系:如何科学衡量你的检索系统好不好
"感觉挺好的"这五个字,差点让老周丢了工作
老周是个做了四年开发的老兵,去年公司上了一套内部知识库RAG系统,他负责开发和维护。
系统上线后,老周每天收到的反馈还不错。没人投诉,大家也在用。他心里挺踏实的。
直到有一天,业务总监在周会上点名问了他一个问题:
"我们的RAG系统,效果到底怎么样?能不能说具体一点?"
老周想了想,说:"感觉挺好的,用户反馈都还行,没啥大问题。"
会议室里沉默了三秒。
总监转向旁边的数据分析师:"上个月有多少个问题被用户手动转人工了?"
数据分析师翻出数字:"1842个。"
"占总问题量的多少?"
"34%。"
老周的脸当时就白了。三分之一的问题AI没能解决,要靠人工兜底,这意味着AI帮了你66%,剩下34%还在人工消化——但公司的人工成本一分没降。
更要命的是,他没有任何数据能说清楚:这34%失败的问题,是因为检索没找到答案?还是找到了答案但LLM回答错了?还是答案本身就不在知识库里?
他没有评估体系。所以他说不清楚。
这个问题之后,老周花了三周建立了一套RAG评估体系。那1842个转人工的问题里,他分析出:
- 47%是检索没召回相关文档(检索层问题)
- 31%是检索到了但LLM答非所问(生成层问题)
- 22%是知识库确实没有答案(知识盲区)
有了这个数据,他知道该先优化哪里,两个月后转人工率降到了19%。
这篇文章,我们就来讲怎么建立这套评估体系。
先说结论(TL;DR)
| 评估维度 | 衡量什么 | 工具 |
|---|---|---|
| 检索相关性(Context Relevance) | 检索到的文档和问题相关吗 | RAGAS, 手工标注 |
| 答案忠实度(Faithfulness) | 答案是否基于检索内容,没有幻觉 | RAGAS, LLM判断 |
| 答案完整性(Answer Relevance) | 答案是否完整回答了问题 | RAGAS, 人工评估 |
| 上下文利用率(Context Utilization) | LLM有效利用了多少检索内容 | 自定义指标 |
核心结论:没有评估体系的RAG,就是在蒙眼飞行。
RAG评估的四大维度
要评估一个RAG系统,需要从四个维度同时考量。这四个维度互相独立,任何一个出问题都会影响最终效果。
维度1:检索相关性(Context Relevance)
问题:检索回来的文档,和用户的问题相关吗?
这是评估检索层质量的核心指标。
常见失败场景:
- 用户问"如何配置Redis超时",检索出了"Redis数据类型介绍"
- 检索出5个文档,其中3个完全不相关
- 检索出了相关主题但错了版本(问的是Spring Boot 3.x,给了2.x的答案)
评估方法:对于每个检索到的文档块,用LLM或人工判断它和原问题的相关程度(0-1分)。
维度2:答案忠实度(Faithfulness)
问题:LLM的回答,是基于检索到的内容,还是在"创作"?
这直接关系到幻觉(Hallucination)问题。
常见失败场景:
- 检索内容说"A方法在v2.0中废弃",LLM却给出了A方法的使用教程
- 检索内容没有提到具体数字,LLM捏造了一个"大约提升40%"
- LLM用了知识库以外的知识回答,绕过了检索内容
评估方法:把答案拆成多个陈述,逐条检查每个陈述是否有检索文档支撑。
维度3:答案完整性(Answer Relevance)
问题:LLM的回答,是否完整回答了用户的问题?
注意:这和忠实度不同。忠实度关注"有没有编造",完整性关注"有没有回答"。
常见失败场景:
- 用户问"A方法的参数怎么配置,有哪些注意事项",LLM只回答了参数,忘了注意事项
- 用户问了多个子问题,LLM只回答了一个
- 用模糊笼统的话绕过了具体问题
维度4:上下文利用率(Context Utilization)
问题:检索了10个文档,LLM真正用了几个?
如果检索了很多内容但LLM只用了第一个,说明要么检索策略不对(检索了太多不相关的),要么LLM的上下文窗口利用率低。
Maven依赖与配置
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.laozhang.ai</groupId>
<artifactId>rag-evaluation</artifactId>
<version>1.0.0</version>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.5</version>
</parent>
<properties>
<java.version>17</java.version>
<spring-ai.version>1.0.0-M6</spring-ai.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai-spring-boot-starter</artifactId>
<version>${spring-ai.version}</version>
</dependency>
<!-- Spring Data JPA(存储评估结果) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
</dependency>
<!-- Redis(存储在线评估数据)-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- Micrometer + Prometheus -->
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- Jackson -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- Commons Math(统计计算)-->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-math3</artifactId>
<version>3.6.1</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<repositories>
<repository>
<id>spring-milestones</id>
<url>https://repo.spring.io/milestone</url>
</repository>
</repositories>
</project>spring:
application:
name: rag-evaluation
ai:
openai:
api-key: ${OPENAI_API_KEY}
base-url: ${OPENAI_BASE_URL:https://api.openai.com}
chat:
options:
model: gpt-4o
temperature: 0.0 # 评估任务用0温度,结果更稳定
datasource:
url: jdbc:postgresql://localhost:5432/rag_eval
username: postgres
password: postgres
jpa:
hibernate:
ddl-auto: update
redis:
host: localhost
port: 6379
evaluation:
# 每次评估的样本量
sample-size: 50
# 评估使用的LLM模型
judge-model: gpt-4o
# 指标阈值(低于阈值触发告警)
thresholds:
context-relevance: 0.7
faithfulness: 0.8
answer-relevance: 0.75
context-utilization: 0.6
# 自动评估定时任务
schedule:
enabled: true
cron: "0 0 3 * * ?" # 每天凌晨3点
management:
endpoints:
web:
exposure:
include: health,metrics,prometheus构建评估数据集
评估的基础是一套高质量的"黄金标准问答对"(Ground Truth)。
package com.laozhang.ai.evaluation.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
/**
* 评估数据集生成器
* 从知识库文档中自动生成问答对,用于评估RAG系统
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class EvalDatasetGenerator {
private final ChatClient chatClient;
/**
* 从文档生成评估问答对的提示词
* 好的提示词能生成多样性强的问题
*/
private static final String QUESTION_GEN_PROMPT = """
你是一个专业的测试工程师,需要从以下文档中生成评估RAG系统用的问题集。
文档内容:
{document}
要求:
1. 生成{count}个问题,覆盖文档中的不同知识点
2. 问题类型要多样:
- 事实性问题("X是什么"):占30%
- 操作性问题("如何做X"):占40%
- 对比性问题("X和Y的区别"):占20%
- 推理性问题("如果X,那么Y"):占10%
3. 每个问题必须能从文档中找到答案
4. 问题要自然,像真实用户会问的那种
输出格式(JSON数组):
```json
[
{
"question": "问题内容",
"question_type": "factual/operational/comparative/inferential",
"ground_truth_answer": "基于文档的标准答案",
"relevant_quotes": ["文档中支持答案的原文片段1", "原文片段2"]
}
]
```
只输出JSON,不要其他内容。
""";
/**
* 从文档列表生成评估数据集
*
* @param documents 文档内容列表
* @param perDocCount 每个文档生成的问题数
* @return 评估问答对列表
*/
public List<EvalQaPair> generateEvalDataset(
List<String> documents, int perDocCount) {
List<EvalQaPair> allPairs = new ArrayList<>();
for (int i = 0; i < documents.size(); i++) {
String doc = documents.get(i);
log.info("正在为文档{}/{}生成评估问题...", i + 1, documents.size());
try {
List<EvalQaPair> pairs = generateForDocument(doc, perDocCount);
allPairs.addAll(pairs);
log.debug("文档{}生成了{}个问答对", i + 1, pairs.size());
} catch (Exception e) {
log.error("文档{}生成失败,跳过", i + 1, e);
}
}
log.info("评估数据集生成完成,共{}个问答对", allPairs.size());
return allPairs;
}
/**
* 为单个文档生成问答对
*/
private List<EvalQaPair> generateForDocument(String document, int count) {
String prompt = QUESTION_GEN_PROMPT
.replace("{document}", document)
.replace("{count}", String.valueOf(count));
String response = chatClient.prompt()
.user(prompt)
.call()
.content();
return parseQaPairs(response);
}
/**
* 解析LLM返回的问答对JSON
*/
@SuppressWarnings("unchecked")
private List<EvalQaPair> parseQaPairs(String jsonResponse) {
try {
// 清理JSON标记
String cleaned = jsonResponse
.replaceAll("```json\\s*", "")
.replaceAll("```\\s*", "")
.trim();
com.fasterxml.jackson.databind.ObjectMapper mapper =
new com.fasterxml.jackson.databind.ObjectMapper();
List<java.util.Map<String, Object>> raw = mapper.readValue(cleaned, List.class);
return raw.stream()
.map(item -> EvalQaPair.builder()
.question((String) item.get("question"))
.questionType((String) item.get("question_type"))
.groundTruthAnswer((String) item.get("ground_truth_answer"))
.relevantQuotes((List<String>) item.get("relevant_quotes"))
.build())
.toList();
} catch (Exception e) {
log.error("解析问答对失败", e);
return List.of();
}
}
@lombok.Data
@lombok.Builder
@lombok.NoArgsConstructor
@lombok.AllArgsConstructor
public static class EvalQaPair {
private String id;
private String question;
private String questionType;
private String groundTruthAnswer;
private List<String> relevantQuotes;
}
}自动化评估流水线
package com.laozhang.ai.evaluation.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
/**
* RAG自动化评估引擎
* 实现四大评估维度的自动化打分
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class RagEvaluationEngine {
private final ChatClient chatClient;
// ==================== 评估提示词 ====================
/**
* 检索相关性评估提示词
*/
private static final String CONTEXT_RELEVANCE_PROMPT = """
你是一个RAG系统评估专家。请评估检索到的文档与问题的相关性。
问题:{question}
检索到的文档:
{context}
评分标准:
- 1.0:文档完全相关,直接回答了问题
- 0.8:文档基本相关,包含回答问题所需的信息
- 0.6:文档部分相关,有些信息有用但不充分
- 0.4:文档轻微相关,仅有边缘性的关联
- 0.2:文档几乎不相关
- 0.0:文档完全不相关
请对每个文档单独打分,然后给出平均分。
输出格式(JSON):
{
"scores": [0.8, 0.6, 0.9],
"average_score": 0.77,
"reasoning": "文档1关于XXX,直接回答了问题;文档2关于YYY,部分相关..."
}
只输出JSON。
""";
/**
* 答案忠实度评估提示词
*/
private static final String FAITHFULNESS_PROMPT = """
你是一个RAG系统幻觉检测专家。请评估LLM生成的答案是否忠实于检索到的文档。
检索到的文档(事实来源):
{context}
LLM生成的答案:
{answer}
评估步骤:
1. 将答案拆分为多个独立陈述
2. 检查每个陈述是否有文档支撑
3. 标记"有支撑"、"无支撑"或"与文档矛盾"
评分:
- 有支撑的陈述比例 = 忠实度分数
- 如果有与文档矛盾的陈述,额外扣分
输出格式(JSON):
{
"statements": [
{"statement": "陈述内容", "supported": true, "evidence": "文档中的支撑证据"},
{"statement": "陈述内容", "supported": false, "reason": "文档中没有提到这一点"}
],
"faithfulness_score": 0.85,
"has_contradiction": false,
"reasoning": "总体评估说明"
}
只输出JSON。
""";
/**
* 答案完整性评估提示词
*/
private static final String ANSWER_RELEVANCE_PROMPT = """
你是一个问答质量评估专家。请评估答案是否完整回答了用户的问题。
用户问题:{question}
LLM生成的答案:{answer}
评分标准:
- 1.0:完整、准确地回答了问题的所有方面
- 0.8:回答了主要问题,有小部分遗漏
- 0.6:回答了问题的一半左右
- 0.4:只回答了问题的边缘部分
- 0.2:基本没有回答问题
- 0.0:完全没有回答问题或答非所问
输出格式(JSON):
{
"score": 0.85,
"aspects_covered": ["问题的哪些方面被回答了"],
"aspects_missing": ["问题的哪些方面没有被回答"],
"reasoning": "评估说明"
}
只输出JSON。
""";
/**
* 上下文利用率评估提示词
*/
private static final String CONTEXT_UTILIZATION_PROMPT = """
你是一个RAG系统分析专家。请评估LLM在生成答案时利用了多少检索到的文档。
检索到的文档:
{context}
LLM生成的答案:{answer}
评估:
1. 分析答案中的每个信息点来自哪个文档
2. 标记被利用的文档和未被利用的文档
3. 计算利用率
输出格式(JSON):
{
"utilized_documents": [0, 2],
"unutilized_documents": [1, 3, 4],
"utilization_rate": 0.4,
"reasoning": "文档0和文档2的内容被引用,文档1、3、4的内容未被使用"
}
只输出JSON。
""";
// ==================== 评估方法 ====================
/**
* 完整评估一个RAG响应
*
* @param question 用户问题
* @param contexts 检索到的文档列表
* @param answer LLM生成的答案
* @param groundTruth 标准答案(可选,有则更准确)
* @return 完整评估结果
*/
public EvaluationResult evaluate(
String question,
List<String> contexts,
String answer,
String groundTruth) {
log.info("开始评估RAG响应,question={}", question.substring(0, Math.min(50, question.length())));
String contextText = formatContexts(contexts);
// 四个维度并行评估(实际项目中可以用CompletableFuture并行)
double contextRelevance = evaluateContextRelevance(question, contextText);
double faithfulness = evaluateFaithfulness(contextText, answer);
double answerRelevance = evaluateAnswerRelevance(question, answer);
double contextUtilization = evaluateContextUtilization(contextText, answer);
// 综合分数(可根据业务重要性调整权重)
double overallScore = contextRelevance * 0.25
+ faithfulness * 0.35 // 忠实度权重最高,幻觉是最严重的问题
+ answerRelevance * 0.25
+ contextUtilization * 0.15;
EvaluationResult result = EvaluationResult.builder()
.question(question)
.answer(answer)
.contextRelevance(contextRelevance)
.faithfulness(faithfulness)
.answerRelevance(answerRelevance)
.contextUtilization(contextUtilization)
.overallScore(overallScore)
.contextCount(contexts.size())
.build();
log.info("评估完成:contextRelevance={:.2f}, faithfulness={:.2f}, " +
"answerRelevance={:.2f}, overall={:.2f}",
contextRelevance, faithfulness, answerRelevance, overallScore);
return result;
}
/**
* 评估检索相关性
*/
private double evaluateContextRelevance(String question, String context) {
try {
String prompt = CONTEXT_RELEVANCE_PROMPT
.replace("{question}", question)
.replace("{context}", context);
String response = chatClient.prompt()
.user(prompt)
.call()
.content();
return parseScoreFromJson(response, "average_score");
} catch (Exception e) {
log.error("检索相关性评估失败", e);
return 0.5; // 失败时返回中等分数,不影响整体
}
}
/**
* 评估答案忠实度
*/
private double evaluateFaithfulness(String context, String answer) {
try {
String prompt = FAITHFULNESS_PROMPT
.replace("{context}", context)
.replace("{answer}", answer);
String response = chatClient.prompt()
.user(prompt)
.call()
.content();
return parseScoreFromJson(response, "faithfulness_score");
} catch (Exception e) {
log.error("答案忠实度评估失败", e);
return 0.5;
}
}
/**
* 评估答案完整性
*/
private double evaluateAnswerRelevance(String question, String answer) {
try {
String prompt = ANSWER_RELEVANCE_PROMPT
.replace("{question}", question)
.replace("{answer}", answer);
String response = chatClient.prompt()
.user(prompt)
.call()
.content();
return parseScoreFromJson(response, "score");
} catch (Exception e) {
log.error("答案完整性评估失败", e);
return 0.5;
}
}
/**
* 评估上下文利用率
*/
private double evaluateContextUtilization(String context, String answer) {
try {
String prompt = CONTEXT_UTILIZATION_PROMPT
.replace("{context}", context)
.replace("{answer}", answer);
String response = chatClient.prompt()
.user(prompt)
.call()
.content();
return parseScoreFromJson(response, "utilization_rate");
} catch (Exception e) {
log.error("上下文利用率评估失败", e);
return 0.5;
}
}
/**
* 格式化检索文档列表
*/
private String formatContexts(List<String> contexts) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < contexts.size(); i++) {
sb.append("[文档").append(i + 1).append("]\n");
sb.append(contexts.get(i)).append("\n\n");
}
return sb.toString();
}
/**
* 从JSON响应中解析分数
*/
@SuppressWarnings("unchecked")
private double parseScoreFromJson(String jsonResponse, String scoreKey) {
try {
String cleaned = jsonResponse
.replaceAll("```json\\s*", "")
.replaceAll("```\\s*", "")
.trim();
com.fasterxml.jackson.databind.ObjectMapper mapper =
new com.fasterxml.jackson.databind.ObjectMapper();
Map<String, Object> parsed = mapper.readValue(cleaned, Map.class);
Object score = parsed.get(scoreKey);
if (score instanceof Number) {
return ((Number) score).doubleValue();
}
return 0.5;
} catch (Exception e) {
log.warn("分数解析失败,返回默认值0.5,响应: {}", jsonResponse.substring(0, Math.min(200, jsonResponse.length())));
return 0.5;
}
}
/**
* 评估结果数据模型
*/
@lombok.Data
@lombok.Builder
@lombok.NoArgsConstructor
@lombok.AllArgsConstructor
public static class EvaluationResult {
private String question;
private String answer;
private double contextRelevance;
private double faithfulness;
private double answerRelevance;
private double contextUtilization;
private double overallScore;
private int contextCount;
private String timestamp;
}
}评估结果持久化与统计
package com.laozhang.ai.evaluation.entity;
import jakarta.persistence.*;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 评估结果实体(存储到PostgreSQL)
*/
@Entity
@Table(name = "rag_evaluation_records")
@Data
@NoArgsConstructor
public class EvaluationRecord {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 1000)
private String question;
@Column(columnDefinition = "TEXT")
private String answer;
@Column(name = "context_relevance")
private Double contextRelevance;
@Column(name = "faithfulness")
private Double faithfulness;
@Column(name = "answer_relevance")
private Double answerRelevance;
@Column(name = "context_utilization")
private Double contextUtilization;
@Column(name = "overall_score")
private Double overallScore;
@Column(name = "context_count")
private Integer contextCount;
/**
* 评估触发来源:SCHEDULED/MANUAL/ONLINE
*/
@Column(name = "eval_source", length = 20)
private String evalSource;
/**
* 关联的RAG系统版本(便于版本对比)
*/
@Column(name = "system_version", length = 50)
private String systemVersion;
@Column(name = "created_at")
private LocalDateTime createdAt;
@PrePersist
public void prePersist() {
this.createdAt = LocalDateTime.now();
}
}package com.laozhang.ai.evaluation.service;
import com.laozhang.ai.evaluation.entity.EvaluationRecord;
import com.laozhang.ai.evaluation.repository.EvaluationRecordRepository;
import io.micrometer.core.instrument.Gauge;
import io.micrometer.core.instrument.MeterRegistry;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
/**
* 评估调度服务
* 定时运行评估流水线,将结果写入DB并更新监控指标
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class EvaluationScheduler {
private final EvaluationRecordRepository repository;
private final RagEvaluationEngine evaluationEngine;
private final EvalDatasetGenerator datasetGenerator;
private final MeterRegistry meterRegistry;
@Value("${evaluation.sample-size:50}")
private int sampleSize;
@Value("${evaluation.thresholds.context-relevance:0.7}")
private double contextRelevanceThreshold;
@Value("${evaluation.thresholds.faithfulness:0.8}")
private double faithfulnessThreshold;
// 最新的平均指标(用于Gauge)
private volatile double latestContextRelevance = 0;
private volatile double latestFaithfulness = 0;
private volatile double latestAnswerRelevance = 0;
private volatile double latestOverallScore = 0;
/**
* 初始化Gauge指标(应用启动时注册)
*/
@jakarta.annotation.PostConstruct
public void initGauges() {
Gauge.builder("rag.eval.context_relevance", this, e -> e.latestContextRelevance)
.description("RAG检索相关性平均分")
.register(meterRegistry);
Gauge.builder("rag.eval.faithfulness", this, e -> e.latestFaithfulness)
.description("RAG答案忠实度平均分")
.register(meterRegistry);
Gauge.builder("rag.eval.answer_relevance", this, e -> e.latestAnswerRelevance)
.description("RAG答案完整性平均分")
.register(meterRegistry);
Gauge.builder("rag.eval.overall_score", this, e -> e.latestOverallScore)
.description("RAG综合评分")
.register(meterRegistry);
}
/**
* 定时评估任务(每天凌晨3点运行)
*/
@Scheduled(cron = "${evaluation.schedule.cron:0 0 3 * * ?}")
public void runScheduledEvaluation() {
log.info("定时评估任务开始");
try {
// 这里从评估数据集中取样本进行评估
// 实际项目中,你可以:
// 1. 从预先准备的黄金数据集中随机抽取
// 2. 从最近的用户查询日志中抽取
// 3. 使用固定的回归测试集
// 示例:假设我们有预先准备的评估集
List<EvalDatasetGenerator.EvalQaPair> evalSet = loadEvalDataset();
List<EvalDatasetGenerator.EvalQaPair> sample = sampleFromEvalSet(evalSet, sampleSize);
double totalContextRelevance = 0;
double totalFaithfulness = 0;
double totalAnswerRelevance = 0;
double totalContextUtilization = 0;
double totalOverall = 0;
int successCount = 0;
for (EvalDatasetGenerator.EvalQaPair pair : sample) {
try {
// 实际运行RAG系统获取响应
// 这里需要注入你的RAG服务
RagResponse ragResponse = runRagQuery(pair.getQuestion());
// 评估
RagEvaluationEngine.EvaluationResult result = evaluationEngine.evaluate(
pair.getQuestion(),
ragResponse.getContexts(),
ragResponse.getAnswer(),
pair.getGroundTruthAnswer()
);
// 保存记录
saveEvaluationRecord(result, "SCHEDULED");
totalContextRelevance += result.getContextRelevance();
totalFaithfulness += result.getFaithfulness();
totalAnswerRelevance += result.getAnswerRelevance();
totalContextUtilization += result.getContextUtilization();
totalOverall += result.getOverallScore();
successCount++;
} catch (Exception e) {
log.error("单条评估失败,question={}", pair.getQuestion(), e);
}
}
if (successCount > 0) {
// 更新Gauge指标
latestContextRelevance = totalContextRelevance / successCount;
latestFaithfulness = totalFaithfulness / successCount;
latestAnswerRelevance = totalAnswerRelevance / successCount;
latestOverallScore = totalOverall / successCount;
log.info("定时评估完成:样本数={},综合评分={:.3f},忠实度={:.3f}",
successCount, latestOverallScore, latestFaithfulness);
// 检查是否低于阈值,触发告警
checkThresholdsAndAlert();
}
} catch (Exception e) {
log.error("定时评估任务失败", e);
}
}
/**
* 阈值检查与告警
*/
private void checkThresholdsAndAlert() {
if (latestContextRelevance < contextRelevanceThreshold) {
log.warn("告警:检索相关性({:.3f})低于阈值({})",
latestContextRelevance, contextRelevanceThreshold);
meterRegistry.counter("rag.eval.alert", "type", "context_relevance").increment();
}
if (latestFaithfulness < faithfulnessThreshold) {
log.warn("告警:答案忠实度({:.3f})低于阈值({})",
latestFaithfulness, faithfulnessThreshold);
meterRegistry.counter("rag.eval.alert", "type", "faithfulness").increment();
}
}
/**
* 保存评估记录到数据库
*/
private void saveEvaluationRecord(
RagEvaluationEngine.EvaluationResult result,
String source) {
EvaluationRecord record = new EvaluationRecord();
record.setQuestion(result.getQuestion());
record.setAnswer(result.getAnswer());
record.setContextRelevance(result.getContextRelevance());
record.setFaithfulness(result.getFaithfulness());
record.setAnswerRelevance(result.getAnswerRelevance());
record.setContextUtilization(result.getContextUtilization());
record.setOverallScore(result.getOverallScore());
record.setContextCount(result.getContextCount());
record.setEvalSource(source);
record.setSystemVersion("1.0.0"); // 实际项目中从配置读取
repository.save(record);
}
// 以下为占位方法,实际项目中需要实现
private List<EvalDatasetGenerator.EvalQaPair> loadEvalDataset() {
// 从文件或数据库加载评估数据集
return List.of();
}
private List<EvalDatasetGenerator.EvalQaPair> sampleFromEvalSet(
List<EvalDatasetGenerator.EvalQaPair> set, int n) {
if (set.size() <= n) return set;
// 随机采样
List<EvalDatasetGenerator.EvalQaPair> shuffled = new java.util.ArrayList<>(set);
java.util.Collections.shuffle(shuffled);
return shuffled.subList(0, n);
}
private RagResponse runRagQuery(String question) {
// 调用RAG服务,获取答案和上下文
// 实际项目中注入RAG服务
return new RagResponse(List.of(), "");
}
@lombok.Data
@lombok.AllArgsConstructor
private static class RagResponse {
private List<String> contexts;
private String answer;
}
}用户反馈:拇指评价系统
线下评估之外,还需要收集用户的实时反馈。
package com.laozhang.ai.evaluation.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
/**
* 用户反馈收集服务
* 实现简单的拇指评价(好评/差评)和详细反馈
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class UserFeedbackService {
private final RedisTemplate<String, Object> redisTemplate;
private static final String FEEDBACK_KEY_PREFIX = "rag:feedback:";
private static final String STATS_KEY = "rag:feedback:stats";
private static final Duration FEEDBACK_TTL = Duration.ofDays(90);
/**
* 收集拇指评价
*
* @param sessionId 会话ID
* @param messageId 消息ID
* @param isPositive 是否好评
* @param comment 用户评论(可选)
*/
public void recordFeedback(String sessionId, String messageId,
boolean isPositive, String comment) {
String key = FEEDBACK_KEY_PREFIX + messageId;
Map<String, Object> feedback = new HashMap<>();
feedback.put("sessionId", sessionId);
feedback.put("messageId", messageId);
feedback.put("isPositive", isPositive);
feedback.put("comment", comment != null ? comment : "");
feedback.put("timestamp", LocalDateTime.now().toString());
// 存储到Redis
redisTemplate.opsForHash().putAll(key, feedback);
redisTemplate.expire(key, FEEDBACK_TTL);
// 更新统计
if (isPositive) {
redisTemplate.opsForHash().increment(STATS_KEY, "positive_count", 1);
} else {
redisTemplate.opsForHash().increment(STATS_KEY, "negative_count", 1);
}
redisTemplate.opsForHash().increment(STATS_KEY, "total_count", 1);
log.info("收到用户反馈:messageId={}, isPositive={}", messageId, isPositive);
// 差评触发详细分析
if (!isPositive) {
analyzeNegativeFeedback(messageId, comment);
}
}
/**
* 获取好评率统计
*/
public FeedbackStats getFeedbackStats() {
Map<Object, Object> stats = redisTemplate.opsForHash().entries(STATS_KEY);
long total = getLong(stats, "total_count");
long positive = getLong(stats, "positive_count");
long negative = getLong(stats, "negative_count");
double positiveRate = total > 0 ? (double) positive / total : 0;
return FeedbackStats.builder()
.totalCount(total)
.positiveCount(positive)
.negativeCount(negative)
.positiveRate(positiveRate)
.build();
}
/**
* 对差评进行分析(找规律)
*/
private void analyzeNegativeFeedback(String messageId, String comment) {
log.info("分析差评,messageId={}, comment={}", messageId, comment);
// 实际项目中:
// 1. 将差评保存到数据库供人工review
// 2. 如果短时间内差评数量激增,触发告警
// 3. 定期对差评做NLP分析,找出高频问题类型
}
private long getLong(Map<Object, Object> map, String key) {
Object val = map.get(key);
if (val == null) return 0;
return Long.parseLong(val.toString());
}
@lombok.Data
@lombok.Builder
@lombok.NoArgsConstructor
@lombok.AllArgsConstructor
public static class FeedbackStats {
private long totalCount;
private long positiveCount;
private long negativeCount;
private double positiveRate;
}
}Grafana看板指标设计
以下是需要在Grafana中展示的核心指标(Prometheus格式):
# RAG综合评分趋势(折线图)
rag_eval_overall_score
# 四维指标雷达图数据
rag_eval_context_relevance
rag_eval_faithfulness
rag_eval_answer_relevance
# 用户反馈好评率(仪表盘)
rag_user_feedback_positive_rate
# 告警次数(条形图)
rag_eval_alert_total{type="faithfulness"}
rag_eval_alert_total{type="context_relevance"}
# 评估耗时分布(热力图)
rag_evaluation_duration_seconds_bucketGrafana告警规则示例(JSON格式):
{
"alert": {
"name": "RAG质量下降告警",
"conditions": [
{
"evaluator": {
"params": [0.7],
"type": "lt"
},
"operator": {
"type": "and"
},
"query": {
"params": ["A", "5m", "now"]
},
"reducer": {
"type": "avg"
},
"type": "query"
}
],
"executionErrorState": "alerting",
"frequency": "10m",
"handler": 1,
"message": "RAG综合评分低于0.7,请检查系统",
"noDataState": "no_data",
"notifications": []
}
}不同Chunking策略的效果对比实验
评估体系建好后,你可以用它来对比不同chunking策略的效果:
| Chunking策略 | 上下文相关性 | 答案忠实度 | 答案完整性 | 综合评分 |
|---|---|---|---|---|
| 固定512 token | 0.68 | 0.79 | 0.72 | 0.73 |
| 固定256 token | 0.74 | 0.82 | 0.65 | 0.74 |
| 固定1024 token | 0.61 | 0.76 | 0.80 | 0.72 |
| 按段落分割 | 0.78 | 0.84 | 0.76 | 0.79 |
| 按句子分割+重叠 | 0.76 | 0.83 | 0.78 | 0.79 |
| 语义分割(SBERT) | 0.82 | 0.86 | 0.81 | 0.83 |
结论:语义分割效果最好,但实现复杂度高。按段落分割是性价比最高的方案。
持续优化循环
评估体系最大的价值是形成闭环:
常见问题解答
Q1:用LLM评估LLM,这样可靠吗?
这确实是个经典问题。LLM作为评估者(LLM-as-judge)有一定偏差,尤其是对自己生成的内容评分偏高(自我评估偏差)。解决方案:一是用比被评估模型更强的模型来评估(用GPT-4o评估GPT-4o-mini的输出);二是多轮评估取平均;三是定期用人工标注来校准LLM评估的可信度。
Q2:评估数据集多大合适?
做方向性决策(比如对比两种chunking策略),50-100条就够了。做精确的质量基准,需要200-500条,覆盖不同查询类型。黄金标准数据集建议由产品/业务专家手工标注,而不是全程用LLM生成。
Q3:评估一次成本多少?
以GPT-4o为例,评估一条记录需要4次LLM调用(四个维度),每次约500-1000 tokens,大约花费$0.01-0.02。评估100条约$1-2,一年跑200次约$200-400。相比RAG系统的价值,这个成本可以接受。
Q4:忠实度分数一直很低怎么办?
忠实度低说明LLM在"脑补"。先检查prompt:有没有明确要求只基于检索内容回答?然后检查检索结果:如果相关内容根本没检索到,LLM自然只能靠训练数据。忠实度问题60%是prompt问题,40%是检索问题。
Q5:怎么发现知识盲区(知识库里没有的问题)?
方法一:上下文相关性低且答案忠实度也低的记录,通常是知识盲区。方法二:收集用户差评,人工review其中知识库确实没有相关内容的case。方法三:定期分析检索结果的最高相似度分布,低于0.5的查询大概率是盲区。
Q6:评估指标稳定后还需要跑评估吗?
需要。知识库内容变更、模型升级、prompt修改,任何一个变更都可能影响质量。建议每次变更后都跑一次回归评估,确认指标没有回退。
总结
评估体系不是锦上添花,而是RAG系统走向生产级的必要条件。
可操作行动清单:
