一个做 RAG 知识库的朋友上周找我诉苦:文档切了,向量也存了,检索出来的片段就是驴唇不对马嘴。我让他把进库前的原始文本贴一段给我看,结果里面全是 PDF 复制出来的残留换行、表格错位的制表符、还有一大段没滤掉的页眉页脚。问题根本不在模型,在数据进门之前就没洗干净。
这篇就想把 RAG 里最容易糊弄过去的“文本清洗”环节掰开揉碎讲清楚。不管你是准备给企业知识库做 RAG,还是在用 langchain + pgvector 或 milvus 搭检索管道,只要你需要把非结构化文档喂给大模型,这篇涉及的规则、代码和排错思路都能直接抄。
1. 为什么说清洗决定 RAG 的检索上限
1.1 检索效果差,根子往往不在模型而在数据
很多人以为 RAG 效果不行就是 embedding 模型选得不好,或者 rerank 没加,甚至怀疑大模型本身的推理能力。实际我接手过好几个“检索效果差”的项目,逐层排查下来,最普遍的问题都出在源头:文本进库之前太脏。
举个例子。从 PDF 里直接抽取的文本,经常是断行断在莫名其妙的位置,比如“本公”“司成立于”“2010年”。这种片段切进分块器之后,语义被拦腰截断。embedding 模型再强,也无法把一个完整的句子从两个断块里拼回去。检索召回的自然就是一堆残缺信息,最后生成出来的答案自然也是东拼西凑。
另一个常见情况是 HTML 转文本时没去掉导航栏、底部版权、弹窗文案。这些噪音片段在向量空间里和真正的业务内容“抢地盘”,导致检索时明明该召回《员工手册》里的请假流程,最后召回了全站每个页面都有的那行“若有疑问请联系IT服务台”。
所以在 RAG 里,清洗不是可有可无的预处理,它直接决定了检索的上限。你可以在召回后面加一堆 rerank、重排、压缩的模块,但如果源文本本身的语义就是碎的,后面所有环节都是给垃圾做精装修。
1.2 一个被低估的放大效应:脏数据如何拖垮整个链路
脏数据在 RAG 链路里有一个比较隐蔽的放大效应,很多人没意识到。
文本清洗发生的位置在“解析 - 分块 - embedding - 检索”的最前端。如果这个环节出了 5% 的错,比如某类文档的连字符换行没合并、Markdown 表格被错误拆开,那这 5% 的错误会直接污染它所属的所有分块。一个分块脏了,它对应的向量就脏了;向量脏了,检索时它就可能会被错误召回,也可能把真正该召回的块挤掉。到了生成阶段,大模型基于脏检索结果给出的答案,可信度直线下降。
最麻烦的是,这种错误不像代码报错那样会立刻暴露。代码编译不过你会知道,但检索结果“看起来还行,细看不对”这种软错误,往往要上线很久、被用户反复反馈之后才浮出水面。我在一个企业知识库项目里就遇到过:检索“报销上限”时,向量数据库召回的全是“出差报销管理制度”PDF 里被表格撑碎的行,每一条都带一串“|”符号和数字列,把排序模型直接带偏。
所以做 RAG 工程,一定要建立的一个观念是:清洗是整个管道里杠杆最大的环节,它花掉的时间会在后面的检索和生成环节成倍地省回来。
1.3 清洗成本与幻觉代价的权衡
有人会问:既然清洗这么重要,那我是不是应该把所有文本都用大模型洗一遍?我的建议是:别。
大模型做文本重写,成本高、速度慢,而且有“过度加工”的风险——模型会把原文没有的信息补进去。RAG 追求的是忠实检索,不是让清洗环节去“创作”。我见过有人用 GPT 把几千篇文档全部改写了一遍再入库,结果文档里的关键数字全被“润色”得和原始材料对不上,用户追问出处时完全无法溯源。
清洗的投入产出比存在一个拐点:对于规则能处理的问题,用正则和字符串操作解决,便宜又快;对于规则无法处理的语义级问题,才考虑上模型。这个判断标准很重要。文本清洗的目标不是“完美文本”,而是“满足检索与分块的输入要求”。把 PDF 里黏在一起的段落拆开、把导出的 HTML 转成干净的纯文本、把全角半角统一——这些规则足够解决 90% 的问题,剩下 10% 再用人工或模型兜底。
2. 文本清洗六大核心维度拆解
不同来源的文本,脏的程度和类型完全不一样。我把实际项目里最常见的清洗项分为六个维度,每个维度都配了可以直接抄的规则思路。
2.1 第一维:字符归一化与不可见字符清理
这是最基础也最容易被忽视的一步。文本里除了可见字符,还藏着一堆肉眼看不见的东西:
- Unicode 零宽空格(
\u200b、\u200c、\u200d),这是我从网页复制内容时最常遇到的东西,它们不会显示,但会打断分词; - 不间断空格
\u00a0( 转过来的),在中文文本里表现为一个“空格”,但字节序列和普通空格完全不同; - 各种控制字符,比如
\x00、\x01这种从二进制文件里带出来的内容; - 全角英文字母和数字(
ABC123),多见于从老系统导出的文本。
我在清洗函数里的做法是先用一个“归一化映射表”把全角字符转成半角,然后删掉所有控制字符和零宽字符,最后把多种空白字符统一成普通空格。这一步做完,文本的“底子”才算干净。
import re import unicodedata def normalize_characters(text: str) -> str: # 全角转半角(注意:中文标点如,。!应该保留全角) normalized = [] for ch in text: code = ord(ch) if code == 0x3000: # 全角空格 normalized.append(' ') elif 0xFF01 <= code <= 0xFF5E: # 全角 ASCII 范围 normalized.append(chr(code - 0xFEE0)) else: normalized.append(ch) text = ''.join(normalized) # 去掉零宽字符与不可见控制字符 text = re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f\x7f\u200b-\u200f\u202a-\u202e\u2060\ufeff]', '', text) # 统一空白 text = re.sub(r'[\t\u00a0\u2000-\u200a\u3000]+', ' ', text) return text.strip()关键点:中文标点不要跟着转半角。全角逗号“,”转换后英文逗号“,”会被当成分词边界,很多中文语义就变了。上面代码里只处理了0xFF01到0xFF5E的范围,这些是英文字母、数字和英文标点的全角形式,中文标点(,。!等)不在此范围内,能安全保留。
2.2 第二维:噪音内容识别与移除
噪音内容指的是“不属于正文”的片段,包括:
- 页眉页脚(公司名、页码、文档标题反复出现);
- 版权声明、免责声明、联系方式;
- 网页里的导航菜单、相关推荐、评论区占位;
- 从聊天记录或工单系统导出的固定文案。
移除噪音有三种策略,按性价比排序:
- 规则匹配:适合页码(
第 1 页 / 共 3 页)、版权(Copyright © 2024)、网址邮箱这类模式高度固定的内容。直接正则干掉,副作用小,速度最快。 - 位置启发式:页眉页脚往往出现在每个页面的顶部和底部固定区域。如果你的解析器能拿到每页文本(比如 PyMuPDF 按页读取),可以统计哪些行在大量页面重复出现,重复次数超过阈值就整段删除。
- 语义分类:对某些难以用规则覆盖的场景,可以训练一个小分类器或直接用大模型做 few-shot 判定,只用来处理“规则不确定”的长尾情况。
一个比较实用的思路是“正则第一、频率第二、模型兜底”。先跑规则能解决的,再用“跨页重复行”杀掉页眉页脚,最后如果还剩漏网的,人工看一批错例再补规则。纯靠模型做噪音过滤,一次处理几万页文档的成本非常吓人。
2.3 第三维:结构与语义信息保留
清洗不是把文本越短越好,关键在于保留对检索有用的结构信息。
比如 Markdown 格式的标题层级。你清洗时如果把# 一级标题直接删掉只留“一级标题”四个字,那分块时就丢了章节边界信息,父子分块里的父节点也就无法利用标题概括子块内容。再比如表格,直接转成纯文本会把行列关系拍平,检索“第二季度营收”时,向量里只剩一串第二季度 营收 1000万 500万,语义边界完全丢失。
我比较推荐的做法是:如果原始文档带有 Markdown 结构,清洗时保留标题标记;如果是 PDF 转出来的纯文本,则用空行和大段缩进来猜测段落边界;如果是 HTML 转文本,尽量先用工具把<table>转成 Markdown 表格,不要用.get_text()一把梭。
# 保留 Markdown 标题的清洗示例 def clean_markdown(text: str) -> str: # 保留 # 标题标记,但压缩多余空行 lines = text.splitlines() cleaned = [] blank_count = 0 for line in lines: line = line.rstrip() if not line.strip(): blank_count += 1 if blank_count <= 1: # 最多保留一个空行做段落分隔 cleaned.append('') continue blank_count = 0 cleaned.append(line) result = '\n'.join(cleaned) # 避免出现标题后紧跟普通文本导致边界丢失的问题 result = re.sub(r'(#{1,6}.*)\n(?!\n)', r'\1\n', result) return result.strip()清洗和保留结构之间要取一个平衡点。我的经验是:凡是 embedding 模型可能利用的语义分隔信号,都尽量保留。标题、列表符号、表格的行列分隔符,只要不变成噪音,留着它比删掉它对检索更友好。
2.4 第四维:正文去重与近重复文本处理
企业知识库里经常存在大量重复文档:同一份制度文件,一个版本放在 OA 里,另一个版本放在共享盘里,长得几乎一样但修订日期不同。如果不去重,向量数据库里会有多份相互重叠的内容。检索时它们会占满召回名额,真正的不同版本信息反而被挤掉。
去重分为精确去重和近重复去重。前者用哈希就够,后者需要算文本相似度。
import hashlib from difflib import SequenceMatcher def exact_dedup(documents: list[dict]) -> list[dict]: seen: set[str] = set() result = [] for doc in documents: h = hashlib.md5(doc['text'].encode('utf-8')).hexdigest() if h not in seen: seen.add(h) result.append(doc) return result def near_dedup(documents: list[dict], threshold: float = 0.92) -> list[dict]: result = [] for doc in documents: text = doc['text'][:500] # 取前500字符做粗筛,避免全文比对太慢 is_dup = False for kept in result: kept_text = kept['text'][:500] ratio = SequenceMatcher(None, text, kept_text).ratio() if ratio > threshold: is_dup = True break if not is_dup: result.append(doc) return result这里要注意near_dedup是 O(n²) 的复杂度,文档量超过几万份时不能用SequenceMatcher硬比。工程上更常见的做法是先抽 MinHash 指纹或 SimHash,用候选集合并再精确比对,能省掉 99% 的无谓比较。
更精细的近重复判断,还可以结合向量余弦相似度做一次粗筛,比如先召回 top20 最相近的向量,再对候选文本做精确比对。这样既能控制精度,速度也能接受。
2.5 第五维:语言与字符集统一
企业文档经常混着多种语言。比如一份中文文档里嵌着代码、英文缩写、繁体中文、还有从 AutoCAD 或老系统导出的乱码字符。语言不统一对 embedding 的影响很大,因为多语言模型虽然能处理多种语言,但混合语言片段的向量分布往往不够稳定,检索时容易出现一类文档集体“隐身”的情况。
这一维最实际的操作是:
- 所有文档统一为 UTF-8 编码读入,遇到无法解码的字节用替换符标记出来,人工决定是删还是转;
- 简体繁体做统一转换(如果需要,可以用 OpenCC,否则检索时简体查繁体容易漏掉内容);
- 对代码和自然语言混合的文本,识别程序语言片段,用特殊标记包裹起来或在分块时单独处理。
还有一个容易被忽略的坑:从扫描版 PDF OCR 出来的中文文本,经常把中文标点识别成西文标点,还会把“0”识别成“o”。这种情况 OCR 清洗要单独做一套纠错规则,和普通文本清洗不是一回事。我建议对 OCR 文本额外跑一遍常见错误对照表,比如中文字符后跟英文逗号改成中文逗号,数字和字母混淆通过上下文修正。
2.6 第六维:清洗结果长度与完整性控制
最后一个维度很多人会漏掉:清洗之后,文本的长度和完整性可能已经不是预期状态了。比如 PDF 抽取出两栏排版,清洗时如果不做分栏还原,文本会变成“左栏第一行 + 右栏第一行”交叉排列,一句话读到一半跳到另一栏去。这种语义错乱用常规清洗规则根本无法修复,必须回到解析阶段,用布局分析先还原阅读顺序。
长度控制指的是文本在清洗后是否过短。一个制度文件的某一个分页可能只有一句“见附录”,单独成块入库毫无意义。我会在清洗流程末尾做一次“空块/极短块剔除”:字符数低于某个阈值(比如 20 个字符)的块直接丢弃,除非它本身是有效标题。这个阈值需要结合你文档的语言来调整,中文 20 个字符可能是一句话,英文 20 个字符可能是半个单词,需要分开处理。
另外,清洗时不要破坏句子完整性。如果你的清洗规则里包含“删除包含某些词的行”这种操作,一定要检查删除后相邻两行会不会拼接成错误语义。一个保险的做法是删除前记录被删行的首尾字符,删除后如果拼接处没有空格或标点,就补一个空格。
3. 清洗与分块策略的联动:进库前的最后一道设计
3.1 为什么分块效果差,先回去看清洗
分块策略在 RAG 里是一门大学问,常被单独拿出来讨论。但很少有人意识到,分块效果差,很多时候不是 chunk_size 和 overlap 参数的问题,而是前置清洗没做好。
假如你设置chunk_size=500字符、overlap=50,对一段干净的文本来说,这个配置可能刚好够用。但如果文本里有大量被拆断的换行、被空格挤开的英文单词、带\r\n的旧 Windows 换行符,那 500 字符里真正有效的语义内容可能只有 300 字符,另外 200 字符全是换行符和半截单词。模型分出来的块自然质量很差。
我在项目里的经验是:先清洗,再分块;分块参数要基于“清洗后的平均字符密度”来调,而不是基于原始文档的页数或字节数。一个含大量代码的文档和一个纯政策文本的文档,即使页数相同,清洗后的有效字符密度也完全不一样,分块参数必须分别调整。
3.2 页面级分块、语义分块与父子分块对清洗的不同要求
- 页面级分块:直接按 PDF 的页面或 Word 的分页符切块。这种策略对清洗要求最低,只要确保页眉页脚去掉、页码不混入即可。但由于一个页面可能包含多个主题,检索精度相对粗糙。
- 固定窗口分块(如 langchain 的
RecursiveCharacterTextSplitter):对清洗要求最高。它依赖分隔符列表来切分文本,如果文本里还残留大量无意义换行,分块器会优先在错误位置切分,产生大量语义不完整的块。清洗时必须把“有意义的分隔符”(段落边界、标题)和“无意义的换行”区分开。 - 语义分块(如按句子向量相似度聚类):对清洗要求也很高,因为相似度计算对文本质量极其敏感。一个夹在正文中间的表格,会破坏相邻句子的语义连贯性,导致聚类切出奇怪的边界。
- 父子分块(Parent-Child Chunking):对清洗的要求是“块边界要可回溯”。如果清洗时把原始文档的行号、页码信息弄丢了,父子分块的小块命中后无法正确回溯到大块对应位置,引用溯源就断了。
所以我一直建议:在设计分块策略前,先做一个“清洗质量抽样检查”——随机抽出 10 段清洗后的文本,人工看一眼断句是否合理、标题是否完整、表格是否错位。检查不过关,先调清洗规则,不要急着调分块参数。
3.3 一个可落地的清洗-分块联动管线示例
下面这个示例是一个我实际用过的处理流程,适合企业知识库常见文档(PDF、Word、HTML、Markdown)。它把清洗和分块放在同一个管道里,每个环节输出都有日志和抽样检查接口,方便定位问题。
from langchain.text_splitter import RecursiveCharacterTextSplitter import re class RagTextPipeline: def __init__(self, chunk_size: int = 500, chunk_overlap: int = 50): self.splitter = RecursiveCharacterTextSplitter( chunk_size=chunk_size, chunk_overlap=chunk_overlap, separators=["\n\n", "\n", "。", "!", "?", ". ", "! ", "? ", ";", "; ", " ", ""], ) self.stats = {} def clean(self, text: str) -> str: # 1. 基础字符归一化 text = normalize_characters(text) # 2. 去页码 text = re.sub(r'第\s*\d+\s*页[,,]\s*共\s*\d+\s*页', '', text) text = re.sub(r'Page\s*\d+\s*of\s*\d+', '', text, flags=re.IGNORECASE) # 3. 去网址、邮箱 text = re.sub(r'https?://\S+|www\.\S+', '', text) text = re.sub(r'[\w.+-]+@[\w-]+\.[\w.-]+', '', text) # 4. 压缩空行 text = re.sub(r'\n{3,}', '\n\n', text) # 5. 去除粘贴 PDF 时常见的断字符 text = re.sub(r'-\n(?=[a-z])', '', text) # 英文连字符断行还原 self.stats['char_count'] = len(text) return text.strip() def dedup_and_chunk(self, documents: list[str]) -> list[str]: cleaned_docs = [self.clean(d) for d in documents] cleaned_docs = exact_dedup([{'text': d} for d in cleaned_docs]) cleaned_docs = [d['text'] for d in cleaned_docs] chunks = [] for doc in cleaned_docs: chunks.extend(self.splitter.split_text(doc)) # 剔除极短块 chunks = [c for c in chunks if len(c.strip()) >= 20] return chunks这个管道里的separators顺序值得留意。RecursiveCharacterTextSplitter按分隔符的优先级依次尝试,优先用双换行(段落边界),然后单换行,再到中文句号。这样设计是为了让分块过程优先尊重语义边界,而不是机械按字符数硬切。清洗把这个判断留给分隔符去完成,不去破坏段落结构,两者配合起来效果比“清洗后全压成一行再分块”好很多。
4. 工程落地:清洗流程的技术选型与实现方案
4.1 从文件到文本:解析层的清洗盲区
文本清洗不只是对字符串做处理,还要考虑“文本是怎么来的”。同一个 PDF,用 PyMuPDF 抽取和用 pdfplumber 抽取,得到的字符串换行位置可能完全不同。同一个 HTML,用 BeautifulSoup 的get_text和用markdownify转 Markdown,结果差异巨大。
要把清洗做好,必须对解析层有认知。
| 格式 | 推荐解析方式 | 常见清洗盲区 |
|---|---|---|
| PDF(文本型) | PyMuPDF / pdfplumber | 多栏排版顺序乱、页眉页脚、连字符断行 |
| PDF(扫描件) | OCR(如 PaddleOCR / Tesseract) | OCR 错字、中文标点错乱、表格识别差 |
| Word | python-docx 或先转 Markdown | 批注、修订痕迹、文本框内容丢失 |
| HTML | BeautifulSoup / markdownify | 导航、脚本、样式残留;表格转文本错位 |
| Markdown | 直接读文本 | 代码块被误删、标题层级丢失 |
选型时我倾向于:能用 Markdown 中间格式的,优先转 Markdown 再做清洗,因为 Markdown 保留标题和表格结构,对后续分块和向量化最友好。PDF 则是先尝试文本抽取,如果文本层提取出来乱序,再考虑 OCR 或布局分析。
4.2 清洗规则引擎:不要为了 AI 而过度清洗
清洗规则堆多了之后,代码会变得混乱。我建议用一个规则列表来管理,而不是在代码里到处写正则。每个规则有名字、有启停开关、有适用范围,方便出问题时定位是哪条规则把内容洗坏了。
CLEANING_RULES = [ {"name": "remove_control_chars", "enable": True, "func": remove_control_chars}, {"name": "merge_english_hyphen_newline", "enable": True, "func": merge_english_hyphen_newline}, {"name": "deduplicate_blank_lines", "enable": True, "func": deduplicate_blank_lines}, {"name": "remove_page_number", "enable": True, "func": remove_page_number}, {"name": "remove_boilerplate_copyright", "enable": False, "func": remove_boilerplate_copyright}, {"name": "fix_ocr_common_errors", "enable": True, "func": fix_ocr_common_errors}, ] def apply_cleaning_rules(text: str, doc_type: str = "general") -> str: for rule in CLEANING_RULES: if not rule["enable"]: continue try: text = rule["func"](text, doc_type) except Exception as e: print(f"[Cleaning Warning] rule {rule['name']} failed: {e}") return text有人可能会问:为什么不上大模型直接做清洁?我的回答是:清洗环节的“确定性”比“智能性”重要得多。规则清洗跑一万遍结果都是一样的,出了问题容易回溯;大模型清洗每次输出可能都不一样,遇到涉及时效的合同条款,哪怕是万分之一概率的改写错误,都可能引发严重后果。文本清洗应该尽量用可控的手段解决,大模型只做最后兜底。
4.3 用评分和采样来校验清洗质量
清洗规则写完了,怎么知道它有没有效果?我强烈建议在清洗流程里加一个“清洗质量评分”模块。不需要多复杂,用几个可量化的指标就能暴露大部分问题。
def cleaning_quality_score(text: str) -> dict: total_chars = len(text) lines = [l for l in text.splitlines() if l.strip()] unreasonably_short_lines = sum(1 for l in lines if 0 < len(l.strip()) < 8) pages_markers = len(re.findall(r'第\s*\d+\s*页', text)) url_count = len(re.findall(r'https?://', text)) control_chars = len(re.findall(r'[\x00-\x08\x0b\x0c\x0e-\x1f]', text)) avg_line_len = sum(len(l) for l in lines) / max(1, len(lines)) warning_ratio = (unreasonably_short_lines / max(1, len(lines))) * 100 score = 100 if warning_ratio > 15: score -= 25 if pages_markers > 0: score -= 10 if url_count > 0: score -= 10 if control_chars > 0: score -= 20 if avg_line_len < 15: score -= 15 return { "score": max(0, score), "avg_line_len": round(avg_line_len, 1), "short_line_ratio": round(warning_ratio, 1), "pages_markers": pages_markers, "url_count": url_count, "control_chars": control_chars, }这个评分函数只适合做“快速冒烟检查”,它的价值在于给你一个粗糙的反馈信号。如果一批文档清洗后平均评分低于 60,就不要接着做后面的分块和向量化了,先回头把清洗规则调好。宁可花时间在清洗上,也不要浪费算力去向量化一批注定检索不准的脏数据。
5. 用检索质量反向评估清洗效果
5.1 离线评估:答案召回率是最终标尺
清洗规则再怎么调,最终都要看“检索能不能找到该找的东西”。离线评估的核心是构造一个测试集:从知识库里挑出若干真实的使用场景,每个场景写一个 query,并标注这条 query 应该命中哪些文档或哪些关键段落。
然后分别用“清洗前的数据”和“清洗后的数据”跑一遍检索,比较 top5/top10 的命中率。
def evaluate_retrieval(retriever, queries: list[dict]) -> dict: hits_at_5 = 0 hits_at_10 = 0 for item in queries: q = item["query"] gold_ids = set(item["relevant_chunk_ids"]) docs = retriever.get_relevant_documents(q, top_k=10) retrieved_ids = {d.id for d in docs} if gold_ids & retrieved_ids: hits_at_10 += 1 top5_ids = {d.id for d in docs[:5]} if gold_ids & top5_ids: hits_at_5 += 1 return { "recall@5": hits_at_5 / len(queries), "recall@10": hits_at_10 / len(queries), }只有这种量化结果才能说服你自己,也才能说服业务方:清洗这步投入是值得的。我在一个项目里测过,清洗前 recall@10 大概在 0.52,清洗后能到 0.78。差距大得惊人,而且这还是没有换任何 embedding 模型的情况下达成的。
5.2 基线对照实验怎么设计才不算数
做清洗效果评估时,最容易犯的错是“只跑一遍新流程,发现效果好了,就断言是清洗的功劳”。实际上,你可能同时改了分块参数、换了 embedding 模型、加了新的文档解析器,多个变量混在一起,根本无法定位到底是哪一步带来提升。
正确的做法是:每次只改一个变量。比如你先固定分块参数和 embedding 模型不动,只把清洗从“无”改成“有”,跑离线测试集。得到差值之后,再在“有清洗”的基础上,单独调分块参数,看还能不能进一步涨点。这样你的优化才是可解释的,而不是一个整体黑盒。
另一个需要小心的点是测试集 contamination。如果你用来评估的 query 原本就是某个文档片段改写的,那召回率高只是因为你把原文放进去了,这不代表你的检索质量真的提升。测试集最好从真实用户提问里采样,并且保证 query 和答案不是简单的一字不差,要有改写和同义转述。
5.3 线上指标:无回答率与误召回
离线测试集终究覆盖不了真实线上所有场景。上线之后,我还会盯着三个指标:
- 无回答率:用户提问之后系统返回“未找到相关信息”的比例。清洗变好,无回答率通常会下降,因为文档在向量空间里的覆盖变完整了。
- 误召回率:召回的片段里和问题不相关的比例。这个只能通过用户反馈或人工抽检来统计,比较费人力,但很有价值。
- 引用溯源成功率:如果你的 RAG 系统输出答案时必须带引用,那清洗好的数据应该更容易从检索结果追溯到原始文档的具体位置。清洗时如果弄丢了页码、章节号,这个指标就会明显恶化。
我见过不少团队上线后只盯着“回答质量”看,一旦回答变差了就怀疑是不是大模型指令没调好,其实问题出在检索侧——清洗规则在某个文档类型上出了问题,导致一批文档向量化后语义错乱。所以建议 RAG 系统上线之后,至少要在日志里记录每次检索命中的 top10 片段 ID,方便回溯分析是否存在误召回,而不是只能看到最终的生成结果。
6. RAG 清洗实战中的高频坑与排查思路
6.1 案例一:转义符污染导致的全文粘连
现象:某天知识库里的文档检索效果突然大幅下降,查“请假流程”返回的片段全是密密麻麻的一整行,断句全部丢失。
排查过程:我第一反应是分块参数被改了,但排查后发现参数没动。后来抽了一条原始文本出来,发现文本里有大量\n的字符串,而不是真正的换行符。这些字符串来自某个工具把文本导出成了 JSON,JSON 里的换行符被转义成\\n,后面解析时没做反转义,结果整个文本显示为一大段带反斜杠的垃圾。
根因:清洗流程里没有处理“被转义的换行符”。在从 JSON、数据库、CSV 导入文档时,经常会发生这种问题。
解决办法:在清洗函数最前面加一步,把连续出现的\n字面量(即字符串里真的有两个字符\和n)替换成真正的换行符:
text = text.replace('\\n', '\n').replace('\\t', '\t').replace('\\"', '"')验证:替换后重新跑清洗质量评分,原本的 short_line_ratio 从 0 恢复正常,分块器也能在正确位置切分了。
这个坑的典型特征:检索结果能召回,但召回的文本像“一坨”看不清结构。遇到这种情况,先别急着调 embedding,抽一条原文出来用十六进制看一眼,转义符污染立刻现形。
6.2 案例二:Markdown 表格解析后语义碎片化
现象:含大量表格的制度文档(比如《差旅报销标准》)召回效果极差,检索“住宿标准上限”时,召回的片段经常只有表格里的几个数字,没有表头,也没有上下文。
排查过程:我抽查了原始 Markdown 转文本的结果,发现转换工具把每个单元格变成了单独一行,表格的行列关系完全丢失。分块器按照换行符切分时,一个单元格可能自成一个块,导致向量里存的是“500”“一线城市”这种孤零零的词,没有表头对应关系。
根因:清洗规则里有一行把所有|符号删掉了,本意是想把 Markdown 表格转成纯文本,结果反而破坏了表格结构。
解决办法:如果是 Markdown 源文档,不要急着删|。先用一个智能表格识别逻辑把表格整块提取出来,转换成“表头:xxx;内容:xxx”的自然语言描述,再决定是单独入库还是和其他段落一起入库。如果必须转纯文本,也要保留表头行和单元格之间的逻辑关系。
def table_to_text(markdown_table: str) -> str: lines = [l.strip() for l in markdown_table.splitlines() if l.strip()] if not lines: return "" # 跳过分隔行如 |---|---| content_lines = [l for l in lines if not re.match(r'^[\s\|\-:]+$', l)] if not content_lines: return "" headers = [cell.strip() for cell in content_lines[0].strip('|').split('|')] rows = [] for line in content_lines[1:]: cells = [cell.strip() for cell in line.strip('|').split('|')] if len(cells) > len(headers): cells = cells[:len(headers)] elif len(cells) < len(headers): cells = cells + [''] * (len(headers) - len(cells)) row_desc = ','.join(f"{h}为{c}" for h, c in zip(headers, cells) if c) rows.append(row_desc) result = ';'.join(rows) return result验证:把表格转成自然语言之后再清洗,检索“住宿标准上限”时就能召回包含“城市等级为一线城市;住宿标准上限为500元”的正确块,而不是孤零零的“500”。
6.3 案例三:全英文文档被“半角化”后语义丢失
现象:某个技术文档库既有中文文档也有英文文档。清洗后,英文文档的检索效果不升反降,查 API 名称经常召回不全。
排查过程:我发现清洗规则里的“统一空白字符”把英文单词间的多个空格压缩成了单空格,这本该没问题的。但另一条“全角转半角”规则,把文档里故意保留的等宽字体空格(比如代码缩进)也处理了,导致代码片段被拍平成一行,变量名和字符串之间的边界丢失。
根因:代码和自然语言文档用同一套清洗规则,没有做文档类型分流。
解决办法:清洗流程要支持按文档类型切换规则。代码文档保留缩进和空格,自然语言文档才做空白压缩。在规则引擎里加一个doc_type参数,根据文档来源或语言选择不同的规则集。
验证:改完之后,英文 API 文档的召回率明显回升。后来我把这个经验扩展到所有技术类文档:凡是含代码的技术文档,清洗规则必须单独配置,宁可少洗也不能把代码语义洗没。
6.4 案例四:网页正文抽取混入轮播与推荐位文本
现象:从一个集团官网抓取政策文件做知识库,清洗后检索“IT 服务台联系方式”,每次都召回一篇完全无关的新闻稿。
排查过程:抽检清洗后的内容,发现新闻稿页面里混入了大量导航栏和“相关推荐”的链接文本,这些文本里恰好包含“IT 服务台”几个字。embedding 模型把整个页面文本向量化后,推荐位的噪音在语义上和 query 产生了虚假相关。
根因:HTML 解析时直接用soup.get_text(),没有区分正文区和侧边栏。
解决办法:不要用get_text()一把抓。先用 CSS 选择器或 XPath 定位正文容器,再对正文容器做文本提取。如果网页结构混乱无法精确定位,至少要过滤掉<nav>、<footer>、<aside>、<script>、<style>这些标签的内容。
from bs4 import BeautifulSoup def extract_main_content(html: str) -> str: soup = BeautifulSoup(html, "html.parser") for tag in soup(["script", "style", "nav", "footer", "aside", "header"]): tag.decompose() # 优先找 article / main / 正文容器 main = soup.find("article") or soup.find("main") or soup.find("div", class_=re.compile("content|article|main")) if main: return main.get_text("\n", strip=True) return soup.get_text("\n", strip=True)验证:定位正文章节后,这类误召回就消失了。这个坑的教训是:网页型知识库文档,清洗的优先级不在字符串处理,而是正文抽取策略。抽取层做错了,后面正则写得再漂亮也白搭。
我做了六七年文本处理相关的工作,踩过最多坑的永远是清洗环节。很多人觉得清洗是脏活累活,不如调 prompt、选模型有技术含量,但实际项目里,清洗节省的时间成本远超任何一个后置模块的优化。每次看到检索召回率不达标,我第一反应不是去换 embedding 模型,而是先回去把清洗规则重跑一遍,把清洗前后的文本并列摆在一起看一遍。这个习惯帮我避开了无数个无效加班的深夜。希望这篇能帮你在做 RAG 知识库时少走这几个弯路。