Towards Data Science

Reconstructing the Table of Contents a PDF Forgot to Ship, So RAG Can Scope by Section

8.5内容质量

TL;DR · AI 摘要

本文提出了一种从PDF中重建目录结构的方法,用于增强RAG系统的文档解析能力。

核心要点

  • 当PDF文件缺少内置目录时,可通过页面内容重建目录结构。
  • 使用文档中的内容页标题和页码信息生成toc_df。
  • 重建目录可提升RAG系统在文档检索和分块时的准确性。

结构提纲

按章节快速跳转。

  1. 介绍PDF文件中目录缺失的问题及其对RAG系统的影响。

  2. NIST FIPS 202为例,说明PDF文件中内容页与目录结构的不一致。

  3. 重建目录可提升RAG系统在文档检索、分块和摘要时的准确性。

  4. 通过提取内容页中的标题和页码信息,生成结构化的toc_df

  5. 该方法适用于已有内容页但无内置目录的PDF文件。

思维导图

用一张图看清主题之间的关系。

查看大纲文本(无障碍 / 无 JS 友好)
  • PDF目录重建方法
    • 问题背景
      • PDF缺少内置目录
      • 影响RAG系统性能
    • 解决方案
      • 从内容页提取标题和页码
      • 生成结构化toc_df
    • 适用场景
      • 已有内容页的PDF文件

金句 / Highlights

值得收藏与分享的关键句。

#RAG#PDF解析#文档智能#企业应用
打开原文

重建 PDF 忘记携带的目录,使 RAG 能按章节进行范围限定 | Towards Data Science

大型语言模型

重建 PDF 忘记携带的目录,使 RAG 能按章节进行范围限定

