☰
探矿RAG数据清洗实战:TXT、Word、PDF、网页高精度结构化处理
2026/10/9 14:32:17 网站建设 项目流程

1. 探矿数据清洗到底难在哪:从一堆乱码说起

地质探矿行业的数据,外行看着就是一堆文件,内行才知道这里面的水有多深。我接触过的探矿项目里,资料员发过来的压缩包解压之后,往往是几十个TXT、上百个Word文档、几百兆的PDF扫描件,外加几个从内部系统导出的网页存档。这些文件来自不同年代、不同软件、不同人手里,格式混乱程度堪称灾难现场。

先说TXT。探矿业务里的TXT文件,很多是早期地质队员用记事本直接记录的钻孔编录数据,编码格式五花八门。GBK、GB2312、UTF-8混着来,有的甚至是从老式设备导出的二进制转文本,打开就是满屏问号。更麻烦的是,同一个项目里不同人用的分隔符都不一样,有人用逗号,有人用制表符,还有人用连续空格。你直接拿去做RAG检索,向量化出来的结果基本没法用。

再说Word。地质报告、勘探设计方案、储量核实报告,这些文档动辄几十页上百页,里面夹杂着大量表格、公式、图片。表格里是品位数据、厚度数据,公式是储量计算公式,图片是钻孔柱状图。普通解析库遇到这些内容,要么直接跳过,要么把表格拍扁成一行乱码。我见过最离谱的情况,一个Word文档里的表格被解析成了“孔号品位厚度”连在一起的一串字符,完全丢失了行列对应关系。

PDF的问题更突出。探矿行业大量历史资料是扫描件,尤其是上世纪八九十年代的地质报告,很多只有纸质版,后来扫描成了PDF。这些PDF本质上是图片,没有文字层,直接解析出来就是空白。还有一些PDF虽然有文字层,但排版极其复杂,多栏、脚注、页眉页脚混在一起,解析出来的文字顺序完全是乱的。

网页数据相对好一点,但探矿业务里的网页来源很杂。有内部OA系统导出的HTML,有地质云平台的数据页面,还有一些行业论坛的技术讨论帖。这些网页的DOM结构千差万别,正文和导航栏、广告、评论区混在一起,不做清洗直接抽取,噪声比有效信息还多。

这些问题的本质是什么?是数据源头的异构性和非标准化。探矿业务链条长、参与方多、历史跨度大,数据从产生那一刻起就没有统一的规范。而RAG系统的核心逻辑是“检索增强生成”,检索的质量直接决定了生成的质量。如果喂进去的是乱码和噪声,检索出来的片段就是垃圾,大模型再强也生成不出靠谱的答案。

所以,做探矿业务的RAG知识库,清洗环节不是可选项,而是生死线。我个人的经验是,清洗环节投入的时间应该占到整个RAG项目周期的百分之四十到五十。很多人一上来就急着搭向量数据库、调大模型参数,结果发现检索效果怎么调都不行,回头一看,源头数据根本没洗干净。

这篇文章要聊的,就是怎么把探矿业务里这四类典型数据源——TXT、Word、PDF、网页——从乱码状态清洗成高精度检索可用的结构化文本。我会把每个环节的实操步骤、参数选择、踩过的坑都摊开来讲,你照着做就能复现。

2. 清洗方案的整体设计思路

2.1 为什么不能一套方案打天下

很多人做RAG清洗,喜欢找一个万能工具,指望它能把所有格式都处理好。我试过不少开源方案,结论是:不存在这样的工具。TXT、Word、PDF、网页这四类数据,底层结构完全不同,清洗策略必须分开设计。

TXT的核心问题是编码和分隔符,处理重点是字符集检测和格式归一化。Word的核心问题是嵌套结构,表格、公式、图片、文本框层层嵌套,处理重点是结构化抽取和语义保留。PDF的核心问题是文字层的有无和版面还原,处理重点是OCR识别和阅读顺序重建。网页的核心问题是噪声过滤和正文定位,处理重点是DOM解析和内容密度计算。

这四类问题的技术栈重叠度很低。你用一个PDF解析库去处理TXT,纯属杀鸡用牛刀还杀不好。反过来,你用正则表达式去处理PDF版面,基本是自讨苦吃。

