跳到主要内容

后端层:把原始字节变成可处理形态

30 秒导读: 上一章(01-entry-dispatch.md)讲了 Docling 怎么从一个 source 认出格式、挑出「后端 + 流水线」这一对。本章只讲这一对里的后端:它是每种输入格式 的「解码器」,把 PDF / Word / HTML / Markdown 的原始字节,变成下游能处理的形态。关键在于后端 分成两个物种——一种能自己把文档直接变成 DoclingDocument(声明式),另一种只负责按页交付 像素和文本、把「读懂」留给流水线(分页式)。这条分界线,正是「为什么 HTML 配简单流水线、PDF 配识别流水线」的根因。


1. 这是什么(零基础也能懂)

一句话定义: 后端(backend)是 Docling 里每种文件格式各自的读取器,住在 backend/ 目录下, 职责是「把这一坨字节解码成我能处理的东西」。

它解决什么问题: 一个 .docx 是 zip 里的 XML,一个 .pdf 是压缩的绘图指令流,一个 .md 是纯文本。它们长得毫不相干。如果让上层流水线直接去啃这些字节,每加一种格式都要改一遍流水线。 后端层就是那道隔离墙:上层只跟一个统一契约打交道,脏活(解 zip、调 PDF 解析器、跑 marko 解析 Markdown)全塞进各自的后端里。

一句话直觉/类比: 把后端想成读卡器。SD 卡、U 盘、光盘物理接口各不同,但插上读卡器后, 操作系统看到的都是「一个文件系统」。后端就是让千奇百怪的文件格式,对上层呈现出同一副面孔

但这里有个关键分叉。 不是所有格式都「一样好读」:

  • Markdown / HTML / Word 这类格式,结构是明写在字节里的——# 就是标题,<table> 就是 表格。读一遍就能直接搭出结构化文档。这类叫**声明式(declarative)**后端。
  • PDF / 扫描图片 这类格式,字节里只有「在坐标 (x,y) 画了个字符」,没有「这是标题」「这是 表格」的语义。要靠 AI 模型一页页去版面、认表格、认公式。这类后端只能先把每一页的像素和 文本单元交出来,叫**分页式(paginated)**后端。

这一章就把这两个物种、它们共享的契约、以及各自的代表实现讲清楚。


2. 顶层全景(后端层大概怎么转)

2.1 一张图:两个物种,一个共同祖先

先看继承关系。所有后端都从 AbstractDocumentBackend 长出来,然后立刻分成两支:

AbstractDocumentBackend ← 共同契约(abstract_backend.py:19)
__init__ / is_valid /
supports_pagination /
supported_formats / unload

┌───────────────┴────────────────┐
▼ ▼
DeclarativeDocumentBackend PaginatedDocumentBackend
多一个: convert() → 直接产出 多一个: page_count()
DoclingDocument "我是多页文档,按页处理"
(abstract_backend.py:66) (abstract_backend.py:54)
│ │
┌────────┼─────────┐ ▼
▼ ▼ ▼ PdfDocumentBackend
Markdown HTML Word/Excel/ load_page(n) / iter_pages()
Csv/Epub Latex PPT/ODF/XML… supports_random_page_access
… (pdf_backend.py:59)

┌──────────┼───────────┐
▼ ▼ ▼
DoclingParse PyPdfium Image / Threaded…

怎么读这张图: 从上往下是「越来越具体」。顶层定契约(每个后端都得有的能力);中层分出「能不能 自己产出文档」这条根本分野;底层是一个个真实格式的读取器。

2.2 两个物种,一句话职责

物种多出来的能力产物谁用它代表格式
声明式 DeclarativeDocumentBackendconvert()直接是一个 DoclingDocumentSimplePipeline(直出)MD、HTML、DOCX、XLSX、PPTX、CSV、ODF、XML、EPUB…
分页式 PaginatedDocumentBackend / PdfDocumentBackendpage_count() + load_page() / iter_pages()每页的像素 + 文本单元StandardPdfPipeline(逐阶段识别)PDF、扫描图片、METS-GBS

