做 AI 应用最难的不是模型,而是“脏数据”:文档清洗实战指南

做知识库项目 时,最容易被低估的一步是文档清洗。很多人看到 PDF 能被解析出文字,就认为资料已经可以进向量库;真正接入业务资料后才发现,检索结果里反复出现“第 12 页”“机密文件 请勿外传”,一张审批表被拆成十几个列名,刚修订的制度和三年前的附件同时回答问题。模型表现看上去时好时坏,实际上它只是在忠实地利用质量参差的上下文。

本文仍以内部制度问答为例,但方法同样适用于产品手册、客服知识库、技术规范和历史工单。目标不是用一堆规则“把文本洗得好看”,而是建立一条可追踪的入库流程:每份文件从哪里来、提取到了什么、哪些内容被清理、为什么被拒绝、谁确认后才能继续。这样出现错误时,团队能找到具体环节,而不是重新上传一遍碰碰运气。

一、先认识“脏”:不是乱码才叫脏数据
在文档场景中,脏数据至少有六种常见形态。第一种是重复噪声,例如每页都有的公司名称、密级、页码和打印日期;它们没有业务语义,却因重复次数高而影响向量表示。第二种是结构丢失,例如表格解析后只剩一串“城市 职级 金额 城市 职级 金额”,人和模型都难以判断对应关系。第三种是版本冲突,同一制度被不同部门以相近文件名上传,旧版没有停用。第四种是扫描件 OCR 错误,金额中的 8 被识别成 3,否定词漏掉一个字,风险比完全无法解析更大。

第五种是内容边界错误。一个 PDF 中可能同时包含目录、封面、制度正文、修订记录和附件;它们并不是都应该进入同一个知识库。目录用于导航,不应作为问答依据;修订记录可帮助判断版本,但不能替代生效条款;附件可能仅适用于某部门。第六种是权限或来源不完整:文件确实有文字,却不知道提交者、适用范围、生效时间和是否允许检索。这样的资料即使内容正确,也不具备作为生产知识的资格。

因此,清洗的第一条原则是“保留证据,处理副本”。原始文件要以不可变方式保存,解析后的文本和清洗结果作为派生数据;任何规则都不应覆盖原文。第二条原则是“宁可标记不确定,也不要擅自修正事实”。像页码、连续空格可以自动规范;“台北”是否被 OCR 错成“台北市”则应保留原文并进入复核。第三条原则是“每个决定都可解释”。当一个片段被丢弃时,要能看到它因为重复、过短、权限缺失还是格式异常。

二、从文件到文本:先保存位置和上下文
解析器的选择取决于文件类型,但不管用哪种库,输出都不应只是一个大字符串。最小单元至少包含文件 ID、页码或段落序号、原始文本和提取方法。这样后续看到一段异常内容,才能回到原始页核验;否则清洗规则一旦误伤,就没有可靠的恢复路径。

下面用一个轻量结构表示“页级提取结果”。这里没有绑定具体 PDF 库,是因为不同团队已有的解析工具可能不同;只要最终都能产出同样的记录,后续规范化和质量检查便可复用。source_hash 用于判断同一内容是否被重复处理,extractor 则让问题追踪到具体工具和版本。

from dataclasses import dataclass
from hashlib import sha256

@dataclass(frozen=True)
class RawPage:
document_id: str
page: int
text: str
extractor: str
source_hash: str

def raw_page(document_id: str, page: int, text: str, extractor: str) -> RawPage:
digest = sha256(text.encode("utf-8")).hexdigest()
return RawPage(document_id, page, text, extractor, digest)

很多项目在这一层就把文本拼起来,然后再用正则清洗。它看似简单,却丢掉了两个关键信息:重复内容是否跨页出现,以及错误发生在哪一页。保留页级数据后,可以统计每页首尾的重复行,识别页眉页脚;也可以把“第 6 页 OCR 置信度低”作为后续片段的警告信息。网页则可把页码替换为 URL 和标题锚点,Word 可记录段落或表格编号,思想完全相同。