所以我的方案设计原则是:分而治之,统一输出。每一类数据源用专门的清洗管道处理,最终输出统一的Markdown格式文本,再进入后续的分块和向量化环节。

2.2 清洗管道的分层架构

我把整个清洗流程分成四层,从下到上依次是:

第一层:格式识别与路由层。这一层负责判断输入文件到底是什么格式。听起来简单,实际上坑很多。比如一个文件后缀是.txt,但内容可能是HTML代码;一个文件后缀是.pdf,但里面全是图片没有文字层。我的做法是结合后缀名和文件头魔数双重判断,再对PDF做一次文字层探测,决定走文本解析还是OCR通道。

第二层:内容抽取层。这一层针对不同格式调用不同的解析引擎。TXT用chardet做编码检测后直接读取;Word用python-docx或docx2python做结构化抽取;PDF用PyMuPDF做文字层抽取,无文字层的走PaddleOCR;网页用trafilatura或readability-lxml做正文抽取。

第三层:语义清洗层。抽取出来的原始文本还需要进一步清洗。包括去除页眉页脚、合并断行、修复乱码字符、统一标点符号、处理表格的Markdown化转换、公式的LaTeX化转换等。这一层是决定最终检索精度的关键。

第四层:质量校验层。清洗完的文本不能直接入库,需要做质量抽检。我通常会统计几个指标:有效字符占比、乱码字符数量、平均段落长度、表格转换成功率。低于阈值的文本打回重洗或人工介入。

这个分层架构的好处是,每一层的问题可以独立排查。检索效果不好的时候,你可以快速定位是抽取环节丢了信息,还是清洗环节引入了噪声,还是分块策略不合理。

2.3 输出格式为什么选Markdown

清洗后的文本输出成什么格式,这个决策影响很大。我对比过纯文本、JSON、HTML、Markdown四种方案,最终选择Markdown,理由有三条。

第一,Markdown对表格的支持足够好。探矿数据里大量品位表、厚度表、坐标表,用Markdown表格能保留行列结构,向量化的时候语义损失小。纯文本会把表格拍扁,JSON虽然结构完整但可读性差,HTML标签太多会干扰分词。

第二,Markdown的标题层级天然适合做分块。RAG分块最怕的就是把一个完整语义单元切碎。Markdown的##和###标题可以作为天然的分块边界,保证每个块内部的语义完整性。

第三,Markdown对大模型友好。现在主流大模型在预训练阶段都见过大量Markdown格式的文本,对Markdown结构的理解能力很强。你把清洗后的Markdown喂给大模型做生成,它更容易识别出哪里是标题、哪里是正文、哪里是表格。

注意:Markdown转换过程中要特别小心表格里的合并单元格。探矿报告里的表格经常有跨行跨列的合并单元格,直接转Markdown会丢失合并信息。我的做法是把合并单元格展开成重复值,虽然冗余但保证了语义完整。

3. TXT文件清洗:编码检测与格式归一化实操

3.1 编码检测的坑与解决方案

TXT文件清洗的第一步是搞清楚它到底是什么编码。这个问题看似简单,实际上我踩过的坑能写满一页纸。

最常见的错误是用open(file, 'r')直接读,Python默认用系统编码,在Windows上通常是GBK,在Linux上通常是UTF-8。同一个文件在不同机器上读出来的结果完全不一样。更隐蔽的问题是,有些文件是混合编码的,前面几行是GBK,后面几行是UTF-8,这是早期地质队员用不同软件编辑同一文件留下的历史遗留问题。

我的解决方案是三步走。第一步用chardet或charset-normalizer做编码探测,拿到一个置信度最高的编码。第二步用探测到的编码尝试读取,如果读取过程中抛出UnicodeDecodeError,说明文件是混合编码。第三步对混合编码文件,用errors='replace'先读进来,然后逐行做编码修复。

