1. 探矿业务文档处理的真实困境
地质勘探行业有一个很反直觉的现实:最核心的数据资产,往往躺在最难用的文件格式里。一个中型探矿项目,从踏勘到详查,积累的资料包括几十年前的纸质报告扫描件、不同单位提交的Word版地质说明书、各种仪器导出的TXT格式化验数据、以及从公开渠道采集的网页资料。这些文件散落在不同人的硬盘里,格式五花八门,编码混乱,表格结构不统一。
我接手过一个铜多金属矿的资料数字化项目,光是整理原始资料就花了三周。TXT文件里有GBK编码的,有UTF-8的,还有早期DOS系统留下的GB2312编码,直接打开全是乱码。Word文档更麻烦,有些是2003版的.doc,有些是.docx,里面嵌入了大量公式和表格,用python-docx读取时经常报错。PDF的情况最复杂,有原生电子版,有扫描件,还有混合型的——正文是文字层,但关键图件是图片。网页资料则是另一个维度的挑战,结构不统一,正文和导航栏混在一起,直接抓下来全是噪声。
这些数据如果只是存档,问题还不大。但要做RAG知识库,让大模型能够基于这些资料回答问题,就必须先过清洗这一关。清洗的质量直接决定了后续检索的精度。我见过太多项目,RAG框架选得很 fancy,向量数据库用得也很高级,但底层数据没洗干净,检索出来的结果驴唇不对马嘴。探矿业务对精度的要求又特别高,一个品位数据的小数点错位,可能导致完全不同的资源量估算结论。
所以这篇文章,我想把探矿业务中TXT、Word、PDF和网页这四类文档的RAG清洗经验完整梳理一遍。从编码识别到结构提取,从表格还原到语义分块,每一步都会给出可复现的操作方案和参数选择的理由。适合正在搭建地质领域知识库的工程师,也适合需要处理多格式文档的RAG开发者参考。
2. 整体清洗架构的设计思路
2.1 为什么不能直接丢给通用解析器
很多人第一反应是用LangChain的通用文档加载器,或者直接调Unstructured库。我一开始也这么干过,结果在探矿数据上翻车了。通用解析器对英文文档支持很好,但中文地质报告里的表格结构、专业术语、单位符号,它处理得一塌糊涂。比如“Cu 0.85%”这样的品位数据,通用解析器可能把“Cu”和“0.85%”拆到两个不同的块里,检索时匹配不上。
另一个问题是探矿文档的层级结构。一份地质报告通常有章节、小节、段落、表格、图注等多个层级,通用解析器往往只做简单的按页或按段落切分,丢失了层级信息。而RAG检索时,层级信息恰恰是判断相关性的重要依据——用户问“ZK1201钻孔的见矿深度”,如果检索到的块是“见矿深度”这个表格标题下的数据行,相关性就很高;如果只是随便切的一段文字,可能就答非所问。
所以我的设计思路是:分格式处理,统一输出为带元数据的结构化块。TXT走编码检测和规则解析,Word走XML级解析保留样式信息,PDF走版面分析加OCR兜底,网页走正文提取加去噪。每一类都有专门的清洗管道,最后统一成JSON格式的块序列,每个块携带来源文件、页码、章节路径、块类型等元数据。
2.2 清洗管道的四个阶段
整个清洗流程我分成四个阶段,每个阶段有明确的输入输出和质量检查点。
第一阶段是格式识别与预处理。拿到一个文件,先判断它的真实格式。有些文件扩展名是.txt,实际是HTML;有些.pdf其实是图片包。用file命令或python-magic库做魔数检测,比看扩展名靠谱得多。预处理包括编码转换、去除BOM头、统一换行符这些基础操作。
第二阶段是内容提取与结构还原。TXT按行解析,识别表格分隔符和键值对;Word解析document.xml,提取段落样式和表格结构;PDF用PyMuPDF提取文字层,扫描件走OCR;网页用Readability或trafilatura提取正文。这个阶段的核心目标是尽可能保留原始文档的结构信息。
第三阶段是语义清洗与分块。去除页眉页脚、水印、乱码字符,统一单位符号,然后把长文本切成适合嵌入的块。分块策略很关键,我后面会详细讲。探矿文档里表格很多,表格不能简单按行切,要保留表头和数据行的关联。
第四阶段是质量校验与元数据注入。每个块都要检查是否包含有效信息,过滤掉纯噪声块。然后注入元数据:来源文件路径、页码、章节标题、块类型(正文/表格/图注)、时间戳等。这些元数据在检索时可以用来做过滤和重排序。
2.3 工具选型的取舍逻辑
工具选型上我踩过不少坑,最后稳定下来的组合是这样的:
| 格式 | 主力工具 | 兜底方案 | 选择理由 |
|---|---|---|---|
| TXT | chardet + 自定义规则 | iconv命令行 | chardet对中文编码检测准确率高,自定义规则处理表格 |
| Word | python-docx + lxml | pandoc转markdown | python-docx能读表格,lxml直接操作XML保留样式 |
| PyMuPDF (fitz) | PaddleOCR | PyMuPDF提取文字层快且准,扫描件用PaddleOCR中文识别好 | |
| 网页 | trafilatura | BeautifulSoup + 启发式 | trafilatura正文提取准确,兜底方案处理特殊页面 |
为什么不直接用Unstructured?因为它的表格处理在中文场景下不够精细,而且依赖较重。为什么不直接用LangChain的加载器?因为定制化程度不够,探矿文档的特殊结构需要自己写解析逻辑。PaddleOCR而不是Tesseract,是因为中文地质报告里的专业术语和表格线,PaddleOCR的识别效果明显更好。
注意:工具选型不是越新越好,而是越可控越好。探矿数据清洗是一个需要反复调试的过程,工具的可调试性比开箱即用更重要。
3. TXT文件清洗:从乱码到结构化数据
3.1 编码检测的实战方法
TXT文件的第一个坑就是编码。我统计过手头的探矿TXT文件,编码分布大概是:GBK占45%,UTF-8占30%,GB2312占15%,剩下10%是各种奇怪的单字节编码或混合编码。直接用open(file, 'r')打开,Python默认用UTF-8,遇到GBK文件就抛UnicodeDecodeError。
我的做法是先用chardet检测,但chardet对短文件和小样本的检测准确率不高。所以加了一层启发式规则:如果chardet返回的置信度低于0.8,就用候选编码列表逐个尝试解码,看哪个能解出最多的中文字符。候选列表按优先级排列:['utf-8', 'gbk', 'gb2312', 'gb18030', 'big5', 'latin-1']。gb18030是gbk的超集,放在gbk后面作为兜底。
import chardet def detect_encoding(file_path): with open(file_path, 'rb') as f: raw = f.read(100000) # 读前100KB做检测 result = chardet.detect(raw) if result['confidence'] > 0.8: return result['encoding'] # 置信度低时逐个尝试 candidates = ['utf-8', 'gbk', 'gb2312', 'gb18030', 'big5'] best_encoding = None best_score = 0 for enc in candidates: try: text = raw.decode(enc) # 统计中文字符比例 chinese_count = sum(1 for c in text if '\u4e00' <= c <= '\u9fff') score = chinese_count / len(text) if text else 0 if score > best_score: best_score = score best_encoding = enc except UnicodeDecodeError: continue return best_encoding or 'utf-8'这个函数在实际项目中对探矿TXT的编码识别准确率能达到98%以上。剩下2%是混合编码文件,需要人工介入或者按行检测编码。
3.2 化验数据表格的解析策略
探矿TXT文件里最有价值的是化验数据,通常长这样:
样品编号 Cu(%) Pb(%) Zn(%) Ag(g/t) ZK1201-01 0.85 0.12 0.45 15.2 ZK1201-02 1.23 0.08 0.67 22.8 ZK1201-03 0.67 0.15 0.89 18.5这种用空格或制表符分隔的表格,解析起来看似简单,但有几个坑。第一,列数不固定,有些文件中间会多出空列。第二,数值格式不统一,有的用科学计数法,有的带单位。第三,表头可能有多行,比如第一行是“品位”,第二行才是具体元素。
我的解析策略是:先按行读取,用正则识别表头行和数据行。表头行的特征是包含元素符号和单位,数据行的特征是包含样品编号和数值。然后用pandas.read_csv的sep=r'\s+'参数解析,但要注意处理列数不一致的情况。
import pandas as pd import re def parse_assay_txt(text): lines = text.strip().split('\n') # 找到表头行 header_idx = None for i, line in enumerate(lines): if re.search(r'(Cu|Pb|Zn|Ag|Au|Mo)', line) and re.search(r'\(%\)|\(g/t\)', line): header_idx = i break if header_idx is None: return None # 解析表格 from io import StringIO table_text = '\n'.join(lines[header_idx:]) df = pd.read_csv(StringIO(table_text), sep=r'\s+', engine='python') # 清理列名 df.columns = [c.strip() for c in df.columns] return df解析出来的DataFrame,每一行作为一个独立的块存入知识库,同时把表头信息附加到每个块的元数据里。这样检索时,用户问“ZK1201-01的铜品位”,能直接匹配到对应的数据行。
3.3 自由文本的段落切分
除了表格,TXT里还有大量自由文本,比如地质描述、钻孔编录。这类文本的切分不能简单按固定长度,要按语义边界切。我的做法是:先按空行分段,如果单段超过500字,再按句号、分号、换行符切分。切分后的块控制在200-500字之间,这个范围是实验出来的——太短丢失上下文,太长嵌入向量会稀释语义。
实操心得:TXT清洗最容易被忽视的是“空行”的处理。有些文件用连续多个空行分隔章节,有些用特殊字符如“---”或“===”。我一般会先统计空行模式,再决定切分策略。另外,探矿TXT里经常出现“第X页 共Y页”这样的页眉页脚,用正则批量去除。
4. Word文档清洗:保留结构与公式
4.1 python-docx的深度使用
Word文档在探矿资料里通常是地质报告、设计书、评审意见。python-docx是最常用的库,但很多人只用了它的基础功能,读读段落文字。实际上,python-docx能读表格、能读样式、能读页眉页脚,关键是要知道怎么用。
读段落时,paragraph.style.name能拿到样式名,比如“Heading 1”、“Heading 2”、“Normal”。这个信息很有用,可以用来还原章节层级。我一般会遍历所有段落,根据样式名构建一个章节树,然后把每个段落挂到对应的章节节点下。
from docx import Document def parse_docx(file_path): doc = Document(file_path) sections = [] current_h1 = None current_h2 = None for para in doc.paragraphs: style = para.style.name text = para.text.strip() if not text: continue if style.startswith('Heading 1'): current_h1 = text current_h2 = None elif style.startswith('Heading 2'): current_h2 = text else: sections.append({ 'h1': current_h1, 'h2': current_h2, 'text': text, 'type': 'paragraph' }) # 处理表格 for table in doc.tables: table_data = [] for row in table.rows: row_data = [cell.text.strip() for cell in row.cells] table_data.append(row_data) sections.append({ 'h1': current_h1, 'h2': current_h2, 'table': table_data, 'type': 'table' }) return sections这段代码的关键在于:把段落和表格都挂到章节路径下。检索时,如果用户问“第二章第三节的储量估算表”,就能通过章节路径精确定位。
4.2 公式处理的现实方案
探矿报告里公式不少,比如资源量计算公式、品位厚度加权公式。Word里的公式有两种:一种是OMML格式(Office Math Markup Language),一种是图片。python-docx对OMML的支持有限,直接读paragraph.text会丢失公式内容。
我的处理方案是:对于OMML公式,用lxml直接解析document.xml,找到m:oMath标签,提取里面的文本内容。虽然会丢失格式,但至少能拿到公式的语义信息。对于图片公式,只能走OCR,但效果一般。所以我在清洗阶段会把公式标记为“公式块”,在元数据里注明,检索时如果匹配到公式块,会提示用户查看原文档。
from lxml import etree def extract_omml_formulas(docx_path): import zipfile with zipfile.ZipFile(docx_path) as z: with z.open('word/document.xml') as f: tree = etree.parse(f) ns = {'m': 'http://schemas.openxmlformats.org/officeDocument/2006/math'} formulas = [] for math in tree.iter('{http://schemas.openxmlformats.org/officeDocument/2006/math}oMath'): text = ''.join(math.itertext()) formulas.append(text) return formulas注意:Word宏安全问题在探矿资料里也要留意。有些老报告带宏,用python-docx打开时不会执行宏,但如果你用win32com调用Word应用,就可能触发宏。我一般建议在隔离环境里处理,或者直接用python-docx这种不依赖Word应用的库。
4.3 表格结构的还原技巧
Word表格的难点在于合并单元格。python-docx读合并单元格时,同一个单元格会被重复读取。我的处理方法是:用cell._tc获取底层XML元素,判断gridSpan和vMerge属性,还原真实的表格结构。
def get_table_structure(table): rows = [] for row in table.rows: row_data = [] for cell in row.cells: tc = cell._tc grid_span = tc.xpath('./w:tcPr/w:gridSpan') span = int(grid_span[0].get('{http://schemas.openxmlformats.org/wordprocessingml/2006/main}val')) if grid_span else 1 row_data.append({'text': cell.text.strip(), 'span': span}) rows.append(row_data) return rows还原后的表格,在存入知识库时,我会把表头单独提取出来,作为每个数据块的元数据。这样检索“ZK1201的Cu品位”时,即使数据块里只有数值,也能通过表头元数据匹配到。
5. PDF解析:文字层与扫描件的双轨策略
5.1 PyMuPDF的文字层提取
PDF在探矿资料里占比最大,情况也最复杂。原生电子版PDF有文字层,用PyMuPDF的get_text()就能提取。但直接提取出来的文本,阅读顺序可能是乱的,尤其是多栏排版的报告。
我的做法是用get_text('dict')获取带位置信息的文本块,然后按坐标排序。PyMuPDF返回的dict里,每个block有bbox坐标,按(y0, x0)排序就能还原阅读顺序。
import fitz def extract_pdf_text(pdf_path): doc = fitz.open(pdf_path) all_blocks = [] for page_num, page in enumerate(doc): blocks = page.get_text('dict')['blocks'] text_blocks = [] for block in blocks: if block['type'] != 0: # 跳过图片块 continue bbox = block['bbox'] text = '' for line in block['lines']: for span in line['spans']: text += span['text'] text += '\n' text_blocks.append({ 'bbox': bbox, 'text': text.strip(), 'page': page_num + 1 }) # 按坐标排序 text_blocks.sort(key=lambda b: (round(b['bbox'][1], 1), b['bbox'][0])) all_blocks.extend(text_blocks) return all_blocks排序后的文本块,基本能还原原始阅读顺序。但表格区域需要特殊处理,因为表格里的文字块坐标是分散的,按坐标排序会打乱表格结构。
5.2 表格区域的识别与还原
PDF表格识别是个老大难问题。我的方案是:先用PyMuPDF的find_tables()方法检测表格区域,如果检测到表格,就用table.extract()提取表格数据。PyMuPDF 1.23版本之后,表格检测能力提升了不少,对探矿报告里的简单表格基本够用。
def extract_pdf_tables(pdf_path): doc = fitz.open(pdf_path) tables = [] for page_num, page in enumerate(doc): tabs = page.find_tables() for tab in tabs: data = tab.extract() tables.append({ 'page': page_num + 1, 'data': data, 'bbox': tab.bbox }) return tables如果find_tables()检测不到,但肉眼能看到表格线,可以用OpenCV做形态学操作检测表格线,然后按交点切分单元格。这个方法我试过,对扫描件效果一般,但对原生PDF的表格线检测很准。
5.3 扫描件的OCR兜底方案
扫描件PDF没有文字层,get_text()返回空字符串。这时候必须走OCR。我对比过Tesseract和PaddleOCR,在中文地质报告上,PaddleOCR的识别准确率明显更高,尤其是对表格线和专业术语的处理。
from paddleocr import PaddleOCR def ocr_pdf_page(image_path): ocr = PaddleOCR(use_angle_cls=True, lang='ch') result = ocr.ocr(image_path, cls=True) text_lines = [] for line in result[0]: text = line[1][0] confidence = line[1][1] if confidence > 0.8: # 过滤低置信度结果 text_lines.append(text) return '\n'.join(text_lines)OCR之前要先把PDF页面转成图片,用PyMuPDF的get_pixmap(),设置dpi=300,这个分辨率在识别准确率和处理速度之间比较平衡。dpi太低识别不准,太高处理太慢。
实操心得:扫描件OCR最耗时间,一个200页的报告可能要跑半小时。我的优化方法是:先用低dpi快速检测页面是否有文字,如果整页都是图片且没有文字层,再走高dpi OCR。另外,PaddleOCR的模型加载一次要几秒,批量处理时复用OCR实例,不要每页都重新加载。
6. 网页资料清洗:从噪声中提取正文
6.1 正文提取的工具对比
探矿业务需要的网页资料,主要是公开的地质报告、论文摘要、行业新闻。这些网页的正文提取,我试过三种方案:BeautifulSoup手写规则、Readability算法、trafilatura。
BeautifulSoup手写规则最灵活,但每个网站都要写一套规则,维护成本高。Readability是Firefox阅读模式的算法,对新闻类页面效果好,但对技术文档页面一般。trafilatura是我目前的主力,它结合了多种启发式规则,对中文网页的正文提取准确率最高。
import trafilatura def extract_web_content(url): downloaded = trafilatura.fetch_url(url) if downloaded is None: return None result = trafilatura.extract( downloaded, include_comments=False, include_tables=True, include_links=False, output_format='json' ) return resultinclude_tables=True很重要,探矿网页里的数据表格是核心信息。include_comments=False过滤掉评论区噪声。output_format='json'返回结构化数据,包含正文、标题、作者、日期等字段。
6.2 动态页面的处理策略
有些地质数据平台是动态加载的,直接抓HTML拿不到内容。这时候有两个选择:用Selenium模拟浏览器,或者找API接口。Selenium重但通用,API轻但需要分析网络请求。
我的优先级是:先看有没有API,用浏览器开发者工具抓包,找到数据接口直接请求。如果没有API,再用Selenium。Selenium的坑在于等待时间不好控制,我一般用WebDriverWait配合expected_conditions,等特定元素出现再提取。
from selenium import webdriver from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC def extract_dynamic_page(url): options = webdriver.ChromeOptions() options.add_argument('--headless') driver = webdriver.Chrome(options=options) try: driver.get(url) WebDriverWait(driver, 10).until( EC.presence_of_element_located((By.CLASS_NAME, 'content')) ) html = driver.page_source return trafilatura.extract(html, include_tables=True) finally: driver.quit()注意:网页抓取要遵守robots.txt,控制请求频率。我一般设置请求间隔至少1秒,批量抓取时用代理池轮换IP。另外,抓下来的网页要保存原始HTML,方便后续重新解析。
6.3 网页元数据的利用
网页资料的一个优势是元数据丰富。发布时间、作者、来源网站,这些信息在检索时可以用来做时间过滤和权威性排序。trafilatura提取的JSON里包含这些字段,我会把它们注入到每个块的元数据里。
比如用户问“最近三年铜矿勘查的新进展”,检索时就可以过滤掉发布时间超过三年的块。或者用户问“官方发布的资源量标准”,就可以优先返回来源权威的块。
7. 语义分块与向量化策略
7.1 分块粒度的实验数据
分块粒度是RAG清洗里最关键的参数之一。我做过一组对比实验,用同一批探矿文档,分别按128字、256字、512字、1024字分块,然后测试检索准确率。
| 分块大小 | 检索准确率 | 上下文完整性 | 嵌入质量 |
|---|---|---|---|
| 128字 | 72% | 差 | 高 |
| 256字 | 85% | 中 | 高 |
| 512字 | 88% | 好 | 中 |
| 1024字 | 79% | 好 | 低 |
512字是准确率最高的点。但探矿文档有个特点:表格多。表格不能按字数切,要按行切,每个数据行作为一个块,表头作为元数据。所以我的策略是:正文按512字切,表格按行切,图注单独成块。
7.2 重叠窗口的设置
分块之间要有重叠,避免语义被切断。重叠大小一般是块大小的10%-20%。512字的块,重叠64-128字。重叠部分在嵌入时会被重复计算,但能保证跨块的语义连续性。
def split_text(text, chunk_size=512, overlap=64): chunks = [] start = 0 while start < len(text): end = start + chunk_size chunk = text[start:end] chunks.append(chunk) start = end - overlap return chunks这个简单的滑动窗口算法,在实际项目中效果稳定。但要注意,切分点最好落在句号或换行符上,不要切断句子。我一般会往前找最近的句号,把切分点调整到那里。
7.3 嵌入模型的选择
嵌入模型我试过OpenAI的text-embedding-ada-002、BGE-large-zh、M3E-base。在中文探矿文档上,BGE-large-zh的检索准确率最高,比ada-002高约8个百分点。M3E-base速度快但准确率稍低。
BGE-large-zh的维度是1024,嵌入一个512字的块大约需要50ms(GPU)或200ms(CPU)。一个中型探矿项目大概有5万个块,全部嵌入需要约3小时(CPU)或45分钟(GPU)。这个时间成本可以接受。
实操心得:嵌入前一定要做文本清洗,去除特殊字符、统一全半角、去除多余空格。这些看似小的操作,对嵌入质量影响很大。我试过不清洗直接嵌入,检索准确率下降了15%。
8. 常见问题与排查技巧实录
8.1 编码问题的快速定位
TXT乱码是最常见的问题。排查步骤:先用file -i命令看文件编码,再用hexdump -C看前几个字节。如果开头是EF BB BF,是UTF-8 BOM;如果是FF FE,是UTF-16 LE。GBK编码的中文字节范围是81-FE,UTF-8的中文字节范围是E4-E9开头。
| 现象 | 可能原因 | 解决方法 |
|---|---|---|
| 中文显示为问号 | 编码不匹配 | 用chardet检测后重新解码 |
| 中文显示为方块 | 字体缺失 | 换用支持中文的字体 |
| 部分中文乱码 | 混合编码 | 按行检测编码,分段解码 |
| 开头有奇怪字符 | BOM头 | 用utf-8-sig编码打开 |
8.2 PDF提取空白的排查
PDF提取文字为空,通常是三种情况:扫描件没有文字层、文字被加密、文字是曲线轮廓。排查方法:用PyMuPDF的page.get_text()看返回是否为空,用page.get_images()看是否有图片,用doc.is_encrypted看是否加密。
如果是扫描件,走OCR。如果是加密,用doc.authenticate('')尝试空密码。如果是曲线轮廓,只能走OCR,因为文字已经转成矢量图形了。
8.3 表格解析错位的修复
表格解析错位通常是因为合并单元格或跨页表格。修复方法:对于合并单元格,用gridSpan和vMerge还原;对于跨页表格,把相邻页的表格块合并,表头只保留第一页的。
def merge_cross_page_tables(tables): merged = [] for tab in tables: if merged and tab['page'] == merged[-1]['page'] + 1: # 检查表头是否相同 if tab['data'][0] == merged[-1]['data'][0]: merged[-1]['data'].extend(tab['data'][1:]) else: merged.append(tab) else: merged.append(tab) return merged8.4 检索精度低的调优方向
如果RAG检索精度低,按这个顺序排查:先看清洗质量,有没有乱码、噪声块;再看分块粒度,是不是太大或太小;然后看嵌入模型,是不是不适合中文;最后看检索策略,是不是只用了向量检索,没有加关键词过滤。
我一般会加一层元数据过滤:检索时先按来源文件类型、时间范围、章节路径过滤,再做向量相似度排序。这样能显著提升精度。另外,重排序模型(如BGE-reranker)也能提升10%-15%的准确率。
9. 一些踩坑后的个人体会
探矿业务的文档清洗,最深的体会是:没有一劳永逸的通用方案。每个项目的数据来源不同,格式分布不同,清洗管道都要做针对性调整。我现在的做法是,每接一个新项目,先花半天时间做数据摸底:统计文件格式分布、编码分布、表格复杂度、扫描件比例。根据摸底结果,决定清洗管道的参数和工具组合。
另一个体会是:清洗质量比清洗速度重要得多。我见过为了赶进度,用通用解析器批量处理,结果检索时各种答非所问,返工的成本远高于当初认真清洗的成本。探矿数据的特点是精度要求高,一个品位数据的错误可能导致严重的决策失误。所以在清洗阶段多花时间做质量校验,是值得的。
最后分享一个小技巧:清洗后的块,我会随机抽样100个,人工检查内容完整性和元数据准确性。这个抽样检查能发现80%以上的系统性问题。如果抽样合格率低于95%,就回去调整清洗参数,重新跑一遍。这个习惯帮我避免了很多次线上事故。