这张表就是后续所有章节的分水岭:「有 convert()」的走 03-pipelines.md 里的 声明式直出;「只有按页接口」的走 04-pdf-recognition-stages.md 里的 逐阶段模型识别。本章只讲后端这一层为什么这么分,不讲流水线怎么编排。

2.3 一次调用里,后端在哪一步登场

上层 DocumentConverter 拿到输入后,会为它实例化对应后端,再把后端交给流水线:

source ──认格式──▶ FormatOption(后端类 + 流水线类)


backend = 后端类(in_doc, 字节, options) ← 本章主角:字节 → 可处理形态

┌─────────────┴──────────────┐
▼ ▼
声明式: backend.convert() 分页式: for page in backend.iter_pages()
→ DoclingDocument → 每页交给识别模型

3. 共同契约:AbstractDocumentBackend

本节讲:所有后端无论哪个物种,都必须实现的那几件事。看懂这五个方法,就看懂了上层能对任意 后端做什么。

契约定义在 backend/abstract_backend.py:19AbstractDocumentBackend。它是个 ABC(抽象基类), 用 @abstractmethod 强制子类实现这几样:

方法签名要点干什么
__init__(in_doc, path_or_stream, options)记下文件名、字节流、文档哈希、输入格式、后端选项
is_valid()-> bool「这份字节我能读吗」——读不了返回 False,上层据此判失败
supports_pagination()classmethod -> bool「我是不是分页的」——在实例化之前就能问的类方法
supported_formats()classmethod -> set[InputFormat]「我认哪些格式」
unload()关流、释放资源处理完把 BytesIO 关掉、指针置空

__init__ 做的事很朴素——把输入文档的元信息摊平存下来:

# 真实源码 backend/abstract_backend.py:21-31 · AbstractDocumentBackend.__init__
self.file = in_doc.file
self.path_or_stream = path_or_stream
self.document_hash = in_doc.document_hash
self.input_format = in_doc.format
self.options = BaseBackendOptions() if options is None else options

这段是说:后端不自己去「找」文件,一切(路径或内存字节流 path_or_stream、内容哈希、已判定的 格式)都由上层 InputDocument 喂进来。后端只管解码,不管定位(定位是 01 章的活)。

unload() 有个默认实现值得一看——它是唯一有默认体的方法:

# 真实源码 backend/abstract_backend.py:42-46 · AbstractDocumentBackend.unload
def unload(self):
if isinstance(self.path_or_stream, BytesIO):
self.path_or_stream.close()
self.path_or_stream = None

即:如果输入是内存字节流就关掉它。PDF 这类持有原生句柄(pypdfium2 / docling-parse)的后端会 重写它,额外释放 native 内存(见 §5.3)。

supports_pagination() 为什么是类方法? 因为上层想在还没开始解码时就知道该配哪种流水线。 类方法让它不必先造一个后端实例、耗掉解析成本,就能问「你是分页的吗」。


4. 分野一:声明式后端(有 convert(),直接产出文档)

本节讲:结构写在字节里的格式,后端怎么「读一遍就产出成品」。

4.1 它多出来的那一个方法

DeclarativeDocumentBackend(backend/abstract_backend.py:66)在契约上只加了一件事:

# 真实源码 backend/abstract_backend.py:73-86 · DeclarativeDocumentBackend
def __init__(self, in_doc, path_or_stream, options=None):
if options is None:
options = DeclarativeBackendOptions() # 声明式默认选项
super().__init__(in_doc, path_or_stream, options)

@abstractmethod
def convert(self) -> DoclingDocument: # ← 核心:一步到位
pass

convert() 的语义在类文档字符串里写得很直白:"can transform to DoclingDocument straight without a recognition pipeline"(backend/abstract_backend.py:66-71)——不需要识别流水线,直接变成 DoclingDocument。这就是「声明式」三个字的全部含义。

4.2 代表实现 A:HTML 后端

HTMLDocumentBackend(backend/html_backend.py:403)。它在 __init__ 里用 BeautifulSoup 把字节 解析成 DOM 树:

# 真实源码 backend/html_backend.py:452-459 · HTMLDocumentBackend.__init__
raw = (
path_or_stream.getvalue() # 内存流
if isinstance(path_or_stream, BytesIO)
else Path(path_or_stream).read_bytes() # 或磁盘文件
)
self._raw_html_bytes = raw
self.soup = BeautifulSoup(raw, "html.parser") # 解析成 DOM

is_valid() 就是「soup 建起来了吗」;supports_pagination() 明确返回 False——它不是分页的:

# 真实源码 backend/html_backend.py:467-473
def is_valid(self) -> bool:
return self.soup is not None

@classmethod
def supports_pagination(cls) -> bool:
return False

convert() 则遍历 DOM,把 <title>、标题层级、段落、表格逐个 doc.add_*(...) 进一个新建的 DoclingDocument:

# 真实源码 backend/html_backend.py:487-497 · HTMLDocumentBackend.convert(节选开头)
def convert(self) -> DoclingDocument:
if not self.is_valid():
raise RuntimeError("Invalid HTML document.")
origin = DocumentOrigin(
filename=self.file.name or "file",
mimetype="text/html",
binary_hash=self.document_hash,
)
doc = DoclingDocument(name=self.file.stem or "file", origin=origin)
# …随后遍历 self.soup,把标题/段落/表格 add_* 进 doc…

重点看:从字节到成品文档,全程在这一个方法里跑完,没有任何模型识别环节。

4.3 代表实现 B:Markdown 后端

MarkdownDocumentBackend(backend/md_backend.py:98)套路完全一样,只是解析器换成 marko(一个 Markdown → AST 解析库):

# 真实源码 backend/md_backend.py:696-711 · MarkdownDocumentBackend.convert(节选)
if self.is_valid():
marko_parser = Markdown()
parsed_ast = marko_parser.parse(self.markdown) # 文本 → 抽象语法树
self._iterate_elements( # 遍历 AST,填充 doc
element=parsed_ast, depth=0, doc=doc, parent_item=None,
visited=set(), creation_stack=[],
list_ordered_flag_by_ref={}, list_last_item_by_ref={},
)
self._close_table(doc=doc)

有个巧妙的细节:Markdown 里可以内嵌 HTML 块。当检测到 HTML 块时,MD 后端会把中间产物导出成 HTML,再委托给 HTMLDocumentBackend 去解析(backend/md_backend.py:713-736)——声明式后端之间 互相复用,而不是各自重复造 HTML 解析轮子。

4.4 为什么声明式后端配 SimplePipeline

因为活儿已经在 convert() 里干完了。上层流水线拿到的是「一个后端,convert() 一喊就吐出成品 DoclingDocument」——流水线只需喊一声、收下结果、做点富化收尾即可。这就是 01 章里 SimplePipeline 的由来,细节留给 03-pipelines.md


5. 分野二:分页后端(按页交付,把「读懂」留给流水线)

本节讲:PDF / 图片这类「字节里没有语义结构」的格式,后端怎么只做「交付原料」这一半的活。

5.1 它多出来的方法:按页

PaginatedDocumentBackend(backend/abstract_backend.py:54)只加了 page_count()。真正干活的 PdfDocumentBackend(backend/pdf_backend.py:59)在此之上定义了完整的按页接口:

# 真实源码 backend/pdf_backend.py:78-105 · PdfDocumentBackend(节选)
supports_random_page_access: ClassVar[bool] = True # 能否随机跳页

@abstractmethod
def load_page(self, page_no: int) -> PdfPageBackend: # 取第 n 页
pass

@abstractmethod
def page_count(self) -> int: # 共几页
pass

def iter_pages(self) -> Iterator[PdfPageBackend]: # 顺序遍历所有页
for page_index in range(self.page_count()):
yield self.load_page(page_index)

@classmethod
def supported_formats(cls) -> Set[InputFormat]:
return {InputFormat.PDF}

@classmethod
def supports_pagination(cls) -> bool:
return True

注意 iter_pages() 有默认实现——默认就是「数页数,一页页 load_page。这个默认让「随机取页」 的后端不必单独写遍历逻辑;而流式后端会重写它(见 §5.4)。

5.2 页级契约:PdfPageBackend