import chardet from charset_normalizer import from_bytes def detect_and_read_txt(file_path): with open(file_path, 'rb') as f: raw = f.read() # 先用charset-normalizer做检测 result = from_bytes(raw).best() if result and result.encoding: encoding = result.encoding else: # 回退到chardet detection = chardet.detect(raw) encoding = detection['encoding'] or 'utf-8' # 尝试解码 try: text = raw.decode(encoding) except UnicodeDecodeError: # 混合编码处理:逐行解码 lines = raw.split(b'\n') decoded_lines = [] for line in lines: try: decoded_lines.append(line.decode(encoding)) except UnicodeDecodeError: # 尝试用其他常见编码 for fallback in ['utf-8', 'gbk', 'gb2312', 'latin-1']: try: decoded_lines.append(line.decode(fallback)) break except UnicodeDecodeError: continue else: decoded_lines.append(line.decode('utf-8', errors='replace')) text = '\n'.join(decoded_lines) return text

这段代码的关键在于errors='replace'的兜底策略。有些乱码字符确实无法还原,与其让程序崩溃,不如用替换字符占位,后续在语义清洗层再处理。

3.2 分隔符归一化与结构化解析

编码问题解决之后,下一个问题是分隔符。探矿TXT数据常见的分隔方式有:逗号分隔、制表符分隔、连续空格分隔、固定宽度分隔。固定宽度是最麻烦的,因为不同文件的列宽定义不一样,需要根据表头来推断。

我的做法是先做分隔符探测。统计每一行中逗号、制表符、连续空格的出现次数,如果某一种分隔符在多数行中出现的次数一致,就判定为分隔符。对于固定宽度分隔,用表头的位置信息来切分数据行。

import re from collections import Counter def detect_delimiter(text): lines = [l for l in text.split('\n') if l.strip()][:50] if not lines: return None candidates = { 'comma': [l.count(',') for l in lines], 'tab': [l.count('\t') for l in lines], 'space': [len(re.findall(r'\s{2,}', l)) for l in lines], } for name, counts in candidates.items(): if len(set(counts)) == 1 and counts[0] > 0: return name # 固定宽度检测 if all(len(l) == len(lines[0]) for l in lines): return 'fixed_width' return None

分隔符归一化之后,把TXT数据转成Markdown表格。这里有个细节:探矿数据里经常有缺失值,用空字符串或“-”表示。转Markdown表格的时候,缺失值统一用空单元格表示,不要用“N/A”之类的占位符,因为向量化的时候这些占位符会引入噪声。

3.3 数值字段的清洗与单位统一

探矿TXT数据里大量是数值字段,品位、厚度、坐标、高程。这些数值的格式也很乱,有的用科学计数法,有的带单位后缀,有的用中文全角数字。

我通常会做以下几件事。第一,全角数字转半角。第二,去除数值后面的单位后缀,把单位信息提取到单独的列或元数据里。第三,统一小数位数,品位数据通常保留两位小数,坐标数据保留六位小数。第四,处理特殊值,比如“未检出”统一转成“0”或空值,“大于”转成对应的数值上限。

def clean_numeric_field(value): if not value or value.strip() in ['-', '—', 'N/A', '无']: return '' # 全角转半角 value = value.translate(str.maketrans('0123456789.', '0123456789.')) # 提取数值和单位 match = re.match(r'([<>]?)\s*([\d.]+)\s*([a-zA-Z%‰]*)$', value.strip()) if match: prefix, num, unit = match.groups() try: num = float(num) if prefix == '<': num = num / 2 # 小于某值的处理策略 elif prefix == '>': num = num * 1.5 # 大于某值的处理策略 return f"{num:.2f}" except ValueError: return value return value

提示:小于和大于的处理策略要根据业务场景来定。品位数据里,“<0.01”通常意味着低于检出限,我一般直接转成0.005或者0。但如果是厚度数据,“>50”可能意味着厚度超过测量上限,这时候转成50还是保留原样,需要跟业务方确认。

4. Word文档清洗:表格、公式与图片的语义保留

4.1 表格抽取的三种策略对比

Word文档里的表格是探矿数据的核心载体。一个钻孔编录表可能包含孔号、孔深、岩性、品位、厚度等十几列数据,跨页是常态。表格抽取的质量直接决定了后续检索的精度。

我试过三种策略,各有优劣。

策略一:python-docx直接读取。这是最直接的方法,通过document.tables遍历所有表格,逐行逐列读取单元格文本。优点是速度快、依赖少。缺点是遇到合并单元格会重复读取或丢失数据,遇到嵌套表格直接歇菜。

策略二:docx2python转换。这个库会把Word文档转成嵌套的Python列表结构,表格的行列关系保留得比较好。合并单元格会展开成重复值,嵌套表格也能处理。缺点是转换后的结构比较深,需要写递归函数来遍历。

