本节导读:垃圾进、垃圾出(Garbage In, Garbage Out)是 RAG 系统的铁律。本节系统讲解 RAG 知识库实战中数据清洗的完整方法论,从格式规范化、噪声去除、重复检测到质量评分体系,帮你建立一套可复用的数据质量保障流程。
RAG 系统的回答质量取决于两个环节:检索质量和生成质量。而检索质量的前提是入库文档的质量。如果向量库中充斥着格式混乱、内容重复、信息过时的文档,再好的检索算法和嵌入模型也无法返回有价值的结果。
我在实际项目中见过太多因为数据质量问题导致的失败案例:一份 PDF 解析后变成了乱码,但没人检查就入了库,导致所有涉及该主题的查询都返回乱码片段;两份内容几乎相同的文档同时存在于库中,检索结果前五名有三个是重复的,挤占了其他相关文档的位置;三年前的过时政策文档和最新版本并存,LLM 基于旧文档生成了错误的回答。
| 类型 | 表现 | 对 RAG 的影响 | 发生频率 |
|---|---|---|---|
| 格式噪声 | 乱码、特殊字符、HTML 标签残留 | 嵌入向量失真,检索不相关 | 高 |
| 内容重复 | 同一文档的多个版本、Copy-Paste 重复 | 检索结果被重复占位 | 高 |
| 信息过时 | 旧版本政策、已失效的链接 | 生成错误回答(幻觉来源) | 中 |
| 质量低下 | 过短文档、无实质信息、纯目录页 | 浪费检索位置,降低整体精度 | 中 |
文档从各种格式(PDF、Word、HTML、Markdown)解析为纯文本后,往往携带大量格式残留。这些噪声如果不清理,会干扰嵌入模型对语义的理解。
import re def clean_text(text: str) -> str: """通用文本清洗 Pipeline""" # 1. 去除 HTML 标签残留 text = re.sub(r'<[^>]+>', '', text) # 2. 去除 URL(保留有意义的锚文本) text = re.sub(r'https?://\S+', '', text) # 3. 统一 whitespace text = re.sub(r'[ \t]+', ' ', text) text = re.sub(r'\n{3,}', '\n\n', text) # 4. 去除特殊控制字符(保留中文标点) text = re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]', '', text) # 5. 修复常见编码问题 text = text.replace('\xa0', ' ') # 不换行空格 text = text.replace('\ufeff', '') # BOM # 6. 去除连续重复标点 text = re.sub(r'([。!?,、;:])\1+', r'\1', text) return text.strip() def clean_whitespace_only(text: str) -> str: """轻量清洗:只处理空白和编码问题,保留原始格式""" text = text.replace('\xa0', ' ') text = text.replace('\ufeff', '') text = re.sub(r'\n{3,}', '\n\n', text) return text.strip()
什么时候用「轻量清洗」什么时候用「完整清洗」? 如果你的文档是 Markdown 或结构化文本,且解析质量较好,用轻量清洗保留原始格式更有利于检索。如果文档来自 PDF 或 OCR,解析噪声较多,必须用完整清洗。建议对同一文档集做 A/B 对比:随机抽 20 篇文档分别用两种方式清洗后检索同一个查询集,看哪种检索质量更高。
不是所有文档都值得入库。过短的文档(如导航栏文本、页脚信息)、纯列表文档(如目录页)、内容空洞的文档应该被过滤掉。
import jieba class DocumentQualityScorer: """文档质量评分器""" def __init__(self, min_chars: int = 100, min_info_density: float = 0.3): self.min_chars = min_chars self.min_info_density = min_info_density def score(self, text: str) -> dict: """评估文档质量,返回分数和各项指标""" # 指标1:长度 char_count = len(text.strip()) length_score = min(1.0, char_count / 500) # 指标2:信息密度(非停用词占比) words = list(jieba.cut(text)) stop_words = {"的", "了", "是", "在", "有", "和", "与", "或", "不", "都", "这", "那", "就", "也", "被", "把", "让", "给", "对"} info_words = [w for w in words if len(w) > 1 and w not in stop_words] info_density = len(info_words) / len(words) if words else 0 # 指标3:句子完整性(句号结尾的句子占比) sentences = re.split(r'[。!?]', text) complete_sentences = [s.strip() for s in sentences if len(s.strip()) > 5] sentence_score = min(1.0, len(complete_sentences) / max(len(sentences) - 1, 1)) # 综合评分 total_score = (length_score * 0.2 + info_density * 0.5 + sentence_score * 0.3) return { "total_score": round(total_score, 3), "length_score": round(length_score, 3), "info_density": round(info_density, 3), "sentence_score": round(sentence_score, 3), "char_count": char_count, "should_keep": total_score >= self.min_info_density and char_count >= self.min_chars }
权重为什么这样分配? 信息密度占 50% 权重,因为它是区分「有实质内容」和「空洞文档」最有效的指标。一个纯列表页可能有 500 字但信息密度极低(全是序号和简短标签)。句子完整性占 30%,因为片段化的文本(如导航栏、按钮文字)虽然字数不少但无法提供完整信息。长度只占 20%,因为极短文档直接被 min_chars 过滤,而长文档不一定质量高。
PDF 是 RAG 知识库中最常见也最棘手的数据源。不同类型的 PDF 需要不同的清洗策略:
文字型 PDF(可直接复制文字):主要问题是表格、多栏排版和页眉页脚。清洗策略是用正则识别并去除页眉页脚(通常出现在每页的固定位置)、将表格转换为列表格式。
扫描型 PDF(需要 OCR):主要问题是 OCR 识别错误和布局丢失。清洗策略包括:用语言模型纠正常见 OCR 错误(如把「rn」识别为「m」)、重建段落结构(OCR 输出通常是按行排列的,需要根据缩进和间距合并为段落)。
混合型 PDF(部分文字、部分图片):最复杂的情况。建议先用文字提取工具提取可直接复制的文字,再对图片区域做 OCR,最后合并。合并时注意去重——同一内容可能被提取了两次。
一个实用的 PDF 清洗经验:先用 PyMuPDF 提取文字(速度快、质量高),对提取失败的页面再用 OCR 补充。这样可以覆盖 90% 以上的 PDF 文档,只有极少数复杂排版的 PDF 需要特殊处理。
重复文档是 RAG 知识库的隐形杀手。它们不会报错,但会占用检索结果的宝贵位置。重复检测需要处理三种情况:完全重复(内容完全相同)、近似重复(只有少量文字差异)、语义重复(不同表述但信息相同)。
from difflib import SequenceMatcher import hashlib class DuplicateDetector: """文档去重检测器""" def __init__(self, exact_threshold: float = 0.95, near_threshold: float = 0.85): self.exact_threshold = exact_threshold self.near_threshold = near_threshold self.seen_hashes = set() self.seen_signatures = [] # (doc_id, signature) def _text_hash(self, text: str) -> str: """规范化后计算哈希,用于精确去重""" normalized = re.sub(r'\s+', '', text.lower()) return hashlib.md5(normalized.encode()).hexdigest() def _text_signature(self, text: str) -> str: """提取文本签名(取前200字+后200字),用于近似去重""" cleaned = re.sub(r'\s+', '', text) if len(cleaned) <= 400: return cleaned return cleaned[:200] + cleaned[-200:] def check(self, doc_id: str, text: str) -> dict: """检查文档是否重复""" # 精确去重 text_hash = self._text_hash(text) if text_hash in self.seen_hashes: return {"is_duplicate": True, "type": "exact", "doc_id": doc_id} # 近似去重 sig = self._text_signature(text) for seen_id, seen_sig in self.seen_signatures: ratio = SequenceMatcher(None, sig, seen_sig).ratio() if ratio >= self.exact_threshold: return {"is_duplicate": True, "type": "exact", "similar_to": seen_id, "similarity": round(ratio, 4)} if ratio >= self.near_threshold: return {"is_duplicate": True, "type": "near", "similar_to": seen_id, "similarity": round(ratio, 4)} # 未重复,记录 self.seen_hashes.add(text_hash) self.seen_signatures.append((doc_id, sig)) return {"is_duplicate": False, "doc_id": doc_id}
近似重复的阈值怎么选? 0.85 是比较保守的设置,只去除高度相似的文档。如果你的文档集有大量模板化内容(如合同模板、报告模板),可以降到 0.80 以去除更多重复。但对于技术文档,0.85 是合适的——低于这个相似度的两个文档通常确实包含不同信息。注意 SequenceMatcher 的时间复杂度是 O(n²),对于百万级文档集需要用 SimHash 等近似算法替代。
知识库中过时文档是最危险的数据质量问题,因为它们不会在格式或重复检测中被发现,但会导致 LLM 生成过时的、甚至错误的回答。
from datetime import datetime, timedelta class FreshnessChecker: """文档时效性检查器""" def __init__(self, max_age_days: int = 365, critical_keywords=None): self.max_age_days = max_age_days # 包含这些关键词的文档对时效性更敏感 self.critical_keywords = critical_keywords or [ "政策", "规定", "流程", "制度", "价格", "费率", "截止日期", "有效期", "版本", "更新" ] def check(self, text: str, doc_date: str = None, doc_metadata: dict = None) -> dict: """检查文档是否可能过时""" issues = [] # 检查1:文档日期 if doc_date: try: doc_dt = datetime.fromisoformat(doc_date[:10]) age_days = (datetime.now() - doc_dt).days if age_days > self.max_age_days: issues.append(f"文档已 {age_days} 天未更新") except (ValueError, TypeError): issues.append("无法解析文档日期") # 检查2:时间敏感关键词 has_critical_kw = any(kw in text for kw in self.critical_keywords) if has_critical_kw: issues.append("包含时间敏感关键词,建议人工确认时效性") # 检查3:过期标记 expiry_patterns = [r'截止[至到]\s*(\d{4}年\d+月\d+日)', r'有效期[至到]\s*(\d{4}年\d+月\d+日)', r'废止日期[::]\s*(\d{4}年\d+月\d+日)'] for pattern in expiry_patterns: match = re.search(pattern, text) if match: issues.append(f"发现过期标记:{match.group(0)}") return { "is_fresh": len(issues) == 0, "issues": issues, "needs_review": len(issues) > 0 }
为了让你对数据清洗的价值有直观感受,我分享一组实测数据。在一个包含 5000 篇技术文档的知识库上,对比「原始数据直接入库」和「经过完整清洗后入库」的检索效果:
注意,清洗没有改变嵌入模型和检索算法,纯粹通过提升数据质量就获得了 16 个百分点的召回率提升。这比换一个更贵的嵌入模型效果还好,而且成本几乎为零。这个数据也印证了开头说的那句话:垃圾进、垃圾出。
class DataCleaningPipeline: """完整的 RAG 数据清洗 Pipeline""" def __init__(self): self.cleaner = clean_text self.scorer = DocumentQualityScorer() self.dedup = DuplicateDetector() self.freshness = FreshnessChecker() self.stats = {"total": 0, "kept": 0, "dropped_quality": 0, "dropped_dup": 0, "flagged_freshness": 0} def process(self, doc_id: str, text: str, doc_date: str = None) -> dict: """处理单篇文档,返回清洗结果""" self.stats["total"] += 1 # 1. 格式清洗 cleaned = self.cleaner(text) # 2. 质量评分 quality = self.scorer.score(cleaned) if not quality["should_keep"]: self.stats["dropped_quality"] += 1 return {"status": "dropped", "reason": "low_quality", **quality} # 3. 去重 dup_result = self.dedup.check(doc_id, cleaned) if dup_result["is_duplicate"]: self.stats["dropped_dup"] += 1 return {"status": "dropped", "reason": "duplicate", **dup_result} # 4. 时效性检查 fresh = self.freshness.check(cleaned, doc_date) if fresh["needs_review"]: self.stats["flagged_freshness"] += 1 self.stats["kept"] += 1 return {"status": "kept", "cleaned_text": cleaned, "quality": quality, "freshness": fresh} def get_stats(self) -> dict: """获取清洗统计""" total = max(self.stats["total"], 1) return { **self.stats, "keep_rate": round(self.stats["kept"] / total * 100, 1), "drop_rate": round((self.stats["dropped_quality"] + self.stats["dropped_dup"]) / total * 100, 1) }
# 演示完整的数据清洗流程 pipeline = DataCleaningPipeline() test_docs = [ ("doc1", "公司年假政策:员工入职满一年后享有 10 天带薪年假..."), ("doc2", " \n\n "), # 空文档 ("doc3", "公司年假政策:员工入职满一年后享有 10 天带薪年假..."), # 重复 ("doc4", "首页 导航 产品 解决方案 关于我们 联系方式"), # 导航页 ] for doc_id, text in test_docs: result = pipeline.process(doc_id, text) print(f"{result['status']:>6} | {doc_id} | {result.get('reason', '')}") print(f"\n统计: {pipeline.get_stats()}")
A:取决于你的数据源质量。如果文档来自内部 Wiki 或结构化系统,轻量清洗(去空白、去编码问题)通常就够。如果来自 PDF、OCR 或爬虫抓取,需要完整的清洗 Pipeline。一个实用标准:随机抽 50 篇入库文档人工检查,如果超过 5 篇有明显的格式或内容问题,说明清洗不够。
A:SequenceMatcher 的 O(n²) 复杂度确实不适合大规模数据。对于百万级文档,建议用 SimHash 或 MinHash 算法替代——它们可以在 O(n) 时间内完成近似重复检测。Elasticsearch 和 Milvus 也内置了去重相关功能。对于中小规模(<10 万篇),SequenceMatcher 配合签名截断足够快。
A:最佳实践是维护文档版本链。当新版本入库时,将旧版本标记为「已归档」而非直接删除——这样在需要追溯历史时仍可查到。向量库中可以用元数据过滤(如 version_status=active)确保只检索最新版本。如果旧版本和新版本差异很小,直接用新版本覆盖即可。
A:建议在标注数据上做实验。取 100 篇人工判断为「有入库价值」和 100 篇「无价值」的文档,统计它们的 total_score 分布,找到最佳分割点。如果没有标注数据,从 0.3 开始,观察入库后的检索效果,如果发现大量低质量文档出现在检索结果中,提高阈值。另一个实用技巧是把评分处于阈值附近的文档(0.25–0.35)单独拿出来人工审核,这批文档的判断结果可以帮你更精确地校准阈值。
A:建议在标注数据上做实验。取 100 篇人工判断为「有入库价值」和 100 篇「无价值」的文档,统计它们的 total_score 分布,找到最佳分割点。如果没有标注数据,从 0.3 开始,观察入库后的检索效果,如果发现大量低质量文档出现在检索结果中,提高阈值。
本节围绕 RAG 知识库实战中的数据清洗与质量保证,系统讲解了从格式清洗、内容质量过滤、重复检测到时效性检查的完整 Pipeline。实测数据表明,仅通过提升数据质量就能将 Top-5 召回率提升 16 个百分点,这比换更贵的嵌入模型效果还要好。
核心建议有三点:第一,清洗前先采样审查,了解你的数据到底有什么问题,不要照搬别人的清洗方案。第二,渐进式建设——先做格式清洗和质量过滤这两个投入产出比最高的步骤,观察效果后再加入去重和时效性检查。第三,保留清洗日志和原始数据对照,清洗是一个需要持续迭代的过程,日志是你调优的基础。下一节(2.5 数据集构建最佳实践)将在此基础上,讲解如何将清洗后的高质量文档组织成结构化的数据集。
关键词:RAG 知识库实战, 数据清洗, 质量保证, 去重检测, 文档质量评分, 数据预处理
难度:入门
预计阅读:25 分钟