数据截至 (上游 commit c4b5ed6202d6)
PDF 识别流水线:逐阶段的模型
30 秒导读: 一页 PDF 里没有"段落""表格""标题"这些概念——只有像素和散落的字符。Docling 的
StandardPdfPipeline把每一页依次交给一串各司其职的模型:先取页图、补 OCR、检测版面框、识别表结构,再把框组装成元素、排出阅读顺序、定标题层级,最后对图片做富化。本章逐个讲这些模型:它们统一的调用接口、流水线顺序、每个模型解决的小问题和关键源码。
本章聚焦 docling/models/stages/ 下被 StandardPdfPipeline 依次调用的各阶段模型——这是整个项目工程含量最高的地方。流水线的线程编排(多线程分阶段、背压、超时)不在这里讲,见 03-pipelines.md;产物 DoclingDocument 的数据模型与 RAG 导出见 05-datamodel-output-rag.md。
1. 这是什么(零基础也能懂)
一句话定义: 一条识别流水线——把"一页 PDF"从像素/字符,一步步还原成结构化的"文档"。
它要解决的问题。 PDF 是"打印格式",不是"结构格式"。文件里存的是"在坐标 (x,y) 画字符 A、画一条线、贴一张位图",没有"这是标题""这是表格第 2 行第 3 列"这类信息。要做 RAG / 检索,就必须把这些语义结构重新识别出来。
怎么解决:分工。 Docling 不用一个大模型硬吞整页,而是排一支专家小队,每人只干一件小事,产物交给下一个人:
| 阶段模型 | 干的那一件事 |
|---|---|
| page_preprocessing | 渲染页图 + 抽 PDF 自带的文本单元(cell) |
| ocr | 给"图片型"区域补文字(扫描件、位图) |
| layout | 检测版面框:哪块是标题/正文/表格/图 |
| table_structure | 把"表格框"识别成行列单元格 |
| page_assemble | 把框 + 文字组装成 TextElement / Table / Figure |
| reading_order | 排出人类阅读顺序,生成 DoclingDocument |
| heading_hierarchy | 给标题定层级(用 PDF 书签/编号/字号) |
| enrichment(富化) | 事后加料:代码/公式、图片分类、图片描述、图表转表 |
一句话直觉: 像一条装配线——毛坯(一页像素)从一端进,每个工位拧一个螺丝(OCR、版面、表格……),另一端出来一个装好的文档。工位之间只靠一个标准托盘(Page 对象)传递,谁也不用知道上一位怎么干的。
2. 顶层全景:统一接口 + 固定顺序
2.1 所有阶段模型长一个样:BasePageModel
这是整条流水线能"即插即换"的关键。每个页级模型都实现同一个接口——吃 (ConversionResult, 一批 Page),吐"同一批 Page"(就地改过):
# docling/models/base_model.py:38
class BasePageModel(ABC):
@abstractmethod
def __call__(
self, conv_res: ConversionResult, page_batch: Iterable[Page]
) -> Iterable[Page]:
pass
因为签名统一,流水线可以把模型当积木塞进线程阶段,循环调用而不关心里面是 OCR 还是版面检测(见 03 的线程 编排)。conv_res 是整份文档的上下文(用来记时、记信心分、报错),page_batch 是要处理的页;模型把预测结果挂到 page.predictions.* 上再 yield page。
记住这条铁律:页级模型不返回新对象,而是就地丰富
Page并原样 yield。后面每个模型都遵守它。
2.2 富化模型是另一个接口:GenericEnrichmentModel
图片分类、代码公式、图表这类"针对某个已生成的文档元素"的模型,吃的不是 Page 而是 DoclingDocument 里的 NodeItem:
# docling/models/base_model.py:146
class GenericEnrichmentModel(ABC, Generic[EnrichElementT]):
elements_batch_size: int = settings.perf.elements_batch_size
def is_processable(self, doc, element) -> bool: ... # 这个元素归我管吗?
def prepare_element(self, conv_res, element): ... # 备料(可能裁剪出图像)
def __call__(self, doc, element_batch) -> Iterable[NodeItem]: ... # 处理并回填
两个接口的分工很清楚:
| 接口 | 何时跑 | 吃什么 | 典型成员 |
|---|---|---|---|
BasePageModel | 识别阶段(逐页) | 一批 Page | preprocessing / ocr / layout / table / assemble |
GenericEnrichmentModel | 富化阶段(逐元素) | DoclingDocument 的 NodeItem | code_formula / picture_classifier / picture_description / chart |
reading_order 和 heading_hierarchy 两个"文档级"模型两边都不属于——它们吃整个 conv_res / DoclingDocument,是普通类(见 §5、§6)。
2.3 固定的流水线顺序
StandardPdfPipeline._init_models 一次性建好所有重模型(docling/pipeline/standard_pdf_pipeline.py:601),然后 _create_run_ctx 把前五个页级模型串成线程阶段,顺序写死在 wiring 里:
# docling/pipeline/standard_pdf_pipeline.py:727
preprocess.add_output_queue(ocr.input_queue)
ocr.add_output_queue(layout.input_queue)
layout.add_output_queue(table.input_queue)
table.add_output_queue(assemble.input_queue)
assemble.add_output_queue(output_q)
组装完的页汇总后,再在 _assemble_document 里跑两个文档级模型(standard_pdf_pipeline.py:1049):
conv_res.document = self.reading_order_model(conv_res) # 排序 + 生成 DoclingDocument
conv_res.document = self.heading_hierarchy_model(conv_res) # 定标题层级
一张图看清全程(从左到右,页图进、文档出;虚线是"事后"的富化):
一页 PDF
│
▼
┌───────────────┐ 取页图 + 抽 PDF 文本单元(cell)
│ preprocess │ PagePreprocessingModel
└──────┬────────┘
▼
┌───────────────┐ 给图片型区域补文字
│ ocr │ BaseOcrModel(easyocr/tesseract/rapid/mac/auto…)
└──────┬────────┘
▼
┌───────────────┐ 检测版面框 → Cluster(标题/正文/表/图)
│ layout │ LayoutModel.predict_layout
└──────┬────────┘
▼
┌───────────────┐ 表格框 → 行列单元格
│ table │ TableStructureModel.predict_tables
└──────┬────────┘
▼
┌───────────────┐ Cluster → TextElement/Table/Figure
│ assemble │ PageAssembleModel
└──────┬────────┘
▼ (所有页汇总)
┌───────────────┐ 阅读序 + 生成 DoclingDocument
│ reading_order │ ReadingOrderModel
└──────┬────────┘
▼
┌───────────────┐ 标题层级(书签 > 编号 > 字号)
│ heading_hier. │ HeadingHierarchyModel
└──────┬────────┘
▼
DoclingDocument ┈┈┈▶ 富化(逐元素,可选)
code_formula / picture_classifier
picture_description / chart_extraction
3. page_preprocessing:取页图 + 抽文本单元
它要解决的小问题: 后面所有模型都需要两样东西——页面的位图(给视觉模型看)和 PDF 自带的文字单元(programmatic cells,能直接读的字)。这个阶段就是把这两样准备好挂到 Page 上。
__call__ 对每页做两步:渲染图 + 解析 cell(docling/models/stages/page_preprocessing/page_preprocessing_model.py:40):
# page_preprocessing_model.py:44
with TimeRecorder(conv_res, "page_parse"):
page = self._populate_page_images(page) # 渲染并缓存页图
if not self.options.skip_cell_extraction:
page = self._parse_page_cells(conv_res, page) # 从 backend 抽文本单元
_parse_page_cells 还顺手给这页打一个解析质量分:对每个 cell 的文本跑 rate_text_quality,再取 10% 分位数作为 parse_score(page_preprocessing_model.py:88)。质量分的作用是标记"这页 PDF 抽出来的字是不是垃圾"——比如出现 GLYPH<...> 占位符、� 替换符、/G12/G13 这种字体乱码就直接判 0 分:
# page_preprocessing_model.py:120 rate_text_quality
blacklist_chars = ["�"]
if (any(text.find(c) >= 0 for c in blacklist_chars)
or self.GLYPH_RE.search(text) # GLYPH<00A3>
or self.SLASH_G_RE.search(text) # /G12/G13...
or self.SLASH_NUMBER_GARBAGE_RE.match(text)):
return 0.0
关键细节: page.get_image(scale=1.0) 会把页图放进内部缓存,后续 layout/table 阶段再取同一张图就不用重渲染。skip_cell_extraction 是给"纯 VLM 处理"留的旁路——那条路不需要 PDF 文本单元。
4. OCR:多引擎 + 工厂选型
它要解决的小问题: PDF 里有些内容是图片(扫描件、截图、贴的位图),没有文字层。OCR 阶段的活是:找出这些图片区域,识别里面的字,再把新字并回页面——同时别覆盖已经有的、可靠的 PDF 原生文字。
4.1 共同骨架:BaseOcrModel
所有 OCR 引擎(easyocr / tesseract / rapid / mac / nemotron / kserve)都继承 BaseOcrModel,共享两段核心逻辑,只有"真正识别文字"那步各写各的。
第一步:算出该 OCR 哪些矩形。 get_ocr_rects 把页面上所有位图矩形画进一张二值图,膨胀 10 像素让相邻位图连成块,再取连通域当作候选 OCR 区域(docling/models/base_ocr_model.py:94)。三种结果:
| 位图覆盖率 | 返回 |
|---|---|
force_full_page_ocr 或 覆盖率 > 0.75 | 整页一个大矩形(当扫描件处理) |
高于 bitmap_area_threshold | 各个位图区域的框 |
| 太低 | 空(这页基本是文字,不 OCR) |
第二步:去重合并。 post_process_cells(base_ocr_model.py:292)把 OCR 结果和已有 PDF 原生字交给 _merge_ocr_and_pdf_cells:优先侧(prioritized,按 OCR 模式可由 PDF 或 OCR 担任)全保留,次要侧与优先侧空间重叠的 cell 丢弃——用 R-tree 做空间索引加速这个"是否相交"的判断:
# base_ocr_model.py:289 _merge_ocr_and_pdf_cells
idx = index.Index(properties=p) # R-tree
# 索引较小的一侧,保留与 prioritized 不重叠的 secondary cell(弱但有效的判据)
例外:FULL_PAGE OCR 模式下 PDF 原生字被认为不可靠,直接全用 OCR 结果替换(base_ocr_model.py:310;旧的 force_full_page_ocr 选项已弃用,由 OcrOptions.mode 收编)。
4.2 auto:按平台+已装库自动挑引擎
OcrAutoModel 是"我不想选引擎"的默认:构造时按优先级逐个 try-import,谁装了就用谁(docling/models/stages/ocr/auto_ocr_model.py:27)。选择级联(命中即停):
darwin? → 试 ocrmac(苹果系统自带 OCR)
linux? → 试 nemotron
仍无? → 试 rapidocr(onnxruntime 后端)
仍无? → 试 easyocr
仍无? → 试 rapidocr(torch 后端)
仍无? → 警告:没有可用 OCR 引擎
跑的时候它只是个转发壳——把 __call__ 委托给选中的真引擎(auto_ocr_model.py:140)。没启用或没引擎就原样放行页面。
4.3 引擎怎么被"注册"和"选中":OCR 工厂
具体用哪个引擎,由 pipeline_options.ocr_options 的类型决定——EasyOcrOptions → EasyOcrModel,TesseractOcrOptions → TesseractOcrModel,以此类推。这套"选项类型 → 模型类"的映射由工厂管理(详见 §8)。_make_ocr_model 就是拿工厂按选项造实例:
# standard_pdf_pipeline.py:655 _make_ocr_model
factory = get_ocr_factory(
allow_external_plugins=self.pipeline_options.allow_external_plugins
)
return factory.create_instance(options=self.pipeline_options.ocr_options, ...)
5. layout:版面框检测 → Cluster
它要解决的小问题: 页面上一堆文字和图,哪块是标题、哪块是正文、哪块是表格、哪块是图?这是"读懂文档结构"的第一步,也是后面所有阶段的骨架。
5.1 统一接口在基类,推理在子类
BaseLayoutModel.__call__ 只是薄薄一层——把批量页转给 predict_layout,再把预测挂回每页(docling/models/base_layout_model.py:53):
# base_layout_model.py:58
pages = list(page_batch)
predictions = self.predict_layout(conv_res, pages)
for page, prediction in zip(pages, predictions):
page.predictions.layout = prediction
yield page
真正的检测在 LayoutObjectDetectionModel.predict_layout:把整页图打包成批量输入,交给可插拔的 object-detection 推理引擎 engine.predict_batch,得到一批带 label + 置信度 + bbox 的框,包成 Cluster(docling/models/stages/layout/layout_object_detection_model.py:84;旧的 LayoutModel 已退化为它上面的弃用垫片):
# layout_object_detection_model.py:157
for idx, (label_id, score, bbox_coords) in enumerate(
zip(engine_output.label_ids, engine_output.scores, engine_output.bboxes)
):
label = self._label_map.get(label_id)
...
clusters.append(Cluster(id=idx, label=label,
confidence=score, bbox=bbox, cells=[]))