编辑:浮世Talk
一份结构化的 RAG 流水线需要文档的目录(table of contents):检索按章节划定范围,分块器(chunker)在标题边界而非句中切分。但有些文档根本没有目录。一篇直接从 LaTeX 导出的论文既没有原生大纲,也没有印刷出来的目录页,于是流水线无 toc_df 可读。那些标题依然一行行地躺在正文上,比周围的文字更大、更粗。第 5septies 篇(从印刷版目录页重建目录)处理的是至少印了目录页的文档;而这篇文章要处理的文档连目录页都没有。所以流水线只能从剩下的唯一信号——标题「长什么样」——来重建 toc_df。
本文是企业文档智能(Enterprise Document Intelligence)系列中关于文档解析的配套篇,该系列用四块积木(brick)构建一个企业级 RAG 系统。它属于积木 1(文档解析),并收束了由第 5 篇(文档解析)、第 5B 篇(关系型数据模型)和第 5septies 篇(从印刷版目录页重建目录)所开启的「目录重建」这条线索。

📓 可运行的配套笔记本带着 attention 论文(data/paper/1706.03762v7.pdf)走完这个循环:从 line_df + span_df 到一个 24 行的 toc_df,其中包含 21 个真实标题和 3 个被 LLM 校验环节丢弃的误报(false positive):doc-intel/notebooks-vol1。
1. 本文的位置与所做的事
第 5septies 篇(从印刷版目录页重建目录)划了一条界线:目录检测读取一个已经存在的页面(原生大纲或印刷版目录页);而从正文字体排印(typography)中恢复结构,则属于摘要(summarisation)的范畴。这条界线是务实的。它让目录级联保持小巧、可测。但摘要意图有不同的失败模式(长上下文、提示词优先)和不同的保证(没有固定形状、没有来源列)。对于正文中带有清晰字体排印标题的文档,我们需要的不是摘要循环,而是与其他地方完全相同的 toc_df 形状,只不过由正文携带的信号来填充。于是边界移动了:正文字体排印重建是第四个检测案例,而不是摘要的退路。
输入:一个
line_df(当解析器暴露字体排印时再加上span_df),其原生 TOC 与印刷版目录页都为空。输出:一份标准第 5B 篇形状的toc_df,于是检索、分块、摘要都能保持不变地继续工作。
一旦有了这第四个案例,三种相邻的情形也顺带解决:
PDF 完全没有结构,即刚才描述的情况。 PDF 有一个部分原生大纲,停在二级,而正文明显有三级( 3.2.1 ...、3.2.2 ...)。正文字体排印这一步加深了大纲给你的内容。PDF 是复合文档:两个或多个文档拼接而成。章节编号在文件中途重新初始化,而原生大纲(如果有的话)是扁平的。同一步骤加一个合并环节,就能调和这多条流。
文章其余部分按级联尝试它们的顺序,逐一走查这三种情形。
2. 四个案例,一条级联
级联多了一个案例。顺序不变:最便宜的优先,当一个案例不触发或返回太少时,下落到下一个。案例 4 是新的;前三个来自第 5septies 篇(从印刷版目录页重建目录),保持不变。

案例 1,原生大纲;以及 案例 2,带链接的目录页。第 5septies 篇(从印刷版目录页重建目录)覆盖了两者。确定性、便宜、有效时精确。 案例 3,不带链接的印刷版目录页。同一篇文章,用了文本模式匹配加标签到页码的对齐。 案例 4,根本没有目录页。本文。正文字体排印浮现出标题候选;一个 LLM 循环保留真实的那些。
案例 4 在级联中是「按需开启」(opt-in)的,因为它确实最贵:最坏情况下要跑若干遍 LLM,当确定性信号足够干净时一遍即可。通过将 methods=("links", "contents_text", "llm", "body_structure") 传给 reconstruct_toc_df 来启用它;或者当你事先就知道文件没有目录页时,直接调用 reconstruct_toc_from_body。
3. 级联如何重建一份目录
四个案例已就位,下面看看级联到底如何把文档的页面逐步变成一个 toc_df:从它在页面上读到的信号,到最后的 LLM 检查。
3.1 解析器矩阵:每种解析方法给了我们什么
正文字体排印检测器读到的每一条规则,都是抽取出来的表格(table)的一列。所以问题首先不是「那六个信号是什么」,而是「解析器到底给了我哪些列」。答案取决于 PDF 是怎么被解析的。四块积木在上一层;这里我们活在积木 1 内部,而积木 1(在此前各篇中已经)有多种实现。

