探矿RAG数据清洗实战:TXT、Word、PDF、网页四类格式处理链路

发布时间:2026/10/8 3:16:03
探矿RAG数据清洗实战:TXT、Word、PDF、网页四类格式处理链路 1. 探矿数据为什么总在清洗环节翻车搞过探矿项目的人都有一个共同体会钻探编录、地质填图、采样化验这几类数据原始形态远比想象中杂乱。一个中型勘查区跑下来TXT格式的测井曲线记录、Word写的钻孔柱状图说明、PDF扫描的化验报告、还有从内部系统导出的网页表格四种格式混在一起是常态。这些数据直接喂给RAG系统检索出来的结果基本没法看——问ZK3201孔在多少米见矿系统可能给你返回一段测井仪器的操作说明因为那段文字里恰好也出现了3201这个数字。问题的根子不在RAG框架本身而在清洗环节。探矿业务的文本有三个特殊之处数字密度极高品位、深度、坐标、倾角单位体系混杂g/t、%、m、ppm交替出现表格与正文强耦合化验结果往往嵌在段落中间。通用的PDF解析库和Word读取工具默认按文本流处理遇到表格就散架遇到公式就乱码遇到扫描件直接返回空字符串。我见过太多团队在这一步偷懒用现成的解析接口一把梭结果后面检索精度怎么调都上不去回头查才发现是清洗阶段就把数据搞坏了。这篇内容面向的是正在做或准备做探矿领域RAG知识库的工程师、地质信息化人员以及需要把历史勘查资料数字化的技术负责人。我会把TXT、Word、PDF、网页这四类数据在探矿场景下的清洗难点逐个拆开给出可复现的处理链路重点讲清楚每一步为什么这么设计以及我在实际项目中踩过的坑。全文不涉及任何特定商业平台所有方案都可以用开源工具落地。2. 先搞清楚探矿文本的脏数据到底长什么样2.1 四类格式的典型污染模式在动手写清洗代码之前得先建立对脏的具体认知。我整理过一批实际勘查资料四类格式的问题分布差异很大格式主要污染类型对检索的直接影响TXT编码混乱、列对齐靠空格、无表头字段错位数值与标签对不上Word公式对象、文本框、合并单元格解析后公式变乱码表格结构丢失PDF扫描件无文本层、双栏排版、页眉页脚返回空内容或跨栏拼接错误网页动态渲染、嵌套表格、导航噪声抓到的正文里混入大量无关链接TXT的问题最容易被低估。很多老测井设备导出的就是纯文本用固定宽度对齐比如深度 品位 岩性三列靠空格数量区分。一旦编码从GBK转UTF-8没处理好中文字段全变问号数值列反而正常这种半好半坏的状态最迷惑人。Word的坑集中在公式和表格。地质报告里经常用公式编辑器插入品位计算公式这些公式在.docx里是OLE对象或OMML标记普通文本提取工具读出来是一串XML残片。表格更麻烦合并单元格在解析后往往变成空字符串导致样品编号和化验结果的对应关系断裂。PDF分两种有文本层的和扫描件。有文本层的PDF如果排版是双栏按行读取会把左右栏内容交错拼接读出来语义完全错乱。扫描件没有文本层必须走OCR而地质报告里的手写批注、印章、表格线都是OCR的噩梦。网页数据的噪声最隐蔽。从内部系统导出的表格页面往往带着导航栏、页脚、操作按钮的文字直接抓取会把上一页 下一页 导出Excel这类内容也塞进知识库检索时这些高频词会严重干扰相关性排序。2.2 为什么不能用一个通用解析器通吃市面上很多RAG教程推荐用LangChain的文档加载器一把梭这在探矿场景下是灾难。通用加载器的设计目标是尽可能多地提取文本而不是保留结构关系。它会把表格拍平成一行字符串把公式丢弃把页眉页脚当正文。对于问答类知识库丢一点文本无所谓但对于探矿这种数值即结论的场景丢掉一个单位符号或者错位一列数据检索结果就是错的。我的做法是按格式分而治之每种格式走独立清洗链路最后统一到结构化中间层。这个中间层不是纯文本而是带字段标记的结构化记录比如{孔号, 深度起, 深度止, 品位, 单位, 岩性}。只有到了这一层才谈得上后续的分块和向量化。3. TXT清洗编码、对齐与字段还原3.1 编码探测不能靠猜TXT清洗的第一步永远是编码。国内勘查资料大量使用GBK/GB2312少数老文件甚至是GB18030。直接open(file, encodingutf-8)遇到GBK文件会抛异常但更危险的是用errorsignore它会静默丢弃无法解码的字节导致部分中文字段消失而你毫无察觉。我的处理策略是三级探测import chardet def detect_encoding(file_path): with open(file_path, rb) as f: raw f.read(100000) # 取前100KB足够判断 result chardet.detect(raw) confidence result[confidence] encoding result[encoding] # 置信度低时用GB18030兜底它兼容GBK且覆盖更全 if confidence 0.7 or encoding is None: return gb18030 return encoding这里有个经验GB18030是GBK的超集用它兜底几乎不会出错代价是极少数生僻字可能映射异常但探矿文本里基本用不到那些字。另外探测时取前100KB而不是整个文件是因为大文件全读会拖慢速度而编码在文件内通常是一致的。注意如果文件是UTF-8 with BOMchardet可能返回UTF-8-SIG读取后首行会带一个不可见字符导致字段名匹配失败。统一用encodingutf-8-sig读取可以自动去掉BOM。3.2 固定宽度对齐的解析逻辑测井曲线和部分化验台账是固定宽度对齐的。这类文件的解析不能简单split()因为字段内可能含空格。正确做法是先识别列边界再按位置切片。判断列边界的方法统计每一列位置上非空格字符的出现频率频率突变的列就是字段分隔处。实际操作中更简单可靠的办法是找表头行用表头文字的位置确定每列的起止索引然后对数据行按相同索引切片。def parse_fixed_width(lines, header_idx0): header lines[header_idx] # 找出表头中每个字段的起止位置 fields [] start None for i, ch in enumerate(header): if ch ! and start is None: start i elif ch and start is not None: fields.append((start, i)) start None if start is not None: fields.append((start, len(header))) records [] for line in lines[header_idx1:]: if not line.strip(): continue record {} for (s, e), name in zip(fields, [header[s:e].strip() for s, e in fields]): record[name] line[s:e].strip() records.append(record) return records这个逻辑的关键在于用表头定列宽而不是用数据行。因为数据行里数值长度不一靠数据行推断列宽会漂移。如果文件没有表头那就得人工确认一次列宽写成配置不要试图全自动——探矿数据的列宽一旦搞错后面全盘皆输。3.3 数值与单位的分离存储探矿文本里3.5g/t这种写法很常见数值和单位粘在一起。如果直接存成字符串后续做数值范围检索比如品位大于2g/t的样品就没法做。我的做法是在清洗阶段就把数值和单位拆开import re UNIT_PATTERN re.compile(r^([\d.])\s*(g/t|%|ppm|m|°|wt%)?$) def split_value_unit(text): text text.strip() m UNIT_PATTERN.match(text) if m: value float(m.group(1)) if m.group(1) else None unit m.group(2) if m.group(2) else return value, unit return None, text # 非数值内容原样返回拆开之后中间层记录里存value和unit两个字段。检索时既能做精确数值过滤也能在向量化时把单位作为语义信息保留。这一步看起来琐碎但它是后面高精度检索的基础——没有结构化数值RAG只能做模糊语义匹配精度上不去。4. Word文档公式、表格与文本框的三重处理4.1 公式对象为什么必须单独处理地质报告里的公式有两类一类是Word自带的公式编辑器OMML格式一类是MathType等第三方插件插入的OLE对象。python-docx库对这两类都支持不好读取段落文本时公式位置会变成空或者乱码。我的处理方案是双通道提取文本通道用python-docx读普通段落公式通道直接解析docx的XML。docx本质是zip包公式在word/document.xml里以m:oMath标签存在。用lxml解析这个XML把公式节点提取出来转成LaTeX或纯文本描述。from docx import Document from lxml import etree import zipfile def extract_formulas(docx_path): formulas [] with zipfile.ZipFile(docx_path) as z: xml_content z.read(word/document.xml) root etree.fromstring(xml_content) ns {m: http://schemas.openxmlformats.org/officeDocument/2006/math} for omath in root.iter({http://schemas.openxmlformats.org/officeDocument/2006/math}oMath): # 提取公式内的文本节点 texts omath.itertext() formula_text .join(texts) formulas.append(formula_text) return formulas提取出来的公式文本如果是简单公式如品位金属量/矿石量直接作为文本存入即可。如果是复杂公式建议转成LaTeX保留结构因为LaTeX本身是可读的文本向量化后仍能保留语义。实操心得很多团队纠结公式要不要转LaTeX我的建议是看用途。如果知识库只做问答检索公式转成自然语言描述品位等于金属量除以矿石量效果更好因为用户提问用的是自然语言。如果要做公式计算那必须保留LaTeX或结构化表达式。4.2 合并单元格导致的字段错位Word表格的合并单元格是解析重灾区。python-docx读取合并单元格时被合并的位置会返回空字符串导致一行里的字段数量对不上表头。比如表头是样品编号|品位|岩性某个样品没有品位数据合并后读出来变成样品编号|岩性品位字段直接消失。解决办法是按表格的网格坐标读取而不是按行读取。python-docx的table.rows[i].cells返回的是逻辑单元格合并后会重复。更可靠的是用table._tbl底层的XML按w:tc的实际位置重建网格。def parse_table_grid(table): grid [] for row in table.rows: row_data [] for cell in row.cells: # 合并单元格会重复出现用id去重 row_data.append(cell.text.strip()) grid.append(row_data) return grid如果合并情况复杂建议直接用docx2python这类专门处理合并单元格的库它会把合并区域展开成规整的二维数组。实测下来对于探矿报告里常见的跨行合并的孔号场景docx2python的还原准确率明显高于手写逻辑。4.3 文本框内容的遗漏问题Word里的文本框Text Box内容python-docx默认读不到因为文本框在XML里是独立的w:txbxContent节点不在主文档流里。地质图件的图例说明经常放在文本框里漏掉就丢失了关键信息。处理方式是遍历XML里所有txbxContent节点把里面的段落文本提取出来追加到对应位置。这个操作需要记录文本框在文档中的相对位置否则提取出来的文本会失去上下文。简单做法是把文本框内容统一收集在文档末尾以图例说明的形式附加虽然损失了位置信息但至少不丢内容。5. PDF解析文本层、扫描件与双栏排版的分别应对5.1 先判断PDF有没有文本层PDF处理的第一步不是解析而是判断类型。有文本层的PDF可以直接提取扫描件必须走OCR两者的处理链路完全不同。判断方法很简单用pdfplumber或PyMuPDF读取第一页如果提取出的文本长度超过某个阈值比如50字符就认为有文本层。import fitz # PyMuPDF def has_text_layer(pdf_path, sample_pages3): doc fitz.open(pdf_path) total_text for i in range(min(sample_pages, len(doc))): total_text doc[i].get_text() doc.close() return len(total_text.strip()) 50这个阈值不能设太高因为有些PDF首页是封面文字很少。取前3页综合判断更稳。如果判断为扫描件就转OCR流程如果有文本层走结构化提取。5.2 双栏排版的阅读顺序还原地质报告和期刊论文常用双栏排版。PyMuPDF默认按块的位置从上到下、从左到右读取双栏情况下会把左栏第一行和右栏第一行拼在一起语义完全错乱。还原阅读顺序的方法是按x坐标聚类分栏。先获取所有文本块的边界框根据x坐标的分布判断是单栏还是双栏。如果是双栏把x坐标小于页面中线的块归为左栏大于的归为右栏然后左栏从上到下读完再读右栏。def extract_two_column(page): blocks page.get_text(blocks) page_width page.rect.width mid page_width / 2 left [b for b in blocks if b[0] mid] right [b for b in blocks if b[0] mid] left.sort(keylambda b: b[1]) # 按y坐标排序 right.sort(keylambda b: b[1]) text \n.join(b[4] for b in left right) return text判断是否双栏可以看文本块的x坐标是否明显分成两簇。如果所有块的x坐标都集中在一侧那就是单栏不用分。这个逻辑对探矿报告里的左栏正文右栏图表说明布局特别有效。5.3 扫描件OCR的预处理与后处理扫描件OCR的准确率七分靠预处理三分靠OCR引擎。地质报告扫描件常见的问题是纸张发黄、有装订线阴影、表格线干扰、手写批注。直接丢给OCR表格线会被识别成|或1手写批注会变成乱码。预处理步骤灰度化二值化用OpenCV把图像转灰度再用自适应阈值二值化去掉纸张底色。去噪中值滤波去掉扫描噪点。倾斜校正用霍夫变换检测文本行角度旋转校正。表格线分离用形态学操作提取横竖线从图像中减去避免OCR把线识别成字符。后处理步骤数字纠错OCR对0和O、1和l容易混淆用正则把纯数字字段里的字母替换回数字。单位归一把g/t、gt、g/t.统一成标准写法。表格重建如果原图是表格OCR后按坐标把文本块重新填入网格。注意OCR后的文本一定要人工抽检。我做过统计地质报告扫描件的OCR字符准确率通常在95%左右但数值字段的准确率可能只有85%因为小数点、负号、单位符号容易丢。对于品位、深度这类关键数值建议做二次校验比如用深度递增这个约束去检查OCR结果是否合理。6. 网页数据抓取、去噪与结构化6.1 动态渲染页面的抓取策略内部系统导出的网页表格很多是JavaScript动态渲染的直接请求HTML拿到的是空壳。这时候需要用无头浏览器渲染后再抓取。但无头浏览器速度慢不能对所有页面都用。我的策略是先探测再决定先用普通HTTP请求拿HTML检查目标表格的DOM节点是否存在。如果存在直接解析如果不存在说明是动态渲染再启用无头浏览器。这样大部分静态页面走快速通道只有少数动态页面走慢速通道。import requests from bs4 import BeautifulSoup def fetch_page(url): resp requests.get(url, timeout10) soup BeautifulSoup(resp.text, html.parser) # 检查是否有目标表格 table soup.find(table, {class: data-table}) if table and len(table.find_all(tr)) 1: return resp.text, False # 静态无需渲染 return None, True # 需要动态渲染6.2 导航噪声的识别与剔除网页抓取最大的问题是噪声。导航栏、页脚、侧边栏、操作按钮的文字都会混进正文。这些噪声的特点是在多个页面重复出现。利用这个特点可以做一个简单的去噪抓取同一站点的多个页面统计每个文本块的出现频率频率过高的块判定为模板噪声剔除。对于单页面抓取可以用启发式规则导航链接通常集中在页面顶部和底部的固定区域正文通常在article、main或特定class的div里。优先提取这些语义标签内的内容能过滤掉大部分噪声。6.3 嵌套表格的展平网页表格经常嵌套比如一个钻孔信息表格里嵌了一个样品列表子表格。直接解析会得到错乱的行列关系。处理方式是递归解析遇到嵌套表格先解析子表格为独立记录再作为父表格某个单元格的值。def parse_table_recursive(table): rows [] for tr in table.find_all(tr, recursiveFalse): row [] for td in tr.find_all([td, th], recursiveFalse): nested td.find(table) if nested: row.append(parse_table_recursive(nested)) else: row.append(td.get_text(stripTrue)) rows.append(row) return rows这样解析出来的结构嵌套表格变成列表嵌套后续可以按需展平或保留层级。对于探矿数据我倾向于保留层级因为钻孔-样品本身就是父子关系展平反而丢失了语义。7. 统一中间层让四种格式汇入同一套结构7.1 中间层的字段设计四种格式清洗完之后必须汇入统一的中间层否则后续分块和向量化没法统一处理。中间层的设计要兼顾探矿业务的核心实体和RAG检索的需求。我用的字段结构如下字段类型说明doc_idstring源文档唯一标识doc_typeenumtxt/word/pdf/webhole_idstring钻孔编号可空depth_fromfloat起始深度可空depth_tofloat结束深度可空valuefloat数值品位等可空unitstring单位categorystring数据类型化验/测井/编录raw_textstring原始文本片段contextstring上下文描述这个设计的核心思路是结构化字段用于精确过滤raw_text和context用于语义检索。检索时先用结构化字段缩小范围比如ZK3201孔、深度100-200米再在缩小后的集合里做向量相似度匹配。这就是高精度检索的实现路径——不是靠调大模型而是靠清洗阶段把结构信息保留下来。7.2 分块策略按语义边界而非固定长度通用RAG教程喜欢按固定字符数分块比如500字一块这在探矿场景下很糟糕。一个化验表格被从中间切断前半块的数值和后半块的单位分家检索出来就是错的。我的分块策略是按语义边界切分TXT的固定宽度表格一行一条记录不切分。Word的段落按段落切分表格整体作为一个块。PDF的正文按章节标题切分表格和公式单独成块。网页表格一行一条记录嵌套表格整体保留。每个块的大小不固定但保证语义完整。块内如果包含结构化字段把这些字段作为元数据附加到块上向量化时只对raw_text和context做嵌入元数据用于过滤。7.3 元数据与向量的配合检索检索流程分两步过滤和排序。过滤用元数据排序用向量相似度。举个例子用户问ZK3201孔在150米附近的品位是多少。系统先解析出过滤条件hole_idZK3201depth范围140-160。用这个条件在中间层里筛出候选块可能只有十几条。然后对这十几条做向量匹配找到最相关的。因为候选集小向量匹配的精度天然就高。如果跳过过滤直接全库向量检索几万条记录里找相似度排序很容易被无关内容干扰。这就是为什么清洗阶段的结构化如此重要——它直接决定了检索的上限。8. 实测中的几个坑与应对8.1 编码探测在混合编码文件上失效有些老文件是部分GBK部分UTF-8的混合编码chardet会判断失败。这种情况我遇到过两次都是历史归档文件。解决办法是逐行探测对每一行单独判断编码能解码就用对应编码不能解码就标记为可疑行人工复核。虽然麻烦但比整文件丢弃强。8.2 PDF表格跨页断裂化验报告表格经常跨页第一页的表头在第二页不重复。解析时第二页的表格没有表头字段对应关系丢失。处理方式是检测跨页表格并继承表头如果上一页末尾是表格当前页开头也是表格且列数一致就把上一页的表头应用到当前页。8.3 OCR把手写批注识别成正文地质报告里的手写批注比如此处见矿被OCR识别后混入正文会干扰检索。识别手写和印刷体的方法是看字符的笔画特征但这需要额外的模型。简单做法是按字体大小和颜色过滤手写批注通常颜色不同蓝色或红色在预处理阶段提取颜色通道把手写区域单独标记OCR后单独存放不混入正文。8.4 网页表格的隐藏行有些网页表格有隐藏行display:none用于存放临时数据。抓取时如果不过滤会把这些隐藏数据也抓进来。处理方式是解析时检查元素的style属性跳过display为none的行。9. 清洗质量的自检清单清洗做完之后不能直接进RAG得先自检。我总结了一个清单每次项目交付前过一遍随机抽10条记录人工核对结构化字段是否与原文一致。统计数值字段的解析成功率低于95%要查原因。检查是否有整段文本为空的情况空段说明解析失败。验证单位字段的归一化是否彻底有没有漏网的变体。用几个已知答案的问题做检索测试看能否命中正确记录。这个清单看起来简单但能拦住大部分低级错误。我见过太多项目跳过自检上线后检索不准回头查发现是清洗阶段把g/t和%搞混了这种错误在自检时一眼就能看出来。10. 关于工具选型的一点个人看法工具选型上我的原则是能用成熟库就不自己造轮子但关键环节必须自己控制。TXT编码用chardetWord解析用python-docx加docx2pythonPDF用PyMuPDF加pdfplumberOCR用PaddleOCR或Tesseract网页用requests加BeautifulSoup加Playwright。这些都是经过大量项目验证的稳定性有保障。但有几个环节我坚持自己写逻辑固定宽度表格的列边界识别、双栏PDF的阅读顺序还原、中间层的字段映射。这三个环节直接决定数据质量通用库的处理逻辑是为通用场景设计的不一定适配探矿数据的特殊性。自己写虽然多花几天但后面调检索精度时省心得多。另外提醒一句清洗链路的每一步都要留日志。哪条记录在哪个环节被丢弃、被修改都要能追溯。探矿数据往往涉及重要决策清洗过程的可审计性和结果本身一样重要。我在项目里会给每条记录打上处理标记出问题时能快速定位是哪个环节的锅。这套链路跑下来一个中型勘查区的四类数据从原始文件到可检索的结构化知识库大概需要三到五天取决于数据量和扫描件比例。比起直接丢给通用解析器然后反复调检索参数这个前期投入是值得的——清洗阶段多花一天后面调优能省一周。