1. 探矿数据为什么总在清洗环节翻车
做过探矿项目的人都有一个共同体会:数据拿不到的时候愁,数据拿到了更愁。地质队给过来一个压缩包,里面塞着几十个TXT格式的钻孔编录、几份Word写的勘探报告、一堆扫描版PDF的化验单,外加从内部系统导出的网页表格。这些东西单看都能打开,但你要把它们喂给RAG做检索,问题就全冒出来了。
我最早接触这类需求是在一个铜矿的储量核实项目上。当时的目标很朴素:让工程师能用自然语言问"ZK1203钻孔在320米到380米之间的铜品位是多少",系统能直接给出答案并附上出处。听起来不难,但真正动手才发现,光是把这些异构文件变成能检索的文本块,就花掉了整个项目七成的时间。
探矿业务的RAG清洗和通用文档处理有本质区别。通用场景下,你丢一段乱码进去,模型猜一猜也能凑合。但探矿数据不行,一个品位数字错位、一个地层代号被截断,可能导致整个矿体圈定出现偏差。所以这里的清洗不是"尽量干净",而是"必须精确到字段级"。
这篇文章想聊的就是这件事:面对TXT、Word、PDF和网页这四类最常见的探矿数据源,怎么一步步把它们清洗成高精度可检索的RAG知识库。我会把每一类文件的坑、处理逻辑、参数选择和实测经验都摊开讲,适合正在做地质、矿业、勘探相关RAG项目的同行参考,也适合任何需要处理专业领域异构文档的开发者。
2. 先搞清楚探矿RAG到底要检索什么
2.1 探矿数据的四类信息颗粒度
在动手写清洗代码之前,必须先明确一件事:你的RAG最终要回答什么问题。这决定了清洗的颗粒度。探矿业务的信息大致分四层:
- 钻孔级:一个钻孔的编号、坐标、孔深、开孔日期、终孔层位。这是最粗的颗粒,通常一个钻孔一条记录。
- 回次级:每个回次的起止深度、岩芯长度、采取率、岩性描述。一个钻孔有几十到上百个回次。
- 样品级:样品编号、采样深度、化验元素、品位值、分析方法。这是检索频率最高的层级。
- 报告级:整份勘探报告里的段落、结论、附表。这类信息适合做语义检索而非精确匹配。
很多项目失败的原因,是把这四层混在一个向量库里检索。结果就是用户问一个精确的品位数字,系统返回一段报告里的定性描述,答非所问。正确的做法是分层建库,或者至少用元数据把颗粒度标记清楚。
2.2 为什么通用清洗方案在探矿场景会失效
我见过不少团队直接拿开源的文档解析工具往上套,结果在探矿数据上全军覆没。原因有三个:
第一,探矿TXT大量使用固定宽度或制表符对齐,而不是标准的CSV。通用解析器按逗号或空格切分,遇到岩性描述里本身带空格的情况,字段直接错位。第二,Word报告里的表格经常是合并单元格加嵌套表格,通用解析器要么丢数据,要么把表头和数据混在一起。第三,扫描版PDF的化验单是图片,不做OCR根本拿不到文字,而通用工具默认只提取文本层。
提示:判断一份PDF是不是扫描版,最直接的方法是看能不能选中文字。如果鼠标拖过去选不中任何内容,那就是图片型PDF,必须走OCR流程。
2.3 高精度检索的判定标准
什么叫"高精度"?我给一个可量化的标准:对于样品级查询,系统返回的品位值必须与原始记录完全一致,误差为零;对于钻孔级查询,返回的深度区间必须覆盖用户询问的范围;对于报告级查询,返回的段落必须包含答案且附有页码或章节出处。达不到这三条,就不能叫高精度。
这个标准听起来苛刻,但探矿业务的容错率就是这么低。一个品位数字从0.45%变成4.5%,矿体就从贫矿变富矿,经济评价完全两回事。所以清洗环节的每一个决策,都要以"不引入错误"为第一原则。
3. TXT钻孔编录的字段对齐与乱码修复
3.1 固定宽度文本的切分逻辑
探矿TXT最典型的格式是这样的:每行一条回次记录,字段之间用多个空格或制表符分隔,但字段本身可能包含空格。比如岩性描述"灰绿色蚀变安山岩 含黄铜矿细脉",中间的空格和字段分隔符长得一模一样。
直接按空格split必然出错。我的处理方式是先统计每列字符的起始位置。具体做法是:读取前100行,对每一行记录每个非空格字符的索引,然后统计哪些索引位置在所有行中都出现过字符。这些"稳定出现"的列位置就是字段边界。
def detect_column_boundaries(lines, sample_size=100): sample = lines[:sample_size] max_len = max(len(line) for line in sample) col_has_char = [0] * max_len for line in sample: for i, ch in enumerate(line): if ch != ' ': col_has_char[i] += 1 # 出现频率超过80%的位置视为字段起始候选 threshold = len(sample) * 0.8 boundaries = [i for i, cnt in enumerate(col_has_char) if cnt >= threshold] return boundaries拿到边界后,用切片而不是split来提取字段。这样即使岩性描述里有空格,也不会被误切。实测下来,这个方法对地质队常用的几种编录模板都能自动适配,不需要为每个模板写正则。
3.2 中文乱码的三种成因与对应处理
TXT乱码是探矿数据清洗绕不开的坎。我总结下来主要有三种:
第一种是编码不一致。地质队的老系统可能用GBK,新系统用UTF-8,混在一起就乱。处理方式是先用chardet检测编码,检测置信度低于0.8的,手动指定GBK重试。第二种是字节截断。文件在传输过程中被截断,一个中文字符的两个字节只保留了一个,显示成问号或方块。这种只能丢弃该行或该字段,无法恢复。第三种是字体映射错误,常见于从老式仪器导出的数据,字符本身是错的但编码是对的。这种需要建立映射表,把错误字符替换回正确字符。
import chardet def read_txt_safe(filepath): with open(filepath, 'rb') as f: raw = f.read() result = chardet.detect(raw) encoding = result['encoding'] confidence = result['confidence'] if confidence < 0.8: # 置信度低,尝试GBK try: return raw.decode('gbk') except UnicodeDecodeError: return raw.decode('utf-8', errors='replace') return raw.decode(encoding, errors='replace')注意:errors='replace'会把无法解码的字节替换成特殊符号,方便后续定位问题行。不要用errors='ignore',那样会静默丢数据,在探矿场景里是致命的。
3.3 回次记录与样品记录的关联重建
TXT文件里,回次记录和样品记录经常是分开的两段,中间用空行或分隔线隔开。清洗时要做的关键一步是把它们关联起来。关联的钥匙是深度区间:样品记录的采样深度落在哪个回次的起止深度之间,就属于哪个回次。
这个关联逻辑看似简单,但有个坑:深度单位可能不统一。有的文件用米,有的用厘米,还有的用"m"和"cm"混写。清洗时必须先统一单位。我的做法是提取深度字段后,检查数值范围,如果最大值超过10000,基本可以判定是厘米,除以100转成米。
关联完成后,每个样品记录都会带上所属回次的岩性信息。这样用户检索某个样品时,系统能同时返回品位和岩性描述,信息更完整。
4. Word勘探报告的表格与公式处理
4.1 合并单元格表格的结构还原
Word勘探报告里最麻烦的是表格。地质报告喜欢用合并单元格做表头,比如"化验结果"下面横跨三列,分别是"铜品位""钼品位""金品位"。用python-docx读取时,合并单元格会重复出现,或者只在一个位置有值。
我的处理策略是:先把表格转成一个二维数组,记录每个单元格的合并跨度。然后根据表头行的合并情况,重建列名。具体来说,如果第一行某个单元格横跨3列,那这3列的表头就是该单元格的值加上第二行对应列的值。
from docx import Document def parse_word_table(table): rows = [] for row in table.rows: cells = [] for cell in row.cells: cells.append(cell.text.strip()) rows.append(cells) # 处理合并单元格:相邻重复值合并 for i, row in enumerate(rows): for j in range(len(row) - 1, 0, -1): if row[j] == row[j-1] and row[j] != '': row[j] = '' return rows这个逻辑对大多数地质报告表格有效。但遇到嵌套表格(表格里还有表格)时,python-docx支持不好,需要降级到解析XML。我的经验是,嵌套表格在探矿报告里占比不高,如果遇到,单独标记出来人工处理,比写复杂的自动逻辑更划算。
4.2 公式与特殊符号的保留策略
勘探报告里经常有品位计算公式、换算系数、单位符号。这些内容如果被清洗掉,检索时就找不到。比如用户问"铜当量怎么算",报告里有个公式,但公式被转成了图片或丢失了,系统就答不上来。
Word里的公式有两种:一种是OMML格式(Word原生公式),一种是图片。OMML可以用python-docx的XML接口提取,转成LaTeX或纯文本。图片公式则需要OCR,但公式OCR准确率普遍不高,我的建议是保留图片并在旁边标注"公式图片,需人工确认"。
特殊符号方面,地质报告常用‰、℃、°、±等符号。这些在UTF-8里都有对应编码,清洗时不要过滤掉。我见过有团队为了"干净"把所有非中文字符都删了,结果把品位单位g/t也删了,检索直接废掉。
4.3 段落层级与章节编号的提取
报告级检索需要保留章节结构。用户问"第三章结论是什么",系统得知道哪段属于第三章。Word的标题样式(Heading 1、Heading 2)是天然的层级标记,但地质报告经常手动加粗而不套用样式。
我的处理方式是双管齐下:优先读样式,样式为空时用正则匹配章节编号模式(如"第三章""3.1""(二)")。匹配到的段落打上层级标签,存入元数据。检索时可以用层级过滤,比如只在"结论"章节里搜。
import re def extract_section_level(paragraph): text = paragraph.text.strip() style = paragraph.style.name if paragraph.style else '' if 'Heading 1' in style: return 1 if 'Heading 2' in style: return 2 # 正则兜底 if re.match(r'^第[一二三四五六七八九十]+章', text): return 1 if re.match(r'^\d+\.\d+', text): return 2 return 0这套组合拳实测下来,章节识别准确率能到九成以上。剩下的长尾情况,靠人工补标也花不了多少时间。
5. PDF化验单与扫描件的OCR精度控制
5.1 文本型PDF与图片型PDF的分流
PDF处理的第一步是分流。文本型PDF直接用pdfplumber或PyMuPDF提取文字,速度快、精度高。图片型PDF必须走OCR,慢且容易出错。分流的方法很简单:用PyMuPDF打开,尝试提取第一页的文字,如果字符数少于某个阈值(比如50),就判定为图片型。
import fitz def is_scanned_pdf(filepath, threshold=50): doc = fitz.open(filepath) text = doc[0].get_text() doc.close() return len(text.strip()) < threshold这个阈值不是拍脑袋定的。化验单即使有文字层,通常也就几十个字符(表头加几个数字)。如果第一页提取出的文字少于50个字符,大概率是扫描件。实测中这个判断的准确率很高。
5.2 化验单表格的OCR后处理
化验单OCR出来最大的问题是表格线丢失,数字和文字挤在一起。我的处理流程是:先用OCR引擎(如PaddleOCR)拿到带坐标的文字块,然后根据坐标重建表格。具体来说,把文字块按y坐标聚类成行,按x坐标排序成列,再根据列的对齐情况判断表格结构。
化验单里最关键的字段是样品编号和品位值。这两个字段的OCR必须零错误。我的做法是对这两个字段做二次校验:样品编号通常有固定格式(如"ZK1203-H1"),用正则校验;品位值是数字,检查是否在合理范围内(比如铜品位0.01%到10%之间)。超出范围的标记出来人工复核。
提示:PaddleOCR对中文和数字混排的识别效果比Tesseract好很多,尤其是化验单这种密集数字场景。如果预算允许,建议用PaddleOCR而不是Tesseract。
5.3 多页PDF的跨页表格拼接
化验单经常跨页,一个表格从第3页延续到第4页。如果按页处理,表格会被截断。处理方式是:提取每页的表格后,检查第一页表格的最后一行和第二页表格的第一行,如果列数相同且第二页第一行不是表头,就判定为跨页表格,合并处理。
跨页拼接的难点在于表头识别。有的PDF每页都重复表头,有的只在第一页有。我的判断逻辑是:如果某行的内容与已知表头高度相似(用编辑距离判断),就视为表头行,不纳入数据。
这套逻辑跑下来,化验单的表格还原准确率能到95%以上。剩下的5%主要是扫描质量太差或手写体,这种只能人工介入。
6. 网页端矿权数据的抓取与结构化
6.1 动态渲染页面的数据获取
矿权数据经常发布在网页上,有的是静态HTML,有的是JavaScript动态渲染。静态页面用requests加BeautifulSoup就够了,动态页面需要用到浏览器自动化工具。
我的选择标准是:先看页面源码里有没有数据。如果源码里有完整的表格HTML,说明是服务端渲染,直接解析。如果源码里只有一个空的div,数据是JS加载的,那就需要浏览器自动化。浏览器自动化工具我常用Playwright,它比Selenium快,而且对异步加载的处理更自然。
from playwright.sync_api import sync_playwright def fetch_dynamic_page(url): with sync_playwright() as p: browser = p.chromium.launch() page = browser.new_page() page.goto(url, wait_until='networkidle') content = page.content() browser.close() return contentwait_until='networkidle'是关键参数,它确保所有网络请求都完成了再取内容。如果页面有懒加载,还需要滚动到底部触发加载。
6.2 表格数据的字段映射
网页抓下来的表格,列名往往和我们的数据库字段对不上。比如网页上叫"许可证号",我们库里叫"license_no"。这需要一个字段映射层。我的做法是维护一个映射字典,把常见的中文列名映射到标准字段名。
FIELD_MAPPING = { '许可证号': 'license_no', '项目名称': 'project_name', '矿种': 'mineral_type', '面积': 'area', '有效期起': 'valid_from', '有效期止': 'valid_to', }映射不上的列,不要直接丢弃,而是存入一个extra字段,保留原始信息。这样后续如果需要,还能补映射。
6.3 抓取频率与数据更新策略
矿权数据更新频率不高,通常按月或按季度。没必要频繁抓取。我的策略是:首次全量抓取,之后每周增量检查一次。增量检查的方法是比对页面的更新时间戳或记录总数,有变化才触发全量更新。
抓取频率还要考虑对方服务器的承受能力。我一般设置请求间隔至少3秒,避免给对方造成压力。这不是技术问题,是基本的网络礼仪,也能降低被封的风险。
7. 四类数据统一入库的向量化策略
7.1 分块粒度与重叠窗口的设定
四类数据清洗完后,要统一入库。入库前最关键的一步是分块。分块粒度直接决定检索精度。我的经验是:
- 样品级数据:一条记录一个块,不拆分。因为样品记录本身就是最小完整单元。
- 回次级数据:一个回次一个块,如果岩性描述超过500字,按段落拆。
- 报告级数据:按章节拆,每个块不超过800字,块之间重叠100字。
- 网页表格:一行一个块,表头信息附加到每个块上。
重叠窗口的作用是防止答案被切断。比如一个结论跨了两个段落,没有重叠的话,检索可能只命中一半。100字的重叠对中文来说大约是一到两句话,足够覆盖大多数跨段情况。
7.2 元数据设计:让检索能按钻孔和深度过滤
光有向量还不够,探矿检索经常需要精确过滤。比如"ZK1203钻孔320米到380米之间的样品",这是一个范围查询,纯向量检索做不好。必须靠元数据过滤。
我的元数据设计包含这些字段:钻孔编号、起始深度、结束深度、数据类型(样品/回次/报告)、来源文件、页码或行号。检索时先用元数据过滤出ZK1203且深度在320到380之间的块,再在这些块里做向量相似度排序。
metadata = { 'hole_id': 'ZK1203', 'depth_from': 320.0, 'depth_to': 380.0, 'data_type': 'sample', 'source_file': 'ZK1203编录.txt', 'line_number': 156, }这套元数据设计让范围查询的准确率从纯向量的六成提升到了接近百分之百。
7.3 混合检索:关键词与向量的权重分配
纯向量检索对数字不敏感。用户问"铜品位0.45%的样品有哪些",向量检索可能返回一堆品位相近但不等于0.45%的结果。这时候需要关键词检索兜底。
我的方案是混合检索:先用关键词精确匹配数字和编号,再用向量做语义补充。权重分配上,如果查询里包含明确的数字或编号,关键词权重占七成,向量占三成;如果查询是纯语义描述(如"哪些钻孔见矿效果好"),则反过来。
这个权重不是固定的,可以根据实际检索日志动态调整。我一般会记录每次检索的点击情况,用点击数据反过来优化权重。
8. 清洗质量的验证与常见返工原因
8.1 抽样比对:清洗前后的一致性检查
清洗完必须验证。我的方法是随机抽100条记录,把清洗后的结构化数据和原始文件逐字段比对。重点检查数字字段和编号字段,这两个字段错一个就是大问题。
比对可以用脚本自动化:从原始文件里用正则提取关键字段,和清洗结果对比。不一致的标记出来人工看。实测中,自动化比对能发现八成以上的错误,剩下的两成靠人工抽查。
8.2 检索命中率的量化评估
清洗质量最终要体现在检索效果上。我会构造一组测试查询,覆盖四类数据和各种查询类型,然后统计命中率。命中率的定义是:返回的结果中包含正确答案,且正确答案排在前三位。
如果命中率低于八成,说明清洗或分块有问题。常见原因是分块太大导致噪声多,或者元数据缺失导致过滤失效。这时候要回到分块和元数据环节调整。
8.3 我踩过的三个返工坑
第一个坑是编码检测的置信度阈值设太低。有次一个GBK文件被误判成UTF-8,清洗出来全是乱码,但脚本没报错,直到检索测试才发现。后来我把阈值提到0.9,宁可多试几种编码,也不放过可疑文件。
第二个坑是Word表格的合并单元格处理不彻底。有个报告的表头跨了四列,我的脚本只合并了三列,导致最后一列的数据全部错位。后来我加了一个校验:合并后的列数必须和表头声明的列数一致,不一致就报警。
第三个坑是PDF的OCR没有做数字校验。有张化验单把0.45识别成了0.4S,字母S混进了数字。后来我加了正则校验,所有品位值必须是纯数字加小数点,含字母的直接标记复核。
这三个坑的共同点是:错误不会导致程序崩溃,但会静默产生错误数据。在探矿场景里,静默错误比崩溃更可怕。所以清洗流程里必须有多重校验,宁可误报,不可漏报。
9. 一些实际项目中的取舍心得
做探矿RAG清洗,最大的体会是:不要追求全自动。我早期总想把所有环节都自动化,结果发现异常情况太多,自动处理反而引入更多错误。后来我调整了策略:常规数据自动处理,异常数据自动标记、人工处理。人工处理的比例大概占5%到10%,但这部分投入换来的是整体数据质量的可靠。
另一个心得是关于工具选型。PDF解析我试过pdfplumber、PyMuPDF、camelot,最后发现没有哪个工具能通吃。我的做法是组合使用:文本型PDF用PyMuPDF(快),表格型PDF用camelot(准),扫描件用PaddleOCR(识别率高)。根据文件特征自动路由到不同工具。
最后说一个容易被忽视的点:清洗日志。每次清洗都要记录处理了哪些文件、用了什么参数、遇到什么异常、人工干预了什么。这些日志在后续排查问题时价值极高。我有个项目隔了半年要复现清洗结果,全靠当时的日志才搞清楚参数是怎么设的。
探矿数据的RAG清洗没有一劳永逸的方案,每个项目的数据源都有差异。但只要你把字段对齐、编码处理、表格还原、OCR校验这几个核心环节做扎实,再配合元数据和混合检索,高精度检索是完全可以做到的。