有两个塑造了代码的实践要点:
PyMuPDF 暴露的是每个 span 的字体排印,而不是每行。 一个 span 是同一字体、字号、字重、斜体和颜色下的一段连续字符。一个混有粗体和非粗体文本的行会展开成多个 span。所以该模块提供一个辅助函数 enrich_line_df_with_style(line_df, span_df),把每行的 span 聚合到一行,并追加font_size(按字符加权)、bold_ratio、is_bold、is_italic、dominant_font_name。当解析器没有字体排印时,调用方传入line_df;当它有字体排印时,传入line_df加上span_df。缺失字体排印不会让循环崩溃。 六个信号函数各自检查自己需要的列,缺失时返回一个零序列(zero series)。这种情况下字号和粗体对分数的贡献为 0.0;四个位置和文本信号(数字前缀、短长度、左对齐、上方空行)挑起大梁。Azure OCR Layout 和 EasyOCR 都属于这种情形。
Mistral OCR 是个特例:它已经以 markdown 形式返回文档,其中的 # / ## / ### 标题就是重建出来的大纲。当你拿到 Mistral 的输出,就完全跳过案例 4,直接读取 markdown 结构。那是解析器矩阵里的「捷径列」。
3.2 标题释放出的六个信号
现在看这六个信号本身。每一个都是「一个输入帧上的单个函数」,各自返回一个 pd.Series[float](取值在 [0, 1]),合并分数是一次普通的加权和。
字号比(Font size ratio)。标题用的字号比正文散文大。Score = (font_size / median_body_size) - 1,裁剪到 [0, 1]。在 10pt 正文中一行 12pt 得 0.2;在 10pt 正文中 16pt 则饱和到 1.0。粗体(Boldness)。标题常常加粗。当富化后的 line_df带有bold_ratio时用它;否则退回用is_bold作为 0/1 的兜底。数字前缀(Numeric prefix)。 1.、1.2、1.2.3、I.、A.。正则^\s*(?:\d+(?:\.\d+)*\.?|[IVXLCDM]+\.|[A-Z]\.)(?:\s|$)。有则返回 1,无则返回 0。它还携带层级信号:1.2.3是三级标题。fitz 假象(Fitz artefact)。 一个 LaTeX 导出的 PDF 常把"1"和"Introduction"作为两行分别吐出;循环在打分前通过merge_split_headings把光秃秃的数字行与其下一行预先合并,于是数字前缀在合并后的行上被找回。短长度(Short length)。标题很短。分数从不足 20 字符的 1 线性衰减到 90 字符的 0。段落很少只落在一行;标题也很少跨两行。 左对齐(Left alignment)。标题从左边距(或该块自身的左缩进)开始。当 x0在很小的容差内匹配页面的中位数左边距时,分数为 1,随距离衰减。上方空行(Blank line above)。标题处在视觉隔离中。当该行上方的垂直间距宽于该页散文行之间的中位数间距时,分数为 1。
单看其中任何一个都不足以定论。只是「又粗又大」会抓到图注("Figure 3: architecture" 常常又粗又大)。只是数字前缀会抓到段内的枚举列表。关键在于把它们组合起来,而组合它们正是权重所在。
# The six per-line signals, cheap, deterministic, engine-agnostic.
from docintel.parsing.pdf.toc.body_structure import (
enrich_line_df_with_style,
score_font_size_ratio,
score_is_bold,
score_has_numeric_prefix,
score_is_short,
score_is_left_aligned,
score_has_blank_before,
)
line_df = enrich_line_df_with_style(line_df, span_df)
signals = {
"font_size_ratio": score_font_size_ratio(line_df),
"is_bold": score_is_bold(line_df),
"has_numeric_prefix":score_has_numeric_prefix(line_df),
"is_short": score_is_short(line_df),
"is_left_aligned": score_is_left_aligned(line_df),
"has_blank_before": score_has_blank_before(line_df),
}
3.3 把信号组合成候选
一个加权和把六个分数变成一个。权重来自先验信念,而非一次训练跑。数字前缀是最强的单一信号(1.6)。字号比次之(1.4)。粗体居中(1.0)。上方空行(0.8)、短长度(0.6)、左对齐(0.4)是辅助证据。权重之和为 5.8。默认阈值是 3.0(约为总和的一半),在 attention 论文上它能抓住每个真实标题,外加一小撮表格单元格误报。
candidates_df = detect_body_headings(line_df, threshold=3.0)
candidates_df[["text", "heading_score", "candidate_level",
"signal_font_size_ratio", "signal_has_numeric_prefix"]].head()
在 attention 论文(Vaswani et al. 2017,一份 arXiv 的 LaTeX 导出)上,阈值 3.5 的确定性一遍找到了 24 个候选:21 个真实章节标题(从 1 Introduction 到 7 Conclusion,含所有子节)和 3 个误报(28.4、4.33、26.4,来自第 8–9 页结果表中恰好又粗又短的 BLEU 分数)。对真实标题的召回率(recall)是 91%(23 个真实标题中的 21 个,1 Introduction 刚好落在阈值之下,在 3.0 时才回来)。精确率(precision)是 88%。来自数字前缀的层级准确率在被恢复的行上是 100%。这些是一次真实运行的真实数字,不是推演。
3.4 规则提议,LLM 校验
这里才是 LLM 做真正检测工作的地方。不是自由形式(free-form),而是固定 schema。提示词描述什么是标题、什么是误报(图注、表格行标签、正文内的加粗强调、结果表中的 BLEU 分数),按阅读顺序给出候选及其页码与片段,并要求返回一个被保留条目的 JSON 列表。被漏掉的标题(小型大写字母、斜体、一个未编号的跋)也能被同一步骤补上,因为它还看到了周围若干行的切片。
循环是有界的。最多 max_passes 次迭代(默认 3)。每次迭代读取当前的保留列表,可以删除条目或提议新增。当某次迭代什么都不改变时即收敛(convergence)。
def my_llm_parse(system_prompt: str, user_content: str) -> list[dict]:
... # user's LLM client, returns the kept-entries JSON list
toc_df = reconstruct_toc_from_body(
line_df,
span_df=span_df,
mode="no_toc",
max_passes=3,
llm_parse=my_llm_parse,
)
有两点值得说清。第一,LLM 被告知它可以返回什么。响应 schema 是固定的(title、page、level、source),与目录级联一致,于是下游代码无需针对「触发了哪个案例」分叉。第二,llm_parse 是一个被注入的可调用对象(callable)。测试时传入一个 mock;CI 可以从 JSON 缓存回放;该模块自己从不打开一个 socket。这是本系列里每个触及 LLM 的模块都遵循的纪律。
3.5 扩展一个部分原生大纲
有些 PDF 的原生大纲停在二级。1. Introduction、1.1 Motivation、2. Method、2.1 Data,然后文件就结束了。但正文明明跑到了 2.1.1、2.1.2、2.2.1。检索积木想要这些。如果章节很长,分块器也想要。
reconstruct_toc_from_body 用 mode="extend_native" 处理这种情况。原生大纲逐字保留(每一行携带 source="native")。正文字体排印这一步在其上运行,任何「标题+页码」组合尚未存在于原生大纲中的候选,都以 source="body_structure" 追加。读者可以逐行看到哪些条目来自文件、哪些来自正文这一步。source 列的存在正是为了这个。
deeper_toc = reconstruct_toc_from_body(
line_df,
existing_toc_df=native_toc, # from doc.get_toc(), stops at level 2
span_df=span_df,
mode="extend_native",
)
deeper_toc[deeper_toc["source"] == "body_structure"].head()
默认的 mode="auto" 会在 existing_toc_df 非空且其最大层级 ≤ 2 时自动走这个分支,于是大多数调用方不必操心该传哪个 mode。
3.6 复合文档,以及一次调用里的 API
另一种情形:单个 PDF 文件拼接了两个或多个文档。一篇 arXiv 论文后面跟着一份补充备忘录。一个提案征集(RFP)包,其中每个供应商的答复都是独立文档。一份「来函与答复」并排摆放的审阅卷宗。其原生大纲(如果有的话)是扁平且混乱的:两个 "1. Introduction" 条目、页码并不单调递增、编号在半途重新初始化。
detect_document_boundaries 寻找三个确定性信号:编号重新初始化(在 "N.something" 之后某页出现一个 "1.")、风格断裂(页面 N-1 与 N 之间中位数字号出现跳变)、封面页(一个带大标题、行数少、无正文的页面)。每个信号返回一个小的边界行帧(frame)。一旦任意边界被确认,reconstruct_toc_from_body(..., mode="composite") 就以新的根重建大纲:每个内部文档成为一个顶层条目("Document 1"、"Document 2"、……),原始大纲嵌套在其下。
这正是「合并」值得花一遍 LLM 的情形,因为编号的重新初始化也可能只是一份在章节内合法重启计数器的文档。合并提示词(MERGE_PROMPT)让 LLM 确认或否决每个边界,然后以 JSON 列表的形式返回合并后的大纲。整个子包通过一个公开入口收敛:
toc_df = reconstruct_toc_from_body(line_df, span_df=span_df, mode="no_toc")
toc_df = reconstruct_toc_from_body(
line_df, existing_toc_df=native_toc, span_df=span_df, mode="extend_native",
)
toc_df = reconstruct_toc_from_body(
line_df, existing_toc_df=native_toc, span_df=span_df, mode="composite",
)
toc_df = reconstruct_toc_from_body(line_df, existing_toc_df=native_toc, span_df=span_df, mode="auto")
对于已经在使用 reconstruct_toc_df、想把正文字体排印案例作为级联兜底而非单独调用的流水线调用方,只需在 methods 元组里加上 "body_structure":reconstruct_toc_df(pdf_path, methods=("links", "contents_text", "llm", "body_structure"))。级联会先尝试目录方法,在它们返回为空时下落至案例 4。
4. 与原生大纲对照测试:效果到底如何
为这套东西打分,诚实的做法是对照「地面真值」(ground truth)。取六份确实携带原生大纲的 PDF,把大纲藏起来,在正文上跑正文字体排印循环,再把重建结果与该文件藏着的大纲对比。这六个测试夹具(fixture)都是一级(tier-1)开源资料,带有不同风格的结构:
Vaswani et al.,《Attention Is All You Need》(arXiv 1706.03762,arXiv 非独占分发)。一份 LaTeX 导出的 NIPS 论文,十进制编号。 NIST,《Zero Trust Architecture》(SP 800-207,美国政府作品,公有领域)。十进制编号,带点引导符(dot-leader)的目录。 NIST,《Protecting Controlled Unclassified Information in Nonfederal Systems and Organizations》(SP 800-171r2,公有领域)。嵌套数字前缀,大量前置内容(front matter)。 NIST,《Standards for Security Categorization of Federal Information and Information Systems》(FIPS 199,公有领域)。很短,附录很多。 NIST,《Securing Distributed Energy Resources: An Example of Industrial Internet of Things Cybersecurity》(SP 1800-32,公有领域)。一份大型实践指南,152 页,混合编号。 FEMA,《NFIP Flood Insurance Manual, Appendices (Policy Forms)》(美国政府作品,公有领域)。这组里最难的 case,用罗马数字命名附录( IV. PROPERTY NOT INSURED)并带有具名子表单。
运行该评测的脚本位于 scripts/checks/eval_toc_body_structure_vs_native.py;下面每一行都仅是最确定性的一步(无 LLM 校验),阈值 3.0、使用默认权重,开启 span_df 字体排印富化。

