文档预处理全攻略-OCR-PDF解析与表格提取
> **[进阶选读]** 本篇适合需要处理非纯文本文档(扫描版 PDF、含表格的 Word/PPT、带图片的手册)的读者。真实企业文档往往格式复杂,预处理质量直接决定 RAG 的上限。
文档预处理全攻略:OCR、PDF 解析与表格提取
[进阶选读] 本篇适合需要处理非纯文本文档(扫描版 PDF、含表格的 Word/PPT、带图片的手册)的读者。真实企业文档往往格式复杂,预处理质量直接决定 RAG 的上限。
1.1 为什么文档预处理是 RAG 项目最耗时的部分
文档预处理五阶段流程——解析、表格识别、清洗、分块到向量入库
很多工程师第一次做 RAG 项目时,脑子里的时间分配大概是这样的:向量化占 30%,检索逻辑占 40%,文档处理随便搞一下占 10%。
现实往往是这样的:文档预处理独占 60% 到 70% 的开发时间,剩下的部分加起来才占三成。
为什么?因为真实世界的文档从来不"乖"。
你会遇到:用扫描仪扫出来的 PDF,每一页都是图片;带有复杂嵌套表格的 Word 文件,转成纯文本之后数据全乱了;网页抓下来满是广告、导航栏、版权声明,真正有用的内容只有中间那几段;还有图片里的文字——比如产品截图、白板照片、印章——根本没法直接读取。
这个过程很像 ETL(Extract-Transform-Load,数据抽取转换加载,把原始数据从各种来源抽取出来、清洗转换、再加载到目标系统的工程实践)工程。不同的数据源格式千奇百怪,在进入核心处理流程之前,必须先把原始数据清洗成统一、干净、可用的结构。文档预处理就是 RAG 的 ETL 层,它的质量直接决定最终检索效果的上限。
1.2 常见文档类型与各自的挑战
在动手之前,先搞清楚你面对的是什么类型的文档,因为不同类型需要完全不同的处理方式。
1.2.1 PDF:两种截然不同的动物
PDF 是最常见的文档格式,但 PDF 内部分两种截然不同的类型,必须区分对待。
可复制文本 PDF:用 Word 导出的 PDF、用 LaTeX 排版的 PDF、从网站打印的 PDF——这类 PDF 内部存储了真实的文本字符,你可以在 PDF 阅读器里直接选中文字。处理这类 PDF 速度快、精度高,用纯文本提取工具就够了。
扫描版 PDF:把纸质文档放在扫描仪上扫描得到的文件。本质上,每一页都是一张图片,打包进了 PDF 容器。里面没有任何文本数据,全是像素。你在阅读器里无法选中文字。处理这类 PDF 必须先经过 OCR(Optical Character Recognition,光学字符识别)——一种把图像中的文字转换成计算机可读文本的技术。
混合 PDF 更麻烦:部分页面是可复制文本,部分页面是扫描图片。需要逐页判断再分别处理。
1.2.2 Word 和 Excel
Word 文档的挑战主要是格式信息:段落层级、粗体强调、列表结构——这些语义信息如果丢失,文本块的含义就会变得模糊。Excel 表格如果直接展开成纯文本,行与行之间的对应关系就彻底消失了。
1.2.3 网页 HTML
网页的问题是噪音太多。一个新闻页面中,真正的正文可能只占 20%,其余是导航菜单、侧边栏广告、评论区、页脚版权信息。这些噪音如果不清除,就会混入检索结果,严重干扰答案质量。
1.2.4 图片中的文字
产品截图、白板照片、扫描版合同印章、驾照身份证——这些场景中的文字需要 OCR 才能提取。
1.3 PDF 解析工具对比
非程序员可跳过代码,重点看文字说明。
目前主流的 Python PDF 解析工具有四种,各有侧重:
| 工具 | 主要优势 | 主要限制 | 适用场景 |
|---|---|---|---|
| PyMuPDF(fitz) | 速度极快,内存占用小 | 表格提取能力弱 | 大量文本型 PDF 批处理 |
| pdfplumber | 表格提取精准 | 速度较慢 | 含复杂表格的 PDF |
| Unstructured | 自动识别文档结构,多格式支持 | 安装依赖多,速度慢 | 混合格式、需要结构感知的场景 |
| PDFMiner | 精细控制版面分析 | API 复杂,开发成本高 | 需要精确控制位置信息时 |
1.3.1 用 PyMuPDF 提取文本
PyMuPDF 是处理可复制文本 PDF 的首选。速度快到处理一个 200 页的报告只需要不到 1 秒。
# 安装: pip install pymupdf
import fitz # PyMuPDF 的包名是 fitz
def extract_text_with_metadata(pdf_path: str) -> list[dict]:
"""
提取 PDF 文本,同时保留页码和章节位置信息
返回格式: [{"page": 1, "text": "...", "bbox": (x0, y0, x1, y1)}, ...]
"""
doc = fitz.open(pdf_path)
results = []
for page_num in range(len(doc)):
page = doc[page_num]
# 按段落块提取,每个块包含位置信息
blocks = page.get_text("blocks")
for block in blocks:
x0, y0, x1, y1, text, block_no, block_type = block
# block_type: 0 = 文本块, 1 = 图片块
if block_type == 0 and text.strip():
results.append({
"page": page_num + 1,
"text": text.strip(),
"bbox": (x0, y0, x1, y1) # 在页面上的位置坐标
})
doc.close()
return results
# 使用示例
pages = extract_text_with_metadata("annual_report.pdf")
print(f"共提取 {len(pages)} 个文本块")
1.3.2 用 pdfplumber 提取表格
pdfplumber 的表格提取能力远超其他工具,它能识别单元格边界,以二维列表形式返回表格数据。
# 安装: pip install pdfplumber
import pdfplumber
def extract_tables_from_pdf(pdf_path: str) -> list[dict]:
"""
提取 PDF 中所有表格,转换为结构化数据
"""
all_tables = []
with pdfplumber.open(pdf_path) as pdf:
for page_num, page in enumerate(pdf.pages):
tables = page.extract_tables()
for table_idx, table in enumerate(tables):
if not table:
continue
# table 是二维列表,第一行通常是表头
headers = table[0]
rows = table[1:]
# 转换为字典列表,便于后续处理
structured_rows = []
for row in rows:
row_dict = {}
for col_idx, header in enumerate(headers):
if header and col_idx < len(row):
row_dict[str(header).strip()] = str(row[col_idx] or "").strip()
structured_rows.append(row_dict)
all_tables.append({
"page": page_num + 1,
"table_index": table_idx,
"headers": headers,
"rows": structured_rows
})
return all_tables
1.4 OCR 处理扫描版 PDF
当检测到 PDF 是扫描版时,需要引入 OCR 技术。
1.4.1 Tesseract vs PaddleOCR
| 对比维度 | Tesseract | PaddleOCR |
|---|---|---|
| 开发方 | Google 开源 | 百度开源 |
| 中文识别精度 | 一般,需要下载语言包 | 优秀,专门优化过中文 |
| 安装难度 | 需要系统级安装 | pip 直接安装 |
| 速度 | 较慢 | 有 GPU 加速版 |
| 复杂版面支持 | 弱 | 强(支持倾斜、旋转) |
| 推荐场景 | 英文文档、资源受限环境 | 中文文档、复杂版面 |
对于中文场景,PaddleOCR 几乎是标准选择。
1.4.2 用 PaddleOCR 处理扫描 PDF
# 安装: pip install paddlepaddle paddleocr pdf2image
# 还需要安装 poppler(pdf2image 的依赖)
from paddleocr import PaddleOCR
from pdf2image import convert_from_path
import numpy as np
# 初始化 OCR 引擎(首次运行会自动下载模型,约 100MB)
ocr_engine = PaddleOCR(use_angle_cls=True, lang="ch") # ch 表示中文
def ocr_scanned_pdf(pdf_path: str, dpi: int = 200) -> list[dict]:
"""
对扫描版 PDF 做 OCR,提取文字内容
dpi: 分辨率,越高越清晰但越慢。200 是精度和速度的平衡点
"""
# 第一步:把 PDF 每页转成图片
images = convert_from_path(pdf_path, dpi=dpi)
results = []
for page_num, image in enumerate(images):
# 第二步:把 PIL 图像转成 numpy 数组(PaddleOCR 的输入格式)
img_array = np.array(image)
# 第三步:执行 OCR
ocr_result = ocr_engine.ocr(img_array, cls=True)
# 第四步:提取文字和置信度
page_text = ""
for line in ocr_result[0]:
text = line[1][0] # 识别出的文字
confidence = line[1][1] # 置信度,0 到 1 之间
if confidence > 0.8: # 只保留高置信度结果
page_text += text + "\n"
results.append({
"page": page_num + 1,
"text": page_text.strip(),
"source_type": "ocr"
})
return results
# 使用示例
pages = ocr_scanned_pdf("scanned_contract.pdf")
for page in pages[:2]:
print(f"第 {page['page']} 页:{page['text'][:100]}...")
1.5 表格数据的特殊处理
表格是文档预处理中最棘手的部分之一。很多新手会直接把表格拍平成一段文字,然后按固定长度切块。这是错误的。
1.5.1 为什么表格不能直接切成文本块
想象一张价格表:
产品名称 | 规格 | 单价 | 库存
A产品 | 10kg | 50元 | 200
B产品 | 5kg | 30元 | 500
如果直接展开成文字再按 500 字切块,可能变成:
"产品名称规格单价库存A产品10kg50元200B产品5kg30元500"
用户问"A产品的单价是多少",这段文字能被检索到,但 LLM 很难从这堆数字中准确找到对应关系,因为行列对应已经断裂。
1.5.2 表格转 Markdown 格式
Markdown 表格格式能让 LLM 更好地理解行列关系:
def table_to_markdown(headers: list, rows: list[dict]) -> str:
"""
把结构化表格数据转成 Markdown 格式
"""
if not headers or not rows:
return ""
# 表头行
header_row = "| " + " | ".join(str(h) for h in headers) + " |"
# 分隔行
separator = "| " + " | ".join("---" for _ in headers) + " |"
# 数据行
data_rows = []
for row in rows:
cells = [str(row.get(str(h), "")) for h in headers]
data_rows.append("| " + " | ".join(cells) + " |")
return "\n".join([header_row, separator] + data_rows)
# 示例输出:
# | 产品名称 | 规格 | 单价 | 库存 |
# | --- | --- | --- | --- |
# | A产品 | 10kg | 50元 | 200 |
# | B产品 | 5kg | 30元 | 500 |
Markdown 表格作为一个整体单元进行索引,不要对它做进一步切分。
1.5.3 表格转结构化 JSON
对于需要精确查询的场景,可以把每一行转成独立的 JSON 文档:
import json
def table_rows_to_chunks(table_info: dict) -> list[str]:
"""
把表格的每一行转成一个可检索的文本块
包含表格上下文,让每行数据独立可理解
"""
chunks = []
for row in table_info["rows"]:
# 把行数据转成自然语言描述
row_text = f"(来自第 {table_info['page']} 页表格)\n"
row_text += json.dumps(row, ensure_ascii=False, indent=2)
chunks.append(row_text)
return chunks
1.6 文档清洗:去除噪音
原始文档中充满了不需要的内容,让它们混入知识库会严重干扰检索质量。
1.6.1 常见噪音类型及清洗策略
import re
def clean_document_text(text: str) -> str:
"""
清洗文档文本,去除常见噪音
"""
# 1. 去除页眉页脚(通常是重复出现的短文本,如公司名、日期、页码)
# 页码模式:单独一行只有数字或"第X页"
text = re.sub(r'\n\s*\d+\s*\n', '\n', text)
text = re.sub(r'\n第\s*\d+\s*页\s*\n', '\n', text)
# 2. 去除目录区域(大量连续的...和数字)
text = re.sub(r'\.{5,}\s*\d+', '', text)
# 3. 去除多余空行(超过2个连续换行压缩为2个)
text = re.sub(r'\n{3,}', '\n\n', text)
# 4. 去除水印文字(通常是斜对角重复出现的单词,如"机密"、"CONFIDENTIAL")
watermark_patterns = ['机密', 'CONFIDENTIAL', '内部资料', 'DRAFT']
for pattern in watermark_patterns:
# 只去除单独成行的水印(不去除正文中包含这些词的句子)
text = re.sub(rf'\n\s*{pattern}\s*\n', '\n', text)
return text.strip()
1.7 元数据提取
文档元数据是 RAG 系统中容易被忽视的宝贵信息。当用户问"最新的合同模板在哪"时,文件创建时间就非常关键;当用户问"张三写的那个方案"时,作者信息就是关键过滤条件。
import os
from datetime import datetime
import fitz
def extract_pdf_metadata(pdf_path: str) -> dict:
"""
提取 PDF 的元数据信息
"""
doc = fitz.open(pdf_path)
file_stat = os.stat(pdf_path)
# PDF 内置元数据
pdf_meta = doc.metadata
metadata = {
# 文件系统信息
"file_name": os.path.basename(pdf_path),
"file_path": os.path.abspath(pdf_path),
"file_size_kb": round(file_stat.st_size / 1024, 2),
"last_modified": datetime.fromtimestamp(file_stat.st_mtime).isoformat(),
# PDF 内置信息
"title": pdf_meta.get("title", ""),
"author": pdf_meta.get("author", ""),
"creation_date": pdf_meta.get("creationDate", ""),
"page_count": len(doc),
# 结构信息:提取章节标题(目录)
"outline": []
}
# 提取书签/目录作为章节信息
toc = doc.get_toc()
for level, title, page in toc:
metadata["outline"].append({
"level": level,
"title": title,
"page": page
})
doc.close()
return metadata
1.8 文档预处理完整流程
1.9 工具选型决策建议
综合来看,一个生产级文档预处理管线的选型建议如下:
- 文本型 PDF 批处理:首选 PyMuPDF,速度是其他工具的 5-10 倍
- 含表格的 PDF:先用 PyMuPDF 提取文本,再用 pdfplumber 单独处理表格页
- 中文扫描文档:PaddleOCR,效果远好于 Tesseract
- 多格式统一处理:Unstructured,虽然慢,但能统一处理 PDF、Word、HTML,适合格式多样的场景
- 网页内容:BeautifulSoup(一个用于解析 HTML 网页并提取其中内容的 Python 库)+ 自定义清洗规则,或者 Trafilatura 库(专门做正文提取)
文档预处理没有银弹,真实项目中往往需要组合使用多种工具,并针对具体文档的特点编写专门的清洗规则。在这上面多花时间是值得的——基础数据质量差,后续所有的优化都是在沙滩上建高楼。