🐸 青蛙编程网
首页 / AI / 检索增强 RAG / 9. 进阶:PaddleOCR 处理扫描件

9. 进阶:PaddleOCR 处理扫描件与图片

这是什么:OCR(光学字符识别) = 把图片里的像素认成文字。PaddleOCR 栈 讲的是「怎么把图片转成文本」;本章讲的是转出来的文本怎么才能喂进 RAG。

为什么用:扫描版 PDF 和截图没有文字层,直接抽文本只能抽到一堆空白。必须先 OCR 出文字,才有东西可切、可嵌入、可检索。

如何用:本章先判断 PDF 到底有没有文字层,再用 PaddleOCR 识别图片,做置信度过滤 → 阅读顺序排序 → 页眉页脚清理,最后合成一个统一的入库入口。

9.1 先判断:这份 PDF 需要 OCR 吗

不是所有 PDF 都要 OCR。先花三秒判断,能省掉一整套重流程:

from pypdf import PdfReader


def has_text_layer(pdf_path, sample_pages=3, min_chars=30):
    """抽前几页的文字,够多就说明有文字层(不需要 OCR)"""
    reader = PdfReader(str(pdf_path))
    for page in reader.pages[:sample_pages]:
        text = page.extract_text() or ""
        # 去掉空白后够长才算真的有文字层
        if len("".join(text.split())) >= min_chars:
            return True
    return False

用法:

if has_text_layer("合同.pdf"):
    print("有文字层 → 走 MinerU 解析(第 8 章)")
else:
    print("扫描件 → 走 OCR 流水线")

⚠️ 注意:有些 PDF 有文字层但内容是乱的——比如字体编码异常,抽取出来是「¤¤¤」。这种情况 OCR 同样能救回来。所以生产环境里可以加一条兜底:抽出来的文字里非中文非字母的比例过高,也当成扫描件处理。

9.2 跑通 PaddleOCR

PaddleOCR 的安装与基础用法在 OCR 栈第 4 章 已经讲全了,这里只回顾最关键的三行:

pip install paddlepaddle      # ① 推理引擎
pip install paddleocr          # ② OCR 工具包
# 表格结构化(可选)
pip install "paddleocr[doc-parser]"
from paddleocr import PaddleOCR

ocr = PaddleOCR(
    use_doc_orientation_classify=False,   # 关掉方向分类(提速)
    use_doc_unwarping=False,              # 关掉矫正(提速)
    use_textline_orientation=False,
)

result = ocr.predict("scan.png")[0]

print(result["rec_texts"])    # 识别出的文本行
print(result["rec_scores"])   # 每行的置信度
print(result["rec_polys"])    # 每行的四点坐标

三个键就是全部原料:

键 类型 含义
rec_texts list[str] 每行识别出的文字
rec_scores list[float] 每行的置信度(0~1)
rec_polys list[多边形] 每行在图上的位置,用来排阅读顺序

predict 还能直接吃文件夹,并且自带导出:

for res in ocr.predict("screenshots/"):      # 整个文件夹一次跑完
    res.save_to_img("./out")                 # 带框可视化图
    res.save_to_json("./out")                # JSON,便于下游处理

大批量时用 predict_iter(),它是生成器,内存占用恒定。

9.3 OCR 文本不能直接入库

这是本章最值钱的一段:OCR 出来的文本有三个典型毛病——

毛病 表现 后果
噪声行 水印、页眉「机密」、扫描噪点 检索时全被召回,挤掉正文
顺序错乱 表格和多栏被拆成左右两串 语义断裂,块读不懂
断句乱 整页一个巨型「行」 切分无从下手,一块几千字

所以 OCR 结果要过三道工序才能进 RAG。

工序一:置信度过滤

低于阈值的行直接扔掉——它们多半是识别错了的:

MIN_SCORE = 0.8        # 经验值:正文 0.9+,模糊字 0.6~0.8

别设太高(0.95 会把模糊扫描件清空),也别太低(0.5 会放进一堆乱码)。

工序二:按阅读顺序重排

rec_polys 给的是每行在图上的坐标。中文文档通常是从上到下、从左到右,所以:

  1. 按 y 排序 → 分行(同一行的几段文字 y 差不多)
  2. 行内按 x 排序 → 从左到右拼起来
def ocr_to_lines(result, min_score=0.8, page_height=None):
    """predict 结果 → 按阅读顺序排好的文本行列表"""
    lines = []
    for poly, text, score in zip(result["rec_polys"], result["rec_texts"], result["rec_scores"]):
        if score < min_score or not text.strip():
            continue
        xs = [p[0] for p in poly]
        ys = [p[1] for p in poly]
        lines.append({
            "text": text.strip(),
            "score": float(score),
            "x": min(xs),
            "y": min(ys),
            "h": max(ys) - min(ys),
        })

    # 按 y 聚成行:y 差值不超过行高一半就算同一行
    lines.sort(key=lambda l: l["y"])
    rows, current = [], []
    for line in lines:
        if current and abs(line["y"] - current[-1]["y"]) > max(8, current[-1]["h"] * 0.5):
            rows.append(current)
            current = []
        current.append(line)
    if current:
        rows.append(current)

    out = []
    for row in rows:
        row.sort(key=lambda l: l["x"])
        text = " ".join(l["text"] for l in row).strip()
        if text:
            out.append({"text": text, "y": row[0]["y"], "h": row[0]["h"]})
    return out

