探矿资料RAG清洗实战:从乱码到结构化知识库
1. 探矿资料为什么必须做 RAG 清洗
1.1 从一堆“打不开的乱码”说起
做过探矿项目的人都有一个共同体会:资料不是没有,而是“有但没法用”。一个中型勘探项目,从立项到结题,积累下来的文档动辄上千份——地质报告是 Word,钻孔柱状图是 PDF 扫描件,化验数据是 TXT,历史资料是某个老系统导出的网页快照,甚至还有从老地质队档案室里翻拍的照片。这些资料堆在硬盘里,文件名五花八门,编码格式各不相同,打开之后经常是一屏乱码。
我接手过一个铜矿的补勘项目,甲方给过来的资料包解压之后有 3.2GB,里面 40% 的 TXT 文件用记事本打开全是问号和方块。一开始我以为是文件损坏,后来用十六进制工具一看,是 GBK 编码被当成了 UTF-8 读取。这种问题在探矿行业太常见了,因为很多资料是十几年前用老版本软件生成的,编码标准不统一,加上中间经过多次拷贝、压缩、转存,编码信息早就丢了。
RAG(检索增强生成)在探矿业务里的价值,说白了就是让这些“死资料”变成“活知识”。你问一个问题,比如“XX 矿区 3 号勘探线 ZK302 钻孔在 450 米深处的铜品位是多少”,系统能直接从 PDF 报告里把那段表格抠出来,而不是让你自己去翻 200 页的文档。但前提是,这些文档必须先经过清洗,变成机器能理解的结构化文本。清洗没做好,后面检索出来的全是乱码片段,RAG 就成了“垃圾进、垃圾出”的典型。
1.2 探矿资料的四种典型形态与清洗难点
探矿业务的资料形态,我大致归为四类,每一类的清洗逻辑完全不同。
TXT 文本:看起来最简单,实际上坑最多。探矿行业的 TXT 来源很杂——有化验室仪器导出的原始数据,有老式测井仪生成的日志,有从其他系统导出的 CSV 改后缀来的。编码问题首当其冲,GBK、GB2312、UTF-8、UTF-8 with BOM、甚至 ISO-8859-1 都可能出现。更麻烦的是,有些 TXT 里混着二进制控制字符,比如测井数据里的换页符、退格符,直接读进来会污染整个文本流。
Word 文档:地质报告的主力格式。难点不在读取,而在结构还原。一份典型的勘探报告有封面、目录、章节标题、正文、表格、图注、附录,Word 里的样式层级如果没被正确解析,清洗出来的就是一大坨没有层次的文字。表格更是重灾区,合并单元格、嵌套表格、跨页表格,用 python-docx 直接读经常错位。还有公式,探矿报告里的品位计算公式、储量估算公式,很多是用 MathType 或 AxMath 插入的 OLE 对象,普通解析器根本读不出来。
PDF 文档:分两种,一种是原生 PDF(文字可选),一种是扫描件(图片)。原生 PDF 的清洗相对可控,用 pdfplumber 或 PyMuPDF 能提取文字和表格,但遇到多栏排版、页眉页脚、水印就会乱。扫描件 PDF 必须走 OCR,而探矿报告里的 OCR 难度极高——地质图件上的标注字体小、背景复杂,化验数据表格的线条密集,通用 OCR 的准确率可能只有 70% 左右,必须做后处理校正。
网页资料:探矿行业的历史资料很多是从老地质网站、内部 OA 系统、甚至论坛上采集的。网页清洗的核心是去噪——导航栏、广告、版权声明、相关链接,这些都要去掉,只保留正文。但探矿网页经常有表格和图片混排,正文边界不好判断。另外,网页的编码声明可能和实际编码不一致,需要做编码探测。
1.3 清洗质量直接决定 RAG 检索精度
我做过一个对比实验:同一批 200 份探矿报告,一份不做清洗直接切块入库,一份做完整清洗后入库。问同样 50 个问题,不清洗的那组,检索命中率只有 38%,而且经常返回乱码片段;清洗后的那组,命中率到了 82%,返回的内容可以直接引用。差距就是这么明显。
清洗的核心目标有三个:编码统一(全部转成 UTF-8)、结构还原(标题、段落、表格、图注各归其位)、噪声去除(页眉页脚、水印、乱码字符)。这三个目标做到了,RAG 的检索精度才有保障。下面我按资料类型,把每一类的清洗实操拆开讲。
2. TXT 文件清洗:编码探测与结构化预处理
2.1 编码探测的三种可靠方法
TXT 清洗的第一步永远是编码探测。我试过很多方案,最后稳定下来的是“chardet + charset-normalizer + 人工规则”三层兜底。
chardet 是老牌库,速度快,但对短文本和混合编码的准确率一般。charset-normalizer 是 requests 作者做的,对中文编码的识别更准,尤其是 GBK 和 GB18030 的区分。我的做法是先用 charset-normalizer 探测,如果置信度低于 0.8,再用 chardet 交叉验证,两者不一致时,用规则判断——比如文件里如果出现“锟斤拷”这种典型乱码,基本可以确定是 GBK 被误读。
from charset_normalizer import from_path import chardet def detect_encoding(file_path): # 第一层:charset-normalizer result = from_path(file_path).best() if result and result.chaos < 0.2: return result.encoding # 第二层:chardet 交叉验证 with open(file_path, 'rb') as f: raw = f.read(100000) chardet_result = chardet.detect(raw) if chardet_result['confidence'] > 0.7: return chardet_result['encoding'] # 第三层:规则兜底 for enc in ['utf-8', 'gb18030', 'gbk', 'big5']: try: raw.decode(enc) return enc except UnicodeDecodeError: continue return 'utf-8'注意:gb18030 是 gbk 的超集,优先用 gb18030 解码,能覆盖更多生僻字。探矿报告里经常出现地名生僻字,用 gbk 会报错,gb18030 基本不会。
2.2 控制字符与乱码的清理策略
编码转对之后,下一步是清理控制字符。探矿 TXT 里常见的控制字符有:\x00(空字符)、\x0c(换页符)、\x08(退格符)、\x1a(DOS 文件结束符)。这些字符如果不清掉,切块的时候会把一个完整的段落切断,检索出来就是半截话。
我的清理规则是:保留\n、\t、\r,其余 C0 控制字符全部替换为空格。另外,连续的空格和换行要压缩——探矿数据里经常有大量连续空格用来对齐表格,清洗时要保留结构但去掉冗余。
import re def clean_control_chars(text): # 保留换行、制表符,其余控制字符替换为空格 text = re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]', ' ', text) # 压缩连续空格(保留单个空格用于分词) text = re.sub(r'[ \t]{3,}', ' ', text) # 压缩连续空行(最多保留两个换行) text = re.sub(r'\n{3,}', '\n\n', text) return text.strip()乱码的清理要分情况。如果是“锟斤拷”这种编码转换错误产生的乱码,基本不可逆,只能整段丢弃或标记。如果是“?”这种替换字符,说明原始编码信息已丢失,需要结合上下文判断——比如“?矿”可能是“铜矿”,这种要靠领域词典做模糊匹配修复。我在实际项目里维护了一个探矿领域词典,包含常见矿物名、地层名、钻孔编号格式,用来辅助修复。
2.3 化验数据 TXT 的结构化提取
化验数据 TXT 是探矿业务里最有价值的一类,通常是仪器导出的固定格式。比如某化验室的输出格式是:
样品编号 Cu(%) Pb(%) Zn(%) Ag(g/t) ZK302-01 0.85 0.12 0.45 12.3 ZK302-02 1.23 0.08 0.67 15.8这种数据的清洗,关键是识别列分隔符和表头。我一般用正则先匹配表头行,确定列名和列数,然后逐行解析。注意有些仪器导出的 TXT 用固定宽度对齐,列之间是多个空格,不是单个制表符,这时候要用split()不带参数(自动按任意空白分割),而不是split('\t')。
def parse_assay_txt(text): lines = text.strip().split('\n') # 找到表头行(包含 Cu、Pb 等元素符号) header_idx = None for i, line in enumerate(lines): if re.search(r'Cu|Pb|Zn|Ag|Au', line): header_idx = i break if header_idx is None: return None headers = lines[header_idx].split() records = [] for line in lines[header_idx+1:]: if not line.strip(): continue values = line.split() if len(values) == len(headers): records.append(dict(zip(headers, values))) return records实操心得:化验数据 TXT 里经常有“<0.01”这种检出限表示,解析时要保留原样,不要转成数字,否则会丢失“低于检出限”的语义。RAG 检索时,用户问“有没有低于检出限的样品”,这个信息很关键。
3. Word 文档清洗:样式解析与表格还原
3.1 用 python-docx 还原文档层级
Word 文档清洗的核心是还原层级结构。一份勘探报告,标题层级通常是:一级标题(章)、二级标题(节)、三级标题(小节)、正文、表格、图注。python-docx 可以通过paragraph.style.name拿到样式名,比如“Heading 1”“Heading 2”“Normal”“Caption”。
但实际项目里,很多报告的样式是乱的——有人用加粗+大字号模拟标题,而不是用 Heading 样式。这时候需要结合字体大小、加粗、居中等属性做启发式判断。我的规则是:如果段落居中、加粗、字号大于正文 2pt 以上,判定为标题;如果段落以“图”“表”开头且居中,判定为图注或表注。
from docx import Document from docx.shared import Pt def extract_word_structure(doc_path): doc = Document(doc_path) structure = [] body_font_size = 12 # 默认正文大小,后续动态调整 for para in doc.paragraphs: text = para.text.strip() if not text: continue style_name = para.style.name if para.style else '' is_heading = 'Heading' in style_name # 启发式判断 if not is_heading: runs = para.runs if runs: font_size = runs[0].font.size is_bold = runs[0].font.bold is_center = para.alignment == 1 # CENTER if is_bold and is_center and font_size and font_size > Pt(body_font_size + 2): is_heading = True if is_heading: level = 1 if 'Heading' in style_name: try: level = int(style_name.split()[-1]) except: level = 1 structure.append({'type': 'heading', 'level': level, 'text': text}) elif text.startswith('图') or text.startswith('表'): structure.append({'type': 'caption', 'text': text}) else: structure.append({'type': 'paragraph', 'text': text}) return structure3.2 表格提取的合并单元格处理
Word 表格是清洗里最头疼的部分。python-docx 的table.rows和table.columns对合并单元格的支持不好,合并单元格在遍历时会重复出现。我的做法是:先构建一个二维网格,记录每个单元格的实际位置和跨度,然后按网格输出。
def extract_table(table): # 构建网格 grid = [] for row in table.rows: grid_row = [] for cell in row.cells: grid_row.append(cell.text.strip()) grid.append(grid_row) # 去重合并单元格(相邻相同内容且属于同一合并区域的) # 简化处理:如果一行中连续多个单元格内容相同,保留第一个 cleaned = [] for row in grid: new_row = [] prev = None for cell in row: if cell != prev: new_row.append(cell) prev = cell cleaned.append(new_row) return cleaned注意:这个方法对简单的横向合并有效,纵向合并需要额外处理。探矿报告里的钻孔数据表经常有纵向合并(一个钻孔多个样品),清洗时要保留钻孔编号的归属关系,否则检索“ZK302 的所有样品”会漏掉。
更稳妥的方案是用docx2python库,它对合并单元格的处理更完善,能直接输出带行列索引的表格结构。我现在的项目里,Word 表格提取基本都用 docx2python 打底,python-docx 做补充。
3.3 公式与特殊对象的处理
探矿报告里的公式,主要是品位计算、储量估算、坐标转换这几类。MathType 和 AxMath 插入的公式是 OLE 对象,python-docx 读出来是空字符串。我的处理方案是:先用docx2python提取,如果发现公式位置为空,再用olefile读取 OLE 对象的二进制流,尝试提取 MathType 的 MTEF 数据,转成 LaTeX。
这个方案实现起来比较复杂,实际项目中我更多是用“占位符+人工补录”的方式——清洗时把公式位置标记为[FORMULA],然后让地质工程师在后期审核时补录。如果公式数量大,可以考虑用 Mathpix 的 API 做批量转换,但成本较高。
另一个坑是 Word 里的嵌入图片。探矿报告里的图件(地质图、剖面图、柱状图)很多是嵌入的图片,清洗时要把图片单独提取出来,存到指定目录,并在文本中保留图片引用位置。这样 RAG 检索到相关段落时,可以同时返回图片路径,方便用户查看。
4. PDF 文档清洗:原生解析与 OCR 校正
4.1 原生 PDF 的文字与表格提取
原生 PDF 的清洗,我首选 PyMuPDF(fitz),它的文字提取精度高,速度快,对多栏排版的支持也比 pdfplumber 好。表格提取则用 pdfplumber,它的extract_tables()对有线表格的识别很准。
import fitz import pdfplumber def extract_pdf_text(pdf_path): doc = fitz.open(pdf_path) full_text = [] for page in doc: # 按块提取,保留段落结构 blocks = page.get_text("blocks") blocks.sort(key=lambda b: (b[1], b[0])) # 按纵坐标、横坐标排序 for block in blocks: text = block[4].strip() if text: full_text.append(text) return '\n\n'.join(full_text) def extract_pdf_tables(pdf_path): tables = [] with pdfplumber.open(pdf_path) as pdf: for page in pdf.pages: page_tables = page.extract_tables() for table in page_tables: tables.append(table) return tables多栏排版的 PDF 是难点。PyMuPDF 的get_text("blocks")会把不同栏的文字混在一起。我的处理方式是:先用page.get_text("dict")拿到每个文字块的坐标,然后根据 x 坐标聚类,判断栏数,再按栏分别提取。一般探矿报告是双栏排版,x 坐标会明显分成两组。
4.2 扫描件 PDF 的 OCR 方案与后处理
扫描件 PDF 必须走 OCR。我试过 Tesseract、PaddleOCR、EasyOCR,最后在探矿场景下稳定用的是 PaddleOCR。原因很简单:PaddleOCR 对中文的识别准确率明显高于 Tesseract,而且对表格线条的干扰有一定的鲁棒性。
但即使 PaddleOCR,直接跑探矿报告的扫描件,准确率也只有 75% 左右。问题主要出在:地质图件的标注字体小、背景有纹理;化验数据表格的线条密集,OCR 会把线条识别成字符;页眉页脚的水印干扰。
我的后处理流程是:
- 图像预处理:先做二值化、去噪、倾斜校正。探矿扫描件经常有倾斜,用
cv2.minAreaRect做倾斜校正,能提升 5-10% 的准确率。 - 区域分割:用版面分析模型(如 PP-Structure)把页面分成文字区、表格区、图片区,分别处理。
- 表格校正:OCR 出来的表格数据,用正则匹配数字模式(如品位值通常是 0.xx 到 xx.xx 之间),把明显不是数字的识别结果标记出来,人工复核。
- 领域词典纠错:用探矿领域词典做模糊匹配,比如“铜矿”被识别成“同矿”,“品位”被识别成“品住”,自动纠正。
from paddleocr import PaddleOCR import cv2 import numpy as np ocr = PaddleOCR(use_angle_cls=True, lang='ch') def ocr_pdf_page(image_path): img = cv2.imread(image_path) # 倾斜校正 gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) coords = np.column_stack(np.where(gray < 128)) angle = cv2.minAreaRect(coords)[-1] if angle < -45: angle = 90 + angle if abs(angle) > 0.5: h, w = img.shape[:2] M = cv2.getRotationMatrix2D((w//2, h//2), angle, 1.0) img = cv2.warpAffine(img, M, (w, h), flags=cv2.INTER_CUBIC, borderMode=cv2.BORDER_REPLICATE) result = ocr.ocr(img, cls=True) lines = [] for line in result[0]: text = line[1][0] confidence = line[1][1] if confidence > 0.6: lines.append(text) return '\n'.join(lines)实操心得:OCR 后的文本一定要做人工抽检。我的做法是每 50 页抽 1 页,逐字核对,统计错误率。如果错误率超过 10%,就要调整预处理参数或换 OCR 引擎。探矿报告里的关键数据(品位、坐标、储量)错一个数字,后果可能很严重。
4.3 PDF 清洗中的页眉页脚与水印去除
页眉页脚和水印是 PDF 清洗的常见噪声。页眉通常是报告名称或章节名,页脚是页码和日期。这些内容如果不清掉,会在每个切块里重复出现,严重影响检索精度。
我的去除策略是:先统计每页顶部和底部 10% 区域的文字,如果某个字符串在超过 80% 的页面中出现,就判定为页眉或页脚,从正文中剔除。水印的处理类似,但水印通常是倾斜的、半透明的,PyMuPDF 提取时可能会混入正文。这时候可以用page.get_text("dict")检查文字的方向和透明度属性,把倾斜角度大于 30 度且颜色浅的文字块标记为水印。
def remove_header_footer(doc): page_texts = [] for page in doc: blocks = page.get_text("blocks") page_texts.append(blocks) # 统计顶部和底部区域的重复文本 top_texts = {} bottom_texts = {} for blocks in page_texts: page_height = max(b[3] for b in blocks) if blocks else 1 for b in blocks: text = b[4].strip() if not text: continue if b[1] < page_height * 0.1: top_texts[text] = top_texts.get(text, 0) + 1 elif b[3] > page_height * 0.9: bottom_texts[text] = bottom_texts.get(text, 0) + 1 threshold = len(page_texts) * 0.8 headers = {t for t, c in top_texts.items() if c > threshold} footers = {t for t, c in bottom_texts.items() if c > threshold} return headers, footers5. 网页资料清洗:正文提取与编码统一
5.1 网页正文提取的三种方案对比
探矿行业的网页资料,来源包括老地质网站、内部 OA、行业论坛、新闻页面。网页清洗的核心是正文提取,把导航、广告、侧边栏、评论区去掉。
我试过三种方案:
| 方案 | 原理 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 正则规则 | 按 HTML 标签和 class 名匹配 | 快,可控 | 维护成本高,网站改版就失效 | 固定几个来源 |
| readability-lxml | 基于文本密度和链接密度 | 通用性好 | 对中文网页偶尔误判 | 通用新闻、博客 |
| trafilatura | 多特征融合,支持多语言 | 准确率高,支持元数据 | 速度稍慢 | 批量采集 |
我现在的项目里,主力是 trafilatura,它对中文网页的正文识别准确率明显高于 readability。如果 trafilatura 提取失败,再用 readability 兜底。
import trafilatura from readability import Document import requests def extract_web_content(url): try: downloaded = trafilatura.fetch_url(url) if downloaded: text = trafilatura.extract(downloaded, include_tables=True, include_comments=False) if text and len(text) > 200: return text except Exception as e: print(f"trafilatura failed: {e}") # 兜底:readability try: resp = requests.get(url, timeout=10) resp.encoding = resp.apparent_encoding doc = Document(resp.text) return doc.summary() except Exception as e: print(f"readability failed: {e}") return None5.2 网页表格与图片的处理
探矿网页里的表格,很多是化验数据或钻孔坐标。trafilatura 的include_tables=True能把表格转成文本,但格式可能不理想。我的做法是:先用 BeautifulSoup 单独提取所有<table>标签,转成 Markdown 表格,然后替换回正文。
from bs4 import BeautifulSoup def html_table_to_markdown(html): soup = BeautifulSoup(html, 'html.parser') tables = soup.find_all('table') markdown_tables = [] for table in tables: rows = table.find_all('tr') md_rows = [] for row in rows: cells = row.find_all(['td', 'th']) md_cells = [cell.get_text(strip=True) for cell in cells] md_rows.append('| ' + ' | '.join(md_cells) + ' |') if md_rows: # 添加分隔行 header_sep = '| ' + ' | '.join(['---'] * len(md_rows[0].split('|')[1:-1])) + ' |' md_rows.insert(1, header_sep) markdown_tables.append('\n'.join(md_rows)) return markdown_tables网页图片的处理,主要是保留图片 URL 和 alt 文本。探矿网页里的地质图、剖面图,alt 文本往往包含关键信息,比如“XX 矿区 3 号勘探线剖面图”。清洗时要把图片位置标记为[IMAGE: url, alt: xxx],这样 RAG 检索到相关段落时,可以同时返回图片链接。
5.3 编码声明与实际编码不一致的修复
网页编码问题比 TXT 更复杂,因为 HTML 里有<meta charset="xxx">声明,但实际编码可能不一致。比如声明是 UTF-8,实际是 GBK,浏览器可能能自动纠正,但程序读取就会乱码。
我的处理流程是:先用requests获取响应,检查resp.encoding,如果resp.encoding是 ISO-8859-1(requests 的默认值),说明服务器没返回正确的编码,这时候用resp.apparent_encoding重新设置。如果apparent_encoding也不对,就用charset-normalizer对resp.content做探测。
import requests from charset_normalizer import from_bytes def fetch_with_correct_encoding(url): resp = requests.get(url, timeout=10) if resp.encoding == 'ISO-8859-1': # 尝试从 HTML meta 标签提取编码 import re match = re.search(rb'charset=["\']?([\w-]+)', resp.content[:2000]) if match: resp.encoding = match.group(1).decode('ascii') else: # 用 charset-normalizer 探测 result = from_bytes(resp.content).best() if result: resp.encoding = result.encoding return resp.text注意:有些老地质网站的编码是 GB2312,但实际内容包含 GBK 的生僻字,用 GB2312 解码会报错。这时候统一用 GB18030 解码,它是 GB2312 和 GBK 的超集,兼容性最好。
6. 清洗后的切块与入库策略
6.1 按语义切块而不是按字数切块
清洗完的文本,下一步是切块入库。很多人习惯按固定字数切块,比如每 500 字一块。这在探矿业务里是灾难——一个钻孔的描述可能跨了 800 字,按字数切会把“ZK302 在 450 米处见矿”和“品位 0.85%”切到两个块里,检索时只能命中一个,信息不完整。
我的切块策略是按语义边界切块:优先按标题切,标题下的内容如果超过 800 字,再按段落切;表格单独成块,不拆分;图注和正文关联的,合并成一块。切块时保留标题作为元数据,这样检索时可以用标题做过滤。
def semantic_chunk(structure, max_chunk_size=800): chunks = [] current_chunk = [] current_size = 0 current_heading = None for item in structure: if item['type'] == 'heading': # 遇到新标题,先保存当前块 if current_chunk: chunks.append({ 'heading': current_heading, 'text': '\n'.join(current_chunk) }) current_heading = item['text'] current_chunk = [item['text']] current_size = len(item['text']) elif item['type'] == 'table': # 表格单独成块 if current_chunk: chunks.append({ 'heading': current_heading, 'text': '\n'.join(current_chunk) }) chunks.append({ 'heading': current_heading, 'text': item['text'], 'type': 'table' }) current_chunk = [] current_size = 0 else: text = item['text'] if current_size + len(text) > max_chunk_size and current_chunk: chunks.append({ 'heading': current_heading, 'text': '\n'.join(current_chunk) }) current_chunk = [text] current_size = len(text) else: current_chunk.append(text) current_size += len(text) if current_chunk: chunks.append({ 'heading': current_heading, 'text': '\n'.join(current_chunk) }) return chunks6.2 元数据设计:让检索更精准
切块之后,每个块要附带元数据。探矿业务的元数据至少包括:来源文件、文件类型、矿区名称、勘探线号、钻孔编号、章节标题、页码。这些元数据在检索时可以做过滤,比如用户问“XX 矿区 ZK302 的化验数据”,系统可以先用元数据过滤出 ZK302 相关的块,再做向量检索,精度会高很多。
元数据的提取,一部分可以从文件名和路径推断,一部分要从正文里用正则抽取。比如钻孔编号通常是“ZK+数字”的格式,矿区名称通常在报告标题或页眉里。
import re def extract_metadata(text, file_path): metadata = { 'source': file_path, 'file_type': file_path.split('.')[-1].lower() } # 钻孔编号 drill_holes = re.findall(r'ZK\d+', text) if drill_holes: metadata['drill_holes'] = list(set(drill_holes)) # 勘探线号 lines = re.findall(r'\d+号勘探线', text) if lines: metadata['exploration_lines'] = list(set(lines)) # 品位数据 grades = re.findall(r'(\d+\.?\d*)\s*%', text) if grades: metadata['has_grade_data'] = True return metadata6.3 向量化与检索的配合
切块和元数据准备好之后,就是向量化。探矿业务的文本,我建议用支持中文的 embedding 模型,比如 BGE 或 M3E。向量化时,把标题和正文拼在一起做 embedding,这样标题的语义也能被检索到。
检索时,先用元数据做粗筛,再用向量做精排。比如用户问“XX 矿区铜品位大于 1% 的样品”,先用元数据过滤出 XX 矿区的块,再用向量检索“铜品位大于 1%”的语义,最后用正则从检索结果里提取具体的品位数值做精确过滤。这种“元数据+向量+规则”的三层检索,比单纯向量检索的精度高很多。
实操心得:探矿业务的检索,数值精确性很重要。用户问“品位 0.85%”,向量检索可能返回 0.83%、0.87% 的块,因为语义相近。这时候要在检索后加一层数值过滤,把不匹配的块剔除。我一般用正则从块里提取数值,和查询条件做精确比对。
7. 常见问题与排查技巧实录
7.1 编码问题速查表
| 现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 中文显示为“锟斤拷” | UTF-8 被 GBK 解码 | 用十六进制查看字节 | 用 UTF-8 重新解码 |
| 中文显示为“???” | 编码信息丢失 | 检查文件头是否有 BOM | 用 charset-normalizer 探测 |
| 部分生僻字乱码 | GBK 不支持该字 | 定位乱码位置 | 改用 GB18030 解码 |
| 网页中文乱码 | meta 声明与实际不符 | 对比 resp.encoding 和 apparent_encoding | 用 charset-normalizer 探测 |
| PDF 提取文字乱码 | PDF 内嵌字体编码问题 | 用 PyMuPDF 查看字体信息 | 改用 OCR 或换解析库 |
7.2 表格提取错位的排查思路
表格提取错位是最高频的问题。我的排查顺序是:
- 检查表格是否有合并单元格:用 docx2python 或 pdfplumber 的可视化功能,看表格结构是否被正确识别。
- 检查是否有跨页表格:跨页表格在 PDF 里会被分成两个表,需要手动合并。我的做法是检测连续两页的表头是否相同,如果相同就合并。
- 检查是否有嵌套表格:Word 里的嵌套表格,python-docx 读出来会错位。这时候要用 docx2python,它能正确处理嵌套。
- 检查列分隔符:有些表格用空格对齐,不是制表符。解析时用
split()不带参数,而不是split('\t')。
7.3 OCR 准确率提升的五个实操技巧
- 图像分辨率至少 300 DPI:低于 300 DPI 的扫描件,OCR 准确率会明显下降。如果原始扫描件分辨率低,先用超分辨率模型放大。
- 二值化阈值动态调整:探矿扫描件经常有深浅不一的背景,固定阈值效果差。用 Otsu 自适应阈值。
- 表格线去除:OCR 前用形态学操作把表格线去掉,避免线条被识别成字符。
- 领域词典纠错:维护一个探矿领域词典,OCR 后用编辑距离做模糊匹配,自动纠正。
- 人工抽检:每 50 页抽 1 页逐字核对,统计错误率,超过 10% 就要调整方案。
7.4 清洗流程的自动化与监控
清洗流程一旦跑起来,就要做监控。我一般会记录每个文件的清洗状态:成功、失败、部分成功。失败的文件要记录失败原因,比如“编码探测失败”“OCR 置信度过低”“表格解析异常”。这些日志对于后续优化很重要。
另外,清洗后的文本要做质量抽检。我写了一个简单的质量评分函数,从编码正确率、乱码比例、表格完整度、段落连贯性四个维度打分,低于 60 分的文件自动标记,人工复核。
def quality_score(text): score = 100 # 乱码比例 garbled = len(re.findall(r'[锟斤拷\ufffd]', text)) if len(text) > 0: garbled_ratio = garbled / len(text) score -= min(30, garbled_ratio * 1000) # 控制字符比例 control = len(re.findall(r'[\x00-\x08\x0b\x0c\x0e-\x1f]', text)) if len(text) > 0: control_ratio = control / len(text) score -= min(20, control_ratio * 500) # 段落连贯性(平均段落长度) paragraphs = [p for p in text.split('\n') if p.strip()] if paragraphs: avg_len = sum(len(p) for p in paragraphs) / len(paragraphs) if avg_len < 20: score -= 20 elif avg_len > 500: score -= 10 return max(0, score)这套清洗流程,我在三个探矿项目里跑过,累计处理了 5000 多份文档。最深的体会是:清洗不是一次性的工作,而是一个持续迭代的过程。每接入一批新资料,都可能遇到新的编码格式、新的表格样式、新的 OCR 难点。关键是要把清洗流程模块化,每个环节都可以单独替换和优化,同时做好日志和监控,出了问题能快速定位。
另外,探矿业务的 RAG 清洗,一定要有地质工程师参与。很多清洗规则,比如“什么样的表格结构是合理的”“哪些数值范围是正常的”,只有领域专家才能判断。纯技术团队做清洗,很容易把关键信息洗掉,或者把错误数据保留下来。我现在的做法是,清洗规则由技术团队实现,但规则的定义和验收由地质工程师把关,每批数据清洗完,抽检 5% 让工程师确认,确认通过才入库。