课程0基础Agent开发课 / RAG与向量数据库 / 文档预处理全攻略-OCR-PDF解析与表格提取
— 16 min read

文档预处理全攻略-OCR-PDF解析与表格提取

> **[进阶选读]** 本篇适合需要处理非纯文本文档(扫描版 PDF、含表格的 Word/PPT、带图片的手册)的读者。真实企业文档往往格式复杂,预处理质量直接决定 RAG 的上限。

文档预处理全攻略:OCR、PDF 解析与表格提取

[进阶选读] 本篇适合需要处理非纯文本文档(扫描版 PDF、含表格的 Word/PPT、带图片的手册)的读者。真实企业文档往往格式复杂,预处理质量直接决定 RAG 的上限。


1.1 为什么文档预处理是 RAG 项目最耗时的部分

原始文档
PDF/Word/HTML/扫描件

解析
OCR/文本提取

表格识别
结构化提取

清洗
去噪/去重

分块
Chunking

向量化
Embedding 入库

文档预处理五阶段流程——解析、表格识别、清洗、分块到向量入库

很多工程师第一次做 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 秒。

python
# 安装: 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 的表格提取能力远超其他工具,它能识别单元格边界,以二维列表形式返回表格数据。

python
# 安装: 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

python
# 安装: 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 为什么表格不能直接切成文本块

想象一张价格表:

code
产品名称 | 规格 | 单价 | 库存
A产品   | 10kg | 50元 | 200
B产品   | 5kg  | 30元 | 500

如果直接展开成文字再按 500 字切块,可能变成:

"产品名称规格单价库存A产品10kg50元200B产品5kg30元500"

用户问"A产品的单价是多少",这段文字能被检索到,但 LLM 很难从这堆数字中准确找到对应关系,因为行列对应已经断裂。

1.5.2 表格转 Markdown 格式

Markdown 表格格式能让 LLM 更好地理解行列关系:

python
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 文档:

python
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 常见噪音类型及清洗策略

python
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 系统中容易被忽视的宝贵信息。当用户问"最新的合同模板在哪"时,文件创建时间就非常关键;当用户问"张三写的那个方案"时,作者信息就是关键过滤条件。

python
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 文档预处理完整流程

PDF

Word/Excel

HTML

图片

可复制文本PDF

扫描版PDF

混合PDF

原始文档输入

判断文档类型

判断PDF类型

python-docx/openpyxl 解析

BeautifulSoup 清洗

直接 OCR

PyMuPDF 提取文本

pdf2image 转图片

逐页检测类型

PaddleOCR 识别文字

是否含表格?

pdfplumber 提取表格

文本清洗

表格转 Markdown/JSON

去除页眉页脚/目录/水印

提取元数据

输出标准化文档块

进入分块流程 Chunking


1.9 工具选型决策建议

综合来看,一个生产级文档预处理管线的选型建议如下:

  • 文本型 PDF 批处理:首选 PyMuPDF,速度是其他工具的 5-10 倍
  • 含表格的 PDF:先用 PyMuPDF 提取文本,再用 pdfplumber 单独处理表格页
  • 中文扫描文档:PaddleOCR,效果远好于 Tesseract
  • 多格式统一处理:Unstructured,虽然慢,但能统一处理 PDF、Word、HTML,适合格式多样的场景
  • 网页内容:BeautifulSoup(一个用于解析 HTML 网页并提取其中内容的 Python 库)+ 自定义清洗规则,或者 Trafilatura 库(专门做正文提取)

文档预处理没有银弹,真实项目中往往需要组合使用多种工具,并针对具体文档的特点编写专门的清洗规则。在这上面多花时间是值得的——基础数据质量差,后续所有的优化都是在沙滩上建高楼。

本页目录