L2-1:RAG通关系列:第1关 | 别让垃圾数据污染你的AI大脑:数据清洗避坑指南

0 阅读10分钟

今天我们正式进入RAG通关系列教程,我是你们的RAG闯关向导,接下来我会用两周左右的时间,带你们一起闯关RAG。

🎯 学习价值

读者学完这9关后,将能够: 理解RAG全流程原理 掌握每个环节的核心技术 避开常见坑点 独立构建生产级RAG系统

rag_quality_0

做过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有几个设计亮点:

  1. 配置化:所有清洗规则都可通过CleaningConfig灵活配置,不用改代码就能适配不同场景

  2. 日志记录:每一步处理都有日志,方便定位问题

  3. 渐进式清洗

    • 先做编码规范化(因为它会影响后续所有操作)
    • 再做噪声清理
    • 最后做格式规范化
  4. 有效性判断:清洗后会自动过滤掉"几乎为空"的内容


最佳实践

实践一:建立可配置的规则体系

不要把清洗规则硬编码。根据不同文档类型建立不同配置:

# 技术文档:保留代码块结构
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

rag_quality_1

思考题

  1. 场景分析:如果你负责清洗一批医疗文档,其中包含患者的姓名、身份证号等敏感信息,除了清洗噪声,你还应该做什么处理?

  2. 权衡取舍:清洗规则越严格,数据越干净,但处理时间越长、处理成本越高。在实际项目中,你如何平衡"清洗彻底性"和"处理效率"?

  3. 边界情况:对于OCR扫描的PDF文档,常见的噪声是"把字母l识别成数字1"或"把字母O识别成数字0"。这种情况下,你如何处理?


本关小结

要点关键内容
核心理念垃圾进,垃圾出;数据质量决定RAG上限
常见噪声页眉页脚、水印、乱码、HTML标签、版权声明
三大陷阱过度清洗、编码陷阱、正则误杀
解决方案配置化Pipeline、渐进式处理、效果验证
代码核心DocumentCleaner类 + Deduplicator去重器

预告:下一关我们将进入**文档分块(Chunking)**环节,聊一聊如何把清洗后的文档切分成适合检索的小片段。

记住:好的清洗是RAG成功的一半。数据准备好了,后面的优化才能事半功倍。