今天我们正式进入RAG通关系列教程,我是你们的RAG闯关向导,接下来我会用两周左右的时间,带你们一起闯关RAG。
🎯 学习价值
读者学完这9关后,将能够: 理解RAG全流程原理 掌握每个环节的核心技术 避开常见坑点 独立构建生产级RAG系统
做过RAG项目的同学,应该都有过这种体验:明明模型很强大、Embedding调得很溜、Prompt写得也很讲究,但系统上线后就是"睁眼说瞎话",答案飘得离谱。
这个时候别急着骂模型,先问问自己:你的数据干净吗?
今天咱们就来聊聊RAG的第一关——数据清洗。
开篇:你的RAG正在"中毒"
想象一下这个场景:老板让你做一个客服机器人,你吭哧吭哧搞了两周,Vector DB搭好了、LLM接好了、前端也调好了,兴冲冲上线测试。
结果用户问"怎么重置密码",AI回答:"根据我们的政策,请联系您的上级主管进行审批,审批通过后可在第三个工作日前往线下网点办理。"
你懵了——明明文档里清清楚楚写着"点击忘记密码即可重置"。
问题出在哪?
大概率是PDF解析时把页眉页脚、广告水印、重复版权声明一股脑儿塞进了Embedding里。你以为模型在学"密码重置",实际上它在学"第三工作日前往线下网点"。
垃圾进,垃圾出(Garbage In, Garbage Out)。
这句话在RAG领域简直是真理。你喂给模型什么,它就学什么。你喂给它噪声,它就学噪声。
核心概念:数据质量如何毁掉你的RAG
脏数据对Embedding的影响
Embedding模型(就是那个把文本转成向量的东西)不是魔法,它本质上是根据输入文本的语义生成向量表示。
如果你的文本长这样:
第3页 / 共15页 机密文档
================================================================
3.2 用户密码管理
点击此处下载PDF | 联系我们 | 隐私政策 | 广告位招租
根据《网络安全法》第三十二条之规定,用户在使用本系统时应当遵守以下条款...
© 2024 XX科技有限公司 版权所有
当这段文字被Embedding之后,生成的向量实际上是在为以下"语义"的混合物建模:
- 密码管理知识(有用)
- 页面元数据(无用)
- 导航链接(无用)
- 法律条款片段(可能有用,但上下文被打断了)
- 版权声明(完全无用)
结果:向量空间里"密码管理"这个概念被严重稀释,检索时很难精准命中。
常见垃圾数据类型
| 类型 | 示例 | 影响 |
|---|---|---|
| 页眉页脚 | "公司保密文件 第3页" | 重复噪声干扰向量 |
| 水印文本 | "机密"、"样章"、"Draft" | 误导语义判断 |
| 导航菜单 | "[首页][返回][联系我们]" | 引入无意义动作指令 |
| 版权声明 | "© 2024 XXX Corp." | 高度重复,稀释主题 |
| HTML标签 | <div><span class="xxx"> | 污染纯文本语义 |
| OCR乱码 | □■■�、乱码字符 | 导致向量化异常 |
| 重复段落 | 文档内多出出现的相同内容 | 检索结果重复权重过高 |
| 编码问题 | \u200b、\ufeff、BOM头 | 不可见字符干扰 |
三类典型"病情"
病情一:结构丢失
原始文档是这样的结构:
# 模型部署
## Docker部署
### 步骤1:构建镜像
...
### 步骤2:运行容器
...
## K8s部署
解析后变成一坨连续文本,标题层级全丢了。切分时Docker和K8s内容可能混进同一个chunk,问Docker问题时给你召回K8s的内容。
病情二:表格打散
参数表格被打成一行行碎片,列与列的对应关系完全丢失。
病情三:语义碎片化
一个完整的FAQ被打散成多个片段,问问题时答案"对但不全"。
避坑指南:三个最常见的清洗陷阱
陷阱1:过度清洗——把金子也扔了
很多同学清洗时过于激进,看见"异常字符"就删除,结果把有用的信息也干掉了。
典型案例:
# 错误做法:把所有特殊字符都删掉
text = re.sub(r'[^a-zA-Z0-9\u4e00-\u9fa5]', '', text)
# 结果:
# "Python >= 3.8, 建议使用 >= 3.10 版本"
# → "Python 38 建议使用 310 版本"
正确做法:保留语义相关的标点和格式信息。
# 正确做法:只删除真正的噪声
text = re.sub(r'[\x00-\x1f\x7f-\x9f]', '', text) # 只删控制字符
另一个坑:停用词
很多传统IR系统会删停用词(the、a、is这类常见词),但在现代Embedding检索里不能简单照搬,因为:
- "不"字很重要("删除数据" vs "不删除数据")
- "和"、"或"很重要(条件判断)
- "可以"、"必须"很重要(权限/能力区分)
建议:先做实验,再决定是否处理停用词。不要默认"一删了之"。
陷阱2:编码陷阱——看不见的坑
编码问题是最容易被忽视的清洗环节,因为它肉眼不可见。
常见问题:
# 问题1:BOM头导致JSON解析失败
text = "\ufeff{"name": "张三"}" # 开头多了个不可见字符
# 问题2:全角半角混用
"Python=Python" vs "Python=Python" # 看起来一样,但编码不同
# 问题3:混编码文件
# 文件里既有UTF-8的中文,又有GBK的残留
正确处理:
import unicodedata
def normalize_encoding(text):
# Unicode规范化:统一字符表示
text = unicodedata.normalize('NFC', text)
# 移除BOM
text = text.replace('\ufeff', '')
return text
陷阱3:正则误杀——好文本被误伤
正则表达式用不好,分分钟把正常内容当噪声删掉。
经典翻车现场:
# 想删页码:删除纯数字行
text = re.sub(r'^\d+$', '', text, flags=re.MULTILINE)
# 结果:把"2024"、"HTTP/2"这种有意义的内容也删了
更安全的做法:结合上下文判断,不是简单的一刀切。
代码实战:LangChain构建清洗Pipeline
说了这么多理论,来点硬核的。下面是一个可配置的文档清洗Pipeline,用LangChain实现:
"""
RAG文档清洗Pipeline
第1关配套代码:RAG通关系列
"""
import re
import hashlib
from typing import List, Callable, Optional
from dataclasses import dataclass, field
from langchain_core.documents import Document
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
@dataclass
class CleaningConfig:
"""清洗配置项"""
remove_html: bool = True
remove_urls: bool = True
remove_page_numbers: bool = True
remove_headers_footers: bool = True
remove_copyright: bool = True
normalize_whitespace: bool = True
normalize_encoding: bool = True
remove_control_chars: bool = True
# 可配置的规则
custom_stop_sentences: List[str] = field(default_factory=list)
custom_replace_rules: dict = field(default_factory=dict)
class DocumentCleaner:
"""文档清洗器"""
def __init__(self, config: Optional[CleaningConfig] = None):
self.config = config or CleaningConfig()
self.processing_log: List[dict] = []
def clean(self, documents: List[Document]) -> List[Document]:
"""清洗文档列表"""
cleaned_docs = []
for doc in documents:
original_text = doc.page_content
cleaned_text = self._clean_text(original_text, doc.metadata)
# 只有非空内容才保留
if self._is_valid_content(cleaned_text):
doc.page_content = cleaned_text
cleaned_docs.append(doc)
logger.debug(f"文档清洗完成: {len(original_text)} → {len(cleaned_text)} 字符")
else:
logger.warning(f"文档内容过短,已跳过: {doc.metadata.get('source', 'unknown')}")
return cleaned_docs
def _clean_text(self, text: str, metadata: dict) -> str:
"""核心清洗逻辑"""
original = text
# 1. 编码规范化(最先执行)
if self.config.normalize_encoding:
text = self._normalize_encoding(text)
# 2. 控制字符清理
if self.config.remove_control_chars:
text = self._remove_control_characters(text)
# 3. HTML标签清理
if self.config.remove_html:
text = self._remove_html_tags(text)
# 4. URL清理
if self.config.remove_urls:
text = self._remove_urls(text)
# 5. 页码清理
if self.config.remove_page_numbers:
text = self._remove_page_numbers(text)
# 6. 页眉页脚清理
if self.config.remove_headers_footers:
text = self._remove_headers_footers(text, metadata)
# 7. 版权声明清理
if self.config.remove_copyright:
text = self._remove_copyright(text)
# 8. 空白符规范化
if self.config.normalize_whitespace:
text = self._normalize_whitespace(text)
# 9. 自定义规则
text = self._apply_custom_rules(text)
return text.strip()
def _normalize_encoding(self, text: str) -> str:
"""Unicode规范化"""
import unicodedata
# 统一为NFC形式
text = unicodedata.normalize('NFC', text)
# 移除BOM
text = text.replace('\ufeff', '')
return text
def _remove_control_characters(self, text: str) -> str:
"""删除不可打印控制字符(保留换行和Tab)"""
# 保留 \n, \t,删除其他控制字符
return re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]', '', text)
def _remove_html_tags(self, text: str) -> str:
"""移除HTML标签"""
# 移除常见HTML标签
text = re.sub(r'<[^>]+>', ' ', text)
# 清理残留的 HTML 实体
text = re.sub(r'&[a-zA-Z]+;', ' ', text)
text = re.sub(r'&#\d+;', ' ', text)
return text
def _remove_urls(self, text: str) -> str:
"""移除URL,替换为占位符"""
pattern = r'https?://[^\s<>"{}|\\^`\[\]]+|www\.[^\s<>"{}|\\^`\[\]]+'
return re.sub(pattern, '[URL]', text)
def _remove_page_numbers(self, text: str) -> str:
"""移除页码"""
# 匹配常见页码格式
patterns = [
r'第\s*\d+\s*页', # 第3页
r'Page\s+\d+', # Page 3
r'\d+\s*/\s*\d+\s*页', # 3/15页
r'-\s*\d+\s*-', # - 3 -
]
for pattern in patterns:
text = re.sub(pattern, '', text)
return text
def _remove_headers_footers(self, text: str, metadata: dict) -> str:
"""移除页眉页脚(基于规则的简单实现)"""
lines = text.split('\n')
cleaned_lines = []
for i, line in enumerate(lines):
line = line.strip()
# 跳过纯符号行
if re.match(r'^[_\-=*]{3,}$', line):
continue
# 跳过过短的重复行(可能是页眉页脚)
if len(line) < 50 and line:
# 检查这行是否在文档中频繁出现(页眉页脚特征)
if lines.count(line) > 3:
continue
cleaned_lines.append(line)
return '\n'.join(cleaned_lines)
def _remove_copyright(self, text: str) -> str:
"""移除版权声明"""
patterns = [
r'©\s*\d{4}[^\n]*',
r'版权所有[^\n]*',
r'All\s+rights\s+reserved',
r'Copyright\s+[^\n]*',
]
for pattern in patterns:
text = re.sub(pattern, '', text, flags=re.IGNORECASE)
return text
def _normalize_whitespace(self, text: str) -> str:
"""规范化空白符"""
# 统一换行符
text = text.replace('\r\n', '\n').replace('\r', '\n')
# 合并多余空格
text = re.sub(r'[ \t]+', ' ', text)
# 清理连续空行(保留最多2个)
text = re.sub(r'\n{3,}', '\n\n', text)
return text
def _apply_custom_rules(self, text: str) -> str:
"""应用自定义规则"""
# 移除配置的停用句
for stop_sentence in self.config.custom_stop_sentences:
text = text.replace(stop_sentence, '')
# 应用替换规则
for old, new in self.config.custom_replace_rules.items():
text = text.replace(old, new)
return text
def _is_valid_content(self, text: str) -> bool:
"""判断清洗后内容是否有效"""
# 过滤空内容
if not text or len(text.strip()) < 10:
return False
# 过滤几乎全是符号的内容
alpha_ratio = sum(c.isalnum() for c in text) / len(text)
if alpha_ratio < 0.1:
return False
return True
class Deduplicator:
"""去重器:支持哈希去重 + 语义去重"""
def __init__(self, similarity_threshold: float = 0.85):
self.similarity_threshold = similarity_threshold
self.seen_hashes: set = set()
self.seen_embeddings: List[tuple] = [] # (embedding, text)
def deduplicate_by_hash(self, documents: List[Document]) -> List[Document]:
"""基于MD5哈希的快速去重"""
unique_docs = []
for doc in documents:
content_hash = hashlib.md5(
doc.page_content.encode('utf-8')
).hexdigest()
if content_hash not in self.seen_hashes:
self.seen_hashes.add(content_hash)
unique_docs.append(doc)
removed = len(documents) - len(unique_docs)
if removed > 0:
logger.info(f"哈希去重:移除了 {removed} 个重复文档")
return unique_docs
def deduplicate_by_similarity(
self,
documents: List[Document],
embeddings: List[List[float]]
) -> List[Document]:
"""基于向量相似度的语义去重"""
from sklearn.metrics.pairwise import cosine_similarity
import numpy as np
if len(documents) <= 1:
return documents
unique_docs = []
emb_array = np.array(embeddings)
for i, (doc, emb) in enumerate(zip(documents, emb_array)):
is_duplicate = False
for j, (_, existing_emb) in enumerate(self.seen_embeddings):
sim = cosine_similarity([emb], [existing_emb])[0][0]
if sim >= self.similarity_threshold:
is_duplicate = True
break
if not is_duplicate:
self.seen_embeddings.append((emb, doc.page_content))
unique_docs.append(doc)
removed = len(documents) - len(unique_docs)
if removed > 0:
logger.info(f"语义去重:移除了 {removed} 个相似文档")
return unique_docs
def build_cleaning_pipeline(
config: Optional[CleaningConfig] = None,
enable_dedup: bool = True,
dedup_threshold: float = 0.85
) -> Callable:
"""构建完整的清洗Pipeline"""
cleaner = DocumentCleaner(config)
deduplicator = Deduplicator(similarity_threshold=dedup_threshold)
def pipeline(documents: List[Document]) -> List[Document]:
# Step 1: 基础清洗
cleaned = cleaner.clean(documents)
logger.info(f"基础清洗完成:{len(documents)} → {len(cleaned)} 文档")
# Step 2: 哈希去重
if enable_dedup:
deduped = deduplicator.deduplicate_by_hash(cleaned)
logger.info(f"去重完成:{len(cleaned)} → {len(deduped)} 文档")
return deduped
return cleaned
return pipeline
# ============ 使用示例 ============
if __name__ == "__main__":
from langchain_core.documents import Document
# 示例脏文档
sample_docs = [
Document(
page_content="""
<div>公司产品手册 v2.0</div>
<h1>用户密码管理</h1>
<p>第3页 / 共15页 机密文档</p>
3.1 密码设置要求
<span class="nav">[首页][返回][联系我们]</span>
根据公司安全政策,用户密码应满足以下要求:
1. 长度至少8位
2. 包含大小写字母
3. 包含数字
© 2024 XX科技有限公司 版权所有
<div>广告位招租 | 了解更多请访问 http://example.com</div>
""",
metadata={"source": "manual.pdf", "page": 3}
)
]
# 自定义配置
config = CleaningConfig(
custom_stop_sentences=[
"广告位招租",
"了解更多请访问",
],
custom_replace_rules={
"v2.0": "v2.0(版本)",
}
)
# 构建Pipeline
cleaning_pipeline = build_cleaning_pipeline(config=config)
# 执行清洗
result = cleaning_pipeline(sample_docs)
print("=" * 50)
print("清洗结果:")
print("=" * 50)
for doc in result:
print(doc.page_content)
代码说明
这个清洗Pipeline有几个设计亮点:
-
配置化:所有清洗规则都可通过
CleaningConfig灵活配置,不用改代码就能适配不同场景 -
日志记录:每一步处理都有日志,方便定位问题
-
渐进式清洗:
- 先做编码规范化(因为它会影响后续所有操作)
- 再做噪声清理
- 最后做格式规范化
-
有效性判断:清洗后会自动过滤掉"几乎为空"的内容
最佳实践
实践一:建立可配置的规则体系
不要把清洗规则硬编码。根据不同文档类型建立不同配置:
# 技术文档:保留代码块结构
tech_config = CleaningConfig(
remove_html=True,
remove_page_numbers=True,
# 不删除版权(有时需要保留)
remove_copyright=False,
)
# 客服FAQ:更激进地清理
faq_config = CleaningConfig(
remove_html=True,
remove_page_numbers=True,
remove_headers_footers=True,
remove_copyright=True,
custom_stop_sentences=[
"点击此处了解更多",
"如有疑问请联系",
]
)
实践二:永远保留原始数据备份
# 在清洗前保存原始内容
for doc in documents:
doc.metadata['original_content'] = doc.page_content # 备份
# 清洗后,如有问题可以回溯
实践三:建立清洗日志
# 记录每个文档的处理过程
processing_log = []
for doc in documents:
log_entry = {
'source': doc.metadata.get('source'),
'original_length': len(doc.page_content),
'operations_applied': ['html_removal', 'page_num_removal'],
'final_length': len(cleaned_text),
'removed_ratio': 1 - len(cleaned_text) / len(doc.page_content)
}
processing_log.append(log_entry)
实践四:清洗效果验证
别以为清洗完就完事了,还要验证:
def validate_cleaning(documents):
"""验证清洗效果"""
issues = []
for doc in documents:
# 检查是否还有HTML标签残留
if re.search(r'<[^>]+>', doc.page_content):
issues.append(f"HTML残留: {doc.metadata.get('source')}")
# 检查字符长度是否合理
if len(doc.page_content) > 50000:
issues.append(f"内容过长: {doc.metadata.get('source')}")
# 检查中文字符比例
chinese_chars = len(re.findall(r'[\u4e00-\u9fa5]', doc.page_content))
total_chars = len(doc.page_content)
if total_chars > 0 and chinese_chars / total_chars < 0.1:
issues.append(f"中文比例过低: {doc.metadata.get('source')}")
return issues
思考题
-
场景分析:如果你负责清洗一批医疗文档,其中包含患者的姓名、身份证号等敏感信息,除了清洗噪声,你还应该做什么处理?
-
权衡取舍:清洗规则越严格,数据越干净,但处理时间越长、处理成本越高。在实际项目中,你如何平衡"清洗彻底性"和"处理效率"?
-
边界情况:对于OCR扫描的PDF文档,常见的噪声是"把字母l识别成数字1"或"把字母O识别成数字0"。这种情况下,你如何处理?
本关小结
| 要点 | 关键内容 |
|---|---|
| 核心理念 | 垃圾进,垃圾出;数据质量决定RAG上限 |
| 常见噪声 | 页眉页脚、水印、乱码、HTML标签、版权声明 |
| 三大陷阱 | 过度清洗、编码陷阱、正则误杀 |
| 解决方案 | 配置化Pipeline、渐进式处理、效果验证 |
| 代码核心 | DocumentCleaner类 + Deduplicator去重器 |
预告:下一关我们将进入**文档分块(Chunking)**环节,聊一聊如何把清洗后的文档切分成适合检索的小片段。
记住:好的清洗是RAG成功的一半。数据准备好了,后面的优化才能事半功倍。