数据质量对 RAG 的影响——烂数据进,烂答案出
数据质量对 RAG 的影响——烂数据进,烂答案出
去年我们给一个客户上线了 RAG 系统,前期测试效果挺好,上线后用户反馈一塌糊涂。用户问"我们公司的报销流程是什么",系统给出的答案是 2019 年的旧版报销规定,而且还把两个部门的不同政策混在一起回答了。
排查了两天才找到根源:知识库里塞了三个版本的 HR 文档,最新的、过期的、部分更新的全在里面。向量召回时,这三版内容都进了 context,LLM 只能硬着头皮"综合",综合出来的答案当然乱。
那一次之后我就把数据质量这件事单独提出来认真研究了。这篇文章是我的系统性总结。
为什么 RAG 对数据质量特别敏感
传统搜索引擎也会受数据质量影响,但影响方式不同。关键词搜索返回的是文档列表,用户自己判断哪个对;RAG 是把召回内容直接塞给 LLM 生成最终答案,中间没有人工干预。
这意味着:
- 传统搜索:烂文档 -> 用户看到排名靠后,可以忽略
- RAG:烂文档 -> 被召回 -> 进入 context -> LLM 用来生成答案 -> 用户拿到错误结论
而且 LLM 有个特点,它会尽量把 context 里的所有信息"融合"成一个一致的答案。当 context 里有矛盾信息时,它不会说"我发现这两段内容有矛盾",而是会选择性忽略某些内容,或者生成一个模糊的综合说法。
这就是为什么我说:RAG 系统的质量上限,是由知识库里最差的那批文档决定的。
文档质量问题分类
我把常见的文档质量问题分成三大类,每类对 RAG 的伤害方式不同。
第一类:格式混乱
格式混乱是最普遍的问题,也是最容易被忽视的。具体表现:
编码和字符问题
- PDF 转文本后出现乱码(尤其是中文 PDF,经常出现"?"替代字符)
- Word 文档里的特殊格式符号变成奇怪字符
- 表格被展平成一行,原有的行列关系完全丢失
结构被破坏
- 标题层级消失,全文变成同等缩进的文本块
- 列表符号(•、-、1.2.3.)被当作普通文本,不再代表列举关系
- 注脚、页眉页脚的内容混入正文
对 RAG 的具体影响: 格式混乱直接影响分块质量。如果一段文本的开头是残缺的(因为标题被错误处理),这个 chunk 就缺少了关键的语义上下文,召回后 LLM 也无法正确理解其含义。
我测过一份 40 页的技术手册,格式清洗前后的对比:
- 清洗前:chunk 的平均语义完整度评分(人工打分 0-10)约 5.2
- 清洗后:提升到 8.1
问答准确率从 61% 提升到 79%,只是靠格式清洗。
第二类:内容过时
内容过时比格式混乱更危险,因为它不容易被发现。
典型场景:
- 产品文档更新了,但旧版本没有删除
- 政策文件修订了,知识库里两版共存
- API 文档迭代了,老接口说明还在
对 RAG 的具体影响: LLM 在处理过时内容时,有两种典型的错误模式:
- 平均化错误:把新旧两个版本的信息"平均",给出一个介于两者之间的答案,两边都不对
- 随机选择错误:取决于哪个版本的 chunk 向量距离更近,随机给出某个版本的答案,每次问可能结果不同
在一个实际项目里,我把同一个问题问了 20 次(问题是"XXX 功能的配置参数是什么"),知识库里有两版文档,结果:
- 12 次给出旧版参数
- 6 次给出新版参数
- 2 次给出了混合的错误答案
这种不稳定性对用户来说极度不友好。
第三类:重复冗余
重复文档比想象中更常见。企业知识库经常出现:
- 同一份文档的多个格式版本(.doc、.pdf、HTML 版)
- 翻译件和原文同时存在
- 不同部门各自维护了内容高度重叠的文档
- 文档被分拆后又被整体上传
对 RAG 的具体影响: 重复内容会"稀释"知识库的覆盖范围。假设 top-k 召回 5 个 chunk,如果其中 3 个是同一内容的重复,实际上只有 2 个 chunk 的信息量,但占用了 3 个名额。
更严重的是,重复内容会影响向量检索的排名。语义高度相似的 chunk 会互相"抢分",导致真正相关但表述不同的内容排名靠后,进不了 top-k。
实测数据:不同质量文档对 RAG 准确率的影响
我专门做了一个对比实验,使用同一套问题集,在不同质量的知识库上测试 RAG 准确率。
实验设置:
- 基础文档集:500 份技术文档
- 评估问题集:100 个问题(人工标注标准答案)
- 评估指标:答案准确率(人工判断 + ROUGE-L 辅助)
- 向量模型:text-embedding-3-large
- LLM:GPT-4o
测试的文档质量级别:
| 质量级别 | 描述 | RAG 准确率 |
|---|---|---|
| Level 0 | 原始文档,未处理 | 58.3% |
| Level 1 | 仅做编码清洗 | 64.7% |
| Level 2 | 格式清洗 + 结构恢复 | 72.1% |
| Level 3 | 格式清洗 + 去重 + 过时内容标注 | 81.4% |
| Level 4 | 完整质量处理流水线 | 88.6% |
从 58.3% 到 88.6%,提升了 30 个百分点,全靠数据质量处理,模型没有换。
这个结果让我意识到:很多团队把精力放在调 prompt、换模型、优化检索策略上,但如果底层数据质量不过关,这些优化的边际收益是很低的。
文档质量评估工具:基于规则 + LLM 辅助评分
现在说实现。我们需要一个能对文档质量自动评分的工具,这样才能在入库前做质量把关。
评估维度:
- 格式完整性(是否有乱码、结构是否完整)
- 内容时效性(是否包含过时标记、版本信息)
- 重复度(与知识库已有内容的相似度)
- 语义完整性(LLM 辅助判断)
核心数据结构
@Data
public class DocumentQualityScore {
private String documentId;
private String documentName;
// 各维度评分 0-100
private int formatScore; // 格式完整性
private int freshnessScore; // 内容时效性
private int uniquenessScore; // 内容唯一性(与库中已有内容的差异度)
private int semanticScore; // 语义完整性
// 综合评分(加权平均)
private double overallScore;
// 问题列表
private List<QualityIssue> issues;
// 是否建议入库
private boolean recommendIngest;
// 评估时间
private LocalDateTime evaluatedAt;
}
@Data
@AllArgsConstructor
public class QualityIssue {
private IssueType type;
private IssueSeverity severity;
private String description;
private String location; // 问题位置描述
public enum IssueType {
ENCODING_ERROR, BROKEN_STRUCTURE, OUTDATED_CONTENT,
DUPLICATE_CONTENT, INCOMPLETE_SENTENCE, MISSING_CONTEXT
}
public enum IssueSeverity {
LOW, MEDIUM, HIGH, CRITICAL
}
}格式质量检查器
@Component
public class FormatQualityChecker {
// 乱码检测模式
private static final Pattern GARBLED_PATTERN = Pattern.compile(
"[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]|" + // 控制字符
"[\uFFFD\uFFFE\uFFFF]|" + // Unicode 替换字符
"(\\?{3,})" // 连续问号(PDF 转码失败标志)
);
// 结构完整性检测
private static final Pattern BROKEN_LIST_PATTERN = Pattern.compile(
"^[•\\-\\*]\\s*$", Pattern.MULTILINE // 只有符号没有内容的列表项
);
public FormatCheckResult check(String content) {
FormatCheckResult result = new FormatCheckResult();
List<QualityIssue> issues = new ArrayList<>();
// 1. 乱码检测
Matcher garbledMatcher = GARBLED_PATTERN.matcher(content);
int garbledCount = 0;
while (garbledMatcher.find()) {
garbledCount++;
}
double garbledRatio = (double) garbledCount / content.length();
if (garbledRatio > 0.01) {
issues.add(new QualityIssue(
QualityIssue.IssueType.ENCODING_ERROR,
garbledRatio > 0.05 ? QualityIssue.IssueSeverity.CRITICAL : QualityIssue.IssueSeverity.HIGH,
String.format("检测到%.1f%%的乱码字符", garbledRatio * 100),
"全文"
));
}
// 2. 结构完整性检测
String[] lines = content.split("\n");
int emptyListItems = 0;
int totalLines = lines.length;
for (String line : lines) {
if (BROKEN_LIST_PATTERN.matcher(line).matches()) {
emptyListItems++;
}
}
if (emptyListItems > 3) {
issues.add(new QualityIssue(
QualityIssue.IssueType.BROKEN_STRUCTURE,
QualityIssue.IssueSeverity.MEDIUM,
String.format("发现%d个空列表项,可能是格式转换问题", emptyListItems),
"列表区域"
));
}
// 3. 段落完整性检测(段落过短可能是格式被破坏)
long shortParagraphs = Arrays.stream(content.split("\n\n"))
.filter(p -> p.trim().length() > 0 && p.trim().length() < 20)
.count();
double shortParagraphRatio = (double) shortParagraphs /
Arrays.stream(content.split("\n\n")).filter(p -> p.trim().length() > 0).count();
if (shortParagraphRatio > 0.3) {
issues.add(new QualityIssue(
QualityIssue.IssueType.BROKEN_STRUCTURE,
QualityIssue.IssueSeverity.MEDIUM,
String.format("%.0f%%的段落过短(<20字符),可能是分块异常", shortParagraphRatio * 100),
"全文段落"
));
}
// 4. 计算格式评分
int score = 100;
for (QualityIssue issue : issues) {
switch (issue.getSeverity()) {
case CRITICAL: score -= 40; break;
case HIGH: score -= 20; break;
case MEDIUM: score -= 10; break;
case LOW: score -= 5; break;
}
}
result.setScore(Math.max(0, score));
result.setIssues(issues);
return result;
}
}时效性检查器
@Component
public class FreshnessChecker {
// 过时标记模式
private static final List<Pattern> OUTDATED_PATTERNS = Arrays.asList(
Pattern.compile("(?i)(deprecated|废弃|已废弃|不再支持|停用)"),
Pattern.compile("(?i)(旧版|old version|legacy|过期)"),
Pattern.compile("版本[::]?\\s*v?[12]\\.\\d+"), // 版本号 1.x 或 2.x(相对旧)
Pattern.compile("(201[0-8]|2019)年"), // 较旧的年份
Pattern.compile("(?i)(todo|fixme|待更新|需要更新)")
);
public FreshnessCheckResult check(String content, LocalDate documentDate) {
FreshnessCheckResult result = new FreshnessCheckResult();
List<QualityIssue> issues = new ArrayList<>();
int score = 100;
// 1. 检查文档日期
if (documentDate != null) {
long daysOld = ChronoUnit.DAYS.between(documentDate, LocalDate.now());
if (daysOld > 365 * 2) {
int penalty = Math.min(40, (int)(daysOld / 365) * 10);
score -= penalty;
issues.add(new QualityIssue(
QualityIssue.IssueType.OUTDATED_CONTENT,
daysOld > 365 * 3 ? QualityIssue.IssueSeverity.HIGH : QualityIssue.IssueSeverity.MEDIUM,
String.format("文档创建于%d天前,内容可能过时", daysOld),
"文档元数据"
));
}
}
// 2. 内容中的过时标记
for (Pattern pattern : OUTDATED_PATTERNS) {
Matcher matcher = pattern.matcher(content);
List<String> matches = new ArrayList<>();
while (matcher.find()) {
matches.add(matcher.group());
}
if (!matches.isEmpty()) {
score -= 15;
issues.add(new QualityIssue(
QualityIssue.IssueType.OUTDATED_CONTENT,
QualityIssue.IssueSeverity.MEDIUM,
String.format("发现过时标记:%s", String.join(", ", matches.subList(0, Math.min(3, matches.size())))),
"文档内容"
));
}
}
result.setScore(Math.max(0, score));
result.setIssues(issues);
return result;
}
}重复度检查器(基于向量相似度)
@Component
public class DuplicateChecker {
private final EmbeddingModel embeddingModel;
private final VectorStore vectorStore;
public DuplicateChecker(EmbeddingModel embeddingModel, VectorStore vectorStore) {
this.embeddingModel = embeddingModel;
this.vectorStore = vectorStore;
}
public DuplicateCheckResult check(String content, String documentId) {
DuplicateCheckResult result = new DuplicateCheckResult();
// 取文档前500字和后500字生成代表性向量(避免对超长文档全量embedding)
String sampleContent = buildSampleContent(content);
// 在向量库中搜索相似文档
List<Document> similarDocs = vectorStore.similaritySearch(
SearchRequest.query(sampleContent)
.withTopK(5)
.withSimilarityThreshold(0.85) // 相似度>0.85认为是重复
);
// 过滤掉自身
List<Document> duplicates = similarDocs.stream()
.filter(doc -> !documentId.equals(doc.getMetadata().get("documentId")))
.collect(Collectors.toList());
if (!duplicates.isEmpty()) {
// 找出最相似的文档
Document mostSimilar = duplicates.get(0);
double similarity = (double) mostSimilar.getMetadata().getOrDefault("similarity", 0.0);
result.setDuplicateFound(true);
result.setMostSimilarDocumentId((String) mostSimilar.getMetadata().get("documentId"));
result.setMaxSimilarity(similarity);
result.setScore(similarity > 0.95 ? 0 : (int)((1 - similarity) * 100));
} else {
result.setDuplicateFound(false);
result.setScore(100);
}
return result;
}
private String buildSampleContent(String content) {
if (content.length() <= 1000) {
return content;
}
return content.substring(0, 500) + " ... " + content.substring(content.length() - 500);
}
}LLM 辅助的语义质量评分
纯规则的方法有盲区,比如:文档格式完整,但内容本身逻辑混乱、自相矛盾。这类问题需要用 LLM 来辅助判断。
@Component
public class SemanticQualityEvaluator {
private final ChatClient chatClient;
public SemanticQualityEvaluator(ChatClient.Builder builder) {
this.chatClient = builder.build();
}
private static final String EVALUATION_PROMPT = """
你是一个文档质量评估专家。请评估以下文档片段的语义质量。
评估维度:
1. 内容完整性:信息是否完整,是否有明显缺失
2. 逻辑一致性:内容是否自相矛盾
3. 可理解性:脱离上下文后是否仍能理解主要意思
4. 信息密度:内容是否有实际信息量(非空洞废话)
请以JSON格式返回:
{
"completenessScore": 0-100,
"consistencyScore": 0-100,
"clarityScore": 0-100,
"densityScore": 0-100,
"overallScore": 0-100,
"issues": ["问题描述1", "问题描述2"],
"summary": "一句话评估总结"
}
文档内容(前1000字):
{content}
""";
public SemanticEvaluationResult evaluate(String content) {
// 只取前1000字,避免token消耗过大
String sampleContent = content.length() > 1000 ? content.substring(0, 1000) : content;
String prompt = EVALUATION_PROMPT.replace("{content}", sampleContent);
String response = chatClient.prompt()
.user(prompt)
.call()
.content();
return parseEvaluationResponse(response);
}
private SemanticEvaluationResult parseEvaluationResponse(String response) {
try {
// 提取JSON部分
int start = response.indexOf('{');
int end = response.lastIndexOf('}') + 1;
String json = response.substring(start, end);
ObjectMapper mapper = new ObjectMapper();
JsonNode node = mapper.readTree(json);
SemanticEvaluationResult result = new SemanticEvaluationResult();
result.setCompletenessScore(node.get("completenessScore").asInt());
result.setConsistencyScore(node.get("consistencyScore").asInt());
result.setClarityScore(node.get("clarityScore").asInt());
result.setDensityScore(node.get("densityScore").asInt());
result.setOverallScore(node.get("overallScore").asInt());
result.setSummary(node.get("summary").asText());
List<String> issues = new ArrayList<>();
node.get("issues").forEach(issue -> issues.add(issue.asText()));
result.setIssues(issues);
return result;
} catch (Exception e) {
// LLM响应解析失败,给一个默认中等分数
SemanticEvaluationResult fallback = new SemanticEvaluationResult();
fallback.setOverallScore(60);
fallback.setSummary("评估失败,给予默认分数");
return fallback;
}
}
}综合评分器
把上面几个检查器整合起来:
@Service
public class DocumentQualityService {
private final FormatQualityChecker formatChecker;
private final FreshnessChecker freshnessChecker;
private final DuplicateChecker duplicateChecker;
private final SemanticQualityEvaluator semanticEvaluator;
// 各维度权重
private static final double FORMAT_WEIGHT = 0.25;
private static final double FRESHNESS_WEIGHT = 0.25;
private static final double UNIQUENESS_WEIGHT = 0.20;
private static final double SEMANTIC_WEIGHT = 0.30;
// 建议入库的阈值
private static final double INGEST_THRESHOLD = 60.0;
public DocumentQualityScore evaluate(DocumentEvaluationRequest request) {
DocumentQualityScore score = new DocumentQualityScore();
score.setDocumentId(request.getDocumentId());
score.setDocumentName(request.getDocumentName());
score.setEvaluatedAt(LocalDateTime.now());
List<QualityIssue> allIssues = new ArrayList<>();
// 1. 格式质量
FormatCheckResult formatResult = formatChecker.check(request.getContent());
score.setFormatScore(formatResult.getScore());
allIssues.addAll(formatResult.getIssues());
// 2. 时效性(如果有文档日期的话)
FreshnessCheckResult freshnessResult = freshnessChecker.check(
request.getContent(),
request.getDocumentDate()
);
score.setFreshnessScore(freshnessResult.getScore());
allIssues.addAll(freshnessResult.getIssues());
// 3. 重复度
DuplicateCheckResult duplicateResult = duplicateChecker.check(
request.getContent(),
request.getDocumentId()
);
score.setUniquenessScore(duplicateResult.getScore());
if (duplicateResult.isDuplicateFound()) {
allIssues.add(new QualityIssue(
QualityIssue.IssueType.DUPLICATE_CONTENT,
duplicateResult.getMaxSimilarity() > 0.95
? QualityIssue.IssueSeverity.CRITICAL
: QualityIssue.IssueSeverity.HIGH,
String.format("与文档[%s]的相似度为%.1f%%",
duplicateResult.getMostSimilarDocumentId(),
duplicateResult.getMaxSimilarity() * 100),
"全文"
));
}
// 4. 语义质量(LLM评分,成本较高,可以按需开启)
int semanticScore = 70; // 默认值
if (request.isEnableSemanticEvaluation()) {
SemanticEvaluationResult semanticResult = semanticEvaluator.evaluate(request.getContent());
semanticScore = semanticResult.getOverallScore();
semanticResult.getIssues().forEach(issue ->
allIssues.add(new QualityIssue(
QualityIssue.IssueType.INCOMPLETE_SENTENCE,
QualityIssue.IssueSeverity.LOW,
issue,
"语义层面"
))
);
}
score.setSemanticScore(semanticScore);
// 5. 综合评分
double overall = score.getFormatScore() * FORMAT_WEIGHT
+ score.getFreshnessScore() * FRESHNESS_WEIGHT
+ score.getUniquenessScore() * UNIQUENESS_WEIGHT
+ semanticScore * SEMANTIC_WEIGHT;
score.setOverallScore(overall);
score.setIssues(allIssues);
score.setRecommendIngest(overall >= INGEST_THRESHOLD);
return score;
}
}文档质量处理流水线
有了质量评估工具,接下来要设计完整的处理流水线:评估 -> 分级 -> 处理 -> 入库。
这个流水线的关键设计思路:不是简单的二元判断(入库/不入库),而是分级处理。评分高的直接入库,评分中等的带着质量标记入库(这个标记可以在 RAG 检索时降权),评分低的交给人工,很低的直接拒绝。
质量问题的应对策略
除了在入库前做质量把关,还需要考虑几个运营层面的问题。
版本管理:同一份文档的多个版本,要有明确的"主版本"标记。RAG 检索时,优先返回主版本的内容,历史版本可以保留但需要降权处理。
过期标记:文档入库时记录"有效期至"字段,定期扫描并标记过期文档。在 RAG 的 prompt 里可以加一句:"如果检索到的内容标记为过期,请在回答中注明可能不是最新信息。"
质量监控:定期(每周或每月)对知识库全量文档重新评分,发现新增的质量问题(比如一份文档原来是新的,现在变旧了)。
总结
烂数据进,烂答案出,这不是一句废话,是 RAG 工程的核心约束。
我见过太多团队在 RAG 效果不好的时候,第一反应是"换更好的模型"、"调整 prompt"、"改变检索策略"。这些都有用,但如果知识库本身质量有问题,这些优化的天花板很低。
正确的顺序是:先把数据质量做到位,再谈其他优化。
数据质量处理不是一次性工作,是持续的运营工作。入库有质量门槛,入库后定期扫描,版本有管理机制,这三件事做好了,RAG 系统的可靠性会有质的提升。
下一篇我们说文档预处理的完整流水线,不只是质量评估,而是如何让文档"更适合被 AI 理解"。