一个 PDF 后端交出来的不是文档,而是一叠「页对象」。每个页对象实现 PdfPageBackend (backend/pdf_backend.py:18),暴露的是「原料级」接口——注意这里没有一个方法叫「产出文档」:

页级方法交付什么
get_text_cells()这一页的文本单元(带坐标的字/词/行)
get_text_in_rect(bbox)某个矩形区域内的文本
get_bitmap_rects()页内位图/图片的矩形
get_page_image(scale, cropbox)把这一页渲染成像素图(喂给版面/OCR 模型)
get_size()页面尺寸 Size(width, height)(pdf_backend.py:46-48)
get_segmented_page()解析后的分段页对象

这就是分页后端与声明式后端的本质差别: 声明式后端交「成品」,分页后端交「像素 + 文本单元」, 「这是标题还是表格」的判断不在后端里,而在下游识别流水线的模型里(见 04-pdf-recognition-stages.md)。

5.3 代表实现:PDF 后端体系

Docling 的默认 PDF 后端是 DoclingParseDocumentBackend(backend/docling_parse_backend.py:264), 它同时握着两个原生句柄:pypdfium2 负责渲染像素,docling-parse 负责抽取文本单元

# 真实源码 backend/docling_parse_backend.py:320-333 · DoclingParseDocumentBackend.load_page
def load_page(self, page_no, create_words=True, create_textlines=True):
assert self.dp_doc is not None
with pypdfium2_lock:
ppage = self._pdoc[page_no] # pypdfium2 的页(用于渲染)
return DoclingParsePageBackend( # 包成页级后端
dp_doc=self.dp_doc, page_obj=ppage, page_no=page_no,
create_words=create_words, create_textlines=create_textlines,
)

页级 get_size() 直接问 pypdfium2 要宽高:

# 真实源码 backend/docling_parse_backend.py:239-242 · DoclingParsePageBackend.get_size
def get_size(self) -> Size:
with pypdfium2_lock:
page = self._require_page()
return Size(width=page.get_width(), height=page.get_height())

另一个更轻的 PDF 后端PyPdfiumDocumentBackend(backend/pypdfium2_backend.py:409)——只用 pypdfium2,不跑 docling-parse。它的 page_count() / load_page() / is_valid() 一样精简:

# 真实源码 backend/pypdfium2_backend.py:431-440 · PyPdfiumDocumentBackend
def page_count(self) -> int:
with pypdfium2_lock:
return len(self._pdoc)

def load_page(self, page_no) -> PyPdfiumPageBackend:
with pypdfium2_lock:
return PyPdfiumPageBackend(self._pdoc, self.document_hash, page_no)

def is_valid(self) -> bool:
return self.page_count() > 0 # 能数出页数就算有效

两者的分工:PyPdfium 系拿字符但拿不到高质量的词/行切分,DoclingParse 系文本结构更细——这也是 04 章识别质量的一个上游变量。

命名坑(务必注意): DoclingParseV4DocumentBackend(backend/docling_parse_v4_backend.py:13) 这个名字已在 docling 2.74.0 被移除,如今它只是个发 FutureWarning 的兼容别名,内部直接 super().__init__(...) 转调 DoclingParseDocumentBackend。要引用「当前的 docling-parse PDF 后端」, 应指向 DoclingParseDocumentBackend,而非 v4 那个名字。

5.4 一个关键旗标:supports_random_page_access

PdfDocumentBackend 默认 supports_random_page_access = True——上层可以 load_page(37) 直接跳到 第 37 页。但并非所有分页后端都能随机取页

ThreadedDoclingParseDocumentBackend(backend/docling_parse_backend.py:458)是个流式多线程后端, 它把这个旗标翻成 False,并且禁掉了随机取页,只允许顺序流式遍历:

# 真实源码 backend/docling_parse_backend.py:459, 546-553
supports_random_page_access = False # ← 声明:我不能随机跳页

def load_page(self, page_no):
raise NotImplementedError(
"ThreadedDoclingParseDocumentBackend only supports iter_pages()."
)

def iter_pages(self): # 只能顺序流式产页
for result in self.parser.iterate_results():
yield ThreadedDoclingParsePageBackend(result)