策略三:LibreOffice转HTML再解析。先用LibreOffice命令行把Word转成HTML,然后用BeautifulSoup解析HTML表格。优点是表格结构保留得最完整,合并单元格用rowspan和colspan表示。缺点是需要安装LibreOffice,转换速度慢,而且HTML标签会引入额外噪声。

我的选择是:普通表格用docx2python,复杂表格用LibreOffice转HTML,嵌套表格用python-docx手动递归处理。三种策略组合使用,覆盖所有场景。

from docx2python import docx2python def extract_tables_with_docx2python(file_path): result = docx2python(file_path) tables = [] def find_tables(obj): if isinstance(obj, list): for item in obj: find_tables(item) elif isinstance(obj, tuple): # docx2python的表格结构 tables.append(obj) find_tables(result.body) return tables

4.2 公式处理:从OMML到LaTeX的转换

探矿报告里的公式主要是储量计算公式、品位加权平均公式、坐标转换公式。这些公式在Word里通常是用公式编辑器插入的,底层是OMML格式。直接读取会得到一堆XML标签,完全不可读。

我的处理方案是:用pandoc做OMML到LaTeX的转换。Pandoc对Word公式的支持相当好,转换出来的LaTeX公式可以直接嵌入Markdown。

pandoc input.docx -t markdown -o output.md --extract-media=./media

这条命令会把Word文档转成Markdown,公式转成LaTeX,图片提取到media目录。但pandoc的问题是,它对表格的处理不如docx2python精细,复杂表格会丢失结构。

所以我的实际工作流是:先用pandoc做整体转换,拿到公式和正文;再用docx2python单独抽取表格;最后把两者合并。合并的时候要注意公式在正文中的位置,pandoc转换后的Markdown里公式位置是准确的,表格位置需要根据上下文来对齐。

注意:MathType公式和Word自带公式编辑器的OMML格式不一样。MathType公式在Word里是以OLE对象嵌入的,pandoc无法直接转换。遇到MathType公式,我的做法是用MathType的“转换公式”功能批量转成OMML,然后再用pandoc处理。如果公式数量少,手动截图用OCR识别也是一种办法,但精度不保证。

4.3 图片与图注的关联处理

探矿报告里的图片主要是钻孔柱状图、剖面图、等值线图。这些图片本身包含大量信息,但RAG系统目前对图片的处理能力有限。我的策略是:图片本身不做OCR识别,但把图注和图片周围的文字描述抽取出来,作为图片的语义代理。

具体做法是:用python-docx遍历文档的段落,遇到包含图片的段落时,记录图片的位置,然后向前和向后各找三个段落,把其中的文字作为图片的上下文描述。图注通常在图片下方,格式是“图1 某某矿区钻孔柱状图”,这个直接抽取。

from docx import Document def extract_images_with_captions(file_path): doc = Document(file_path) images_info = [] for i, para in enumerate(doc.paragraphs): if para._element.findall('.//{http://schemas.openxmlformats.org/drawingml/2006/main}blip'): # 这是一个包含图片的段落 caption = '' # 向后找图注 for j in range(i+1, min(i+4, len(doc.paragraphs))): text = doc.paragraphs[j].text.strip() if text.startswith('图') or text.startswith('Figure'): caption = text break # 向前找上下文 context = [] for j in range(max(0, i-3), i): text = doc.paragraphs[j].text.strip() if text: context.append(text) images_info.append({ 'index': i, 'caption': caption, 'context': ' '.join(context) }) return images_info

这样处理之后,图片虽然不能直接被检索,但图片的图注和上下文描述可以被检索到。用户搜索“某某矿区钻孔柱状图”的时候,能定位到图片所在的位置,然后人工去查看原图。

5. PDF清洗:OCR与版面还原的实战细节

5.1 文字层探测与OCR触发条件

PDF清洗的第一步是判断这个PDF有没有文字层。有文字层的直接抽取,没有文字层的走OCR。判断方法很简单:用PyMuPDF打开PDF,随机抽取几页,看page.get_text()返回的文本长度。如果平均每页文本长度小于50个字符,基本可以判定是扫描件。