三、规范化:先做无争议的事情
文本规范化应从低风险操作开始,例如统一换行符、折叠连续空白、删除不可见控制字符、处理全角与半角的明显差异。它们改善搜索和去重,但不改变业务语义。相反,把所有标点删除、把数字转成中文、强行合并相邻段落,都会让来源定位和金额核验变得困难。

下面的函数刻意很短。它不尝试“智能纠错”,只处理编码和排版噪声,并保留段落换行。规则的顺序也重要:先标准化换行,再清除控制字符,最后压缩每行空白;若把所有空白一次压平,条款和段落边界就消失了。

import re
import unicodedata

CONTROL = re.compile(r"[\u0000-\u0008\u000b\u000c\u000e-\u001f]")

def normalize_text(value: str) -> str:
value = unicodedata.normalize("NFKC", value)
value = value.replace("\r\n", "\n").replace("\r", "\n")
value = CONTROL.sub("", value)
lines = [re.sub(r"[ \t]+", " ", line).strip() for line in value.split("\n")]
return "\n".join(lines).strip()

def _demo() -> None:
assert normalize_text(" 标准 \r\n\t2800 元\x00 ") == "标准\n2800 元"

if __name__ == "__main__":
_demo()

执行 python normalize.py 后没有输出,说明这条最小检查通过。它只证明函数处理了给定的排版样本,不证明所有 PDF 都被正确解析。真正的验证方法是随机抽取清洗前后页面,核对数字、日期、否定词和条款标题没有改变;涉及金额或规则的页面应优先抽查。

四、页眉页脚与重复段落:靠统计,不要靠猜文件名
页眉页脚是检索污染的高发来源。制度 PDF 常把“人力资源部”“内部资料”“第 X 页”印在每页顶部或底部,解析器会把它们和正文一起提取。人工浏览时它们很容易被忽略,向量检索却会因为重复出现而赋予它们不必要的权重。

一种朴素而可靠的办法是统计同一文件多页的首行和末行。若某一行出现在较高比例的页面中、文本很短、且不含典型条款信息,就把它标记为候选页眉页脚,而不是立刻删除。候选清单可以在导入日志中输出,供管理员快速确认。文件只有两三页时统计证据不足,应保守处理,不要因为两页都出现“第一章”就把正文删掉。

from collections import Counter

def repeated_edges(pages: list[RawPage], ratio: float = 0.6) -> set[str]:
edges: list[str] = []
for page in pages:
lines = [line for line in normalize_text(page.text).split("\n") if line]
if lines:
edges.extend((lines[0], lines[-1]))
threshold = max(2, int(len(pages) * ratio))
return {line for line, count in Counter(edges).items()
if count >= threshold and len(line) <= 40}

def remove_known_edges(text: str, candidates: set[str]) -> str:
return "\n".join(line for line in normalize_text(text).split("\n")
if line not in candidates)

这个策略故意只删除“已确认的候选”,因为短标题有时会在每页重复,特别是合同和表单。实际操作可先运行 repeated_edges,将候选展示给文档管理员;确认后把结果与规则版本一并保存。若资料类型稳定,再增加白名单或正则规则,例如只处理“第 N 页”形式的页码。先有审计记录,后有自动化 ,远比直接写一条很宽的正则安全。

五、表格和扫描件:承认解析能力的边界
表格不是普通段落。费用标准表中,“城市—职级—上限”三列的关系才是知识,按阅读顺序串成文本后可能变成“台北 高级 2800 台中 普通 1800”。对于简单表格,可以把每行重建成完整句子,例如“台北地区,高级职级,住宿上限 2800 元/晚”,再把表名、页码和列名写进元数据。这样用户问“台北高级职级上限”时,检索的语义单位才完整。

但不要假装所有表格都能自动重建。合并单元格、跨页表格、脚注和多层表头都会使列关系不确定。此时应输出“表格结构待复核”,让业务人员确认后以 CSV、JSON 或人工整理的标准文本入库。一次错误的表格转换可能把报销上限对应到错误城市,这比不回答更难发现。