企业文档智能 [Vol.1 #5septies] – 当 PDF 打印目录页面但不提供大纲时,两种方法将其重新转换为结构,加上被所有人遗忘的页面对齐步骤

Kezhan Shi

2026 年 6 月 21 日

14 分钟阅读

分享

照片由 aboodi vesakaran 提供,来源:Pexels。

本文是企业文档智能系列的文档解析配套文章,该系列通过四个模块构建企业 RAG 系统。它扩展了第 5 篇文章(文档解析)中的一个表格:toc_df,文档的章节结构,第 5 篇文章在有 PDF 本机大纲(PyMuPDF 的 doc.get_toc)时会从该大纲填充该表格。这一部分讨论的是没有大纲的情况,即从文档页面上仍然显示的内容中重建该结构。

该配套文章的位置:它扩展了第 5 篇文章(文档解析),在第二部分(四个模块)中,当 PDF 没有提供目录时重建目录 – 图片由作者提供

打开 NIST FIPS 202,SHA-3 标准(美国政府作品,公共领域,参见 NIST 版权声明),并翻到第七页。这里有一个清晰的目录:左侧是章节标题,右侧是页码。现在在任何 PDF 查看器中打开同一文件并查看书签面板。为空。目录页面是页面上的墨迹,而不是机器可以使用的结构。作者写了一个完美的目录,但文件在传输时没有暴露它。

第 5 篇文章(文档解析)和第 5B 篇文章(关系数据模型)依赖于 doc.get_toc(),即 PDF 的本机大纲,来填充 toc_df。当它存在时,它是准确的。但很多时候它并不存在。许多实际文档、直接从 LaTeX 导出的论文、打印为 PDF 的合同、政府标准,都带有打印的目录页面但没有大纲。对于这些文档,toc_df 会返回空,尽管文档在第七页上以明文方式告诉你其结构。

这种结构不是一种装饰。检索按章节进行(第 7 篇文章)。分块器在标题边界处进行切割(第 5B 篇文章)。摘要按章节逐步进行。所有这些步骤都会读取 toc_df。当它为空时,检索会退回到扫描每一页,分块器在盲目的页面断点处进行分割,答案会失去文档本身的结构。因此,本文回答的问题是狭窄而实用的:当文件没有提供大纲但打印了目录页面时,如何将该页面重新转换为 toc_df?

首先明确一点,因为很容易混淆。这是关于有目录页面的文档。完全没有目录页面的文档,一篇直接以“1. 引言”开头的论文,一份五页的备忘录,一个删除了所有标题的导出文件,是另一个问题。从无结构文档的主体中恢复骨架是摘要,这是另一个意图,它通过从分块中构建地图,而不是从页面上读取。在这里,我们只读取文档已经拥有的目录页面。

1. 两部分:读取条目,然后找到它们的实际页面

它有助于区分内容页提供的两部分内容。第一部分是带有标题和层级结构的章节列表:文档的内容以及阅读顺序。第二部分是每个章节到文件中实际起始位置的映射。原生大纲会免费提供这两部分信息。阅读印刷版的内容页可以直接提供第一部分,但第二部分只能以印刷标签的形式提供,而这些标签并不是实际的页面。这两部分在失败时表现不同,因此本文其余部分将它们保持分离:首先读取条目,然后将其与实际页面对齐。

输入:一个PDF文件,其doc.get_toc()返回空,但打印了内容页。输出:一个与Article 5B定义的结构相同的toc_df(level、title、start_page、end_page、breadcrumb),因此后续所有操作都可以保持不变。

内容页有两种类型,它们的阅读成本不同。

2. 按成本递增的三种情况

级联依次尝试每种情况,并在找到可用的目录时停止。图片由作者提供。

每种情况都有一个检测步骤和一个提取步骤,当失败或返回内容过少时,会继续下一个情况。

  • 情况1,原生大纲。在Article 5中由build_toc_df处理。免费、精确、层级结构。当它有效时无需做任何事情。我们仅重新回顾它以设定成本基准。
  • 情况2,带有链接的内容页。没有大纲,但早期页面列出了作为超链接的标题,这些链接指向文件内部。链接目标是实际页面,因此此情况完全跳过了对齐问题。
  • 情况3,没有链接的内容页。一个看起来像印刷内容页(标题、点线引导、右对齐的页码)但没有链接的页面。它打印的页码是文档自身的编号,而不是实际页码,因此此情况需要对齐步骤。

所有这些内容都位于一个独立的模块中,与原生路径分开,以确保Article 5保持可读性。入口点是reconstruct_toc_df。

3. 跟随链接

情况2是幸运的情况。一些文档没有大纲,但提供了可点击的内容页。NIST网络安全框架就是其中之一:第二页列出了每个章节作为超链接,可跳转到文档中。PyMuPDF可以按页面暴露这些链接,并且每个内部链接直接携带其目标页面。

输入:PDF文件(链接不在line_df中,因此此阅读器打开文件)。输出:带有标题和已解析的物理目标页面的条目。

检测是通过密度检查进行的:一个页面如果有五个或更多内部链接,它是一个导航页,而不是包含偶尔脚注链接的正文页。提取过程将每个链接的可点击矩形重新映射到其下方的文本,然后剥离引导线和尾随页码标签。

python
import fitz   # PyMuPDF
python
def extract_toc_from_links(pdf_path, min_links=5):
    """目录页是包含最多内部链接的页面。"""
    doc = fitz.open(pdf_path)
    best = []
    for page in doc:
        entries = []
        for link in page.get_links():
            if link["kind"] != fitz.LINK_GOTO:        # 仅内部跳转
                continue
            label = clean(text_under_rect(page, link["from"]))
            if label:
                entries.append({"title": label,
                                "start_page": link["page"] + 1,  # 目标页面
                                "level": 1})
        if len(entries) >= min_links and len(entries) > len(best):
            best = entries                            # 链接最多的页面胜出
    return best

在 Framework 上运行它,恢复的目录是干净的:

每个标题都对应到真实的页面,不需要 LLM,也不需要猜测。图片由作者提供。

将检测器的输出与它读取的页面并排显示,你可以通过肉眼检查。Framework 的目录页列出了每个章节,然后是图示列表和表格列表;检测器恢复了所有三组内容,标题和目标页面一一对应。

左边是文档自身的目录页;右边是检测器返回的结果。图片由作者提供。

这是希望实现的情况。它是确定性的,它精确无误,页面映射由文档本身解决。问题是,大多数没有原生大纲的文档也缺乏可点击的链接,这将我们带入了更困难的情况。

4. 读取打印的目录页,然后找到其真实的页面

情况3是常见的:一个打印的目录,其后没有链接,页面标题为“目录”或“Table of contents”,一列标题,一列页码,通常由点线连接。FIPS 202 就是这种情况。人类可以一目了然地阅读它。解析它有两个明确的步骤,而第二步是人们通常跳过的。

4.1 检测和读取目录页

首先,找到目录页。实际上将目录页与正文区分开的信号是点线密度:几行类似“Some title .......... 42”的格式。关键词“contents”可以提高置信度,但不是必需的,单独使用时是一个较弱的信号(一个句子可以说“table of contents”)。读者仅在 line_df 上工作,因此与引擎无关。

输入:line_df。输出:带有标题和 displayed_page(页面上打印的页码)的条目。

python
import re
# "Introduction ......... 12"             "Introduction       12"
DOTTED   = re.compile(r"^(.*?\S)[.…](?:[.…\s]){2,}(\d{1,3})$")
TRAILING = re.compile(r"^(.{2,70}?\S)\s{2,}(\d{1,3})$")

def extract_toc_from_contents(line_df):
    entries = []
    for page in find_contents_pages(line_df):    # 点线密集的页面
        for line in lines_of(line_df, page):
            m = DOTTED.match(line) or TRAILING.match(line)
            if m:
                title, label = m.group(1).strip(), int(m.group(2))
                entries.append({"title": title,
                                "displayed_page": label,      # 打印的标签
                                "level": infer_level(title)}) # "2.3.1" -> 3
    return entries

4.2 标签不是页码

这里的关键在于,目录页上写着“引言……1”,但文件的第一页是封面,而不是引言。封面、前言和目录页本身位于文件的最前面,因此打印的页码和实际的物理页处于不同的编号空间中。如果你打开文件,跳转到标签所指的物理页,你会发现每次都会提前好几页。

因此,打印的页码只是一个标签,它被记录在 displayed_page 中。将其映射到实际的起始页(start_page)是第二步。简单的方法是假设一个固定的偏移量:physical = displayed + shift。为了找到这个偏移量,可以选取几个标题样本,并尝试所有可能的偏移量,保留使最多标题实际出现在对应偏移页的那个偏移量。

code
def infer_page_shift(line_df, entries, max_shift=40):
    """最佳常量偏移:physical_page = displayed_label + shift."""
    page_text = {p: text_of(line_df, p) for p in pages(line_df)}
    sample = [(e["displayed_page"], norm(e["title"])) for e in entries][:20]
    best_shift, best_score = 0, -1
    for shift in range(-max_shift, max_shift + 1):
        hits = sum(1 for label, title in sample
                   if title in page_text.get(label + shift, ""))
        if hits > best_score:              # 最多标题出现在预测的位置
            best_score, best_shift = hits, shift
    return best_shift

一旦找到前言部分的偏移量,打印的标签 1、2、4、7 将分别对应物理页 4、5、7、10。图片由作者提供。

在真实文档中也会出现同样的情况。FIPS 202 的目录页打印在物理页 7 和 8 上,其正文编号在前言部分之后才开始。对它运行检测和对齐操作后,推断出的偏移量为 +8:目录页上称为“第 1 页”的引言实际上从物理页 9 开始。

有八页的前言内容,因此每个打印的标签在文件中都会晚八页出现。图片由作者提供。

与它所读取的页面并排显示,这两列就是整个设计的重点。标签列再现了目录页上打印的内容;页面列显示了每个部分在文件中实际开始的位置。

左边是文档自身的目录页;右边是检测器返回的内容,包括标签和物理页。图片由作者提供。

一个固定的偏移量可以处理常见的情况。当编号在中间部分重新开始(如附录重置为 1、插入的插页),偏移量就不是固定的,此时的回退方法是内容匹配:通过模糊匹配标题的文本与正文内容,找到每个标题的真实页面,并保持页面编号单调非递减。align_toc_df 首先尝试偏移量,如果失败则回退到内容匹配,因此情况 3 会像情况 2 一样将相同的物理起始页传递给下游。

当打印的目录页过于不规则,无法匹配现有模式(如双栏布局、标题换行、导言以不规则空白呈现),则 LLM 提取器将接管,使用类型化模式读取前几页并返回相同的条目结构。这是针对这种情况的最后手段工具,而不是默认方法,因为干净的打印目录页很容易读取,而 LLM 并不容易。在这里,LLM 仍然只读取目录页;它不会为没有结构的文档发明结构。

5. LLM 处理内容,而不是检测内容

两种检测方法都是启发式方法,而启发式方法难免会出错:一个链接矩形可能包含了两个标题,一个内容行的模式可能被错误地分割,编号可能看起来不太对。对于大型语言模型(LLM)的本能反应是将整个文档交给它,让它生成一个目录(TOC)。这种方法虽然昂贵且审计性最差,但却是最直接的方法。更好的分工方式恰恰相反:启发式方法先提出一个TOC,而LLM只需检查这个TOC是否连贯。

python
from pydantic import BaseModel

class TocCoherenceVerdict(BaseModel):       # 带类型的结构化输出
    is_coherent: bool
    issues: list[str]

SYSTEM = ("一个启发式方法已经提出了这个TOC。请不要检测结构。"
          "仅判断:编号是否一致(没有无法解释的跳过),"
          "页码是否非递减,层级是否构成一个合理的树?")

def check_toc_coherence(toc_df):
    view = "\n".join(f"[{r.start_page}] {'  ' * (r.level - 1)}{r.title}"
                     for r in toc_df.itertuples())
    return llm_parse(input=[{"role": "system", "content": SYSTEM},
                            {"role": "user", "content": view}],
                     text_format=TocCoherenceVerdict, label="toc.coherence")

这种方法比完全依赖LLM提取要更快、更便宜且更易于审计,而且它能够优雅地退化:如果LLM不可用,启发式生成的TOC仍然可以使用,但会受到一定的置信度惩罚。

6. 一个统一的 toc_df,无论哪种方法触发

级联处理的目的是让下游代码永远不知道是哪种情况触发的。无论TOC是从链接中提取的,还是来自打印的目录页面,或者由LLM生成的,它都会通过相同的标准化器处理,并最终以Article 5B中定义的 toc_df 形式输出,同时新增了两列:displayed_page(用于审计的打印标签)和source(记录触发方法的字段)。

python
DETECTORS = {"links":         extract_toc_from_links,     # 情况2
             "contents_text": extract_toc_from_contents,  # 情况3
             "llm":           extract_toc_by_llm}         # 硬布局

def reconstruct_toc_df(pdf_path):
    for method in ("links", "contents_text", "llm"):    # 按成本递增的顺序
        entries = DETECTORS[method](pdf_path)
        if not entries:
            continue                                     # 继续尝试下一个方法
        toc_df = canonicalize(entries, source=method)   # 输出统一的格式
        if method == "contents_text":
            toc_df = align_to_physical_pages(toc_df)     # 标签对齐到实际页码
        return toc_df
    return empty_toc_df()       # 没有目录页时,由摘要处理

调用它只需要一个导入和一行代码。返回的表格就是Article 5B中定义的 toc_df,同时还包含一个source列,记录触发该情况的方法。

python
# NIST FIPS 202 打印了目录页,但没有原生的提纲:
# 情况3触发(contents_text),标签到页码的对齐运行,source="contents_text"。

toc_df = reconstruct_toc_df("data/nist/NIST.FIPS.202.pdf")

toc_df.head()              # 标题、层级、起始页码、结束页码、显示页码、来源
toc_df["source"].iloc[0]   # "links" | "contents_text" | "llm"  -- 触发该情况的方法

在两个示例上运行它,级联处理会将它们路由到最经济有效的方法,而调用者每次都能看到一个统一的 toc_df。

链接用于链接目录页,文本模式用于打印目录页。图片由作者提供。

7. 它的效果如何?

与真实情况对比重建结果是值得的。选取那些本身带有原生大纲的文档,隐藏该大纲,运行目录生成方法,然后将结果与原生目录进行评分。scripts/eval_toc_vs_native.py 就是这么做的:召回率(恢复的原生条目)、精确率(重建的条目中真实存在的比例),以及匹配条目中起始页面与原生页面相差不超过一页的比例。

链接阅读器的匹配度接近精确(链接目标具有权威性);文本模式阅读器则较为宽松,因为阅读印刷页面并对齐标签确实更加困难 – 图片由作者提供

链接案例接近精确,因为链接目标具有权威性;文本案例较为宽松,因为阅读印刷页面并对齐标签确实更加困难。请注意链接阅读器的召回率会随着文档的不同而波动(在 SP 800-30r1 中为 86%,在 SP 800-207 中为 45%,其中许多条目不是链接),但其精确率始终保持较高:它恢复的内容都能正确放置。这两种方法都不是万能的,一致性检查的作用就是捕捉遗漏的部分。

结论

只要 PDF 文件本身打印了目录页面,没有原生大纲的 PDF 并不是死胡同。案例 1 读取文件自带的大纲。案例 2 跟随可点击链接,从而免费获取物理页面。案例 3 读取打印的目录页面,然后执行大多数人会跳过的步骤,将打印的标签映射到实际页面。级联方法会优先尝试成本最低的方法,并在第一个有效的方法上停止,LLM 检查一致性而不是进行检测,所有内容最终都以相同的 toc_df 格式输出。一个完全不打印目录页面的文档是另一个问题,即摘要,它从正文构建结构。文章 7(检索)会拾取这个 toc_df 以按章节范围答案。

系列的早期文章:

  • 文档智能:系列简介。系列逐步构建的内容和顺序。
  • 基线企业 RAG,从 PDF 到高亮答案。四步流程完整端到端:PDF 输入,高亮答案输出。
  • 嵌入向量并非魔法:RAG 检索的可预测失败模式。嵌入相似性在哪些情况下胜出(同义词、拼写错误、改写),在哪些情况下可预测失败(未知术语、否定、术语与答案的相关性),以及如何在这些情况下使用它。
  • 重排序器也不是魔法:交叉编码器层是否值得成本。交叉编码器相对于双编码器嵌入向量的额外优势,以及何时值得承担延迟成本。
  • RAG 不是机器学习,而机器学习工具包解决的是错误的问题。为什么块大小调整和微调优化了错误的东西;应根据问题类型进行路由。
  • 从正则表达式到视觉模型:哪种 RAG 技术适合哪种问题。两个轴,文档复杂性和问题控制,用于为每种情况选择技术。
  • 我们在生产中持续看到的 10 个常见 RAG 错误。10 个生产错误,按砖块逐一组织,每个错误都有对应的解决方案。
  • 超越 extract_text:驱动 RAG 质量的 PDF 的两个层次。解析砖块的第一部分:文档的特性、信号和摘要。
  • 停止从 PDF 返回扁平文本:RAG 需要的关系结构。解析砖块的第二部分:每个下游砖块读取的关系表。

撰写者

查看 Kezhan Shi 的所有文章

文档处理

,

Llm

解析

Pdf

Rag

分享本文

  • 在 Facebook 上分享
  • 在 LinkedIn 上分享
  • 在 X 上分享

Towards Data Science 是一份社区出版物。提交你的见解,以触达我们的全球读者,并通过 TDS 作者支付计划获得报酬。

更新 href 为你的实际投稿网址

为 TDS 写作

✦ 结束 CTA ✦