从表里能读出三件事。
聚合报告:macro 与 micro 讲述不同的故事。聚合行是 macro 平均(每个文档权重为一)。而按原生行数加权的 micro 平均则不同:299 / 415 = 72% 的 micro 召回率,以及 299 / 1481 = 20% 的 micro 精确率。macro(77% 召回)与 micro(72% 召回)之间的差距有一个具体成因:FEMA NFIP(186 个原生行,69% 召回)和 NIST SP 1800-32(104 个原生行,62% 召回)占了原生行数的大部分,且两者都低于 macro。macro 高估了 Vaswani(仅 22 个原生行、100% 召回)。两个数字都重要,单看任何一个都不够。
确定性一步在十进制编号的文档上是个高召回滤波器。在 Vaswani、SP 800-207、SP 800-171r2 与 FIPS 199 上召回率 75% 到 100%。Vaswani 上 100% 的召回正是 fitz 假象合并在起作用:每一对 "1" + "Introduction" 都在运行中重聚。在两个非十进制测试夹具上召回率下降(FEMA NFIP 69%,NIST SP 1800-32 62%)。被漏掉的行,是那些数字前缀正则匹配不到的编号,再加上不带任何前缀的三级具名小节(如 National Flood Insurance Program Dwelling Form)。
没有 LLM 时精确率很差。整个语料上 7% 到 38%。这一步拖进来大量噪声,而噪声落在五个可预见的类别里。

