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 给的是每行在图上的坐标。中文文档通常是从上到下、从左到右,所以:
- 按
y排序 → 分行(同一行的几段文字 y 差不多) - 行内按
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 动手改
- 拿一份扫描版 PDF,先用
has_text_layer确认它没有文字层,再走 OCR 路线,把每页文本打印出来人工看 - 把
strip_headers_footers单独跑一遍:打印「被删掉的行」,确认没误伤正文 - 找一张双栏 PDF,对比「直接拼
rec_texts」和「ocr_to_lines重排」的差别 - 把第 9.7 节的路由接进你的
load_documents,让同一个文件夹里的.md、.pdf、.png能一起入库,再问一个只有扫描件里才有的问题,看能不能命中 - 给答案的出处加上页码:
(来源:{source} 第 {page} 页)
9.10 小结
- 先判断有没有文字层,再决定走 MinerU 还是 OCR——这一步省掉大量无用功
- OCR 文本入库前必过三道工序:置信度过滤 → 阅读顺序重排 → 页眉页脚清理
- 顺序重排的关键是「按 y 聚行、行内按 x 排」,双栏才不会串
- 页眉页脚判据:多页重复 + 位置在页面边缘,两个条件缺一不可
- 扫描 PDF 用
pypdfium2渲染成图再 OCR,scale是分辨率旋钮 - 表格要用 PPStructureV3 转 Markdown,整表当一个块
- OCR 块的元数据多一个
page字段——答案能标到第几页
下一章:综合练习,把前面九章拼成一个真能用的知识库助手 🐸