为什么重要: 这个旗标是后端向流水线声明自己的取页能力。想「随机访问 / 乱序调度页面」的 流水线看到 False 就知道只能顺序消费。它是后端层与流水线层之间的一条契约信号——细节属于 03/04。

5.5 为什么分页后端配识别流水线

因为分页后端只交原料、不交成品。要把「第 5 页坐标 (120,88) 的这堆字符」变成「一个二级标题」, 必须再跑版面检测、表格识别、OCR、阅读顺序等一串模型。承接这些的就是 StandardPdfPipeline,逐阶段 拆解见 04-pdf-recognition-stages.md


6. 后端全景表(格式 → 后端类 → 物种)

下表覆盖 _get_default_option()(document_converter.py:236-267)里注册的所有默认后端。「物种」列的 判据:有 convert() 的是声明式,走按页接口的是分页

输入格式 InputFormat默认后端类物种默认流水线
MDMarkdownDocumentBackend声明式SimplePipeline
HTMLHTMLDocumentBackend声明式SimplePipeline
CSVCsvDocumentBackend声明式SimplePipeline
DOCXMsWordDocumentBackend声明式SimplePipeline
XLSXMsExcelDocumentBackend声明式SimplePipeline
PPTXMsPowerpointDocumentBackend声明式SimplePipeline
ODT / ODS / ODPOdt/Ods/OdpDocumentBackend声明式SimplePipeline
ASCIIDOCAsciiDocBackend声明式SimplePipeline
XML_USPTOPatentUsptoDocumentBackend声明式SimplePipeline
XML_JATSJatsDocumentBackend声明式SimplePipeline
XML_DOCLANGDocLangDocumentBackend声明式SimplePipeline
XML_XBRLXBRLDocumentBackend声明式SimplePipeline
JSON_DOCLINGDoclingJSONBackend声明式SimplePipeline
VTTWebVTTDocumentBackend声明式SimplePipeline
LATEXLatexDocumentBackend声明式SimplePipeline
EMAILEmailDocumentBackend声明式SimplePipeline
EPUBEpubDocumentBackend声明式SimplePipeline
PDFDoclingParseDocumentBackend分页StandardPdfPipeline
IMAGEImageDocumentBackend(子类化 PdfDocumentBackend,image_backend.py:127)分页StandardPdfPipeline
METS_GBSMetsGbsDocumentBackend分页StandardPdfPipeline
AUDIONoOpBackend(noop_backend.py:13)特例(不解码)AsrPipeline

读表要点:

  • 声明式和分页的分布,和 §2.2 的「谁配哪种流水线」完全对齐——这不是巧合,是同一条设计线。
  • IMAGE分页:扫描图片和 PDF 一样「只有像素、没有语义」,所以它干脆子类化 PdfDocumentBackend 复用整条 PDF 识别链路。
  • AUDIO 是个特例:NoOpBackend(空操作后端)根本不解码字节,因为音频的「解析」是转写,由 AsrPipeline 里的语音模型完成,后端只是个占位。

7. BackendOptions:按输入给后端「定制参数」

本节讲:后端拿到的第三个构造参数 options 从哪来、怎么按具体输入动态调整。

7.1 每种后端有自己的一套选项

backend/datamodel/backend_options.py 用 Pydantic 定义了一族选项类,共同祖先是 BaseBackendOptions (datamodel/backend_options.py:7),各格式再派生自己的:

选项类服务谁有代表性的字段
HTMLBackendOptions (:24)HTML 后端source_urirender_pagefetch_imagesmax_redirects
MarkdownBackendOptions (:119)MD 后端source_urifetch_images
PdfBackendOptions (:167)PDF 后端passwordenforce_same_font
ThreadedDoclingParseBackendOptions (:182)流式 PDF 后端parser_threadsrelease_native_memory_every_n_pages

这些类被收进一个 discriminator="kind" 的判别联合 BackendOptions(datamodel/backend_options.py:341)—— 每个选项类带一个 kind 字面量("html"/"md"/"pdf"…),序列化/反序列化时靠它认类型。

7.2 backend_options_for_input:按具体输入动态注入