层级准确率是「以匹配为条件」报告的,这个框定掩盖了一个偏差。四个十进制编号文档上 100% 的层级准确率,是(重建层级等于原生层级的匹配行)/ 匹配行数。它告诉你:当算法识别出一个标题、且标题与原生某条匹配时,数字前缀正则会正确读出层级。在 FEMA NFIP 和 NIST SP 1800-32 上,重建正则根本没能分配层级(罗马数字、具名附录),匹配行携带 NaN,条件比率坍缩到 0。诚实的全局指标是(重建预测出正确层级的原生行)/ 全部原生行。它在六个测试夹具上的 micro 恰好是 105 / 415 = 25%。页码准确率在每个匹配行上保持在 ±1 内 100%,但这个近乎同义反复之所以成立,是因为重建与原生都从 fitz 读取 page_num。真正重要的那些行(某标题的原生页码与 fitz 显示文本的位置差了一页)并未出现在这个测试夹具集中。
4.1 算法在真实页面上看到了什么
让这个决策可见的最好办法,是把算法逐行的输出叠加到页面的光栅化图像上。绿色方框是确定性循环打了 3.0 分以上、作为标题候选保留的行。灰色轮廓是算法看过的其他行(正文散文、页眉、脚注)但未被保留。右侧的标签显示 span_df 为每个候选暴露了什么:(level, bold flag, font size, heading score)。