扫描件的处理同理。OCR 结果应携带置信度或至少携带 extractor="ocr" 标记。短页、乱码比例高、数字密度异常的内容可以进入复核队列。不要用语言模型自动“润色 OCR 文本”再直接入库:它会把原始错误和模型改写混在一起,后续无法确定数字来自哪里。若确实需要辅助修订,应保留原始 OCR、建议文本、人工确认人和确认时间四份信息。

六、去重不等于只比文件哈希
文件哈希只能识别完全相同的上传,不能发现“只改了封面日期”的重复,也不能区分“同一制度的新版本”。适合 RAG 的去重至少分三层:原始文件哈希用于避免重复处理;规范化后的页面或片段哈希用于发现内容相同;文档业务键和版本用于表达替代关系。三个层次不可互相替代。

例如,《差旅管理办法 V3》重新导出成 PDF,二进制哈希会变化,但正文大部分片段的内容哈希不变。入库任务可以复用这些片段的向量,减少成本;若其中一条住宿标准变了,只需对变化片段重新生成向量。反过来,两个部门各上传一份相同内容,但访问范围不同,也不能简单合并为一个无权限标签的片段,否则后续授权会出错。

下面给出一个基于规范化文本的片段指纹。它不是近似重复检测:相差一个数字的片段会被视为不同,正好符合制度场景的谨慎原则。近似匹配适合用于生成“疑似重复”列表,最终仍应由版本规则或人工确认决定。

from hashlib import sha256

def content_fingerprint(text: str) -> str:
canonical = "\n".join(
line for line in normalize_text(text).split("\n") if line
)
return sha256(canonical.encode("utf-8")).hexdigest()

def should_skip(existing_hashes: set[str], text: str) -> bool:
return content_fingerprint(text) in existing_hashes

入库时应把“跳过重复”“新建版本”“疑似冲突”分别计数。它们是不同的处理结果:跳过重复不需要人工动作;新建版本需要在校验后切换生效状态;疑似冲突则需要负责人判断哪份资料有效。日志若只写“导入成功”,这些重要差异会全部消失。

七、质量闸门:让不合格资料停在入库之前
清洗完不应立刻写入索引。一个最小质量闸门可以检查:文件是否有来源与版本;文本长度是否异常;乱码比例是否过高;是否存在重复内容;是否包含至少一个可定位字段;是否有明确访问范围。规则本身不复杂,关键在于结果要有三种状态:通过、警告、拒绝。通过的资料自动进入切分;警告资料可以进入人工队列;拒绝资料保持原样并返回原因。

长度阈值不能凭空设成一个“神奇数字”。一页只有标题可能是正常封面,也可能是提取失败;十万字符可能是完整手册,也可能是解析器把隐藏文本重复了几十遍。合理做法是按资料类型设置宽松范围,并结合其他信号判断。例如“文本很短 + OCR + 没有页码”比单独“文本很短”更值得拦截。

from dataclasses import dataclass

@dataclass(frozen=True)
class QualityResult:
status: str
reasons: tuple[str, ...]

def check_page(page: RawPage) -> QualityResult:
text = normalize_text(page.text)
reasons: list[str] = []
if len(text) < 20:
reasons.append("文本过短,可能是封面或提取失败")
if text and sum(ch == "�" for ch in text) / len(text) > 0.01:
reasons.append("替换字符比例过高,建议复核编码或 OCR")
if page.extractor == "ocr" and any(ch.isdigit() for ch in text):
reasons.append("OCR 页包含数字,金额和日期需抽查")
return QualityResult("warning" if reasons else "pass", tuple(reasons))

这不是全面的文档质量模型,它只是明确了几个高风险信号。随着真实失败案例累积,再增加规则;不要在没有证据时堆二十个阈值。每条规则应该能对应一个历史问题或一项合规要求,并有可关闭或调整的版本记录。对质量判断的变更也要跑回归样本,避免新规则把以前正常的文件全部拦下。