聚类这一段是「双栏 PDF 不乱序」的关键:两栏文字的 y 会大量重合,单纯按 y 排会把左栏和右栏交替拼在一起。

工序三:页眉页脚清理

每页都有的「公司名 · 第 3 页」这类行,每页重复,建库后会变成几十个雷同的块,检索时霸占 top-k。

判据:同一行文本在多数页面都出现,且位置在页面顶部或底部。

from collections import Counter


def strip_headers_footers(pages_lines, edge=0.12, min_ratio=0.6):
    """pages_lines: [{'text','y','h','page_h'}, ...] 每页一组"""
    if len(pages_lines) < 2:
        return pages_lines

    counter = Counter()
    for lines in pages_lines:
        counter.update({l["text"] for l in lines})

    threshold = max(2, int(len(pages_lines) * min_ratio))
    repeated = {text for text, n in counter.items() if n >= threshold}

    cleaned = []
    for lines in pages_lines:
        keep = []
        for line in lines:
            ratio = line["y"] / max(1, line["page_h"])
            is_edge = ratio < edge or ratio > 1 - edge
            if line["text"] in repeated and is_edge:
                continue          # 重复出现的页眉页脚,扔掉
            keep.append(line)
        cleaned.append(keep)
    return cleaned

两个参数的意义:edge=0.12 表示页面上下 12% 区域算「页眉页脚区」;min_ratio=0.6 表示同一行要在 60% 以上的页面出现才算页眉。只满足一条不会误删正文——正文里反复出现的关键词(比如「报销」)通常不在页面边缘。

9.4 扫描 PDF:先渲染成图,再 OCR

扫描件是「PDF 里包着一张张图」,没有文字层,但页面本身是可以渲染成图片的。用 pypdfium2 渲染(它是 MinerU 的依赖,顺手就装了):

import pypdfium2 as pdfium


def pdf_pages_to_images(pdf_path, out_dir="./pages", scale=2.0):
    """PDF 每页渲染成 PNG。scale=2 约等于 144 DPI"""
    out_dir = Path(out_dir)
    out_dir.mkdir(parents=True, exist_ok=True)

    pdf = pdfium.PdfDocument(str(pdf_path))
    paths = []
    for index in range(len(pdf)):
        image = pdf[index].render(scale=scale).to_pil()
        path = out_dir / f"page_{index + 1:04d}.png"
        image.save(path)
        paths.append(path)
    return paths

scale 是分辨率旋钮:2.0 约 144 DPI(够用),3.0 约 216 DPI(小字更准,但慢 2 倍多)。

然后逐页 OCR:

def ocr_pdf_pages(pdf_path, ocr, out_dir="./pages", min_score=0.8):
    """扫描 PDF → 每页文本行(已按阅读顺序排好)"""
    pages = []
    for image_path in pdf_pages_to_images(pdf_path, out_dir):
        result = ocr.predict(str(image_path))[0]
        page_height = result.get("img_height") or 1
        lines = ocr_to_lines(result, min_score=min_score)
        for line in lines:
            line["page_h"] = page_height
        pages.append(lines)
    return strip_headers_footers(pages)

9.5 图片与截图:同一套流水线

单张图片(截图、照片、白板)走的是完全一样的路径,省掉 PDF 那一步:

def ocr_image(path, ocr, min_score=0.8):
    """单张图片 → 文本行"""
    result = ocr.predict(str(path))[0]
    page_height = result.get("img_height") or 1
    lines = ocr_to_lines(result, min_score=min_score)
    for line in lines:
        line["page_h"] = page_height
    return lines

📌 批量图别一次性全读进内存。用 ocr.predict_iter() 边出边处理,或者每张处理完立刻释放——几百张截图能把你内存吃光。

9.6 表格与复杂版面:PPStructureV3

普通 OCR 只给你「一堆行」,表格会被拆散。要保留表格结构,用 PP-StructureV3:

from paddleocr import PPStructureV3

structure = PPStructureV3()
result = structure.predict("table.png")[0]
print(result["markdown"])      # 表格还原成 Markdown
print(result["html"])          # 或者 HTML

也可以直接导出:

result.save_to_markdown("./out")    # 出 Markdown
result.save_to_word("./out")        # 出 Word

RAG 里怎么用:表格转成 Markdown 之后,整张表当一个块入库,块的文本前面加上表头。这样问「住宿上限多少」时,模型能同时看到表头和对应行,不会只看到「500 元/晚」这种孤儿数据。