从叠加图上能读出两点。第一,每页的绿框数量很少(Vaswani 第 2 页 4 个,NIST 第 10 页 2 个),这印证了阈值是在挑标题、而非把它们撒满整页。第二,标签显示算法看到了正确的数字:NIST 章节标题 11.9pt 粗体,Vaswani 章节标题 11.9pt 粗体,两者标题分数都接近 4.28(远高于 3.0 阈值)。span_df 暴露出的信号与人工读者会标记为标题的吻合,而打分器组合它们时不需要逐文档调参。
下面是生成这些叠加图的精确代码。它读取流水线其余部分所用的同一个 line_df + span_df,然后遍历该页的候选并画框。
pdf = "data/nist/NIST.SP.800-207.pdf"
line_df = fitz_pdf_to_line_df(pdf)
span_df = build_span_df(pdf)
enriched = enrich_line_df_with_style(line_df, span_df) # per-line font + bold
candidates = detect_body_headings(enriched, threshold=3.0)
page = 10
for _, row in candidates[candidates["page_num"] == page].iterrows():
print(row["text"], row["heading_score"], row["candidate_level"],
row["font_size"], bool(row["is_bold"]))
# 1 Introduction 4.28 1 12.0 True
4.2 LLM 校验带来了什么
上面分类法里的每个类别,都是 LLM 设计成在第一遍就能抓住的。
仅页码:一个文本是单个数字的候选永远不是标题。 目录页的点引导线: "1 Introduction ..........."是一个目录页条目,不是正文标题。封面页作者名:没有数字前缀,页码不对。 表格单元格:又粗又短,但不是章节标题。 HEADING_VALIDATION_PROMPT显式点名了每个类别。以下是在六个测试夹具评测上,用gpt-4.1(Azure OpenAI,开启缓存)跑一遍校验环节的测量效果。