有些参数要等看到具体这一份输入才能填。最典型的是 HTML 的 source_uri——HTML 里常有相对链接 <img src="pics/a.png">,后端得知道「这份 HTML 来自哪个 URL / 路径」才能把相对路径解析成绝对路径。

BaseFormatOption 给了一个可重写的钩子(datamodel/base_models.py:79-82),默认返回 None; HTMLFormatOption 重写它,把当前这个 source 塞进 source_uri:

# 真实源码 document_converter.py:158-171 · HTMLFormatOption.backend_options_for_input
def backend_options_for_input(self, source):
options = self.backend_options
if (
options is None
or options.source_uri is not None # 用户已显式给了,不覆盖
or isinstance(source, DocumentStream) # 内存流没有 URL 可注入
):
return options
return HTMLBackendOptions.model_validate( # 否则把 source 注入 source_uri
{**options.model_dump(), "source_uri": source}
)

逻辑很克制:只有在用户没显式指定、且输入是真实路径/URL 时,才自动注入。注入后,HTML 后端 __init__ 就用它当 base_path 去解析相对资源(backend/html_backend.py:417-419)。

这就是「同一个后端类,面对不同输入拿到不同 options」的机制:FormatOption实例化后端之前先 调 backend_options_for_input(source) 算出这一份输入专属的 options,再传进后端构造函数。上层怎么串起 这一步,属于 01 章的分派流程。


8. 边界与局限(后端层刻意不做什么)

  • 后端不认格式、不找文件。 「这是不是 PDF」「文件在哪」是 01 章 _DocumentConversionInput 的活。 后端只在已知格式、已拿到字节后才登场。
  • 分页后端不产出文档。 它交的是页级像素 + 文本单元,「读懂」由 03/04 的流水线与模型完成。后端里 找不到「这是标题」的判断逻辑。
  • 可选依赖缺失时,后端在解析那一刻才报错。 例如 MD 后端把 marko 的导入包在 try/except 里 (backend/md_backend.py:48-58),因为 DocumentConverter急切导入所有后端;真正解析 Markdown 时才检查依赖在不在,好让缺 format-markdown extra 的精简安装仍能 import docling
  • 流式后端不能随机取页。 ThreadedDoclingParseDocumentBackend.load_page() 直接 raise NotImplementedError(backend/docling_parse_backend.py:546),只认 iter_pages()

9. 代码地图(导航索引)

主题文件路径关键符号
后端共同契约backend/abstract_backend.pyAbstractDocumentBackendis_validsupports_paginationsupported_formatsunload
声明式后端基类backend/abstract_backend.pyDeclarativeDocumentBackendconvert
分页后端基类backend/abstract_backend.pyPaginatedDocumentBackendpage_count
PDF 后端契约backend/pdf_backend.pyPdfDocumentBackendPdfPageBackendload_pageiter_pagesget_sizesupports_random_page_access
HTML 声明式后端backend/html_backend.pyHTMLDocumentBackendconvertsupports_pagination
Markdown 声明式后端backend/md_backend.pyMarkdownDocumentBackendconvert
默认 PDF 后端(docling-parse)backend/docling_parse_backend.pyDoclingParseDocumentBackendDoclingParsePageBackendload_pageget_size
流式多线程 PDF 后端backend/docling_parse_backend.pyThreadedDoclingParseDocumentBackenditer_pagessupports_random_page_access
轻量 PDF 后端(仅 pypdfium2)backend/pypdfium2_backend.pyPyPdfiumDocumentBackendPyPdfiumPageBackend
已废弃的 v4 别名backend/docling_parse_v4_backend.pyDoclingParseV4DocumentBackend(转调 DoclingParseDocumentBackend)
图片后端(复用 PDF 链路)backend/image_backend.pyImageDocumentBackend
后端选项定义datamodel/backend_options.pyBaseBackendOptionsHTMLBackendOptionsPdfBackendOptionsBackendOptions
按输入定制选项document_converter.pyHTMLFormatOption.backend_options_for_inputFormatOption
默认「格式→后端+流水线」映射document_converter.py_get_default_option

上一章: 01-entry-dispatch.md — 入口与三级分派:从 source 到流水线 下一章: 03-pipelines.md — 流水线层:声明式直出 vs 多线程分阶段