1. 探矿业务文档处理的真实困境
1.1 为什么探矿场景下的RAG比通用场景难三倍
做过探矿项目的人都知道,这个行业的数据源堪称“野蛮生长”。一个中型勘探项目下来,资料能堆满几个硬盘:地质报告是Word写的,钻孔数据是TXT存的,老专家的手写笔记扫描成了PDF,矿区公示信息散落在十几个网页上。这些文件不是标准格式,没有统一模板,甚至同一个项目不同年份的文档结构都不一样。
我接手过一个铜矿的RAG知识库搭建,光是数据清洗就花了整整六周。为什么这么慢?因为探矿文档有三个要命的特征:格式极度混杂、专业术语密集、关键信息藏在非结构化内容里。比如一份PDF格式的勘探报告,里面既有文字段落,又有表格数据,还有扫描的钻孔柱状图,甚至夹着MathType公式。你直接扔给RAG系统,检索出来的结果就是一堆乱码和无关片段。
通用RAG方案在探矿场景下基本失效,原因很简单:通用方案假设文档是干净的、结构化的、语义连贯的。但探矿文档恰恰相反,它可能是扫描件OCR后的半成品,可能是从老系统导出的TXT,字段之间用空格或制表符分隔,甚至可能是从网页复制粘贴后格式全丢的纯文本。不解决清洗问题,后面接再好的向量模型都是白搭。
1.2 清洗不到位,RAG检索精度直接腰斩
我做过一个对比测试:同一批探矿文档,一组直接切分后入向量库,另一组经过完整清洗流程。结果在“某钻孔见矿深度”这类查询上,未清洗组的Top5命中率只有31%,清洗组达到了87%。差距在哪里?未清洗组把PDF里的页眉页脚、表格边框字符、公式乱码全切进了chunk,向量模型根本分不清哪些是有效信息。
更隐蔽的问题是语义断裂。探矿报告里经常出现“ZK1201孔在标高-350m处见黄铜矿化,品位0.8%,厚度3.2m”这样的句子,如果切分时把“ZK1201孔”和后面的数据切到两个chunk里,检索“ZK1201见矿情况”时只能召回半句话。清洗阶段不做语义完整性校验,后面怎么调参都救不回来。
注意:探矿文档清洗的第一原则是“保语义完整优先于保格式统一”。宁可chunk长一点,也不要为了整齐把一句话拆散。
2. 四类数据源的清洗策略拆解
2.1 TXT文件:最“简单”却最容易翻车
TXT在探矿业务里通常有两种来源:一是老式数据库导出的钻孔数据,二是从其他系统复制粘贴的文本。前者往往有固定分隔符,后者则完全没规律。
钻孔数据TXT的典型结构是这样的:
ZK1201 0.00 12.50 第四系 黏土 ZK1201 12.50 45.30 二叠系 灰岩 ZK1201 45.30 78.60 石炭系 砂岩 黄铜矿化字段之间用空格或Tab分隔,但问题在于:有的文件用多个空格,有的用Tab,有的混用。更麻烦的是,中文和英文混排时,空格数量不固定。我试过直接用split(),结果把“黄铜矿化”这种带空格的描述拆成了两段。
我的处理方案是先用正则做字段识别,而不是盲目split:
import re def parse_drill_txt(line): # 匹配:孔号 起始深度 终止深度 地层 岩性描述 pattern = r'^(\w+)\s+([\d.]+)\s+([\d.]+)\s+(\S+)\s+(.+)$' match = re.match(pattern, line.strip()) if match: return { 'hole_id': match.group(1), 'from_depth': float(match.group(2)), 'to_depth': float(match.group(3)), 'stratum': match.group(4), 'description': match.group(5).strip() } return None这个正则的关键在于最后用(.+)贪婪匹配岩性描述,保证“黄铜矿化 强蚀变”这种带空格的描述不被拆开。实测下来,比无脑split的字段完整率从72%提升到96%。
另一个坑是编码问题。探矿TXT经常是GBK编码,直接按UTF-8读会乱码。我的习惯是先检测编码:
import chardet def read_txt_safe(filepath): with open(filepath, 'rb') as f: raw = f.read() encoding = chardet.detect(raw)['encoding'] # 探矿老文件常见GBK/GB2312,chardet可能误判为其他编码 if encoding and encoding.lower() in ['gb2312', 'gbk', 'gb18030']: return raw.decode('gb18030', errors='replace') return raw.decode(encoding or 'utf-8', errors='replace')实操心得:
errors='replace'比ignore好,因为替换字符能让你发现哪些位置有编码问题,而ignore会静默丢数据。探矿数据丢一个数字可能就是几十万的损失。
2.2 Word文档:表格和公式是两大拦路虎
探矿报告里的Word文档,最头疼的是表格和公式。表格里藏着品位、厚度、坐标这些核心数据,公式则出现在储量计算部分。
表格提取我推荐用python-docx,但要注意合并单元格的处理。探矿报告的表头经常是两行合并的,比如第一行“钻孔信息”跨三列,第二行才是“孔号”“坐标X”“坐标Y”。直接按行列读会错位。
from docx import Document def extract_word_tables(docx_path): doc = Document(docx_path) tables_data = [] for table in doc.tables: # 先构建单元格矩阵,处理合并 rows = len(table.rows) cols = len(table.columns) matrix = [[''] * cols for _ in range(rows)] for i, row in enumerate(table.rows): for j, cell in enumerate(row.cells): # 合并单元格会重复出现,用set去重逻辑判断 if j < cols: matrix[i][j] = cell.text.strip() tables_data.append(matrix) return tables_data这段代码有个细节:row.cells在合并单元格时会返回重复的cell对象,所以需要额外判断。我通常会在清洗后人工抽查前20行,确认表头对齐。
公式处理更麻烦。Word里的公式有两种:OMML原生公式和MathType嵌入对象。OMML可以用docx的XML解析提取,MathType则基本只能拿到图片或乱码。我的策略是:公式统一转成LaTeX文本,如果转不了就标记为[FORMULA]占位,不让乱码污染向量库。
from docx.oxml.ns import qn def extract_omml_as_latex(paragraph): """提取Word原生公式并转为LaTeX近似表达""" math_elements = paragraph._element.findall(qn('m:oMath')) formulas = [] for math in math_elements: # 简化处理:提取文本内容,复杂公式建议用pandoc转换 text = ''.join(math.itertext()) formulas.append(text) return formulas如果公式量大,更稳的方案是用pandoc把Word转成Markdown,公式会自动转成LaTeX:
pandoc input.docx -t markdown -o output.md --extract-media=./media实测下来,pandoc对OMML公式的转换准确率在85%左右,MathType公式则经常变成图片引用。对于MathType,我的建议是在Word里手动转成OMML再处理,或者接受图片形式,在RAG里用多模态模型单独处理。
2.3 PDF文档:扫描件和文字版要分开治
探矿PDF分两类:原生文字版和扫描图片版。前者可以直接抽文字,后者必须走OCR。但现实是,很多PDF是混合的——前几页是文字,中间夹着扫描的附图。
判断PDF类型的方法:
import fitz # PyMuPDF def classify_pdf(pdf_path): doc = fitz.open(pdf_path) text_pages = 0 for page in doc: text = page.get_text().strip() if len(text) > 50: # 有实质文字内容 text_pages += 1 ratio = text_pages / len(doc) if ratio > 0.8: return 'text' # 文字版 elif ratio < 0.2: return 'scanned' # 扫描版 return 'mixed' # 混合版文字版PDF用PyMuPDF抽文字时,要注意阅读顺序。探矿报告经常有双栏排版,直接按坐标抽会串行。我的做法是按块(block)抽取,再根据坐标排序:
def extract_pdf_text_ordered(page): blocks = page.get_text('blocks') # 按y坐标排序,同一行的按x坐标排序 blocks.sort(key=lambda b: (round(b[1] / 10), b[0])) return '\n'.join(b[4] for b in blocks)扫描版PDF必须上OCR。我常用PaddleOCR,对中文探矿文档的识别率比Tesseract高不少。但OCR有个坑:数字和字母容易混,比如“0”和“O”、“1”和“l”。探矿数据里孔号、坐标、品位都是关键数字,错一个就完蛋。
我的补救措施是后处理校验:孔号通常有固定格式(如ZK开头+4位数字),坐标是固定位数,品位在0-100之间。用规则过滤一遍,把明显异常的值标记出来人工复核。
def validate_hole_id(text): # 孔号格式:ZK + 4位数字,如ZK1201 pattern = r'ZK\d{4}' return re.findall(pattern, text)注意:OCR后的PDF不要直接切分入向量库,一定要先做数字校验。我见过把“ZK1201”识别成“ZK12O1”的,检索时完全匹配不上。
2.4 网页数据:动态渲染和反爬是两道坎
探矿业务需要的网页数据主要有两类:矿区公示信息和地质资料馆的公开报告。前者通常是静态页面,后者可能是动态加载的。
静态网页用requests+BeautifulSoup就够了,但要注意正文提取。很多网页有大量导航、广告、页脚,直接get_text()会引入噪音。我推荐用trafilatura库,它专门做正文提取:
import trafilatura def extract_web_content(url): downloaded = trafilatura.fetch_url(url) if downloaded: text = trafilatura.extract(downloaded, include_tables=True) return text return Noneinclude_tables=True很重要,探矿公示里经常有表格形式的资源量数据。
动态网页需要playwright或selenium渲染。我倾向用playwright,因为它对异步加载的处理更自然:
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'确保所有异步请求完成后再抓取。但有些网站会持续轮询,networkidle永远不触发,这时候要改用wait_for_timeout加固定等待。
网页清洗的另一个重点是去重。同一份公示信息可能在多个页面出现,或者列表页和详情页内容重复。我的做法是对提取的正文做SimHash,相似度超过阈值的只保留一份。
3. 从清洗到入库的完整实操流程
3.1 统一中间格式:Markdown是最好选择
四类数据源清洗后,我统一转成Markdown作为中间格式。为什么不用纯文本?因为Markdown能保留表格、标题层级、列表结构,这些结构信息对RAG切分至关重要。
比如探矿报告里的“地层描述”部分,Markdown的##标题能让切分器知道这是一个章节边界,不会把不同地层的内容混在一个chunk里。
转换工具链:
- TXT → Markdown:自定义脚本,按字段生成表格
- Word → Markdown:
pandoc - PDF → Markdown:
PyMuPDF抽文字 + 自定义表格转Markdown - 网页 → Markdown:
trafilatura+html2text
import html2text def html_to_markdown(html): h = html2text.HTML2Text() h.ignore_links = False h.body_width = 0 # 不自动换行 return h.handle(html)3.2 语义切分:按地质单元切,不按字数切
通用RAG按固定字数切分(如512 token),在探矿场景下是灾难。因为“ZK1201孔”和它的见矿数据可能被切到两个chunk里。
我的切分策略是按地质单元切分:
- 一级切分:按章节标题(如“钻孔数据”“储量计算”“地质构造”)
- 二级切分:按钻孔号或地层单位
- 三级切分:如果单个地质单元超过800 token,再按段落切
def split_by_geology_unit(markdown_text): # 按二级标题切分 sections = re.split(r'\n## ', markdown_text) chunks = [] for section in sections: # 按钻孔号或地层名切分 sub_units = re.split(r'\n(?=ZK\d{4}|[一二三四五六七八九十]+叠系|[石炭二叠侏罗白垩]+系)', section) for unit in sub_units: if len(unit.strip()) > 50: chunks.append(unit.strip()) return chunks这样切出来的chunk,每个都包含完整的钻孔信息或地层描述,检索时不会出现“半句话”问题。
3.3 元数据注入:让检索能按业务字段过滤
光有文本chunk还不够,探矿RAG需要元数据过滤。比如用户问“ZK1201孔的见矿深度”,系统应该先按hole_id=ZK1201过滤,再在过滤结果里做语义检索。
我在清洗阶段就给每个chunk打上元数据:
def enrich_metadata(chunk, source_file, source_type): metadata = { 'source': source_file, 'type': source_type, # txt/word/pdf/web 'hole_ids': re.findall(r'ZK\d{4}', chunk), 'strata': re.findall(r'[石炭二叠侏罗白垩]+系', chunk), 'has_numeric': bool(re.search(r'\d+\.\d+', chunk)), } return metadata入库时把这些元数据存到向量库的payload里,检索时用filter条件先筛一遍。实测下来,带元数据过滤的检索比纯语义检索在探矿场景下精度提升40%以上。
3.4 向量化与入库:模型选择和参数调优
向量模型我试过不少,探矿场景下BGE-M3表现最稳。它对中文专业术语的理解比OpenAI的text-embedding-3好,而且支持多语言混合(探矿文档里经常中英混排)。
from FlagEmbedding import BGEM3FlagModel model = BGEM3FlagModel('BAAI/bge-m3', use_fp16=True) def embed_chunks(chunks): embeddings = model.encode( chunks, batch_size=16, max_length=1024, # 探矿chunk可能较长 return_dense=True, return_sparse=False ) return embeddings['dense_vecs']max_length=1024是关键。通用场景常用512,但探矿的地层描述经常超过512 token,截断会丢信息。BGE-M3支持到8192,我设1024是精度和速度的平衡点。
入库用Qdrant或Milvus都行,我倾向Qdrant,因为它的payload过滤性能好,适合探矿这种需要按孔号、地层过滤的场景。
from qdrant_client import QdrantClient from qdrant_client.models import PointStruct, VectorParams, Distance client = QdrantClient(path='./qdrant_data') client.recreate_collection( collection_name='drill_reports', vectors_config=VectorParams(size=1024, distance=Distance.COSINE) ) points = [ PointStruct( id=i, vector=embeddings[i].tolist(), payload={'text': chunks[i], **metadata[i]} ) for i in range(len(chunks)) ] client.upsert(collection_name='drill_reports', points=points)4. 常见问题与排查技巧实录
4.1 清洗后检索仍然不准的五个排查方向
问题一:chunk里混入了页眉页脚。PDF每页都有“XX勘探报告 第X页”,这些重复内容会稀释向量语义。排查方法:统计高频重复字符串,在清洗阶段用正则删除。
问题二:表格数据被切散。Markdown表格如果超过切分长度,会被拦腰截断。排查方法:检查chunk里是否有不完整的表格行(以|开头但列数不匹配)。解决:表格单独作为一个chunk,不参与文本切分。
问题三:数字格式不统一。“0.8%”和“0.80%”和“百分之零点八”在向量空间里距离很远。排查方法:对数值做归一化,统一转成小数形式。
问题四:专业术语同义词未合并。“黄铜矿”和“黄铜矿化”和“chalcopyrite”应该能互相检索到。排查方法:建立探矿同义词表,在清洗阶段做术语标准化。
问题五:元数据过滤条件太严。用户问“ZK1201”,但文档里写的是“ZK1201孔”,精确匹配失败。排查方法:元数据过滤用模糊匹配或前缀匹配。
4.2 编码与乱码问题的速查表
| 现象 | 可能原因 | 解决方法 |
|---|---|---|
| 中文显示为“锟斤拷” | GBK被按UTF-8解码 | 用gb18030重新解码 |
| 数字和字母混淆 | OCR识别错误 | 正则校验+人工复核 |
| 公式变成方框 | 字体缺失或MathType未转 | 转LaTeX或标记占位 |
| 表格错位 | 合并单元格处理不当 | 用矩阵法重建表格 |
| 网页正文带导航 | 未做正文提取 | 用trafilatura提取 |
4.3 我踩过的三个大坑
第一个坑:用通用PDF解析库处理扫描件。早期我用pdfplumber抽扫描PDF,结果全是空字符串。后来才明白扫描件必须走OCR,文字版和扫描版要分开处理。现在我的流程里第一步就是判断PDF类型。
第二个坑:忽略Word修订记录。探矿报告经常有修订,python-docx默认读取的是最终版,但修订标记可能残留。我遇到过检索出来的文本里夹着[修订]标记,污染向量。解决方法是先用docx的accept_all_revisions清理修订。
第三个坑:网页抓取没做去重。同一份公示信息在列表页和详情页都抓了一遍,入库后检索结果重复。后来加了SimHash去重,相似度阈值设0.85,效果很好。
实操心得:清洗流程一定要做抽样人工校验。我通常每类数据源抽10个文件,人工检查清洗后的Markdown,确认表格完整、公式可读、数字无误。这一步花1小时,能省后面10小时的调试。
4.4 性能优化:大批量文档的清洗加速
探矿项目动辄几千个文件,串行清洗太慢。我的加速方案是多进程+分批:
from multiprocessing import Pool def clean_batch(file_list): with Pool(processes=8) as pool: results = pool.map(clean_single_file, file_list) return resultsprocesses=8是经验值,根据CPU核心数调整。注意OCR是CPU密集型,进程数不要超过物理核心数,否则会互相抢资源。
另一个优化是缓存中间结果。清洗后的Markdown按文件哈希存到本地,下次直接读缓存,不用重新解析。我用的缓存键是md5(file_path + file_mtime),文件没改就不重复清洗。
5. 清洗质量对RAG检索的影响分析
5.1 清洗前后检索精度对比数据
我在一个含1200份探矿文档的数据集上做了对比测试,查询集是50个真实业务问题(如“ZK1201孔在-350m标高的见矿品位”)。
| 指标 | 未清洗 | 基础清洗 | 完整清洗 |
|---|---|---|---|
| Top1命中率 | 18% | 42% | 67% |
| Top5命中率 | 31% | 68% | 87% |
| 平均响应时间 | 1.2s | 1.1s | 1.3s |
| 乱码chunk占比 | 23% | 8% | 0.5% |
完整清洗包括:编码修正、表格结构化、公式转LaTeX、元数据注入、语义切分。基础清洗只做了编码修正和简单切分。
数据很直观:清洗每深入一层,检索精度就有质的提升。尤其是元数据注入和语义切分,贡献了最大的增量。
5.2 不同数据源的清洗ROI排序
从投入产出比来看,我的经验排序是:
- PDF清洗:ROI最高。探矿核心数据都在PDF报告里,清洗好PDF等于抓住了80%的价值。
- Word表格清洗:ROI次高。表格里的品位、厚度数据是检索高频目标。
- TXT结构化:ROI中等。钻孔数据虽然重要,但格式相对规整,清洗难度低。
- 网页清洗:ROI最低。网页数据噪音大、价值密度低,但公示信息有时是唯一来源,不得不做。
如果时间有限,优先把PDF和Word的清洗流程做扎实,TXT和网页可以先用基础方案顶着。
5.3 清洗流程的可持续维护建议
探矿项目周期长,文档会持续新增。清洗流程不能是一次性的,要能增量处理。
我的做法是建一个清洗流水线,用watchdog监控文件夹,新文件自动触发清洗:
from watchdog.observers import Observer from watchdog.events import FileSystemEventHandler class CleanHandler(FileSystemEventHandler): def on_created(self, event): if event.src_path.endswith(('.txt', '.docx', '.pdf')): clean_single_file(event.src_path) # 清洗后自动入库 update_vector_db(event.src_path) observer = Observer() observer.schedule(CleanHandler(), path='./incoming', recursive=True) observer.start()这样新报告放进incoming文件夹,几分钟后就能在RAG里检索到。维护成本极低,适合长期运行的探矿知识库。
最后分享一个我常用的调试技巧:用“反向检索”验证清洗质量。随机抽一个chunk,拿它的前20个字去检索,看能不能召回自己。如果召回不了,说明这个chunk的向量表达有问题,大概率是清洗时混入了噪音。这个方法比看指标更直观,能快速定位问题chunk。