从测量中能读出三件事。
精确率大幅提升。在 attention 论文上从 15% 升到 96%,在 NIST SP 800-207 上从 38% 到 98%,在 FIPS 199 上从 21% 到 100%,在 NIST SP 1800-32 上从 31% 到 82%,在 SP 800-171r2 上从 7% 到 72%。LLM 精准地移除了上面分类法点名的那些误报类别。
六个测试夹具中有五个召回率被保住。LLM 校验是「好的意义上」的保守:它丢弃误报,而不动确定性一步已经找到的真实标题。在 Vaswani 上保住 100% 召回率;在 SP 800-207 上 75%;在 SP 800-171r2 上 97%;在 FIPS 199 上 60%;在 SP 1800-32 上 62%。这五个文档的 micro 聚合与之前相近。
FEMA NFIP 是个例外,LLM 丢掉过多。召回率从 69% 崩到 22%。罗马数字附录(IV. PROPERTY NOT INSURED)和具名子表单(National Flood Insurance Program Dwelling Form)不符合 LLM「带数字前缀的章节标题」心智模型,于是它把这些都连同误报一起丢掉了。这是一个已知的限制:LLM 提示词是针对十进制编号大纲调的。一个按领域定制的提示词、或带纠错批评者(corrective critic)的双遍循环,能在这里挽回。
即便 LLM 也修不好的那个症结更深。看 SP 800-207 有什么是确定性一步真正漏掉的:无编号的顶层标题,如 "References"、"Acronyms",以及封面页的 "NIST SP 800-207, Zero Trust Architecture"。它们不携带数字前缀,字体信号又与正文混在一起。LLM 在看到周围上下文时能提议它们,但确定性召回在构造上就不包含它们。那是该方法的天花板:一份想要被完美嵌套重建大纲的文档,必须为每个标题都带上可见信号。
4.3 这次评测的局限
上面的数字诚实但样本小。严谨的读者应知道四点保留:
阈值 3.0 是默认值,不是调出来的选择。 没有交叉验证,没有 ROC / 精确率-召回率扫描。按语料做一次扫描,很可能明显提升较噪测试夹具上的精确率。 匹配步骤很松。 标题先剥离前导数字做归一化,然后做精确比较。一份带两个名为 Introduction 的小节(正文引言与附录引言)的文档可能匹配任一个,而贪心分配会选最近的页码。 「页码误差在 1 内」这个指标几乎是同义反复。 原生与重建都从 fitz 的 page_num派生page,于是该阶段差一页在构造上就很少见。一个压力测试会随机化地面真值的页码分配,或用一个页码是推断出来的 OCR 测试夹具。六个测试夹具,仅英文,仅一级开源资料。 没有置信区间。没有多语言文档。没有 RTL(从右到左)文字。没有手写 / 扫描的保险 CG(那本身是二级)。该模式以这次评测未能确认的方式泛化。
第 3.4 节的 LLM 校验环节现在已经在这六个测试夹具评测上被测过(见上文 4.2)。micro 精确率从 20% 移到 87%,召回率从 72% 移到 51%(下降完全来自 FEMA NFIP,其罗马数字附录把 LLM 搞糊涂了)。按语料做提示词调参是一个实在的后续工作;当前提示词是针对十进制编号大纲写的。
5. 超越单一层级目录:多标签富化结构
一份被恢复的目录是文档结构的一个维度,但不是全部。两个例子能说清这一点。
保险车险合同。一个标题为 3.2 Collision Guarantee 的小节讲解保障范围,然后点名赔付上限(plafond),再点名适用的除外责任(exclusions)。这些除外责任不一定住在一个独立叫 Exclusions 的章节里。它们住在保障小节内部。一个按章节划定范围(经由 toc_df.start_page、end_page)的检索,被问「哪些除外责任适用于碰撞保障?」时会把整个小节拉回来。答案在那里,但大量噪声也在那里,而查询无法只按除外责任过滤。
复合 / 写得差的文档。一份转录稿、一份会议纪要、一份法律意见、一份取证报告。这些都没有干净的层级目录。主题在段落间反复出现,角色登场又退场,有用的导航不是「哪个章节」而是「哪个主题」。一份转录稿的层级大纲几乎没用。
5.1 每行或每段上的两层:层级来自字体排印,标签来自业务
一句话概括这个想法:算法恢复层级(5octies 循环),业务在其上添加标签。两层叠在同样的行或段上。谁都不替代谁。

