第一次接到这个需求的时候,我心里想的还是“不就是文档解析加个向量库嘛”。直到一周后被一批钻孔编录数据的乱码TXT按在地上摩擦,我才意识到,探矿业务里的RAG,真正决定生死的不是模型选择,而是文档清洗。探矿资料跟一般的企业知识库完全不一样:几十年的老档案、不同地质软件导出的TXT、扫描成图片的老报告、还有从矿业信息平台扒下来的网页,格式五花八门,质量参差不齐。如果你打算在探矿这类业务场景里搭一套高精度检索系统,这篇文章就是把你从“能跑通Demo”推向“真能查到结果”的那条必经之路。我会把处理TXT、Word、PDF、网页这四类文档的解析方案、清洗流水线、分块和元数据设计,以及一套可复现的评测方法全部拆开讲清楚。
1. 从一份乱码钻孔记录开始的翻车现场:为什么清洗决定了RAG的生死
1.1 探矿资料的真实生态:比想象中乱得多的文档集合
探矿业务的知识库,通常不是某一种统一的系统导出的干净数据,而是多年积累下来的资料堆。我接手时看到的文件大概是这样的:
| 文档类型 | 典型来源 | 典型问题 |
|---|---|---|
| TXT | 地质软件导出的钻孔编录、化探采样数据、物探测量记录 | 编码混乱(GBK/UTF-8/UTF-16混存)、字段错位、列分隔符不统一 |
| Word | 岩芯鉴定记录、地质报告章节、勘查工作总结 | 老版.doc难解析、嵌入式表格和公式、段落样式混乱 |
| 历史勘探报告、化验分析报告、矿权申报材料 | 扫描件占比高、多栏排版、页眉页脚和水印干扰、表格提取后错位 | |
| 网页 | 矿业信息平台、勘查规范条文、政策公告 | 动态渲染、正文与导航混杂、网页存档编码问题 |
这些文档里还经常混着同一个信息的不同版本:同一个钻孔的编录数据,可能在2005年的Word报告里有文字版,在2012年Excel导入的TXT里有表格版,又在2020年的PDF扫描件里有原始记录。检索时如果这些版本互相冲突,召回的结果就会让人没法判断到底该信哪一份。
1.2 乱码只是表象,RAG瓶颈藏在解析和清洗之间
很多人一提到RAG项目,第一反应是把文档喂进去、切块、做embedding、建向量库。但真正让检索精度掉下去的,往往是解析阶段和清洗阶段埋下的雷。探矿领域尤其明显:如果一份TXT用UTF-8解码失败,提取出来的全是“锟斤拷”和“�”,embedding之后这些乱码会被嵌入到一个语义上毫无意义的向量位置;如果一份PDF是扫描件而不做OCR,那你建的索引里根本没有可检索的文本;如果Word里的表格被当成普通段落拼接,检索一个“ZK301孔 56.2m-58.4m 岩性为黄铁矿化蚀变花岗岩”的片段时,字段就被打散了。
这些问题的共同点在于:它们不会让程序报错,只会让检索结果“看起来都有,就是没一个准的”。我在这个项目里吃过最大的亏,就是把清洗看得太轻,结果第一版检索命中率低到没法见人。后来才老老实实把处理管线拆成了“解析—清洗—分块—元数据—评测”五段,每一段单独控制质量,最终检索效果才有了质的提升。下面我就按这个顺序,把每一段的实操细节讲清楚。
2. TXT、Word、PDF、网页的解析方案选型与避坑
2.1 TXT:你以为简单,编码问题先教你做人
TXT在探矿数据里通常不是小说和笔记,而是软件导出的原始数据。这就意味着你不能拿“记事本打开能看就行”的标准去处理,必须从字节层面解决编码问题。
第一步永远是判断编码。我习惯用charset-normalizer而不是chardet,它对中文短文本的识别更准,而且能给出置信度。处理逻辑很简单:
from charset_normalizer import from_bytes def decode_text(raw: bytes): best = from_bytes(raw).best() if best and best.confidence and best.confidence > 0.7: return str(best) # 低置信度时,尝试用GBK和UTF-8分别解码,比较谁产生的替换字符更少 for enc in ["utf-8", "gbk"]: try: text = raw.decode(enc) if text.count("\ufffd") == 0: return text except UnicodeDecodeError: continue return raw.decode("gbk", errors="replace")这里有一个实战经验:探矿软件导出的TXT,很多是GBK或GB2312编码,但文件头没有BOM,而且有些是UTF-8 BOM。如果不做检测直接按UTF-8读,GBK文件会当场变成乱码。还有一部分老地质软件导出的是UTF-16 with BOM,charset_normalizer通常也能识别出来,所以检测这一步不能省。
第二步是处理半结构化数据。钻孔编录TXT长这样:
ZK301,0.0,2.5,表土,松散含砾 ZK301,2.5,18.3,强风化花岗岩,褐黄色 ZK301,18.3,56.2,中风化花岗岩,灰白色这种文件真正的价值在于“行内字段的结构”,而不是整篇文本。提取时建议按行解析,把逗号或Tab分隔的字段转成结构化记录,再格式化成可检索的文本块,例如“钻孔ZK301在深度0.0至2.5米处为表土层,岩性描述松散含砾”。如果你不做这步格式化,直接整段切块,语义就被埋没在逗号堆里了。别小看这个过程,它对检索精度的提升比换一个embedding模型还明显。
2.2 Word:docx与doc两个时代的处理差异
Word文档在探矿业务里主要是地质报告、岩芯鉴定表和勘查设计文本。处理Word时,首先要区分是.docx还是老式.doc。.docx本质是ZIP包含XML,处理起来相对清爽,.doc是OLE复合文档,Python的python-docx根本不支持,必须先转换。
.docx我推荐直接用python-docx,按段落和表格分别提取:
from docx import Document doc = Document(path) for para in doc.paragraphs: text = para.text.strip() if text: store_text(text) for table in doc.tables: for row in table.rows: cells = [c.text.strip().replace("\n", " ") for c in row.cells] store_text(" | ".join(cells))这里最容易翻车的是python-docx读不到文本框、批注、SmartArt里的文字。探矿报告里经常用文本框写图注,比如“图3-1 XX矿区地质简图”,这个文字是检索地名和图纸内容的关键线索。如果你漏掉了,用户搜“矿区地质简图”可能就召回不了。我后来是加了soup层面的兜底:把document.xml里所有<w:t>标签直接抽出来,至少保证不丢字,代价是可能会混入重复内容,后续清洗时再处理。
.doc文件我的处理方式是库内统一转换。实际项目中我用了LibreOffice的命令行做批量转换:
soffice --headless --convert-to docx --outdir converted_dir old_report.doc这样能保留表格和大部分格式,让下游走python-docx的路径。如果你的环境不允许装LibreOffice,备选方案是Apache POI的HWPF来读.doc文本,但对表格支持有限,转换后内容容易串行。经验是:别在.doc上花太多精力自己写解析器,转换是性价比最高的路。
2.3 PDF:先用一段代码判断是文本型还是扫描型
PDF是探矿文档里最棘手的一类。老报告扫描件多,表格分布广,而且很多页码还有深色底纹和水印。处理PDF的首要决策是判断它有没有文本层。这个判断不靠肉眼看,直接用代码检测:
import pypdf def has_text_layer(path: str, sample_pages: int = 5) -> bool: reader = pypdf.PdfReader(path) total = min(len(reader.pages), sample_pages) text_len = 0 for i in range(total): extracted = reader.pages[i].extract_text() or "" text_len += len(extracted.strip()) return text_len > 50如果超过采样页的文本量很小,说明是扫描版纯图片PDF,需要走OCR。文本版PDF的提取工具,我在探矿项目里主要用PyMuPDF(fitz)和pdfplumber。选型逻辑很简单:追求速度和批量处理时用PyMuPDF,它解析快,对中文支持也过得去;遇到表格密集的化验报告时用pdfplumber,它能基于坐标还原单元格,提取出来的数据比PyMuPDF直接抽文本完整得多。我在化验分析报告上是吃过亏的,一开始用PyMuPDF抽出来后“Fe 45.2%”“SiO₂ 12.3%”等数字和元素名称位置错乱,换pdfplumber按表格区域切割后,数据才真正对齐。
扫描版PDF的OCR,我的首选是PaddleOCR。它在中文识别上比Tesseract好得多,尤其是手写体和表格里的数字。对扫描版报告,我建议先做图像预处理:转灰度、提高对比度、必要时做deskew纠偏,再跑OCR。不要一上来就整页识别,先按页分块、按区域识别,能大幅降低错字率。OCR结果里常见的问题是把“0”识别成“O”、“1”识别成“l”,这一步残留下来的噪声,到清洗阶段要专门处理。
2.4 网页:正文提取的说难不难、说简单也不简单的门道
网页在探矿RAG里通常是政策条文、勘查规范、矿业信息平台的数据页。这类来源的问题不是缺文本,而是噪声太多,导航栏、相关推荐、广告、版权声明都会混进正文。
我的处理管线是:先用trafilatura做正文抽取。这个库比单纯用BeautifulSoup手写提取稳定得多,它内置了针对多种页面结构的正文识别规则,也支持表格抽取。基本用法:
import trafilatura downloaded = trafilatura.fetch_url(url) text = trafilatura.extract(downloaded, include_comments=False, include_tables=True)对于动态渲染页面(数据是通过JavaScript加载的),直接用requests抓不到有效内容。我使用Playwright无头浏览器渲染后,再交给trafilatura处理。注意设置合理的超时和页面等待条件,否则频繁抓取容易被目标站点限制。
还有两个网页特有的坑必须提:一个是编码识别。老一些的矿业平台页面可能用GBK编码但没在Header标清楚,trafilatura抓下来会出现中文乱码,解码方法跟TXT那段一样要先检测。另一个是去重。同一个政策条文可能被多个网站转载,抓取入库时要用文档指纹去重,不然会稀释检索结果。去重方法后面清洗章节会细说。
3. 清洗流水线:把脏语料按七道工序做成高信噪比样本
解析只是把文字从文件里“挖”出来,真正让文字变得可检索、可信赖的,是清洗。清洗不是写一个正则就完事,而是一条流水线。以我的经验,这套流水线由以下六道工序组成。
3.1 第一道:乱码识别与转码修复
乱码识别不能靠人眼,要靠特征。最常见的几类问题特征:
| 乱码类型 | 特征字符 | 处理方式 |
|---|---|---|
| UTF-8被GBK解码 | “锟斤拷”“烫烫烫” | 反推编码链,尝试重新编码再解码 |
| 解码替换字符 | U+FFFD(�) | 根据上下文替换或放弃该段 |
| 单字节层面破坏 | “�”“□” | 标记为低质量文本,单独存放 |
| OCR数字误读 | O与0、l与1、S与5 | 借助上下文正则和白名单替换 |
我针对“锟斤拷”这类典型的UTF-8/GBK互转乱码,做一个兜底修复:原字节序列通常可以还原为repr或通过encode('gbk', 'ignore')再decode('utf-8', 'ignore')尝试。但说实话,修复率有限,更可靠的做法是回到原始文件重新做编码检测。清洗阶段的乱码识别,更多是把乱码区间切出来标记,而不是强行修复,因为乱码文本embedding之后极大概率连累相邻段落。
对OCR产生的错字,我用“领域白名单+上下文正则”做修正。比如探矿领域常见词“花岗岩”“黄铁矿”“蚀变带”“钻孔”“品位”,如果OCR把“花岗岩”识别成“花岗岩”或“花廿岩”,我可以建一个同形字映射表替换。这里注意不要全局替换“岩”字,只替换完整词组,不然容易误伤。
3.2 第二道:剥离封面、页眉页脚与目录噪声
探矿报告的结构问题特别明显:一份报告动辄上百页,封面、编号页、版权声明、目录占掉十几页,这些内容是“低频检索词”的重灾区,切块后还会占满向量库的空间。
我的做法是优先按报告结构识别:
- 封面页:通常出现在文档头部,其特征是文本稀疏、包含“报告”“矿区”“勘探”“提交单位”等词,判断后整段丢弃。
- 页眉页脚:页眉含报告名称或章节名,页脚含页码。最简单的方式是逐页提取,如果某文本在同一文档中反复出现且长度很短,大概率是页眉页脚。用正则直接过滤,保留正文部分。
- 版权页和资质页:这类页面在PDF里通常是“勘查资质证书编号”“法定代表人”等信息,检索场景里价值低,建议从索引里剔除。
- 目录:目录的最大危害是它会和正文产生“重复片段”,检索时经常命中的是目录而不是正文,必须整段删除。判断方式很简单,检查文本行里是否包含连续的“....”或“……”,以及“第X章”结构且后面跟页码。
这些工序做完,索引的“信噪比”会显著提升。我遇到过的情况是:清洗前用户搜“ZK101孔”,Top5里三条来自目录和封面,清洗后直接命中正文段落。
3.3 第三道:半结构化表格的抽平与还原
表格是探矿文档的核心载体,不能按普通段落处理。表格清洗的目标是,把表格内容转成“可读的文本化描述”或“结构化的JSON记录”。
文本化描述的做法,是给每个单元格补上表头字段名。比如一个简明岩芯鉴定表,原始PDF提取后是:
ZK101 | 12.3 | 灰白 | 花岗岩 | 中等风化这行文本如果不加表头,检索“ZK101的花岗岩风化程度”时,embedding会把这行当成普通字符串,很难正确关联“风化程度”。转成结构化后应该是:
ZK101钻孔深度12.3米,岩性为花岗岩,颜色灰白,风化程度中等。对于复杂表格(合并单元格、跨页表格),我建议用pdfplumber按页提取,然后把跨页表格按行拼接起来。这一道工序最耗时,但也是最值得投入的,因为探矿检索的本质是“从结构化的钻孔数据里查东西”,表格处理得好不好,直接决定检索的上限。
3.4 第四道:OCR错字与领域术语的统一
探矿文档里经常出现同一个术语的不同写法。比如“黄铁矿化”和“黄铁矿产化”,“米”和“m”,“米”和“公尺”,还有金刚石钻进里的“回次”“进尺”等专业词的写法不统一。如果检索时用户写“黄铁矿化”,而库里存的是“黄铁矿产化”,语义上可能能匹配,但如果embedding对这种细粒度差异不够敏感,召回就不稳定。
我的做法是维护一份“术语归一化词典”,用正则或者逐词替换,把常见变体统一成标准写法。这一步对检索精度的提升,比想象中大得多。千万别小看这种“笨功夫”,领域知识库的价值很多时候不来自模型,而来自这些人工积累的规范映射。
3.5 第五道:重复内容清洗与文档指纹去重
同一个信息在多年间被反复引用、复制,文档里会出现大段重复。比如同一地区的地质背景章节,在不同的报告里几乎一字不差。这些重复内容会让检索结果被同一信息的多个副本刷屏,却看不到其他角度。
去重思路分两层。第一层是全文级别的minhash去重,把相似度高于0.9的文档标记为重复,只保留质量最高的一版;第二层是章节级别的相似段落检测,把同一报告里的重复描述合并。具体的指纹提取可以用simhash或rrr这类实现,实际操作中对探矿文书,用Jaccard对称词比已经够用,不用上太重的模型。
3.6 输出结构:一份你想要的JSON也许是清洗终点
清洗完的文本,我建议统一输出成带元数据的JSON结构,而不是散落的TXT文件。每条记录包含file_id、doc_type、title、content、source、year、locality、borehole_id等字段。这样做的目的是为后面分块和检索提供“过滤维度”。
我用一个简单的数据模型来表示清洗后的文档,而不是直接生成一串字符串。这一步看起来多此一举,但在做后续元数据过滤和检索评测时,你会发现它是整个项目中最值得的投入之一。
4. 分块策略和元数据设计:让embedding拿得动长文档
清洗之后,下一步是分块。分块这件事看似简单,但在探矿长文档场景里,它对检索精度的贡献不亚于清洗。分块策略和元数据设计必须联动起来考虑,不能孤立地调一个参数。
4.1 探矿报告的段落天生适合做分块单位
探矿报告的结构通常是“章—节—段落—表格”,每一段描述一个完整的地质信息单元。比如:
“ZK101钻孔位于XX矿区北部,孔深156.2米。0至2.5米为第四系残坡积层,2.5至18.3米为强风化花岗岩,18.3至56.2米为中风化花岗岩……”
这种段落天然适合作为分块单位。如果硬按固定字符窗口(比如512字符)切,很容易把“ZK101钻孔位于……”和下一段的“ZK102钻孔……”拼在一起,检索定位就乱了。
所以我推荐优先做段落级分块,段落太长时再用滑动窗口补充。具体策略是:先按\n\n或者Word中的标题级别把文档切成带层级信息的片段,然后设定最大块长(比如800到1200字符)。超过最大块的段落,用固定窗口二次切分,并保留重叠。重叠长度我习惯设为150到200字符,避免一个检索点正好卡在切分边界上而丢失上下文。
4.2 子块收集技术与Father-Child块:兼顾检索效率和上下文
探矿报告里有一个很现实的问题:一段描述可能长达上千字,把整段作为向量块,检索时用户query的嵌入向量与整段向量的语义相似度会被淹没在大量细节里。比如用户问“ZK101孔的蚀变类型”,如果整个块内容包括了孔径、地层、风化程度、岩性等多个字段,相似度计算时很可能被其它字段稀释。
我的实践是用“父子块”方案。子块是段落里较小的语义单元(比如按句子或按表格行划分),用来做检索匹配;父块是子块所属的完整段落,在检索命中子块后,把父块全文返回给下游LLM。这样既保证检索的精度(子块小而准),又保证生成时的上下文(父块全而完整)。
如果下一步接的是大模型,需要给模型提供完整的岩性描述和上下钻孔的对比信息,父块方案能很好地补齐这一点。
4.3 元数据注入:用矿区、井号、坐标、年份收窄检索范围
检索的时候,如果embedding只负责语义匹配,那么“XX矿区ZK101孔的钼矿品位”这类query,就会面临语义相似但矿区不对、孔号不对的结果干扰。元数据过滤可以在这时候发挥作用。
我在每条分块向量的metadata里放这样几个字段:
| 字段 | 示例 | 作用 |
|---|---|---|
| locality | XX矿区 | 按矿区过滤 |
| borehole_id | ZK101 | 按钻孔过滤 |
| doc_type | 岩芯鉴定表 | 按文档类型过滤 |
| year | 2005 | 按时间过滤 |
| source_file | 2005_XX矿区勘探报告.pdf | 溯源定位 |
检索时先用结构化过滤(比如限定“XX矿区”和“ZK101”),再做向量语义检索。实测下来,这个组合方式让Top5命中率提升了十几个百分点。没有元数据的时候,检索结果经常是“别处的ZK101”或者“另一份报告里的同一个矿区描述”,加了过滤之后结果就精准多了。
4.4 Embedding模型的选择:中文适配性与块长限制
分块和元数据都定了,embedding模型的选择也不能马虎。探矿领域的术语偏专业,通用模型的向量空间对“黄铁矿化”“硅化”“矽卡岩”这类词的区分度是有上限的。我的经验是优先选中文语料表现好的模型,比如text-embedding-3-small的英文模型中文效果还行,但更推荐bge-large-zh这类专用中文模型,或者用领域微调的向量模型,对探矿术语的鲁棒性更好。
块长限制也要留意。很多embedding模型有token上限(如512 token),如果块设置过长会被截断,检索精度会大幅下降。块长和模型上限之间要留有余量,我一般把块长控制在模型上限的60%到70%,这样即使块里有长词和公式,也不会被硬截断。
5. 清洗前后怎么评估:拿数据和铁一样的事实说话
最后一个关键环节,也是很多人做RAG项目时最容易漏掉的:评测。清洗到底有没有用?分块参数调到多少合适?这些问题不能靠“感觉”,要有一套可复现的评测流程。
5.1 构建一个50~100条query的评测集
我建议从真实用户query里整理出50到100条代表性检索问题,覆盖以下几个维度:
- 精确查找:如“ZK301钻孔在12.3米的岩性”
- 属性查询:如“XX矿区黄铁矿化蚀变带的深度区间”
- 跨文档关联:如“最近一次勘查报告中关于花岗岩风化程度的描述”
- 模糊查询:如“钼矿品位相关的数据在哪里有提到”
每条query标记正确答案所在的文档和段落。这份标注集是整个评测的地基,没有它,后面所有指标都无从谈起。
5.2 核心指标:Top5命中率、MRR与真实召回
探矿检索场景里,用户更关心“正确结果有没有出现在前几名”,而不是系统里所有候选排名的平均水平。我主要看三个指标:
- Top5命中率:正确结果出现在前5名的比例。这是最贴近日常使用的指标。
- MRR(Mean Reciprocal Rank):正确结果的排名倒数均值,衡量“排得够不够靠前”。
- 真实召回率:正确结果在所有返回结果中的覆盖率。注意,这一步建议在向量检索之后、重排之前单独统计,这样能隔离出清洗和分块对召回的影响。
如果接入了重排(reranker),我还会额外统计“重排后Top5命中率”和“未重排Top5命中率”,判断重排带来的提升是否真实。
5.3 对比实验:把乱码率、检索准确率摆在一张表上
有了评测集,就能做对比实验了。我当时的对比方式是清洗前 vs 清洗后:
| 指标 | 清洗前 | 清洗后 |
|---|---|---|
| 乱码文本占比 | 12.7% | 0.3% |
| Top5命中率 | 48% | 79% |
| MRR | 0.31 | 0.62 |
| 真实召回率 | 55% | 81% |
这个结果很直白地说明了清洗的作用。如果只换embedding模型不洗数据,提升有限。先把脏数据洗干净,再把分块和元数据调对,才是高精度检索的根基。
评测时另一个值得注意的点是“错误类型分析”。每次评测不只看指标,还要记录下来“哪些query没命中,为什么没命中”。我发现大量失败原因不是模型不行,而是:
- 表格抽平后字段对应错了
- 页眉页脚没清干净,检索命中到重复的章节名
- 分块时把“钻孔编录”和“化验结果”切到了一块,导致语义混杂
- 编码修复后仍有几个段落是乱码,embedding后语义偏移
这些问题每一轮迭代修掉一两个,检索精度会稳步提升。
5.4 迭代流程:每周一次的回归测试值得做
数据清洗和分块调参不是一次性工作。新文档在不断入库,embedding模型也可能升级,所以我在项目中固定了评测节奏:每周跑一次回归测试,把新增的query和文档加入评测集,观察Top5命中率、MRR、召回率的变化。
如果发现某个指标掉了一截,就回溯到清洗管线里去找原因。这个流程看起来重,但对长期维护的探矿知识库来说是必要的。RAG项目的生命周期远不止“上线”那一次,持续维护比首版准确度更体现工程能力。
我个人在实际操作中的体会是,探矿这类专业领域的RAG,真正拉开差距的从来不是用了多大的模型,而是从文档解析到清洗再到分块检索这一整条流水线的精细程度。把TXT的编码问题解决掉、把Word和PDF的表格结构还原出来、把网页噪声清理干净、用元数据收窄检索范围,最后的检索效果会好得让你惊讶。如果你也正被乱码和低精度检索折磨,建议不要先急着换模型,先把清洗流水线和评测闭环搭起来,大概率会先解决掉八成的问题。