
一个做 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这是我从网页复制内容时最常遇到的东西它们不会显示但会打断分词不间断空格\u00a0nbsp;转过来的在中文文本里表现为一个“空格”但字节序列和普通空格完全不同各种控制字符比如\x00、\x01这种从二进制文件里带出来的内容全角英文字母和数字多见于从老系统导出的文本。我在清洗函数里的做法是先用一个“归一化映射表”把全角字符转成半角然后删掉所有控制字符和零宽字符最后把多种空白字符统一成普通空格。这一步做完文本的“底子”才算干净。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_size500字符、overlap50对一段干净的文本来说这个配置可能刚好够用。但如果文本里有大量被拆断的换行、被空格挤开的英文单词、带\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_sizechunk_size, chunk_overlapchunk_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(rPage\s*\d\s*of\s*\d, , text, flagsre.IGNORECASE) # 3. 去网址、邮箱 text re.sub(rhttps?://\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 / TesseractOCR 错字、中文标点错乱、表格识别差Wordpython-docx 或先转 Markdown批注、修订痕迹、文本框内容丢失HTMLBeautifulSoup / 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(rhttps?://, 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_k10) 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 { recall5: hits_at_5 / len(queries), recall10: hits_at_10 / len(queries), }只有这种量化结果才能说服你自己也才能说服业务方清洗这步投入是值得的。我在一个项目里测过清洗前 recall10 大概在 0.52清洗后能到 0.78。差距大得惊人而且这还是没有换任何 embedding 模型的情况下达成的。5.2 基线对照实验怎么设计才不算数做清洗效果评估时最容易犯的错是“只跑一遍新流程发现效果好了就断言是清洗的功劳”。实际上你可能同时改了分块参数、换了 embedding 模型、加了新的文档解析器多个变量混在一起根本无法定位到底是哪一步带来提升。正确的做法是每次只改一个变量。比如你先固定分块参数和 embedding 模型不动只把清洗从“无”改成“有”跑离线测试集。得到差值之后再在“有清洗”的基础上单独调分块参数看还能不能进一步涨点。这样你的优化才是可解释的而不是一个整体黑盒。另一个需要小心的点是测试集 contamination。如果你用来评估的 query 原本就是某个文档片段改写的那召回率高只是因为你把原文放进去了这不代表你的检索质量真的提升。测试集最好从真实用户提问里采样并且保证 query 和答案不是简单的一字不差要有改写和同义转述。5.3 线上指标无回答率与误召回离线测试集终究覆盖不了真实线上所有场景。上线之后我还会盯着三个指标无回答率用户提问之后系统返回“未找到相关信息”的比例。清洗变好无回答率通常会下降因为文档在向量空间里的覆盖变完整了。误召回率召回的片段里和问题不相关的比例。这个只能通过用户反馈或人工抽检来统计比较费人力但很有价值。引用溯源成功率如果你的 RAG 系统输出答案时必须带引用那清洗好的数据应该更容易从检索结果追溯到原始文档的具体位置。清洗时如果弄丢了页码、章节号这个指标就会明显恶化。我见过不少团队上线后只盯着“回答质量”看一旦回答变差了就怀疑是不是大模型指令没调好其实问题出在检索侧——清洗规则在某个文档类型上出了问题导致一批文档向量化后语义错乱。所以建议 RAG 系统上线之后至少要在日志里记录每次检索命中的 top10 片段 ID方便回溯分析是否存在误召回而不是只能看到最终的生成结果。6. RAG 清洗实战中的高频坑与排查思路6.1 案例一转义符污染导致的全文粘连现象某天知识库里的文档检索效果突然大幅下降查“请假流程”返回的片段全是密密麻麻的一整行断句全部丢失。排查过程我第一反应是分块参数被改了但排查后发现参数没动。后来抽了一条原始文本出来发现文本里有大量\n的字符串而不是真正的换行符。这些字符串来自某个工具把文本导出成了 JSONJSON 里的换行符被转义成\\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, stripTrue) return soup.get_text(\n, stripTrue)验证定位正文章节后这类误召回就消失了。这个坑的教训是网页型知识库文档清洗的优先级不在字符串处理而是正文抽取策略。抽取层做错了后面正则写得再漂亮也白搭。我做了六七年文本处理相关的工作踩过最多坑的永远是清洗环节。很多人觉得清洗是脏活累活不如调 prompt、选模型有技术含量但实际项目里清洗节省的时间成本远超任何一个后置模块的优化。每次看到检索召回率不达标我第一反应不是去换 embedding 模型而是先回去把清洗规则重跑一遍把清洗前后的文本并列摆在一起看一遍。这个习惯帮我避开了无数个无效加班的深夜。希望这篇能帮你在做 RAG 知识库时少走这几个弯路。