第一层:层级(LEVEL)。 来自上面的正文字体排印循环。第 3 章、3.2 节、3.2.1 子节。这就是 toc_df,每个段落经由start_page/end_page挂上章节锚点。第二层:标签(TAGS)。 每个段落一个 list[str],取自一个领域分类法。在保险例子里:["garantie", "garantie:collision", "plafond", "exclusion", "exclusion:vitesse"]。标签来自业务定义的固定分类法(或从语料自举、由专家校验),然后由一个 LLM 为每个段落提议标签。
检索含义:查询「哪些除外责任适用于碰撞保障?」变成第二层上的交集(garantie:collision AND exclusion:*),即便答案住在某个保障小节内部、而非一个除外责任章节里,也是精确的。查询「3.2 节全文」仍能在第一层上工作。两者互补。
5.2 一个具体例子,取自一份合成合同
一份合成出来的车险合同段落上的示例(为本文虚构,不点名任何真实保险公司,重点在模式、不在测试夹具):

这个例子是刻意合成的。真实的保险 générales 条件条款(conditions générales)是二级文档,不能在 TDS 上逐字发布;这张图展示的模式是有代表性的,不是某家保险公司的具体措辞。
5.3 多标签层的诚实局限
这个想法很有力。它也很难。先说清四项成本:
与第一层的冗余。 当一个段落住在名为 Auto guarantees 的小节里时,给它打 garantie 标签常常重复了 toc_df.breadcrumb里已有的信息。有用的标签是那些跨章节边界的:exclusion 在 guarantee 小节里、适用于两个不同保障的 condition。分类法的鸡生蛋问题。 没有固定分类法,LLM 每遍都发明新标签,导致标签泛滥与跨文档不一致。有了固定分类法,标签集又跨领域脆(一个车险分类法不经改造就无法用于健康险合同)。 段落单位模糊。 Fitz 的段落边界不是人类会画的语义段落。一个显示的段落可能跨多个 line_df块、被页脚打断、或跨页延续。第二层需要的是一个段落模型,而不只是行流。每份文档的成本。 一千个段落如果逐段跑标注器,就是一千次 LLM 调用。批处理、缓存和层次化聚类(把一组相似段落一起打标签)对实用很关键。
5.4 它落在哪里,又是什么收束它
打标签这一步属于系列里更靠后的一块积木。两个自然的归属:
卷 2(多格式、多意图文档)。 分类意图(classification intent)针对固定分类法给每个段落打标签。那是第二层,被打包成头等 RAG 意图。 卷 3(智能体积木 / Agentic Bricks)。 一个智能体读取分类法,走查它尚未打标签的段落,提议标签,跨同一小节的段落检查一致性,并对标签被拒的段落重跑。那是带反馈环的第二层。
表格是我在这里没有展开的第三根轴。解析积木把每个表格整体保留;检索积木决定是把表格作为一个块打分,还是把带列头的行逐个序列化后打分。Part III 里的一篇后续文章会构建这个行级检索循环,并与这个解析选择配对。
本文构建的这个目录循环本身仍有用。它是「超级结构」的第一根轴。当用例需要时,第二、第三层叠加其上。
6. 结论
一份既没有大纲、也没有印刷版目录页的 PDF 并非死路。正文携带了标题释放出的每个信号:字号、粗体、数字前缀、短长度、左对齐、上方空行。六个逐行打分函数读自一个富化后的 line_df(解析器矩阵说明了哪些解析器给什么),一个加权和浮现出候选,LLM 在一个有界、收敛即停的循环里校验它们。规则提议,LLM 校验。同一个子包扩展部分原生大纲(mode extend_native)并调和复合文件(mode composite),全部经由一个入口,返回与目录级联相同的 toc_df 形状,并带一个 source 列以便审计能看出触发了哪个案例。检索、分块、摘要都保持不变地继续工作。案例 4 是解析积木里 LLM 第一次做真正检测工作、而非检查一致性的地方,它需要周围的工程纪律:注入的 llm_parse 可调用对象、固定的响应 schema、有界的迭代次数、被缓存的原始响应。
下一篇文章(排队中的 5nonies)用一个智能体解析循环收束解析积木:给定一个文档,挑选到底跑哪些解析方法(fitz、Azure、Docling、EasyOCR、视觉 LLM),按正确顺序执行,把它们的输出合成进一个富化语料。本级联的案例 4 就是那个智能体会从工具箱里挑出的工具之一。
夜雨聆风