def table_to_block(markdown_table, caption=""):
    """整表作为一个块,表头重复一遍,便于独立理解"""
    return f"{caption}\n\n{markdown_table}".strip()

9.7 合成统一入库入口

现在把第 8 章和本章合起来,load_documents 变成一个路由器:

SUPPORTED_TEXT = (".md", ".txt")
SUPPORTED_PDF = (".pdf",)
SUPPORTED_IMAGE = (".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tiff")


def load_document(path, ocr=None):
    """按扩展名与 PDF 有无文字层自动路由,返回块列表"""
    suffix = path.suffix.lower()

    if suffix in SUPPORTED_TEXT:
        return blocks_from_text(path.read_text(encoding="utf-8"), path)

    if suffix in SUPPORTED_PDF:
        if has_text_layer(path):
            return load_pdf_blocks(path)              # 第 8 章:MinerU
        return blocks_from_ocr_pages(ocr_pdf_pages(path, ocr), path)

    if suffix in SUPPORTED_IMAGE:
        return blocks_from_ocr_pages([ocr_image(path, ocr)], path)

    return []


def blocks_from_ocr_pages(pages_lines, path):
    """OCR 出来的页面 → 块,元数据标注页码"""
    blocks = []
    for page_no, lines in enumerate(pages_lines, start=1):
        text = "\n".join(l["text"] for l in lines).strip()
        if not text:
            continue
        for i, chunk in enumerate(split_text(text)):
            blocks.append({
                "text": chunk,
                "metadata": {
                    "source": path.name,
                    "path": str(path),
                    "page": page_no,              # 页码:答案能标到第几页
                    "chunk_id": i,
                    "parser": "ocr",
                },
            })
    return blocks

路由表一目了然:

输入 判断依据 走哪条路 块的元数据
.md / .txt — 直读 + 切分(第 3 章) source / chunk_id
.pdf 有文字层 MinerU(第 8 章) source / chunk_id / parser
.pdf 无文字层 渲染成图 → PaddleOCR source / page / chunk_id / parser
.png / .jpg — PaddleOCR source / page / chunk_id / parser

注意 OCR 块多了一个 page 字段——这是扫描件相比纯文本的最大优势:答案可以直接标「第 4 页」,用户一翻就知道对不对。

9.8 常见翻车现场

报错 / 现象 原因 修法
ModuleNotFoundError: paddle 只装了 paddleocr 没装引擎 补 pip install paddlepaddle
扫描 PDF 抽出来是空的 没有文字层 走 OCR 路线(第 9.1 节先判断)
OCR 结果整段是一大坨 没做阅读顺序重排 用 ocr_to_lines,别直接拼 rec_texts
双栏 PDF 左右串行 只按 y 排序 按 y 聚行、行内按 x 排序
检索全是页眉页脚 没清重复边缘行 用 strip_headers_footers
正文被误删 min_ratio 太小、edge 太大 调回 min_ratio=0.6、edge=0.12
OCR 慢 开了方向分类和矫正 关掉 use_doc_orientation_classify 等三个开关
大批量图片内存爆 一次全读进来 换 predict_iter(),或逐张处理
表格数据对不上 用普通 OCR 而非 PPStructureV3 换 PPStructureV3 拿 Markdown
PPStructureV3 导入失败 少装附加组 pip install "paddleocr[doc-parser]"

9.9 动手改

  1. 拿一份扫描版 PDF,先用 has_text_layer 确认它没有文字层,再走 OCR 路线,把每页文本打印出来人工看
  2. 把 strip_headers_footers 单独跑一遍:打印「被删掉的行」,确认没误伤正文
  3. 找一张双栏 PDF,对比「直接拼 rec_texts」和「ocr_to_lines 重排」的差别
  4. 把第 9.7 节的路由接进你的 load_documents,让同一个文件夹里的 .md、.pdf、.png 能一起入库,再问一个只有扫描件里才有的问题,看能不能命中
  5. 给答案的出处加上页码:(来源:{source} 第 {page} 页)

9.10 小结

  • 先判断有没有文字层,再决定走 MinerU 还是 OCR——这一步省掉大量无用功
  • OCR 文本入库前必过三道工序:置信度过滤 → 阅读顺序重排 → 页眉页脚清理
  • 顺序重排的关键是「按 y 聚行、行内按 x 排」,双栏才不会串
  • 页眉页脚判据:多页重复 + 位置在页面边缘,两个条件缺一不可
  • 扫描 PDF 用 pypdfium2 渲染成图再 OCR,scale 是分辨率旋钮
  • 表格要用 PPStructureV3 转 Markdown,整表当一个块
  • OCR 块的元数据多一个 page 字段——答案能标到第几页

下一章:综合练习,把前面九章拼成一个真能用的知识库助手 🐸

📥 下载本章代码

ZIP 内含本章全部代码文件与说明,解压即用,与教程内容同步生成。