import fitz # PyMuPDF def has_text_layer(pdf_path, sample_pages=5): doc = fitz.open(pdf_path) total_pages = len(doc) sample = min(sample_pages, total_pages) total_chars = 0 for i in range(sample): page = doc[i] text = page.get_text() total_chars += len(text.strip()) avg_chars = total_chars / sample doc.close() return avg_chars > 50

这个阈值50是我根据探矿报告的特点调的。有些PDF虽然有文字层,但文字层是OCR软件后期加的,质量很差,错字连篇。这种情况我建议还是走OCR重新识别,用更好的OCR引擎。

5.2 版面分析与阅读顺序重建

有文字层的PDF,抽取文字不难,难的是还原正确的阅读顺序。探矿报告常见双栏排版,直接get_text()会把左右两栏的文字交错在一起,读起来完全不通。

我的解决方案是用PyMuPDF的get_text("dict")拿到每个文本块的坐标信息,然后根据坐标做排序。双栏排版的判断逻辑是:如果文本块的x坐标集中在两个区间,就是双栏;如果x坐标分布连续,就是单栏。

def extract_text_with_layout(page): blocks = page.get_text("dict")["blocks"] text_blocks = [] for block in blocks: if block["type"] == 0: # 文本块 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() }) # 判断单栏还是双栏 x_centers = [(b["bbox"][0] + b["bbox"][2]) / 2 for b in text_blocks] page_width = page.rect.width left_blocks = [b for b in text_blocks if (b["bbox"][0] + b["bbox"][2]) / 2 < page_width / 2] right_blocks = [b for b in text_blocks if (b["bbox"][0] + b["bbox"][2]) / 2 >= page_width / 2] if len(left_blocks) > 3 and len(right_blocks) > 3: # 双栏排版 left_blocks.sort(key=lambda b: b["bbox"][1]) right_blocks.sort(key=lambda b: b["bbox"][1]) ordered = left_blocks + right_blocks else: # 单栏排版 ordered = sorted(text_blocks, key=lambda b: (b["bbox"][1], b["bbox"][0])) return "\n".join([b["text"] for b in ordered])

这个逻辑对大多数探矿报告有效,但遇到三栏排版或者不规则排版会失效。遇到这种情况,我建议用pdfplumber的extract_text(layout=True),它内置了版面分析算法,效果比手写排序好。

5.3 扫描件OCR的参数调优

扫描件OCR是PDF清洗里最耗时的环节。我用的是PaddleOCR,原因是它对中文的识别精度比Tesseract高不少,尤其是对表格和公式的识别。

OCR的参数调优有几个关键点。第一,det_db_thresh控制文本检测的阈值,默认0.3,对于字迹模糊的老报告可以调到0.2。第二,rec_batch_num控制识别批大小,GPU环境下可以调到16或32,CPU环境保持8。第三,use_angle_cls开启角度分类,对于有倾斜的扫描件很有用。