八、清洗前后如何比较:不要伪造“提升百分比”
在没有自己的标注集前,不应写“清洗后准确率提升了多少”。可复现的做法是准备一小批代表性文档和问题,保留清洗前、清洗后两个索引,在其他条件相同的情况下比较:目标条款是否进入候选集;最终引用是否指向正确页码;回答是否能保留金额、日期和适用范围;应拒答的问题是否仍拒答。结果可能改善,也可能没有变化,二者都能指导下一步。

建议把验证分为内容检查和问答检查。内容检查由熟悉资料的人抽样对照原文:页眉是否被去掉、标题是否保留、表格行是否完整、数值是否变化。问答检查则使用预先写好的问题,观察目标片段的位置。若内容检查发现数字被误改,先停下问答调优,修复清洗规则;下游检索再漂亮也无法弥补上游事实错误。

清洗规则也需要回归验证。每当新增一条页眉过滤、段落合并或表格转换规则,都应拿一组已人工确认的页面重新执行,核对正文、金额、日期和章节标题是否仍然存在。尤其是宽泛的正则表达式,往往能解决一个文件的噪声,却误删另一份文件中格式相似的有效条款。将这些页面固定为样本,能让规则演进保持可控,而不是靠导入后才发现资料被悄悄改坏。

可以在每次入库生成一份简短报告:原始页数、可提取页数、通过页数、警告页数、拒绝页数、重复片段数、人工确认项。报告不需要展示用户原文,只展示统计和文档 ID 即可。它既是导入的验收凭据,也让运营人员能在“这份资料为什么搜不到”时快速判断是否卡在清洗阶段。

九、权限、日志和删除:数据治理不在清洗流程之外
文档清洗常被当成纯文本处理任务,但它天然接触原始资料。上传者是谁、文件是否含个人信息、是否允许用于模型上下文、派生文本保存多久,这些都需要在入库阶段记录。尤其是调试日志:开发时把整页文本打印出来很方便,生产环境却可能把薪资、身份证号或客户信息写入长期日志。

最小的防护是按最小权限保存原始文件和派生文本;日志默认只记录文档 ID、页码、哈希、长度和质量原因;确实需要查看内容时走受控的管理员入口。涉及个人信息的字段,应在解析后尽早识别和遮蔽,且区分“用于搜索的文本”和“向终端用户展示的原文”。脱敏不是把所有数字替换为星号,而是依据业务规则决定哪些字段可以被哪类用户看到。

删除也要贯穿全链路。用户撤回文件时,应能找到原文件、页级文本、片段、向量、缓存和导出记录;只从对象存储删除 PDF 而保留向量索引,问答仍可能泄露内容。可以采用软删除加异步清理,但必须在在线检索中立即排除已撤销文档,并记录清理任务是否完成。数据生命周期清楚,才能在出问题时证明系统做了什么。

十、一个够用的落地顺序
如果从零开始,建议按最短闭环推进。第一步,选择十到二十份有代表性的文件,保留原件并提取页级文本。第二步,只实现无争议的规范化和候选页眉页脚统计。第三步,为每份文件补齐来源、版本、权限和生效状态。第四步,建立通过、警告、拒绝三种质量状态,并把警告交给业务人员抽查。第五步,基于确认后的文本切分和索引,再用一组固定问题做检索验证。

这条顺序看起来比“上传即问答”慢一点,实际会节省大量返工。清洗规则不需要一次做到完美,它应该随着真实错误逐步变得更可靠。每一次新增规则前,先问一个简单问题:它解决了哪类已观察到的问题,误伤时如何发现,是否真的需要自动化。能清楚回答这三个问题的规则,才值得进入生产流程。

模型会持续升级,文档质量不会自动变好。把清洗、来源、版本和质量闸门做好,既能提高检索质量,也能让回答错误变得可定位、可修复。对于任何需要被信任的 AI 应用,这比换一个更大的模型更接近真正的基础设施。

十一、把人工复核做成队列,而不是临时救火
自动化流程总会遇到无法安全判断的内容。最糟糕的做法不是有人工复核,而是复核没有入口、没有优先级、也没有处理结果。有人在群里说“这份 PDF 看起来不对”,文件被重新上传一次,几天后相同问题又出现。一个小而明确的复核队列就能避免这种循环。