from paddleocr import PaddleOCR ocr = PaddleOCR( use_angle_cls=True, lang='ch', det_db_thresh=0.2, rec_batch_num=16, show_log=False ) def ocr_pdf_page(image_path): result = ocr.ocr(image_path, 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结果一定要做置信度过滤。低于0.6的识别结果大概率是错的,与其让错误信息进入知识库,不如直接丢弃。丢弃的文本可以在元数据里标记“OCR低置信度”,方便后续人工复核。

5.4 表格与图件的特殊处理

PDF里的表格处理比Word更麻烦,因为PDF没有表格结构信息,只有文字和线条的坐标。我的做法是用pdfplumber的extract_tables()方法,它基于线条和文字对齐来推断表格结构。

import pdfplumber 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: # 清理表格数据 cleaned = [] for row in table: cleaned_row = [cell.strip() if cell else '' for cell in row] cleaned.append(cleaned_row) tables.append(cleaned) return tables

对于图件,PDF里的图件通常是矢量图或位图。矢量图可以用page.get_drawings()拿到线条信息,但重建图件意义不大。我的策略和Word一样:抽取图注和上下文,图件本身不做处理。

6. 网页数据清洗:正文抽取与噪声过滤

6.1 正文抽取工具选型对比

探矿业务的网页数据来源主要有三类:内部OA系统导出的HTML、地质云平台的数据页面、行业论坛的技术帖。这三类页面的DOM结构差异很大,正文抽取策略也需要调整。

我对比过四个工具:trafilatura、readability-lxml、newspaper3k、goose3。实测下来,trafilatura的综合表现最好,对中文网页的支持也最到位。readability-lxml速度快但有时候会漏掉正文,newspaper3k对中文分词有问题,goose3已经很久没维护了。

import trafilatura def extract_web_content(html_content): # trafilatura的抽取 text = trafilatura.extract( html_content, include_comments=False, include_tables=True, include_images=False, output_format='markdown', favor_precision=True ) return text

favor_precision=True这个参数很关键。默认情况下trafilatura会尽量多抽取内容,但探矿业务的网页里导航栏、侧边栏、评论区都是噪声,宁可少抽一点也要保证精度。

6.2 动态网页的抓取策略

有些地质云平台的数据页面是JavaScript动态渲染的,直接请求HTML拿不到数据。这种情况需要用Playwright或Selenium做浏览器渲染。

from playwright.sync_api import sync_playwright def fetch_dynamic_page(url): with sync_playwright() as p: browser = p.chromium.launch(headless=True) page = browser.new_page() page.goto(url, wait_until='networkidle') # 等待特定元素加载 page.wait_for_selector('.data-table', timeout=10000) html = page.content() browser.close() return html

wait_until='networkidle'表示等待网络请求空闲后再抓取,wait_for_selector确保关键数据元素已经渲染。这两个条件配合使用,基本能拿到完整的动态内容。

注意:动态网页抓取要控制频率,避免对目标服务器造成压力。我的做法是每次请求间隔2到3秒,批量抓取时用队列控制并发数不超过3。

6.3 表格与列表的结构化保留

网页里的表格用trafilatura的include_tables=True可以保留成Markdown表格。但网页表格经常有合并单元格、嵌套表格、表头表尾重复等问题,转换效果不如Word和PDF。

我的补充方案是:用BeautifulSoup单独解析HTML表格,处理合并单元格后再转Markdown。

from bs4 import BeautifulSoup def parse_html_table(table_html): soup = BeautifulSoup(table_html, 'html.parser') table = soup.find('table') if not table: return '' rows = [] for tr in table.find_all('tr'): cells = [] for td in tr.find_all(['td', 'th']): text = td.get_text(strip=True) colspan = int(td.get('colspan', 1)) rowspan = int(td.get('rowspan', 1)) cells.append({ 'text': text, 'colspan': colspan, 'rowspan': rowspan }) rows.append(cells) # 展开合并单元格 expanded = expand_merged_cells(rows) # 转Markdown md_lines = [] for i, row in enumerate(expanded): md_lines.append('| ' + ' | '.join(row) + ' |') if i == 0: md_lines.append('| ' + ' | '.join(['---'] * len(row)) + ' |') return '\n'.join(md_lines)

合并单元格的展开逻辑是:遇到colspan大于1的单元格,复制成多个相同值的单元格;遇到rowspan大于1的单元格,在后续行对应位置填充相同值。这样虽然冗余,但保证了每一行的列数一致,Markdown表格不会错位。

7. 常见问题与排查技巧实录

7.1 清洗效果自查清单

清洗完一批数据之后,我通常会做一轮快速自查。下面这张表是我总结的检查项和判断标准,你可以直接拿去用。

检查项判断标准不达标时的处理
有效字符占比大于85%检查是否有大量乱码或空白
乱码字符数量每万字少于10个回溯编码检测环节
表格转换成功率大于90%检查合并单元格处理逻辑
公式转换成功率大于80%检查OMML转LaTeX流程
平均段落长度50到500字之间过短说明断行没合并,过长说明分块有问题
标题层级完整性有明确的章节结构检查Word/PDF的标题样式是否被正确识别

7.2 典型问题速查表

问题现象可能原因排查方法解决方案
TXT读取全是问号编码检测错误用chardet重新检测手动指定编码或逐行解码
Word表格数据错位合并单元格处理不当打印表格行列数用docx2python或LibreOffice转HTML
PDF文字顺序混乱双栏排版未识别查看文本块坐标用pdfplumber的layout模式
OCR识别率低扫描件质量差查看原始图片分辨率提高扫描分辨率或调整OCR阈值
网页正文抽取不全动态渲染未等待查看HTML源码用Playwright等待元素加载
公式转LaTeX失败MathType格式不支持检查公式类型先转OMML再用pandoc
表格转Markdown错位列数不一致检查每行列数展开合并单元格
清洗后文本过短抽取环节丢内容对比原始文件检查解析库的配置参数

7.3 我踩过的三个大坑

第一个坑:用默认编码读TXT。早期我图省事,直接用open(file, 'r')读TXT,结果在Windows上读GBK文件正常,在Linux上读同样的文件全是乱码。后来改成二进制读取加编码检测,问题解决。这个坑的本质是不要依赖系统默认值,所有编码相关的操作都要显式指定。

第二个坑:用PDF解析库处理扫描件。有一次拿到一批PDF,用PyMuPDF抽取文字,结果全是空白。我以为是库的问题,换了好几个库都一样。后来才发现这些PDF是扫描件,根本没有文字层。这个坑教会我:处理PDF之前一定要先探测文字层,有文字层和没文字层是两条完全不同的技术路线。

第三个坑:网页表格直接转Markdown。网页表格里的合并单元格直接转Markdown会错位,我一开始没注意,导致检索出来的表格数据行列对不上。后来加了合并单元格展开逻辑,问题解决。这个坑的教训是:HTML表格和Markdown表格的结构模型不一样,转换的时候必须做结构适配。

7.4 性能优化的几个实用技巧

清洗大批量数据的时候,性能是个绕不开的问题。我总结了几条实用技巧。

第一,批量处理用多进程而不是多线程。文本清洗是CPU密集型任务,Python的GIL会限制多线程的并行效果。用multiprocessing.Pool可以充分利用多核CPU。

第二,OCR结果做缓存。同一批扫描件如果重复清洗,OCR结果可以缓存到本地,避免重复计算。我用的是joblib.Memory,简单好用。

第三,大文件分块读取。有些TXT文件几百兆,一次性读进内存会爆。用生成器逐行读取,内存占用可以控制在几十兆以内。

第四,PDF按页并行处理。PyMuPDF支持按页打开,可以把不同页分配给不同进程处理,最后合并结果。对于几百页的报告,速度提升很明显。

from multiprocessing import Pool import fitz def process_pdf_page(args): pdf_path, page_num = args doc = fitz.open(pdf_path) page = doc[page_num] text = page.get_text() doc.close() return page_num, text def parallel_pdf_extract(pdf_path, num_workers=4): doc = fitz.open(pdf_path) total_pages = len(doc) doc.close() with Pool(num_workers) as pool: args = [(pdf_path, i) for i in range(total_pages)] results = pool.map(process_pdf_page, args) # 按页码排序 results.sort(key=lambda x: x[0]) return '\n'.join([text for _, text in results])

提示:多进程处理PDF的时候,每个进程都要重新打开PDF文件,这会带来额外的IO开销。如果PDF文件不大,可以把整个文件读进内存再传给子进程。如果文件很大,按页打开是更稳妥的做法。

8. 清洗后的分块策略与检索精度验证

8.1 分块大小与重叠度的选择

清洗完的Markdown文本不能直接整篇向量化,需要分块。分块大小和重叠度的选择直接影响检索精度。

我的经验值是:分块大小500到800个中文字符,重叠度100到150个字符。这个范围是经过多轮测试得出的。分块太小,语义不完整,检索出来的片段缺乏上下文;分块太大,噪声比例上升,向量化的语义焦点模糊。

探矿数据的特殊性在于,表格和公式比较多。表格分块的时候要保证一个表格不被切断,如果表格超过分块大小,按行拆分并在每个分块里重复表头。公式分块的时候要保证公式和它的解释文字在同一个块里。

def chunk_markdown(text, chunk_size=600, overlap=120): # 按标题层级优先分块 sections = re.split(r'\n(?=#{1,3}\s)', text) chunks = [] for section in sections: if len(section) <= chunk_size: chunks.append(section) else: # 长段落按句子边界切分 sentences = re.split(r'(?<=[。!?])', section) current_chunk = '' for sentence in sentences: if len(current_chunk) + len(sentence) <= chunk_size: current_chunk += sentence else: if current_chunk: chunks.append(current_chunk) current_chunk = sentence if current_chunk: chunks.append(current_chunk) # 添加重叠 overlapped_chunks = [] for i, chunk in enumerate(chunks): if i > 0: prev_tail = chunks[i-1][-overlap:] chunk = prev_tail + chunk overlapped_chunks.append(chunk) return overlapped_chunks

8.2 检索精度验证方法

清洗和分块做完之后,怎么验证效果?我通常用三个指标:召回率、精确率、MRR(平均倒数排名)。

召回率衡量的是:在所有相关片段中,检索系统能找回多少。精确率衡量的是:检索回来的片段中,有多少是真正相关的。MRR衡量的是:第一个相关片段出现在检索结果的第几位。

验证方法是:人工构造一批测试查询,每个查询标注好应该匹配的片段。然后跑检索,统计指标。召回率低于80%说明清洗或分块有问题,精确率低于70%说明噪声太多,MRR低于0.6说明排序算法需要调整。

def evaluate_retrieval(queries, ground_truth, retriever, top_k=10): recall_sum = 0 precision_sum = 0 mrr_sum = 0 for query, relevant_ids in queries.items(): results = retriever.search(query, top_k=top_k) retrieved_ids = [r['id'] for r in results] # 召回率 hits = set(retrieved_ids) & set(relevant_ids) recall = len(hits) / len(relevant_ids) if relevant_ids else 0 # 精确率 precision = len(hits) / len(retrieved_ids) if retrieved_ids else 0 # MRR mrr = 0 for i, rid in enumerate(retrieved_ids): if rid in relevant_ids: mrr = 1 / (i + 1) break recall_sum += recall precision_sum += precision mrr_sum += mrr n = len(queries) return { 'recall': recall_sum / n, 'precision': precision_sum / n, 'mrr': mrr_sum / n }

8.3 清洗质量对检索效果的影响分析

我做过一组对比实验,同一批探矿数据,一组做完整清洗,一组只做简单清洗,然后跑同样的检索测试。结果差异非常明显。

完整清洗的召回率是87%,简单清洗的召回率只有52%。差距主要来自三个方面:表格数据在简单清洗中被拍扁,导致品位和厚度的对应关系丢失;公式在简单清洗中变成乱码,导致储量计算相关的查询完全无法匹配;页眉页脚在简单清洗中没有去除,导致大量噪声片段被检索出来。

这个实验说明一个道理:RAG系统的检索精度上限是由清洗质量决定的。你后面用再好的向量模型、再精细的排序算法,如果源头数据是脏的,效果提升空间非常有限。与其在检索环节反复调参,不如回头把清洗环节做扎实。

提示:清洗质量验证不需要等到整个知识库建完再做。每清洗完一批数据,抽10到20个查询做一轮快速验证,发现问题及时调整清洗策略。这样比全部做完再返工要高效得多。

8.4 持续迭代的清洗管道维护

探矿业务的数据是持续产生的,清洗管道不是一次性的项目,而是需要持续维护的基础设施。我的做法是:把清洗管道脚本化、配置化,每次新数据进来只需要改配置,不需要改代码。

具体来说,把编码检测规则、分隔符规则、表格处理规则、OCR参数都抽成配置文件。不同项目的数据特点不一样,通过配置文件来适配,代码保持稳定。

# config.yaml txt: encoding_fallback: ['utf-8', 'gbk', 'gb2312'] delimiter_candidates: [',', '\t', ' '] numeric_precision: 2 word: table_engine: 'docx2python' formula_converter: 'pandoc' image_caption_pattern: '^图\d+' pdf: text_layer_threshold: 50 ocr_engine: 'paddleocr' ocr_confidence_threshold: 0.6 layout_mode: 'auto' web: extractor: 'trafilatura' favor_precision: true dynamic_wait: 'networkidle'

配置文件的好处是,换一个探矿项目,只需要调整配置参数,清洗管道的核心逻辑不用动。我维护的清洗管道已经跑了三年多,处理了十几个探矿项目的数据,代码主体基本没变过,改的都是配置。

这个内容后续还可以这样扩展:把清洗管道和RAG知识库的更新流程打通,新数据自动触发清洗、分块、向量化、入库的全流程。再进一步,可以加一个清洗质量监控面板,实时显示每批数据的清洗指标,低于阈值自动告警。这些是我下一步打算做的事,等跑通了再写一篇分享。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询