每个复核项至少应包含文档 ID、页码、触发规则、原始文本预览、处理建议、提交时间和处理状态。状态可以保持简单:待处理、确认通过、修正后通过、拒绝入库。对于“OCR 页含数字”这类警告,处理人只需核对关键金额和日期;对于“疑似版本冲突”,则需要制度负责人判断生效关系。不同类型的事项由不同角色处理,避免让开发人员替业务人员解释政策。

优先级也应该来自风险,而不是文件大小。含金额、期限、合同义务和个人信息的页面优先;纯介绍性资料可以稍后。对紧急更新的制度,可在文档状态中标记“待复核,不参与检索”,等确认后再启用。这样既不会让错误资料提前影响回答,也不会因为整个批次有一个问题而阻塞所有低风险文档。

处理结果要回流为规则证据。若管理员多次确认某种固定页脚可以删除,就把页脚模式加入已确认规则,并记录适用资料类型;若每次都是某供应商导出的表格列错位,则应调整该解析路径,或要求对方提供 CSV,而不是继续在通用清洗器上叠加例外。复核队列的价值不在于替机器做所有事,而在于持续缩小机器不该猜的范围。

十二、版本关系必须由业务定义,不能由上传时间代替
文档库最危险的错误之一,是把最后上传的文件当作最新制度。上传时间只是系统收到文件的时间,不代表生效时间,更不代表它替代了什么。一份临时通知可能晚于正式制度上传,却只在一个月内对某地区生效;一份补充说明可能只修改一条条款,不能让整份主文件失效。

建议把“版本关系”作为文档元数据的显式字段:effective_from 表示何时生效,effective_to 表示何时失效,supersedes_document_id 表示它明确替代的文件,scope 表示适用地区、部门或角色。检索时的有效性判断应以这些业务字段为准,上传时间只用于审计。若资料没有足够信息,状态就应是待确认,而不是默认 active。

对于部分修订,有两种容易维护的方式。其一,把修订通知视为独立文档,并在回答时同时检索主制度和修订通知;要求模型在冲突处优先引用明确的修订条款。其二,在人工确认后生成一份“整合后的当前版本”供检索,同时保留原文和整合依据。这两种方式没有绝对优劣:前者来源更原始,后者检索更简单。数据量不大时,优先选择团队能清楚审计的方案,不必为了追求自动化构建复杂的版本图。

版本核验可以用几道人工问题开始:某日期之前与之后的答案是否不同;同一地区和不同地区是否符合范围;作废文件是否还能通过标题或相似内容被搜到;修订通知的条款是否覆盖了旧数值。把这些问题加入固定回归集,比仅检查“索引条数正确”更能发现版本关系的错误。

十三、可复现的导入报告应该长什么样
一份导入报告的用途不是向管理者展示漂亮数字,而是让任何后来接手的人知道这批资料发生了什么。它可以很朴素:批次 ID、运行的代码版本、输入文件清单与哈希、解析器名称、规则版本、通过与警告数量、被跳过的重复项、待复核项、最终写入索引的片段数。对每个异常保留一个稳定的原因码,例如 TEXT_TOO_SHORT、OCR_NUMERIC_REVIEW、MISSING_SCOPE,而不只保存一段随意的错误文本。

稳定原因码让趋势分析成为可能。一个月后发现 OCR_NUMERIC_REVIEW 激增,团队可以检查扫描来源或 OCR 配置;发现大量 MISSING_SCOPE,则说明上传表单缺少必填字段。若只有自然语言日志,问题会淹没在不同写法中。原因码也更利于在页面上给用户说明下一步:补充范围、重新扫描,或联系制度负责人。

报告要与输入版本绑定,不能在下一次导入后被覆盖。最简单的实现是在每个批次目录里写入一份 JSON 或 CSV,并把报告路径存到文档记录中。涉及敏感内容时报告只保存统计、哈希和受控链接,不复制全文。需要导出给审计或业务方时,再按权限生成摘要。这样既能复现流程,也不会为了可观测性复制更多敏感数据。

上一篇 Fusioncompute 用户如何解锁定
下一篇 Blkid