PageIndex

3 阅读31分钟
  1. 02-PageIndex-检索与实战
  2. 01-PageIndex-深度解析
  3. 04-PageIndex-设计决策与陷阱
  4. 03-PageIndex-逐行代码解析

02-PageIndex-检索与实战

PageIndex 检索与实战——深度解析

本文是 01-PageIndex-深度解析.md 的续篇,聚焦于索引产物的使用方式多文档检索策略真实输出数据结构,以及从源码和测试结果中提炼出的工程实战经验


一、PageIndex 的两个阶段

理解 PageIndex 需要明确区分两个阶段:

阶段一:索引构建(Indexing)
  输入:PDF / Markdown
  输出:JSON 树(包含结构 + 摘要)
  消耗:多次 LLM 调用(一次性成本)
  对应:page_index.py / page_index_md.py

阶段二:检索使用(Retrieval)
  输入:用户查询 + JSON 树
  输出:命中的节点 ID → 读取对应页面内容
  消耗:1-2LLM 调用
  对应:tutorials/ cookbook/

本文重点覆盖阶段二,以及两个阶段配合的完整 RAG 链路。


二、真实输出结构解析

2.1 实际 JSON 树格式

以迪士尼 Q1 FY2025 财报(q1-fy25-earnings_structure.json,49 KB)为例,输出结构如下:

{
  "doc_name": "q1-fy25-earnings.pdf",
  "doc_description": "迪士尼 2025 财年第一季度财报,包含各业务板块的营收、利润数据...",
  "structure": [
    {
      "title": "FINANCIAL HIGHLIGHTS",
      "start_index": 1,
      "end_index": 3,
      "node_id": "0001",
      "summary": "本节展示了迪士尼 Q1 FY2025 的关键财务指标...",
      "nodes": [
        {
          "title": "Revenue by Segment",
          "start_index": 2,
          "end_index": 2,
          "node_id": "0001.0001",
          "summary": "娱乐、体育、体验三大板块的营收细分...",
          "nodes": []
        }
      ]
    },
    {
      "title": "ENTERTAINMENT",
      "start_index": 4,
      "end_index": 15,
      "node_id": "0002",
      "summary": "娱乐业务板块涵盖流媒体、线性电视和内容授权...",
      "nodes": [...]
    }
  ]
}

与第一篇文档的字段对照:

字段说明是否必选
doc_name源文件名
doc_descriptionLLM 生成的文档整体描述配置项(默认 no)
structure节点数组
title章节标题
start_index物理起始页(0-based)
end_index物理结束页(含)
node_id层级编号(0001.0002 格式)配置项(默认 yes)
summary节点摘要配置项(默认 yes)
nodes子节点(替代 children是(空数组表示叶节点)

注意:CHANGELOG 显示字段名从 child_nodes 改为 nodes(2025-04-03),使用时注意版本兼容性。

2.2 不同文档的输出规模对比

文档文件大小特点
earthmover_structure.json3.1 KB短文档,结构简单
2023-annual-report_structure.json14 KB标准年报格式
PRML_structure.json50 KB学术教材,章节细分深
q1-fy25-earnings_structure.json49 KB财报,节点摘要详细
Regulation Best Interest_proposed rule_structure.json130 KB监管文件,最大输出

规律:监管文件 > 教材 > 年报 > 财报 > 短论文,文档结构复杂度直接决定 JSON 树大小。


三、树搜索:检索阶段的核心

3.1 基础 LLM 树搜索

最简单的检索策略,直接将整棵 JSON 树传给 LLM:

prompt = f"""
You are given a query and the tree structure of a document.
You need to find all nodes that are likely to contain the answer.

Query: {query}

Document tree structure: {PageIndex_Tree}

Reply in the following JSON format:
{{
  "thinking": <your reasoning about which nodes are relevant>,
  "node_list": [node_id1, node_id2, ...]
}}
"""

这个 Prompt 的工程含义:

  1. 要求 thinking 字段:强制 LLM 先推理再给答案(Chain-of-Thought),提高定位准确率
  2. node_id 而非标题:避免歧义(同名章节),精确定位到树节点
  3. "all nodes":允许多节点召回,适应答案跨章节分布的情况

检索到节点 ID 后的后续步骤:

# 从 node_id 反查页码范围
def get_page_range(tree, node_ids):
    result = []
    for node_id in node_ids:
        node = find_node_by_id(tree, node_id)
        result.append({
            "node_id": node_id,
            "start_page": node["start_index"],
            "end_page": node["end_index"]
        })
    return result

# 从 PDF 中读取对应页面
for range in page_ranges:
    content = extract_pages(pdf_path, range["start_page"], range["end_page"])
    context += content

# 最终问答
answer = llm.ask(question=query, context=context)

3.2 高级:MCTS 树搜索(官方方案)

官方文档透露其检索 API 内部使用了蒙特卡洛树搜索(MCTS)+ 价值函数

MCTS 树搜索原理:

根节点(文档)
    │
    ├─ 模拟(Simulation):随机选择子节点路径
    ├─ 扩展(Expansion):深入有潜力的节点
    ├─ 回溯(Backpropagation):将"找到答案"的信号传回父节点
    └─ 选择(Selection):UCB 公式平衡探索与利用

为什么用 MCTS?

传统树搜索的问题:深度优先可能陷入错误子树,广度优先对深层文档代价太高。

MCTS 的优势:

  • 自适应搜索深度:有价值的路径被更多探索
  • 并行友好:多个模拟路径可并发执行
  • 适合不确定性:对"答案在哪个节点"的不确定性建模天然合理

价值函数的角色

Value(node) = P(答案在此节点或其子树中 | query, node.summary)

这个函数由 LLM 估算(基于摘要和查询的相关性),指导 MCTS 的节点选择,避免纯随机探索。

3.3 融入专家知识

PageIndex 的树搜索天然支持将领域知识注入 Prompt,这是向量 RAG 做不到的:

# 金融文档专家知识示例
expert_knowledge = """
- 查询涉及 EBITDA 调整时,优先查看 Item 7(MD&A)和 Item 8 附注
- 查询涉及风险因素时,优先查看 Item 1A
- 查询涉及股权结构时,优先查看 Item 12
"""

prompt = f"""
Query: {query}
Document tree: {tree}
Expert Knowledge: {expert_knowledge}

Find all relevant node_ids.
"""

向量 RAG 的对比劣势:要让向量 RAG 利用专家知识,必须重新 fine-tune embedding 模型或修改 chunk 权重,工程成本极高。PageIndex 只需修改 Prompt 字符串。


四、多文档检索策略

PageIndex 默认针对单文档检索,但 tutorials/ 提供了三种多文档扩展方案。

4.1 方案一:元数据过滤(Metadata Search)

适用场景:文档可以用结构化属性区分(公司名 + 年份 + 报告类型)

架构:
  文档集合
      │
      ├─ 建立 SQL 表(doc_id, company, year, type, ...)
      │
  用户查询:"苹果公司 2023 年年报的营收是多少?"
      │
      ├─ LLM → SQL:"SELECT doc_id WHERE company='Apple' AND year=2023 AND type='annual_report'"
      │
      ├─ 命中 doc_id → PageIndex 树搜索
      │
      └─ 答案

工程优势

  • 过滤精确,不存在语义误召回
  • SQL 查询速度快(O(log N) 索引)
  • 对 LLM 的依赖最小(只用于 Query → SQL 转换)

局限:需要预先为每个文档打结构化元数据标签,对无规律命名的文档库不适用。

4.2 方案二:语义向量过滤(Semantics Search)

适用场景:文档内容多样,主题差异大,无法用元数据区分

这是 PageIndex 和向量检索的混合架构

阶段一(文档级粗筛,用向量):
  为每个文档的 chunks 建立向量库
  查询 → 向量检索 → 获取 Top-K chunks 及其 doc_id
  计算每个文档的得分(DocScore)
  选出 Top-M 文档

阶段二(节点级精搜,用 PageIndex):
  对 Top-M 文档分别做 LLM 树搜索
  精确定位到节点 + 页面
  最终问答

关键的 DocScore 公式:

DocScore=1N+1n=1NChunkScore(n)\text{DocScore} = \frac{1}{\sqrt{N+1}} \sum_{n=1}^{N} \text{ChunkScore}(n)

  • NN:该文档命中的 chunk 数量
  • ChunkScore(n)\text{ChunkScore}(n):第 n 个 chunk 的向量相似度得分
  • N+1\sqrt{N+1} 分母:惩罚大文档,防止长文档因 chunk 多而得分虚高

为什么用 N+1\sqrt{N+1} 而非 NN

如果分母是 NN,得分变为均值,对短文档和长文档一视同仁。 如果分母是 N+1\sqrt{N+1}长文档需要更多高分 chunk 才能赢过短文档,避免文档越长越容易被召回的偏差。

本质上这是一个召回率与精确率的 trade-off 调节参数\sqrt{} 是经验值,可根据业务场景调整指数。

4.3 方案三:描述过滤(Description Search)

适用场景:文档数量少(<100),无元数据,主题相近

# 步骤 1:为每个文档生成一句话描述(基于 PageIndex 树)
prompt = f"""
You are given a table of contents structure of a document.
Generate a one-sentence description that makes it easy to distinguish from other documents.

Document tree: {PageIndex_Tree}

Directly return the description.
"""

# 步骤 2:用户查询时,LLM 对比查询与所有文档描述
prompt = f"""
Query: {query}

Documents: [
  {{"doc_id": "001", "doc_name": "...", "doc_description": "..."}},
  {{"doc_id": "002", "doc_name": "...", "doc_description": "..."}},
  ...
]

Return relevant doc_ids as JSON list.
"""

优势:无需额外数据库,实现最简单 劣势:文档多时 Prompt 过长,LLM 处理能力下降,不适合 >100 文档的场景

4.4 三种方案对比

维度元数据过滤语义向量描述过滤
适用文档数任意任意<100
需要预处理结构化标签向量嵌入生成描述
检索精度最高中等较低
实现复杂度中(需 SQL)高(向量库)最低
适用文档类型有规律的多样化少量任意

五、完整 RAG 链路实战

5.1 最小可运行示例(单文档)

参考 cookbook/pageindex_RAG_simple.ipynb,完整链路:

import asyncio
from pageindex import page_index
from openai import OpenAI

client = OpenAI()

# ── 阶段一:建立索引(只做一次)──
async def build_index(pdf_path):
    tree = await page_index(
        pdf_path=pdf_path,
        config={
            "model": "gpt-4o-2024-11-20",
            "if_add_node_summary": "yes",
            "if_add_node_id": "yes",
        }
    )
    return tree

# ── 阶段二:树搜索 ──
def tree_search(tree, query):
    tree_str = json.dumps(tree["structure"], ensure_ascii=False)
    prompt = f"""
Query: {query}
Document tree: {tree_str}

Find all relevant node_ids. Reply in JSON:
{{"thinking": "...", "node_list": ["0001", "0002.0001", ...]}}
"""
    response = client.chat.completions.create(
        model="gpt-4o-2024-11-20",
        messages=[{"role": "user", "content": prompt}]
    )
    result = json.loads(response.choices[0].message.content)
    return result["node_list"]

# ── 阶段三:读取页面内容 ──
def get_content_by_node_ids(tree, node_ids, pdf_path):
    content = ""
    for node_id in node_ids:
        node = find_node(tree["structure"], node_id)
        pages = extract_pdf_pages(pdf_path, node["start_index"], node["end_index"])
        content += f"\n[{node['title']}]\n{pages}"
    return content

# ── 阶段四:最终问答 ──
def answer_question(query, context):
    prompt = f"Context:\n{context}\n\nQuestion: {query}\nAnswer:"
    response = client.chat.completions.create(
        model="gpt-4o-2024-11-20",
        messages=[{"role": "user", "content": prompt}]
    )
    return response.choices[0].message.content

# ── 完整流程 ──
async def rag_pipeline(pdf_path, query):
    tree = await build_index(pdf_path)          # 阶段一
    node_ids = tree_search(tree, query)          # 阶段二
    context = get_content_by_node_ids(           # 阶段三
        tree, node_ids, pdf_path
    )
    answer = answer_question(query, context)     # 阶段四
    return answer

5.2 视觉 RAG 变体(Vision RAG)

对于扫描件 PDF 或图表丰富的文档,cookbook/vision_RAG_pageindex.ipynb 提供了另一条路径:

区别:
  普通 RAG:   PDF → 提取文字 → LLM 读文字
  Vision RAG: PDF → 渲染为图片 → LLM 看图片(GPT-4o Vision)

适用场景

  • 扫描件(文字提取质量差)
  • 含大量图表(文字提取丢失视觉信息)
  • 数学公式密集(LaTeX 提取不完整)

代价:图片 token 消耗约为文字的 3-5 倍,成本更高。


六、从 CHANGELOG 看项目演进

Beta - 2025-04-03
  新增:node_id、node summary、document description
  变更:child_nodes  nodes(字段名简化)

Beta - 2025-04-23
  修复:start_index 传参错误(April 18 引入的回归 bug)

工程观察

  1. 快速迭代:两个 Beta 版本相隔不到三周,说明项目处于活跃开发期
  2. 字段重命名child_nodes → nodes 是简化 API 的好决策,但下游用户需适配
  3. 回归 bugstart_index 错误说明在添加 node_id/summary 功能时引入了副作用,测试覆盖不足

七、Issue 去重系统(.claude/commands/dedupe.md)

这是一个有趣的元工程细节:PageIndex 用 Claude Code + GitHub CLI 自动化 Issue 去重。

7.1 工作流程

触发:新 Issue 创建
    │
    ├─ gh issue view → 读取 Issue 内容
    │
    ├─ 跳过条件检查(已关闭 / 已标记 duplicate / 已有去重评论)
    │
    ├─ 5 路并行搜索(不同关键词策略)
    │   ├─ 精确术语
    │   ├─ 同义词
    │   ├─ 错误信息
    │   ├─ 组件名
    │   └─ 宽泛类别
    │
    ├─ 候选去重分析(相似度 ≥ 85% 才标记)
    │
    └─ 调用 comment-on-duplicates.sh 写评论

7.2 85% 置信度阈值的意义

系统要求"至少 85% 确信"才标记为 duplicate,这是精确率优先的设计选择:

  • 假阳性代价高:错误地把新 Issue 标为 duplicate,导致真实 bug 被忽略
  • 假阴性代价低:漏掉 duplicate 只是多一个重复 Issue,人工审核可处理

阈值设为 85% 而非 95%,是因为 Issue 措辞差异大,过高阈值导致漏检太多。

7.3 对 PageIndex 本身的反证

这个系统本身就是 LLM 辅助工程的一个例子,与 PageIndex 解决文档检索的思路一致:用 LLM 的语言理解能力替代关键词匹配,实现更鲁棒的相似性判断。


八、工程实战中的注意事项

8.1 Token 成本估算

处理一份 100 页、平均每页 500 tokens 的 PDF:

阶段LLM 调用次数(估算)Token 消耗(估算)
TOC 检测~5 次(前 20 页逐页检测)~10K
TOC 提取与转换~3 次~8K
页码对齐验证~5 次~15K
节点摘要生成~N 个节点次~N × 1K
合计(无摘要)~15 次~33K
合计(含摘要,20 节点)~35 次~53K

GPT-4o 价格约 0.005/1Ktokens(输入),首次索引一份标准年报大约花费0.005/1K tokens(输入),首次索引一份标准年报大约花费 **0.3 - $1.0**。

8.2 常见失败模式

失败模式 1:扫描件 PDF

症状:page_index.py 提取文字为空或乱码 根本原因:PyMuPDF/PyPDF2 无法处理图像型 PDF 解决方案:使用 Vision RAG 路径,或先用 OCR 工具(如 Adobe、AWS Textract)转换

失败模式 2:TOC 页码与正文不匹配

症状:节点 start_index 指向错误页面 根本原因:PDF 元数据页码与显示页码偏差非标准,众数算法失效 诊断方式:检查 calculate_page_offset() 的候选偏移量分布,看是否有多个竞争众数 解决方案:手动指定偏移量,或使用 process_no_toc() 路径

失败模式 3:节点摘要生成超时

症状:大型文档(>200 节点)生成摘要耗时极长 根本原因:摘要生成是异步并发,但受 OpenAI API 速率限制 解决方案:设置 if_add_node_summary: "no",先获取结构树,摘要按需生成

失败模式 4:大节点递归过深

症状:递归调用栈溢出,或某个超大章节分解异常 根本原因:某些章节内容极其密集(如附录包含大量表格) 解决方案:降低 max_token_num_each_node 限制,或对附录类节点单独处理

8.3 最佳实践

配置建议:

# 生产环境推荐配置
config = {
    "model": "gpt-4o-2024-11-20",  # 不要降级到 3.5,结构识别质量差很多
    "toc_check_page_num": 30,       # 对序言长的学术书适当加大
    "max_page_num_each_node": 8,    # 节点不宜太大,影响检索精度
    "max_token_num_each_node": 15000,  # 15K 比 20K 更安全
    "if_add_node_id": "yes",        # 必须开启,检索依赖
    "if_add_node_summary": "yes",   # 强烈建议开启,树搜索质量关键
    "if_add_doc_description": "yes", # 多文档场景必须开启
    "if_add_node_text": "no",       # 一般不需要,节省 JSON 大小
}

索引存储建议:

# 建立本地缓存,避免重复索引
import hashlib
import json

def get_or_build_index(pdf_path, config):
    # 用文件内容 hash 作为缓存 key
    with open(pdf_path, "rb") as f:
        file_hash = hashlib.md5(f.read()).hexdigest()

    cache_path = f"./cache/{file_hash}_structure.json"

    if os.path.exists(cache_path):
        with open(cache_path) as f:
            return json.load(f)

    tree = asyncio.run(page_index(pdf_path, config))

    with open(cache_path, "w") as f:
        json.dump(tree, f, ensure_ascii=False, indent=2)

    return tree

九、与其他框架的定位关系

9.1 PageIndex vs LlamaIndex

维度LlamaIndexPageIndex
定位通用 RAG 框架(支持多种检索策略)专用文档结构索引
向量检索核心能力不支持
树形检索支持(TreeIndex)核心能力
PDF 处理基础(文字提取)深度(结构识别)
使用场景通用知识库结构化长文档

组合使用:可以用 LlamaIndex 做向量检索粗筛,用 PageIndex 做精确定位。

9.2 PageIndex vs 传统 PDF 解析(pdfplumber、camelot)

维度传统解析PageIndex
结构识别方式规则/格式启发式LLM 语义理解
适应能力格式特定通用(任何文档结构)
准确率对标准 PDF 高对复杂文档更高
速度快(无 LLM)慢(多次 LLM)
成本接近零API 调用费用

十、总结:PageIndex 的本质

回到最核心的问题:PageIndex 究竟在做什么?

它在做的是一件传统 NLP 长期未能很好解决的事:让计算机理解文档的"意图结构"而非"物理结构"

物理结构(传统 PDF 解析能做到):

  • 这页有多少文字
  • 这里有一条横线(可能是分隔符)
  • 这段文字字号更大(可能是标题)

意图结构(PageIndex 通过 LLM 做到):

  • 这是一个完整的"章节",有逻辑起点和终点
  • 这个标题和目录中的某条目是同一个概念
  • 这一节的"主旨"可以用一句话概括

这种从"物理"到"意图"的跨越,正是 LLM 时代文档处理能力的质变所在。


文档基于 PageIndex 源码、tutorials/、cookbook/ 分析生成 上接:01-PageIndex-深度解析.md(索引构建阶段)


01-PageIndex-深度解析

PageIndex 深度解析

本文基于 source_code/PageIndex 源码,对 PageIndex 的架构设计、核心算法和工程实现进行深入拆解。


一、项目定位与核心思想

1.1 它要解决什么问题?

传统 RAG(Retrieval-Augmented Generation)依赖向量相似度检索,存在以下根本性缺陷:

问题描述
语义漂移用户问题与相关段落的语义表述不同,向量空间距离远
上下文割裂Chunk 边界切断了前后文依赖(如"如上所述"指向切块边界之外)
无结构感知平等对待目录、脚注、正文,无法区分"章节摘要"和"章节详情"
黑盒检索无法解释"为何检索到这段"

PageIndex 的解法是:不做向量检索,而是让 LLM 像人一样"看目录、翻章节"

1.2 核心范式对比

传统 RAG:
  文档 → 分块 → 向量化 → 向量库
  查询 → 向量化 → 相似度检索 → 拼接 ContextLLM

PageIndex:
  文档 → 解析结构 → JSON 树(目录+摘要)
  查询 → LLM 看树 → 推理定位到节点 → 读取节点内容 → LLM

关键创新:将检索问题转化为树上推理问题,利用 LLM 的推理能力替代向量相似度。

1.3 性能表现

在 FinanceBench 基准测试(金融文档问答)上达到 98.7% 准确率,显著优于传统 RAG 方法。原因在于:金融文档(年报、监管文件)结构严谨,目录完整,正好发挥了 PageIndex 的优势。


二、整体架构

2.1 代码结构

PageIndex/
├── run_pageindex.py           # CLI 入口(133 行)
├── pageindex/
│   ├── __init__.py
│   ├── config.yaml            # 默认配置
│   ├── page_index.py          # PDF 处理核心(1143 行)
│   ├── page_index_md.py       # Markdown 处理(338 行)
│   └── utils.py               # 工具函数(711 行)
└── cookbook/                  # Jupyter Notebook 示例

2.2 三层架构

┌──────────────────────────────────────────────────┐
│                  入口层                           │
│   run_pageindex.py (CLI)  /  page_index() (API)  │
└───────────────────┬──────────────────────────────┘
                    │
         ┌──────────┴──────────┐
         ▼                     ▼
┌─────────────────┐   ┌──────────────────┐
│  PDF 处理核心   │   │  Markdown 处理   │
│  page_index.py  │   │ page_index_md.py │
└────────┬────────┘   └────────┬─────────┘
         │                     │
         └──────────┬──────────┘
                    ▼
         ┌──────────────────────┐
         │      工具层          │
         │      utils.py        │
         │  LLM调用 / PDF解析   │
         │  JSON处理 / Token计数 │
         └──────────────────────┘

三、PDF 处理核心:page_index.py 深度解析

3.1 三条主处理路径

PageIndex 对 PDF 的处理根据 TOC(目录)的存在状态分为三条路径:

PDF 文档
    │
    ├─ 检测前 N 页是否有 TOC?
    │
    ├──[有 TOC + 有页码]process_toc_with_page_numbers()
    │
    ├──[有 TOC + 无页码]process_toc_no_page_numbers()
    │
    └──[无 TOC]process_no_toc()

这个三路分支体现了工程上的务实主义:优先利用文档自带的结构信息,逐步降级到纯 LLM 生成

3.2 路径一:有目录且有页码(process_toc_with_page_numbers)

这是最理想的情况,也是最复杂的路径,因为需要解决页码对齐问题。

核心难题:PDF 文档中的"页码"和"物理页索引"往往不一致。

例:一本书的 PDF
  物理页 1-3:封面、版权页、前言(无页码显示)
  物理页 4:正文第 1 页(显示"第 1 页")

  TOC 中写"第 3 章 → 第 47 页"
  实际物理索引 = 47 + 3 = 50

解决方案:calculate_page_offset()

# 核心思路:多数投票确定偏移量
1. 从 TOC 中随机抽取若干章节标题
2. 在 PDF 物理页上逐页搜索这些标题
3. 计算每对"TOC页码 vs 物理索引"的差值
4. 取差值中的众数(majority vote)作为全局偏移

为什么用众数而不是均值?

因为文档中偶尔会有"附录从第 A-1 页开始"这类特殊分页,均值会被这些噪声污染;众数则天然过滤掉少数异常值。

完整流程:

提取 TOC 文本(LLM 抽取)
    ↓
toc_transformer() → 结构化 JSON(1.1 节 → 第 23 页)
    ↓
toc_index_extractor() → 从 PDF 正文提取物理索引
    ↓
extract_matching_page_pairs() → 配对 TOC 页码 & 物理索引
    ↓
calculate_page_offset() → 众数投票得到偏移量
    ↓
add_page_offset_to_toc_json() → 所有节点加偏移
    ↓
verify_toc() → 随机抽查验证
    ↓
[失败] fix_incorrect_toc_with_retries() → 修正(最多 3 轮)

3.3 路径二:有目录但无页码(process_toc_no_page_numbers)

某些文档有目录但不标注页码(如纯大纲式文档)。此时无法做页码对齐,改用逐组扫描策略。

核心算法:page_list_to_group_text()

# 将页面列表按 token 上限分组,保持适度重叠
def page_list_to_group_text(page_list, max_tokens):
    # 1. 计算所有页面的 token 总数
    # 2. 估算需要分多少组
    # 3. 每组保留少量前一组的尾部页面(上下文重叠)
    # 4. 确保每组不超过 max_tokens

然后对每组调用:

add_page_number_to_toc()
# 向 LLM 提问:"这几页里,下列章节标题各从哪一页开始?"

这实际上是在做语义搜索,只是用 LLM 替代了向量相似度计算,且能处理标题措辞变化("第三章" vs "Chapter 3")。

3.4 路径三:无目录(process_no_toc)

最复杂也最鲁棒的路径,完全依赖 LLM 从内容中生成结构。

两阶段生成:

# 阶段一:处理第一个页面组
generate_toc_init(first_group_text, doc_title)
# → LLM 输出:{title, node_id, physical_start_index}[]

# 阶段二:逐组续写,保持连续性
generate_toc_continue(prev_nodes, current_group_text)
# → LLM 输出:续接前面的结构

关键设计generate_toc_continue 会将前一组的最后几个节点传入作为上下文,防止断裂。这类似于 LLM 文本生成中的 "prefix continuation"。

3.5 大节点递归处理

TOC 构建完成后,系统检测超出限制的"大节点":

# 触发条件(任一满足):
if node.page_count > max_page_num_each_node:  # 默认 10 页
    subdivide(node)
if node.token_count > max_token_num_each_node:  # 默认 20000 tokens
    subdivide(node)

process_large_node_recursively() 会将大节点视为独立"小文档",递归地应用同样的三路分支逻辑,生成子树后挂到父节点下。

这是一个优雅的自相似设计:相同的处理管道,应用于不同粒度的文档片段

3.6 验证与修复机制

PageIndex 不信任 LLM 的第一次输出,而是内置了采样验证 → 定向修复的循环:

# verify_toc():随机抽 K 个节点,检查标题是否真的出现在对应页
def verify_toc(structure, page_texts, sample_rate=0.3):
    sampled = random.sample(structure, k=...)
    for node in sampled:
        result = check_title_appearance(node.title, page_texts[node.start_page])
        if not result:
            mark_as_incorrect(node)

# fix_incorrect_toc():针对每个错误节点,缩小搜索范围重新定位
def fix_incorrect_toc(incorrect_node, prev_correct, next_correct):
    search_range = pages[prev_correct.end : next_correct.start]
    # 在缩小的范围内重新让 LLM 找节点起始页

设计哲理:错误不是异常,而是预期内的情况。用有界搜索(bounded search)替代全文重试,兼顾准确性和效率。


四、Markdown 处理:page_index_md.py 深度解析

4.1 基于语法解析而非 LLM

Markdown 有明确的标题语法(######),因此不需要 LLM 来识别结构,直接用正则表达式解析:

HEADER_PATTERN = re.compile(r'^(#{1,6})\s+(.+)$', re.MULTILINE)

# 特殊处理:跳过代码块内的"标题"
# ```python
# # 这不是标题
# ```

这是与 PDF 路径的根本区别:Markdown 处理是确定性的,PDF 处理是 LLM 驱动的

4.2 层次树构建算法

使用经典的栈维护父节点链算法:

def build_tree_from_nodes(flat_list):
    stack = []  # 当前祖先链
    for node in flat_list:
        # 弹出所有层级 >= 当前节点的祖先
        while stack and stack[-1].level >= node.level:
            stack.pop()

        if stack:
            stack[-1].children.append(node)  # 加入父节点
        else:
            root_nodes.append(node)  # 顶级节点

        stack.append(node)

时间复杂度 O(N),N 为节点数。

4.3 Token 驱动的树剪枝(tree_thinning)

对于内容稀疏的 Markdown(节点 token 数极少),系统支持合并小节点

def tree_thinning_for_index(tree, min_token_threshold=5000):
    # 后序遍历(先处理叶子)
    for node in reversed(all_nodes):
        if node.accumulated_tokens < min_token_threshold:
            # 将子节点内容合并到父节点
            merge_into_parent(node)

设计意图:避免索引树过于碎片化,确保每个叶节点包含足够信息支撑 LLM 推理。


五、工具层:utils.py 核心机制

5.1 LLM 调用的容错设计

async def ChatGPT_API_async(prompt, model, max_retries=10):
    for attempt in range(max_retries):
        try:
            response = await client.chat.completions.create(...)
            return response.choices[0].message.content
        except Exception as e:
            await asyncio.sleep(1 * (attempt + 1))  # 线性退避
    raise Exception("Max retries exceeded")

注意:使用线性退避而非指数退避。这在高并发场景下可能导致 API 限流,但对文档处理这种低频调用场景足够。

5.2 JSON 提取的鲁棒性处理

LLM 的输出常常不是干净的 JSON,extract_json() 做了多层容错:

def extract_json(text):
    # 尝试 1:直接 json.loads()
    # 尝试 2:提取 ```json ... ``` 内的内容
    # 尝试 3:将 Python None 替换为 JSON null
    # 尝试 4:移除尾部逗号(LLM 常见错误)
    # 尝试 5:正则提取最外层 {...} 或 [...]

这是生产级 LLM 应用的标配:永远不要假设 LLM 输出格式完全正确

5.3 PDF 双引擎解析

def get_page_tokens(pdf_path, engine="pymupdf"):
    if engine == "pymupdf":
        doc = fitz.open(pdf_path)
        # 优势:更好处理复杂排版,支持 BytesIO
    elif engine == "pypdf2":
        reader = PdfReader(pdf_path)
        # 优势:轻量,适合简单文本 PDF

提供两个解析器选择,应对不同类型的 PDF(扫描件 vs 原生文本)。

5.4 ConfigLoader 的工程细节

class ConfigLoader:
    def load(self, user_options: dict) -> SimpleNamespace:
        defaults = self._load_yaml()          # 读取 config.yaml
        self._validate_keys(user_options)     # 防止拼写错误的配置项
        merged = {**defaults, **user_options} # 用户配置覆盖默认值
        return SimpleNamespace(**merged)      # 支持 config.model 属性访问

_validate_keys() 是个好习惯:早失败胜于晚失败,防止用户误以为某个配置项生效了实际上被忽略了。


六、关键算法深探

6.1 页码偏移量计算(Offset Calculation)

这是整个系统最精妙的工程设计之一。

问题本质:TOC 中的"第 47 页"和 PDF 物理索引 47 之间存在系统性偏差(封面、版权页等占据了物理索引但不计入页码)。

算法流程

步骤 1:抽取若干 TOC 条目(如 5 个章节标题)
步骤 2:对每个章节标题,在物理 PDF 页面中搜索它的实际位置
步骤 3:计算 物理位置 - TOC页码 = 偏移候选值
步骤 4:取所有偏移候选值的众数

例:
  章节 ATOC:25页, 物理:28 → 偏移=3
  章节 BTOC:47页, 物理:50 → 偏移=3
  章节 CTOC:103页, 物理:106 → 偏移=3
  附录 DTOC:A-1页, 物理:150 → 偏移=149(异常值,被众数过滤)

  最终偏移 = 3(众数)

6.2 Token 分组算法(page_list_to_group_text)

def page_list_to_group_text(page_list, max_tokens):
    total_tokens = sum(p.token_count for p in page_list)
    expected_groups = ceil(total_tokens / max_tokens)

    groups = []
    current_group = []
    current_tokens = 0

    for page in page_list:
        if current_tokens + page.token_count > max_tokens and current_group:
            groups.append(current_group)
            # 重叠:保留最后 1-2 页作为下一组的开头
            current_group = current_group[-2:]
            current_tokens = sum(p.token_count for p in current_group)

        current_group.append(page)
        current_tokens += page.token_count

    if current_group:
        groups.append(current_group)

    return groups

重叠设计:保留尾部页面防止章节标题落在组边界导致被割裂。

6.3 结构码树构建(list_to_tree)

节点 ID 使用点分层级表示(11.11.2.3),转化为树的算法:

def list_to_tree(flat_list):
    # 按 node_id 的层级关系建立父子关系
    # "1.2.3" 的父节点是 "1.2"
    # "1.2" 的父节点是 "1"

    node_map = {node.node_id: node for node in flat_list}
    roots = []

    for node in flat_list:
        parent_id = ".".join(node.node_id.split(".")[:-1])
        if parent_id in node_map:
            node_map[parent_id].children.append(node)
        else:
            roots.append(node)

    return roots

七、输出数据结构

PageIndex 的最终输出是一个 JSON 树,每个节点包含:

{
  "title": "3.2 风险因素",
  "node_id": "0003.0002",
  "physical_start_index": 47,
  "physical_end_index": 52,
  "summary": "本节描述公司面临的主要市场风险,包括利率波动...",
  "children": [
    {
      "title": "3.2.1 市场风险",
      "node_id": "0003.0002.0001",
      ...
    }
  ]
}

字段说明

字段类型说明
titlestring章节标题(来自文档)
node_idstring层级编号(0001.0002 格式)
physical_start_indexint在 PDF 中的起始物理页索引(0-based)
physical_end_indexint结束物理页索引
summarystringLLM 生成的节点内容摘要
prefix_summarystring分支节点的整体摘要(含子节点)
textstring原始文本(可选,默认不输出)
childrenarray子节点列表

八、配置系统

8.1 默认配置(config.yaml)

model: "gpt-4o-2024-11-20"        # 使用的 LLM 模型
toc_check_page_num: 20             # 扫描前多少页寻找 TOC
max_page_num_each_node: 10         # 每个节点最多覆盖多少物理页
max_token_num_each_node: 20000     # 每个节点最多多少 token
if_add_node_id: "yes"              # 是否在输出中加节点 ID
if_add_node_summary: "yes"         # 是否生成节点摘要
if_add_doc_description: "no"       # 是否生成文档级描述
if_add_node_text: "no"             # 是否在输出中包含原文

8.2 关键参数影响分析

max_token_num_each_node(20000)

  • 过小:触发更多递归细分,API 调用增多,但每节点内容更聚焦
  • 过大:节点内容多,推理时需 LLM 处理更长文本,但 API 调用少
  • 20000 约对应 GPT-4o 上下文窗口的约 16%,为 RAG 召回留有余量

toc_check_page_num(20)

  • 大部分文档的目录在前 20 页内,增大此值提高覆盖率但增加 API 调用
  • 对于超长序言的学术著作可适当调大

九、异步并发设计

PageIndex 大量使用 Python asyncio 加速 LLM 调用:

# 并发验证多个节点的标题
async def check_title_appearance_in_start_concurrent(items, page_texts):
    tasks = [
        check_title_appearance_in_start(item, page_texts)
        for item in items
    ]
    results = await asyncio.gather(*tasks)  # 并发执行
    return results

性能影响:对于一个 100 节点的文档,并发验证比串行快近 10 倍(受 LLM API 延迟主导,非 CPU 主导)。


十、使用方式

10.1 命令行

# 处理 PDF
python run_pageindex.py \
    --pdf_path path/to/document.pdf \
    --model gpt-4o-2024-11-20 \
    --if-add-node-summary yes

# 处理 Markdown
python run_pageindex.py \
    --md_path path/to/document.md \
    --if-thinning yes \
    --thinning-threshold 5000

10.2 Python API

from pageindex import page_index, md_to_tree

# PDF 处理
result = await page_index(
    pdf_path="report.pdf",
    config={
        "model": "gpt-4o-2024-11-20",
        "if_add_node_summary": "yes"
    }
)

# Markdown 处理
result = await md_to_tree(
    md_content=markdown_text,
    if_thinning=True,
    min_token_threshold=5000
)

十一、设计哲学总结

11.1 务实的技术选型

  • 不追求通用性:三路分支针对 PDF 的实际情况(有/无目录、有/无页码),每条路径都针对性优化
  • 不假设 LLM 完美:内置验证→修复循环,把 LLM 错误当预期行为处理
  • 不过度设计:Markdown 用正则解析,PDF 用 LLM,工具对齐问题复杂度

11.2 层次化的降级策略

最优路径(有 TOC + 有页码)
    ↓ 降级
次优路径(有 TOC + 无页码,逐组扫描)
    ↓ 降级
兜底路径(无 TOC,纯 LLM 生成)

每一级降级都有合理的工程成本(更多 API 调用),但保证了输出质量下限。

11.3 与向量 RAG 的本质区别

维度向量 RAGPageIndex
检索方式相似度匹配(数学)树上推理(语言)
结构感知有(层次树)
可解释性低("为什么相似?")高("第3章第2节")
适用文档结构弱、知识密集结构强(报告、书籍)
维护成本需维护向量库需重新索引(文档更新时)
首次处理快(嵌入计算)慢(多次 LLM 调用)

11.4 适用场景

最适合 PageIndex 的场景:

  • 金融文档(年报、招股说明书、监管文件)
  • 法律合同(条款引用精确)
  • 学术教材(章节结构明确)
  • 技术手册(结构化规范)

不太适合的场景:

  • 无结构的纯文本(新闻、博客)
  • 需要跨文档检索的知识库(向量 RAG 更擅长)
  • 实时性要求高的场景(首次索引 LLM 调用多)

十二、延伸思考

12.1 为什么能在金融文档上取得 98.7%?

金融文档(如 10-K 年报)有极其规范的结构:

  • 目录必然存在且格式统一
  • 章节编号清晰(Item 1, Item 1A, ...)
  • 页码与物理页偏差固定(通常只有封面几页)

这正是 PageIndex 三路分支中最优路径的适用条件。

12.2 潜在改进方向

  1. 混合检索:PageIndex 树用于粗定位,向量检索用于节点内精确定位
  2. 增量更新:文档部分更新时,只重新索引变化的章节
  3. 多文档关联:在树节点间建立跨文档引用关系
  4. 视觉 RAG 扩展:cookbook 中已有 vision_RAG_pageindex.ipynb,处理图表丰富的文档

文档基于 PageIndex 源码分析生成,源码路径:source_code/PageIndex/


04-PageIndex-设计决策与陷阱

PageIndex 设计决策、隐藏陷阱与改进空间

本文是系列第四篇,从代码审查视角出发,整理 PageIndex 中所有值得深究的设计决策(为什么这样做)、已知缺陷(哪里会出问题)、以及可改进方向(如何做得更好)。


一、设计决策深析

1.1 为什么整个 page_list 一次性加载到内存?

# page_index_main
page_list = get_page_tokens(doc)  # 全部页面一次加载

原因: 多个处理阶段都需要随机访问任意页面:

  • TOC 检测:前 N 页
  • 物理索引提取:TOC 后几页
  • 节点验证:随机采样
  • 修复:错误节点附近几页

如果按需读取,每次 I/O 操作需要重新打开 PDF,代价更高。将 PDF 解析代价前置(一次性),换取后续随机访问的 O(1) 复杂度。

代价: 大型 PDF(500 页 × 2KB/页)内存占用约 1-5 MB,对现代机器可接受。


1.2 为什么 temperature=0?

response = client.chat.completions.create(
    model=model,
    messages=messages,
    temperature=0,  # 永远是这个
)

原因: 结构化信息提取需要确定性

同一个 TOC 页面,相同 prompt,每次运行必须得到相同的 JSON 结构,否则:

  • 第一次跑出的节点 ID 和第二次不同
  • 无法做 diff 验证
  • 缓存失效(同样输入不同输出)

唯一例外场景是摘要生成——摘要用自然语言,稍有变化无所谓,但代码统一用了 temperature=0,牺牲了一点摘要多样性换取简单性。


1.3 为什么节点 ID 用 0001 而不是层级路径(如 1.2.3)?

data['node_id'] = str(node_id).zfill(4)  # 顺序编号

节点 ID 是顺序的全局 ID,structure 字段才是层级路径。

设计优势:

  1. 唯一性保证:顺序 ID 无论树结构如何都不冲突
  2. 引用简单:用户说"节点 0042"比"节点 3.2.1.4"更简洁
  3. 稳定性:树结构变化时(如新增章节),只有受影响节点的 ID 变,层级路径式 ID 会导致大量节点 ID 更变

代价:从 node_id 无法直接推断层级位置,需要在树上搜索。


1.4 为什么 add_preface_if_needed 在验证/修复之后?

# tree_parser 中的顺序:
toc_with_page_number = await meta_processor(...)       # 提取+验证+修复
toc_with_page_number = add_preface_if_needed(toc_with_page_number)  # 之后才加前言

原因: 如果在提取阶段就加入 Preface 节点,它会参与验证(verify_toc)。但 Preface 是人工构造的节点(不来自 LLM),它的 physical_index=1 不需要验证,也不会出错。如果强行验证,反而增加了采样噪声。

后置添加,让验证只针对 LLM 生成的节点。


1.5 process_toc_no_page_numbers vs process_no_toc 的本质区别

特性process_toc_no_page_numbersprocess_no_toc
结构来源文档自带 TOC(LLM 提取后转结构)LLM 从内容中自行生成
标题准确性高(原文标题)中(LLM 可能改写)
层级准确性高(原始层级)中(LLM 推断)
页码来源全文扫描(每组询问 LLM)直接读 physical_index 标签
API 调用数更多(每组一次)较少(每组一次,但生成+结构合并)

process_no_toc 中 LLM 直接从带 <physical_index_X> 标签的正文中提取结构,相当于让 LLM 同时做"理解结构"和"定位页码"两件事。process_toc_no_page_numbers 则用已知的 TOC 结构,只让 LLM 做"定位页码"一件事,精度更高。


二、已知 Bug 与陷阱

Bug 1:check_title_appearance_in_start_concurrent 中的 start_index 硬编码

位置: page_index.py 第 88 行

page_text = page_list[item['physical_index'] - 1][0]  # 硬编码 -1

问题: 当这个函数被 process_large_node_recursively 调用时,page_list 是完整文档的页面列表,physical_index 是全局页码(如 47),索引应该是 47 - 1 = 46,这里是正确的。

但函数签名有 logger 参数,暗示它也可以在其他上下文调用。若未来 page_list 被替换为子集(如只传入某章节的页面),physical_index - 1 就会越界。这是一个脆弱的假设,目前碰巧正确,但容易在重构中引入 bug。


Bug 2:chat_history 的引用传递修改

位置: utils.py ChatGPT_API_with_finish_reason

def ChatGPT_API_with_finish_reason(model, prompt, chat_history=None):
    ...
    if chat_history:
        messages = chat_history          # 引用,非拷贝
        messages.append({"role": "user", "content": prompt})

问题: 调用方的 chat_history 列表被修改。在 extract_toc_content 中:

chat_history = [
    {"role": "user", "content": prompt},
    {"role": "assistant", "content": response},
]
prompt = "please continue..."
new_response, finish_reason = ChatGPT_API_with_finish_reason(..., chat_history=chat_history)
# 此时 chat_history 已被追加了一条 user 消息!

下次循环时,chat_history 已经有 3 条消息而不是 2 条。由于循环中每次都重建 chat_history,当前代码没有暴露这个问题,但是潜在的维护陷阱。

修复方案: messages = list(chat_history)messages = chat_history.copy()


Bug 3:extract_json 对多代码块的处理错误

位置: utils.py 第 131 行

end_idx = content.rfind("```")  # 找最后一个,可能找错

触发场景:

Here is the result:
```json
{"answer": "yes"}

Note: compare with the previous result:

{"answer": "no"}

`rfind("```")` 找到最后一个代码块的结束符,`content[start_idx:end_idx]` 会包含中间全部内容,包括 `"Note:"` 和第二个 `{"answer": "no"}`,导致 JSON 解析失败或解析了错误内容。

**实际风险:** LLM 一般不会在结构化输出中添加多个代码块,所以这个 bug 很少触发。

---

### Bug 4:`toc_transformer` 续写时的截断逻辑

**位置:** `page_index.py`  301-303 

```python
position = last_complete.rfind('}')
if position != -1:
    last_complete = last_complete[:position+2]

问题 1: rfind('}') 找最后一个 },但 JSON 内部的值也可能包含 }(如 "title": "Section 3.2}"),截断点可能在错误的位置。

问题 2: [:position+2] 跳过了 } 后一个字符,但不清楚为什么是 +2 而不是 +1。如果 } 后跟 ,+2 能包含逗号;如果后跟 \n+2 包含换行——这是脆弱的字符计数假设。

实际影响: 这段代码只在 TOC 非常长(超出 LLM 单次输出限制)时触发,正常文档不会运行到这里。


Bug 5:verify_toc 的早返回逻辑混淆

位置: page_index.py 第 902 行

if last_physical_index is None or last_physical_index < len(page_list)/2:
    return 0, []  # 返回准确率0,但错误列表为空

调用方 meta_processor 的处理:

accuracy, incorrect_results = await verify_toc(...)

if accuracy == 1.0 and len(incorrect_results) == 0:
    return toc_with_page_number  # ← 不会进入,accuracy=0

if accuracy > 0.6 and len(incorrect_results) > 0:
    ...                          # ← 不会进入,incorrect_results=[]

else:
    # 降级!但原因不对:是索引覆盖不足,不是精度低
    return await meta_processor(..., mode='process_toc_no_page_numbers', ...)

混淆点: 索引覆盖不足(TOC 只覆盖了前 30% 文档)和精度低(50% 节点定位错误)是两种完全不同的问题,但都触发了相同的降级逻辑。降级后的处理方式也不一定适合"覆盖不足"这个根本原因。


Bug 6:process_large_node_recursively 的 AND 触发条件

位置: page_index.py 第 996 行

if node['end_index'] - node['start_index'] > opt.max_page_num_each_node and \
   token_num >= opt.max_token_num_each_node:

问题: 使用 AND,意味着页数多但 token 少的节点(如大量空白页、只有图的节点)不会被细分。这通常是合理的。

但反过来:token 多但页数少的节点(如 5 页密密麻麻的文字,token 超过 20000)也不会被细分,因为 page_count ≤ max_page_num_each_node=10

密集文字的短章节不细分,在 RAG 场景下会导致 LLM 收到超长上下文,可能影响问答质量。


Bug 7:tree_parser 不完整利用 check_toc 结果

位置: page_index.py 第 1034-1040 行

else:
    # 有 TOC 无页码,也走 process_no_toc(而非 process_toc_no_page_numbers)
    toc_with_page_number = await meta_processor(
        page_list,
        mode='process_no_toc',  # ← 应该是 'process_toc_no_page_numbers'?
        ...
    )

check_toc 返回了 page_index_given_in_toc='no'(有 TOC 但无页码),但 tree_parser 忽略了这个信息,直接走 process_no_toc,放弃了文档自带的目录结构。这看起来是一个遗漏——代码只处理"有 TOC + 有页码"和"其他"两种情况,中间那种"有 TOC 但无页码"的路径只能靠 meta_processor 降级触发。


三、性能分析

3.1 API 调用数量的现实估算

以一份 100 页标准年报(有 TOC + 有页码,约 30 个章节)为例:

操作调用次数说明
TOC 页面检测~5前20页,找到TOC后停止
TOC 内容提取1-3取决于是否需要续写
TOC 完整性检查2-6每次提取后验证
TOC 结构转换1-2toc_transformer
转换完整性检查1-2
物理索引提取1toc_index_extractor
页码检测1detect_page_index
节点验证~10verify_toc,抽样约30%
修复(假设2个错误)2fix_incorrect_toc
修复后验证2check_title_appearance
节点开头检查30check_title_appearance_in_start,全并发
摘要生成30每个节点一次,全并发
文档描述1(可选)
合计~90-100

成本估算(GPT-4o,2024年价格):

  • 每次调用平均约 2000 输入 + 500 输出 token
  • 约 90 次 × (2000×0.0025+500×0.0025 + 500×0.01) / 1000 ≈ $0.90

不含摘要:约 0.40;超大文档(300页):约0.40**;超大文档(300页):约 **2-3

3.2 并发对实际耗时的影响

没有并发时:90 次串行调用 × 平均 2 秒 = 180 秒(3 分钟)

有并发时(asyncio.gather):

  • 摘要生成 30 次并发 → 从 60 秒压缩到约 8 秒(受 rate limit)
  • 节点验证 10 次并发 → 从 20 秒压缩到约 3 秒
  • 实际总耗时约 60-90 秒

Rate Limit 是真正的瓶颈:OpenAI GPT-4o 默认 RPM(每分钟请求数)上限为 500,并发 30 个请求通常不会触发,但输入 token 的 TPM(每分钟 token 数)上限可能更快触及。


四、可改进方向

4.1 缓存层

问题: 同一份 PDF 处理两次,全部 API 调用重新发生。

改进: 基于文件 hash + 配置参数的缓存:

cache_key = hashlib.md5(open(pdf_path, 'rb').read()).hexdigest()
cache_key += "_" + hashlib.md5(json.dumps(config).encode()).hexdigest()
cache_path = f"./cache/{cache_key}.json"

if os.path.exists(cache_path):
    return json.load(open(cache_path))

result = page_index_main(pdf_path, opt)
json.dump(result, open(cache_path, 'w'))
return result

效果: 开发调试时节省 90%+ 的 API 费用。


4.2 增量摘要更新

问题: 文档更新一个章节,所有摘要重新生成。

改进: 对比新旧 start_index/end_index,只为内容变化的节点重新生成摘要。需要存储节点内容的 hash:

for node in nodes:
    content_hash = hashlib.md5(node['text'].encode()).hexdigest()
    if node_cache.get(node['node_id']) == content_hash:
        node['summary'] = cached_summary[node['node_id']]
    else:
        node['summary'] = await generate_node_summary(node)
        node_cache[node['node_id']] = content_hash

4.3 extract_json 的鲁棒性增强

当前问题: 多代码块、嵌套括号、JSON 之外有文字都可能失败。

改进: 使用 OpenAI 的 Structured Outputs(JSON mode)彻底避免解析问题:

response = client.chat.completions.create(
    model=model,
    messages=messages,
    response_format={"type": "json_object"},  # 强制 JSON 输出
)

代价:只支持最新模型,且无法使用 Markdown 代码块格式。


4.4 temperature 的场景化调整

当前: 全程 temperature=0

建议:

  • 结构提取(TOC、页码):temperature=0(确定性必要)
  • 摘要生成:temperature=0.3(允许一定多样性,摘要更自然)
  • 文档描述:temperature=0.2(创造性稍高,区分度更好)

4.5 PyMuPDF 作为默认解析器

当前: 默认 PyPDF2,PyMuPDF 是隐藏功能

建议: 暴露 pdf_parser 参数到公共 API 并默认 PyMuPDF:

def page_index(doc, pdf_parser="PyMuPDF", ...):
    user_opt = {arg: value for arg, value in locals().items() if ...}

PyMuPDF 在保留文档布局方面更好,对于有复杂排版的金融报告尤其重要。


4.6 修复 tree_parser 的三路分支缺失

当前: "有 TOC 无页码"被当作"无 TOC"处理

建议:

async def tree_parser(page_list, opt, doc=None, logger=None):
    check_toc_result = check_toc(page_list, opt)

    if check_toc_result["page_index_given_in_toc"] == "yes":
        mode = 'process_toc_with_page_numbers'
    elif check_toc_result["toc_content"]:
        mode = 'process_toc_no_page_numbers'  # 利用已有 TOC 结构
    else:
        mode = 'process_no_toc'

    toc_with_page_number = await meta_processor(..., mode=mode, ...)

这样能更充分利用文档自带的目录信息,提高"有 TOC 无页码"场景的准确率。


4.7 Jupyter Notebook 兼容性

当前问题: asyncio.run() 在 Jupyter 中报错。

最小改动修复:

def page_index_main(doc, opt=None):
    ...
    try:
        loop = asyncio.get_running_loop()
        # 在已有事件循环中运行
        import nest_asyncio
        nest_asyncio.apply()
        return loop.run_until_complete(page_index_builder())
    except RuntimeError:
        # 没有事件循环,正常情况
        return asyncio.run(page_index_builder())

4.8 verify_toc 的采样策略优化

当前: 随机均匀采样 N 个节点

改进: 分层采样,确保验证覆盖到:

  • 文档开头(TOC 偏移量计算相关)
  • 文档中间
  • 文档末尾(覆盖不足问题)
  • 大型章节(更重要的节点)
def stratified_sample(list_result, N):
    n = len(list_result)
    thirds = [list_result[:n//3], list_result[n//3:2*n//3], list_result[2*n//3:]]
    sampled = []
    for third in thirds:
        k = max(1, N // 3)
        sampled.extend(random.sample(third, min(k, len(third))))
    return sampled

五、总结:架构优势与局限的根本来源

优势来源

优势根本原因
高准确率(98.7%)LLM 理解语义,不依赖格式规则
结构感知以文档层次为单位,而非 chunk
可解释每个节点有物理页码,可溯源
无需向量库检索=推理,不存储嵌入

局限来源

局限根本原因
高 API 成本每个页面/节点都需要 LLM 调用
首次处理慢串行的 LLM 调用链无法完全并发
不适合无结构文档算法依赖文档有可识别的层次结构
模型强绑定高度依赖 GPT-4o 的 JSON 输出格式,换模型需重新调优 prompt
Jupyter 不兼容asyncio.run() 的固有限制

本系列完整索引:

  • 01-PageIndex-深度解析.md:架构总览、三路处理路径、核心算法
  • 02-PageIndex-检索与实战.md:检索阶段、多文档策略、工程落地
  • 03-PageIndex-逐行代码解析.md:每个函数的逐行实现细节
  • 04-PageIndex-设计决策与陷阱.md(本文):设计决策、Bug、改进方向

03-PageIndex-逐行代码解析

PageIndex 逐行代码解析

本文是系列第三篇,对 PageIndex 每一个函数的实现细节、边界条件、隐含假设进行逐行级别的精确解析。读完本文你应当能独立重写整个项目。


一、utils.py — 基础设施层

1.1 count_tokens(text, model)

def count_tokens(text, model=None):
    if not text:
        return 0
    enc = tiktoken.encoding_for_model(model)
    tokens = enc.encode(text)
    return len(tokens)

细节:

  • if not text 处理 None""[] 等所有假值,返回 0 而非报错
  • tiktoken.encoding_for_model() 内部有缓存,同一 model 反复调用不会重建编码器
  • 每次调用都重新 encode(),对高频调用(如对每个页面计 token)有轻微开销,但全局影响很小
  • 隐含假设model 参数必须是 tiktoken 能识别的 OpenAI model name;传入自定义模型名会抛 KeyError

1.2 ChatGPT_API_with_finish_reason(model, prompt, ...)

def ChatGPT_API_with_finish_reason(model, prompt, api_key=..., chat_history=None):
    max_retries = 10
    client = openai.OpenAI(api_key=api_key)
    for i in range(max_retries):
        try:
            if chat_history:
                messages = chat_history
                messages.append({"role": "user", "content": prompt})
            else:
                messages = [{"role": "user", "content": prompt}]

            response = client.chat.completions.create(
                model=model,
                messages=messages,
                temperature=0,
            )
            if response.choices[0].finish_reason == "length":
                return response.choices[0].message.content, "max_output_reached"
            else:
                return response.choices[0].message.content, "finished"
        except Exception as e:
            time.sleep(1)

三个版本的 API 函数的区别:

函数返回值适用场景
ChatGPT_APIcontent (str)普通调用,不关心截断
ChatGPT_API_with_finish_reason(content, reason)需要检测输出是否被截断(TOC 提取)
ChatGPT_API_asynccontent (awaitable)并发调用场景

关键细节:

  1. temperature=0 的意义:确定性输出,同样 prompt 每次返回相同结果。对结构解析至关重要——"第3节"不能一次返回 "3" 下次返回 "三"

  2. chat_history 的副作用 bug

    messages = chat_history        # 这是引用,不是复制!
    messages.append(...)           # 会修改原始 chat_history 列表!
    

    这是一个潜在 bug:如果调用方在循环中复用同一个 chat_history 列表,每次调用都会追加消息,导致历史记录无限增长。实际使用中 chat_history 仅在 extract_toc_contenttoc_transformer 的续写逻辑里用到,每次都新建列表,所以暂时没有问题。

  3. client = openai.OpenAI(api_key=...) 在循环内部:同步版本每次 retry 都重建 client 对象,有轻微开销。异步版本用 async with 正确管理了连接池。

  4. 错误处理粒度粗except Exception as e 捕获了一切,包括网络错误、API 限流、内容政策拒绝。不同错误应该有不同处理策略(限流应该指数退避,内容拒绝不应该 retry),这里统一线性等 1 秒重试是简化处理。


1.3 extract_json(content)

def extract_json(content):
    try:
        # 尝试 1:提取 ```json ... ``` 中的内容
        start_idx = content.find("```json")
        if start_idx != -1:
            start_idx += 7
            end_idx = content.rfind("```")
            json_content = content[start_idx:end_idx].strip()
        else:
            json_content = content.strip()

        # 清洗
        json_content = json_content.replace('None', 'null')
        json_content = json_content.replace('\n', ' ').replace('\r', ' ')
        json_content = ' '.join(json_content.split())  # 规范化空白

        return json.loads(json_content)
    except json.JSONDecodeError:
        try:
            # 尝试 2:移除尾部逗号
            json_content = json_content.replace(',]', ']').replace(',}', '}')
            return json.loads(json_content)
        except:
            return {}
    except Exception:
        return {}

每一步的必要性:

清洗步骤修复的 LLM 输出问题
replace('None', 'null')LLM 输出 Python 风格的 None
换行替换为空格JSON 字符串中裸换行是非法的
' '.join(split())多个连续空格规范化
去尾部逗号{"a": 1,} 这类常见 LLM 错误

两个失败场景:

  1. end_idx = content.rfind("```") 找到最后一个反引号对,如果 LLM 输出多个代码块,可能截取错误范围。例如:

    Here's the result:
    ```json
    {"a": 1}
    ```
    Note: ```this is not JSON```
    

    此时 rfind("```") 找到最后一个,content[start_idx:end_idx] 会包含中间的非 JSON 内容。

  2. 无法修复嵌套 JSON 中的错误(如缺少引号、括号不匹配)。失败时静默返回 {},调用方必须处理空字典。


1.4 write_node_id(data, node_id=0)

def write_node_id(data, node_id=0):
    if isinstance(data, dict):
        data['node_id'] = str(node_id).zfill(4)
        node_id += 1
        for key in list(data.keys()):
            if 'nodes' in key:
                node_id = write_node_id(data[key], node_id)
    elif isinstance(data, list):
        for index in range(len(data)):
            node_id = write_node_id(data[index], node_id)
    return node_id

遍历顺序:前序遍历(Pre-order)

树结构:
  根节点 A (node_id=0000)
    子节点 B (node_id=0001)
      子子节点 C (node_id=0002)
    子节点 D (node_id=0003)

关键实现细节:

  • str(node_id).zfill(4):补零到 4 位(00010042),支持最多 9999 个节点
  • if 'nodes' in key:字符串包含检测,能匹配 "nodes""child_nodes" 等任意含 nodes 的字段名
  • for key in list(data.keys())list() 复制键列表,避免在迭代时修改字典(虽然此处只读不修改,但防御性编程)
  • node_id 穿透返回:通过返回值传递计数器,保证全树唯一性,而不用全局变量

与 Markdown 树的差异page_index_md.pybuild_tree_from_nodes 构建时就直接分配了顺序 ID(str(node_counter).zfill(4)),然后 md_to_tree 中再次调用 write_node_id 覆盖——导致 ID 被重写了两次,最终 ID 仍然正确,但有冗余。


1.5 list_to_tree(data)

def list_to_tree(data):
    def get_parent_structure(structure):
        parts = str(structure).split('.')
        return '.'.join(parts[:-1]) if len(parts) > 1 else None

    nodes = {}
    root_nodes = []

    for item in data:
        structure = item.get('structure')
        node = {
            'title': item.get('title'),
            'start_index': item.get('start_index'),
            'end_index': item.get('end_index'),
            'nodes': []
        }
        nodes[structure] = node
        parent_structure = get_parent_structure(structure)

        if parent_structure and parent_structure in nodes:
            nodes[parent_structure]['nodes'].append(node)
        else:
            root_nodes.append(node)

    def clean_node(node):
        if not node['nodes']:
            del node['nodes']  # 叶节点不保留空 nodes 数组
        else:
            for child in node['nodes']:
                clean_node(child)
        return node

    return [clean_node(node) for node in root_nodes]

structure 字段格式"1", "1.1", "1.2.3" — LLM 生成的层级编号

核心逻辑问题:

if parent_structure and parent_structure in nodes:
    nodes[parent_structure]['nodes'].append(node)
else:
    root_nodes.append(node)  # ← 父节点不存在时,当作根节点处理!

边界情况:如果 LLM 返回了 structure="1.2" 但跳过了 "1",那么 "1.2" 会被错误地当作根节点,而不是 "1" 的子节点。这是一个已知的 LLM 输出格式问题,通过 validate_and_truncate_physical_indices 后续验证来缓解,但不能完全修复树结构错误。


1.6 post_processing(structure, end_physical_index)

def post_processing(structure, end_physical_index):
    for i, item in enumerate(structure):
        item['start_index'] = item.get('physical_index')
        if i < len(structure) - 1:
            if structure[i + 1].get('appear_start') == 'yes':
                item['end_index'] = structure[i + 1]['physical_index'] - 1
            else:
                item['end_index'] = structure[i + 1]['physical_index']
        else:
            item['end_index'] = end_physical_index
    tree = list_to_tree(structure)
    ...

appear_start 字段的含义:

这是 check_title_appearance_in_start_concurrent 写入的字段,表示"当前节点的标题是否从该页的最开始出现"。

end_index 计算逻辑:

  • 若下一节点从页面开头开始(appear_start == 'yes'):当前节点的 end = 下一节点的 start - 1(不重叠)
  • 若下一节点从页面中间开始(appear_start == 'no'):当前节点的 end = 下一节点的 start(最后一页共享,包含在内)

为什么要这样处理?

页面 47: [当前章节结尾内容]
         [下一章节标题:3.2 风险因素]
         [下一章节开头内容...]

如果 appear_start='no'(标题不在页首),
那么页面 47 包含两个章节的内容,
current.end_index = 47(包含共享页)
next.start_index  = 47

这确保了共享页的内容在两个节点的范围内都可访问,不会遗漏。


1.7 get_page_tokens(pdf_path, model, pdf_parser)

def get_page_tokens(pdf_path, model="gpt-4o-2024-11-20", pdf_parser="PyPDF2"):
    enc = tiktoken.encoding_for_model(model)
    if pdf_parser == "PyPDF2":
        pdf_reader = PyPDF2.PdfReader(pdf_path)
        page_list = []
        for page_num in range(len(pdf_reader.pages)):
            page = pdf_reader.pages[page_num]
            page_text = page.extract_text()
            token_length = len(enc.encode(page_text))
            page_list.append((page_text, token_length))
        return page_list
    elif pdf_parser == "PyMuPDF":
        if isinstance(pdf_path, BytesIO):
            doc = pymupdf.open(stream=pdf_stream, filetype="pdf")
        elif isinstance(pdf_path, str) and ...:
            doc = pymupdf.open(pdf_path)
        ...

返回值结构list[tuple[str, int]],每个元素是 (页面文本, token数)

PyPDF2 vs PyMuPDF 的实际差异:

特性PyPDF2PyMuPDF (fitz)
文字 PDF良好更好(保留布局)
扫描件无法提取无法提取(需OCR)
表格提取差(无布局感知)较好
速度中等
BytesIO 支持
内存占用中等

PyMuPDF 代码 bug

if isinstance(pdf_path, BytesIO):
    pdf_stream = pdf_path          # 正确
    doc = pymupdf.open(stream=pdf_stream, filetype="pdf")
elif isinstance(pdf_path, str) and ...:
    doc = pymupdf.open(pdf_path)

page_index_main 调用时默认使用 PyPDF2(无参数传入),page_index() 公共 API 也没有暴露 pdf_parser 参数。因此 PyMuPDF 路径实际上只能通过直接调用 get_page_tokens 来使用,是个隐藏的高级功能。


1.8 JsonLogger

class JsonLogger:
    def __init__(self, file_path):
        pdf_name = get_pdf_name(file_path)
        current_time = datetime.now().strftime("%Y%m%d_%H%M%S")
        self.filename = f"{pdf_name}_{current_time}.json"
        os.makedirs("./logs", exist_ok=True)
        self.log_data = []

    def log(self, level, message, **kwargs):
        if isinstance(message, dict):
            self.log_data.append(message)
        else:
            self.log_data.append({'message': message})
        with open(self._filepath(), "w") as f:
            json.dump(self.log_data, f, indent=2)

每次写整个文件的代价:

每次调用 .info() 都会将整个日志列表序列化并覆写文件。对于有 200 个节点的大型文档,可能有几百条日志记录,每次写操作的 I/O 代价递增。

为什么这样设计?

保证日志文件在崩溃时是完整可读的 JSON。如果用追加模式,崩溃时文件末尾可能没有闭合 ],导致 JSON 无效。这是正确性优于性能的选择。

日志级别的虚假性info()error()debug() 最终都调用 log(),并不区分 level,level 参数完全被忽略,日志文件中也没有 level 字段。这是个未完成的实现。


1.9 ConfigLoader

class ConfigLoader:
    def __init__(self, default_path=None):
        if default_path is None:
            default_path = Path(__file__).parent / "config.yaml"
        self._default_dict = self._load_yaml(default_path)

    def _validate_keys(self, user_dict):
        unknown_keys = set(user_dict) - set(self._default_dict)
        if unknown_keys:
            raise ValueError(f"Unknown config keys: {unknown_keys}")

    def load(self, user_opt=None) -> config:
        if isinstance(user_opt, config):      # SimpleNamespace
            user_dict = vars(user_opt)
        elif isinstance(user_opt, dict):
            user_dict = user_opt
        ...
        self._validate_keys(user_dict)
        merged = {**self._default_dict, **user_dict}
        return config(**merged)

Path(__file__).parent:以 utils.py 文件所在目录为基准找 config.yaml,保证打包后路径正确,不依赖当前工作目录。

vars(user_opt) 的作用:将 SimpleNamespace 对象转为 dict。run_pageindex.py 直接构建 config(...) 传入,所以这条分支被用到。

{**self._default_dict, **user_dict} 的优先级:用户配置覆盖默认值,但 _validate_keys 先确保用户不会传入默认配置中不存在的 key,防止"我以为我配置了但其实没生效"的问题。


二、page_index.py — PDF 处理核心

2.1 check_title_appearance(item, page_list, start_index, model)

async def check_title_appearance(item, page_list, start_index=1, model=None):
    if 'physical_index' not in item or item['physical_index'] is None:
        return {'list_index': item.get('list_index'), 'answer': 'no', ...}

    page_number = item['physical_index']
    page_text = page_list[page_number - start_index][0]  # ← 关键索引换算

    prompt = f"""
    Your job is to check if the given section appears or starts in the given page_text.
    Note: do fuzzy matching, ignore any space inconsistency in the page_text.
    ...
    """

page_number - start_index 的必要性:

physical_index 是 1-based 物理页号(第1页=1),page_list 是 0-based Python 列表。start_index 允许处理文档子集(如递归处理大节点时,子列表的起始页不是1)。

Prompt 中的 "fuzzy matching" 指令:

这条指令让 LLM 容忍标题的格式差异:

  • PDF 中的标题可能有额外空格(列宽度对齐产生)
  • 中文字符间距问题
  • 标题中的特殊字符被 PDF 提取器改变

没有这条指令,"Item 1A. Risk Factors" 和 "Item 1A. Risk Factors" 会被判断为不匹配。


2.2 check_title_appearance_in_start(title, page_text, model, logger)

async def check_title_appearance_in_start(title, page_text, model=None, logger=None):
    prompt = f"""
    ...
    If there are other contents before the current section title, then the current section does not start in the beginning of the given page_text.
    If the current section title is the first content in the given page_text, then the current section starts in the beginning.
    ...
    "start_begin": "yes or no"
    """
    response = await ChatGPT_API_async(model=model, prompt=prompt)
    return response.get("start_begin", "no")

check_title_appearance 的区别:

函数问题用途
check_title_appearance标题是否存在于页面验证 TOC 准确性
check_title_appearance_in_start标题是否在页面开头决定 end_index 计算方式

默认返回 "no"response.get("start_begin", "no") 失败时安全降级。"no" 意味着"不确定"时按共享页处理,宁可包含多余内容,也不遗漏。


2.3 check_title_appearance_in_start_concurrent(structure, page_list, model, logger)

async def check_title_appearance_in_start_concurrent(structure, page_list, model=None, logger=None):
    # 跳过没有 physical_index 的项
    for item in structure:
        if item.get('physical_index') is None:
            item['appear_start'] = 'no'

    tasks = []
    valid_items = []
    for item in structure:
        if item.get('physical_index') is not None:
            page_text = page_list[item['physical_index'] - 1][0]
            tasks.append(check_title_appearance_in_start(...))
            valid_items.append(item)

    results = await asyncio.gather(*tasks, return_exceptions=True)
    for item, result in zip(valid_items, results):
        if isinstance(result, Exception):
            item['appear_start'] = 'no'  # 出错时安全降级
        else:
            item['appear_start'] = result

return_exceptions=True 的作用:

没有这个参数,任何一个 task 的异常都会中断整个 gather,其他结果丢失。加了后,异常被捕获为返回值,用 isinstance(result, Exception) 检测,允许部分失败。

注意 page_list[item['physical_index'] - 1][0]:这里用的是硬编码的 -1(假设 start_index=1),而 check_title_appearance 用的是 page_number - start_index。当这个函数用于递归的大节点处理时(start_index 不为 1),这里会用错误的页面索引!


2.4 TOC 检测链:toc_detector_single_pagefind_toc_pages

def toc_detector_single_page(content, model=None):
    prompt = f"""
    ...
    Please note: abstract, summary, notation list, figure list, table list, etc. are not table of contents."""

Prompt 中的排除列表至关重要:

学术论文常有"图表列表"(List of Figures),格式类似 TOC(标题 + 页码),但不是 TOC。没有这条排除指令,toc_detector 会把它误识别为 TOC,导致后续提取的"目录"实际上是图表索引。

def find_toc_pages(start_page_index, page_list, opt, logger=None):
    last_page_is_yes = False
    toc_page_list = []
    i = start_page_index

    while i < len(page_list):
        if i >= opt.toc_check_page_num and not last_page_is_yes:
            break                          # 超出检测范围且未找到 TOC,停止
        detected_result = toc_detector_single_page(page_list[i][0], ...)
        if detected_result == 'yes':
            toc_page_list.append(i)
            last_page_is_yes = True
        elif detected_result == 'no' and last_page_is_yes:
            break                          # 找到 TOC 后遇到非 TOC 页,结束
        i += 1

状态机逻辑:

状态一:[搜索中] → 遇到 yes → 状态二
状态二:[收集中] → 遇到 yes → 继续状态二(多页 TOC)
状态二:[收集中] → 遇到 no  → 终止(找到结尾)
状态一:[搜索中] → 超过页数限制 → 终止

last_page_is_yes 是状态变量,实现了"一旦开始找到 TOC,就跟踪到结束"的逻辑,支持多页 TOC。


2.5 extract_toc_content(content, model) — 续写重试机制

def extract_toc_content(content, model=None):
    response, finish_reason = ChatGPT_API_with_finish_reason(...)

    if_complete = check_if_toc_transformation_is_complete(content, response, model)
    if if_complete == "yes" and finish_reason == "finished":
        return response

    chat_history = [
        {"role": "user", "content": prompt},
        {"role": "assistant", "content": response},
    ]
    prompt = "please continue the generation..."

    while not (if_complete == "yes" and finish_reason == "finished"):
        new_response, finish_reason = ChatGPT_API_with_finish_reason(..., chat_history=chat_history)
        response = response + new_response
        if_complete = check_if_toc_transformation_is_complete(content, response, model)

        if len(chat_history) > 5:
            raise Exception('Failed after maximum retries')

双重终止条件:

  • finish_reason == "finished":API 正常完成(未达 token 限制)
  • if_complete == "yes":LLM 判断内容完整

只有两者同时满足才认为成功。这防止了:

  • API 完成但内容不完整(LLM 只生成了一半)
  • LLM 说"完整了"但实际被截断(finish_reason=length)

len(chat_history) > 5 的限制:

注意这里检查的是 chat_history 列表长度,而不是续写次数。每次续写追加 2 条消息(user + assistant),所以初始 2 条 + 最多 1-2 次续写后就超过 5。实际上最多只能续写约 1-2 次,和注释说的"10 attempts"严重不符。这是一个 bug——注释过时了,实际限制更严格。


2.6 toc_transformer(toc_content, model) — 结构化提取

def toc_transformer(toc_content, model=None):
    init_prompt = """
    ...
    structure is the numeric system: 1, 1.1, 1.2, etc.

    The response should be in the following JSON format:
    {
    table_of_contents: [
        {
            "structure": <"x.x.x" or None>,
            "title": <title>,
            "page": <page number or None>,
        },
    ]
    }"""

    last_complete, finish_reason = ChatGPT_API_with_finish_reason(...)
    if_complete = check_if_toc_transformation_is_complete(...)
    if if_complete == "yes" and finish_reason == "finished":
        last_complete = extract_json(last_complete)
        cleaned_response = convert_page_to_int(last_complete['table_of_contents'])
        return cleaned_response

    # 续写逻辑(处理超长 TOC)
    last_complete = get_json_content(last_complete)
    while not (if_complete == "yes" and finish_reason == "finished"):
        position = last_complete.rfind('}')
        last_complete = last_complete[:position+2]  # 截到最后一个完整对象
        ...

last_complete[:position+2] 的含义:

当 LLM 输出在 JSON 对象中间被截断时(如输出了 {"structure": "3.1", "title": "Risk),需要找到最后一个完整的 } 并在其后加 ] 闭合数组,然后把这个不完整部分作为上文让 LLM 续写。

position + 2} 位置加 2(跳过 }),但实际上 rfind('}') 返回 } 的索引,[:position+2] 应该是包含 } 后再多一个字符,这可能是为了保留 }, 的情况。逻辑较为脆弱,依赖 LLM 输出的格式一致性。


2.7 page_list_to_group_text(page_contents, token_lengths, max_tokens, overlap_page)

def page_list_to_group_text(page_contents, token_lengths, max_tokens=20000, overlap_page=1):
    num_tokens = sum(token_lengths)

    if num_tokens <= max_tokens:
        return ["".join(page_contents)]  # 全部合并为一组

    expected_parts_num = math.ceil(num_tokens / max_tokens)
    average_tokens_per_part = math.ceil(((num_tokens / expected_parts_num) + max_tokens) / 2)

    for i, (page_content, page_tokens) in enumerate(zip(page_contents, token_lengths)):
        if current_token_count + page_tokens > average_tokens_per_part:
            subsets.append(''.join(current_subset))
            overlap_start = max(i - overlap_page, 0)
            current_subset = page_contents[overlap_start:i]  # 重叠:取前一页
            current_token_count = sum(token_lengths[overlap_start:i])
        current_subset.append(page_content)
        current_token_count += page_tokens

average_tokens_per_part 的计算:

average_tokens_per_part = math.ceil(((num_tokens / expected_parts_num) + max_tokens) / 2)

这是 理想均分大小最大限制 的平均值。

例:总 token=50000,max=20000

  • expected_parts_num = ceil(50000/20000) = 3
  • 理想均分 = 50000/3 ≈ 16667
  • average = ceil((16667 + 20000) / 2) ≈ 18334

分组阈值是 18334,而非 20000。这使得每组实际 token 数更接近均匀,避免最后一组过小或过大。

overlap_page=1 的含义:

每个新分组从前一组最后 1 页开始,提供跨组上下文。对 generate_toc_continue 来说,这防止了章节标题落在前一组最后但内容在下一组的情况被遗漏。


2.8 calculate_page_offset(pairs) — 众数算法

def calculate_page_offset(pairs):
    differences = []
    for pair in pairs:
        try:
            physical_index = pair['physical_index']
            page_number = pair['page']
            difference = physical_index - page_number
            differences.append(difference)
        except (KeyError, TypeError):
            continue

    if not differences:
        return None

    difference_counts = {}
    for diff in differences:
        difference_counts[diff] = difference_counts.get(diff, 0) + 1

    most_common = max(difference_counts.items(), key=lambda x: x[1])[0]
    return most_common

为什么捕获 TypeError

page_number 可能是 None(TOC 中某些条目无页码),physical_index - NoneTypeError,捕获后跳过这对,不影响众数计算。

众数算法的局限:

如果偏移量出现平票(如各有 2 个条目支持偏移=3 和偏移=4),max() 返回字典迭代顺序中最后一个最大值——在 Python 3.7+ 中是插入顺序中最后出现的那个。这不是随机的,但也不是有意义的选择,实际上是个隐性 bug(当平票时行为未定义)。


2.9 check_toc(page_list, opt) — 多轮 TOC 检测

def check_toc(page_list, opt=None):
    toc_page_list = find_toc_pages(start_page_index=0, page_list=page_list, opt=opt)
    if len(toc_page_list) == 0:
        return {'toc_content': None, 'toc_page_list': [], 'page_index_given_in_toc': 'no'}
    else:
        toc_json = toc_extractor(page_list, toc_page_list, opt.model)
        if toc_json['page_index_given_in_toc'] == 'yes':
            return {..., 'page_index_given_in_toc': 'yes'}
        else:
            # 没找到页码,尝试继续向后找有页码的 TOC
            current_start_index = toc_page_list[-1] + 1
            while (toc_json['page_index_given_in_toc'] == 'no' and
                   current_start_index < len(page_list) and
                   current_start_index < opt.toc_check_page_num):
                additional_toc_pages = find_toc_pages(current_start_index, ...)
                ...

多轮检测的场景:

某些文档有两个目录:

  1. 前几页:大纲式目录(无页码)
  2. 稍后几页:详细目录(有页码)

check_toc 先找第一个 TOC,发现无页码,继续向后搜索,找到第二个有页码的 TOC。

返回策略:找到有页码的立即返回;若循环结束都没找到有页码的,返回最初那个无页码 TOC。


2.10 meta_processor — 准确率阈值决策

async def meta_processor(page_list, mode=None, ...):
    ...
    accuracy, incorrect_results = await verify_toc(page_list, toc_with_page_number, ...)

    if accuracy == 1.0 and len(incorrect_results) == 0:
        return toc_with_page_number                          # 完美,直接返回

    if accuracy > 0.6 and len(incorrect_results) > 0:
        toc_with_page_number, incorrect_results = await fix_incorrect_toc_with_retries(...)
        return toc_with_page_number                          # 修复后返回
    else:
        # 准确率 ≤ 60%,整个模式失败,降级
        if mode == 'process_toc_with_page_numbers':
            return await meta_processor(... mode='process_toc_no_page_numbers' ...)
        elif mode == 'process_toc_no_page_numbers':
            return await meta_processor(... mode='process_no_toc' ...)
        else:
            raise Exception('Processing failed')  # 已是最后手段,无法降级

60% 阈值的含义:

accuracy > 0.6:超过 60% 的抽样节点验证正确,认为当前模式基本可行,值得投入修复成本。

accuracy ≤ 0.6:超过 40% 错误,说明当前模式根本性失败(可能 TOC 格式完全不匹配),与其修 40% 的错误不如换策略。

注意 verify_toc 的早返回逻辑:

async def verify_toc(page_list, list_result, start_index=1, N=None, model=None):
    last_physical_index = None
    for item in reversed(list_result):
        if item.get('physical_index') is not None:
            last_physical_index = item['physical_index']
            break

    if last_physical_index is None or last_physical_index < len(page_list)/2:
        return 0, []   # ← 特殊情况:索引覆盖不足一半,视为可疑

如果最后一个有效节点的页码不足文档总页数的一半,直接返回准确率 0(但 incorrect_results 为空列表)。

这会导致 meta_processoraccuracy == 0len(incorrect_results) == 0,触发 if accuracy == 1.0 失败,再触发 if accuracy > 0.6 失败,最终降级——但注意此时 len(incorrect_results) == 0,不是"没有错误"而是"没有采样到"。这是个潜在的逻辑混淆点。


2.11 process_large_node_recursively(node, page_list, opt, logger)

async def process_large_node_recursively(node, page_list, opt=None, logger=None):
    node_page_list = page_list[node['start_index']-1:node['end_index']]
    token_num = sum([page[1] for page in node_page_list])

    if node['end_index'] - node['start_index'] > opt.max_page_num_each_node and \
       token_num >= opt.max_token_num_each_node:

        node_toc_tree = await meta_processor(node_page_list, mode='process_no_toc',
                                              start_index=node['start_index'], ...)
        node_toc_tree = await check_title_appearance_in_start_concurrent(node_toc_tree, page_list, ...)

        valid_node_toc_items = [item for item in node_toc_tree if item.get('physical_index') is not None]

        # 去重:如果子树第一个节点就是父节点本身,跳过
        if valid_node_toc_items and node['title'].strip() == valid_node_toc_items[0]['title'].strip():
            node['nodes'] = post_processing(valid_node_toc_items[1:], node['end_index'])
            node['end_index'] = valid_node_toc_items[1]['start_index'] if len(valid_node_toc_items) > 1 else node['end_index']
        else:
            node['nodes'] = post_processing(valid_node_toc_items, node['end_index'])

触发条件是 AND 而非 OR:

if page_count > max_page AND token_num >= max_token:

同时满足两个条件才递归细分。一个 10 页但每页只有几行文字的章节(token 数少)不会被细分,这合理:token 少说明内容少,不需要分。

start_index=node['start_index'] 传入子处理:

这确保子集处理时的物理页索引是正确的(从父节点的起始页开始),而不是从 1 开始。但正如前面指出的,check_title_appearance_in_start_concurrent 内部硬编码了 -1,所以在这里可能用错了基准。

去重逻辑:

LLM 生成子结构时,第一个节点可能就是当前节点本身的标题("3.2 风险因素" 识别到了自己的开头)。如果不跳过,node['nodes'] 中第一个子节点会和 node 本身重复,形成自引用循环。


2.12 tree_parser(page_list, opt, doc, logger) — 顶层编排

async def tree_parser(page_list, opt, doc=None, logger=None):
    check_toc_result = check_toc(page_list, opt)

    if check_toc_result.get("toc_content") and \
       check_toc_result["toc_content"].strip() and \
       check_toc_result["page_index_given_in_toc"] == "yes":
        toc_with_page_number = await meta_processor(..., mode='process_toc_with_page_numbers', ...)
    else:
        # 无论是无 TOC 还是有 TOC 无页码,都走 process_no_toc
        toc_with_page_number = await meta_processor(..., mode='process_no_toc', ...)

注意tree_parser 只区分两种情况:"有 TOC 且有页码" vs "其他一切"。process_toc_no_page_numbers 路径只能通过 meta_processor 内部降级触发,而不是 tree_parser 直接选择。这意味着:

  • 文档有目录但无页码 → tree_parser 直接走 process_no_toc(跳过了 process_toc_no_page_numbers!)
  • 只有 process_toc_with_page_numbers 准确率 < 60% 时,才会降级到 process_toc_no_page_numbers

这是个设计上的不一致:check_toc 能检测出"有目录但无页码",但 tree_parser 没有利用这个信息走相应路径。


2.13 page_index_main(doc, opt) — 入口函数细节

def page_index_main(doc, opt=None):
    ...
    async def page_index_builder():
        structure = await tree_parser(page_list, opt, doc=doc, logger=logger)
        if opt.if_add_node_id == 'yes':
            write_node_id(structure)
        if opt.if_add_node_text == 'yes':
            add_node_text(structure, page_list)
        if opt.if_add_node_summary == 'yes':
            if opt.if_add_node_text == 'no':
                add_node_text(structure, page_list)  # 临时添加文本
            await generate_summaries_for_structure(structure, model=opt.model)
            if opt.if_add_node_text == 'no':
                remove_structure_text(structure)  # 生成完摘要后移除

    return asyncio.run(page_index_builder())

摘要生成的"借用文本"逻辑:

  1. 用户不需要 textif_add_node_text == 'no'
  2. 但摘要生成需要 text 作为输入
  3. 所以临时添加 text → 生成摘要 → 删除 text

这个三步骤保证了用户不会看到 text 字段,但摘要生成还是能工作。

asyncio.run() 的含义:

同步函数 page_index_main 内部通过 asyncio.run() 启动事件循环来运行异步代码。这意味着:

  • 不能在已有事件循环的环境(如 Jupyter Notebook)中直接调用 page_index_main,会报错 "This event loop is already running"
  • 在 Jupyter 中需要用 await page_index_builder()nest_asyncio

三、page_index_md.py — Markdown 处理详解

3.1 extract_nodes_from_markdown(markdown_content)

def extract_nodes_from_markdown(markdown_content):
    header_pattern = r'^(#{1,6})\s+(.+)$'
    code_block_pattern = r'^```'
    in_code_block = False

    for line_num, line in enumerate(lines, 1):
        stripped_line = line.strip()

        if re.match(code_block_pattern, stripped_line):
            in_code_block = not in_code_block  # toggle 切换状态
            continue

        if not in_code_block:
            match = re.match(header_pattern, stripped_line)

代码块检测的边界情况:

  1. 反引号数量:只检测 ```(三个反引号),不处理 (单个)或 ````(四个)
  2. 嵌套代码块:代码块内的代码块会导致 in_code_block 状态错误地切换
  3. ~~~ 风格的代码块完全不处理

line_num 从 1 开始enumerate(lines, 1) 让行号与编辑器显示一致,方便调试。


3.2 update_node_list_with_text_token_count(node_list, model) — 累计 token 计数

# 从后往前处理,确保子节点先于父节点被处理
for i in range(len(result_list) - 1, -1, -1):
    current_node = result_list[i]
    children_indices = find_all_children(i, current_level, result_list)

    node_text = current_node.get('text', '')
    total_text = node_text
    for child_index in children_indices:
        total_text += '\n' + result_list[child_index].get('text', '')

    result_list[i]['text_token_count'] = count_tokens(total_text, model=model)

为什么从后往前?

这个函数用于 tree_thinning,需要知道"一个节点及其所有子孙节点的总 token 数"。如果从前往后,计算父节点时子节点的累计值还没计算,必须从头找所有子孙。从后往前则不需要——虽然这里的实现实际上每次都重新找所有子节点(时间复杂度 O(N²)),并没有利用后序的优势,但至少逻辑正确。


3.3 tree_thinning_for_index(node_list, min_node_token, model)

nodes_to_remove = set()

for i in range(len(result_list) - 1, -1, -1):
    if i in nodes_to_remove:
        continue

    total_tokens = current_node.get('text_token_count', 0)

    if total_tokens < min_node_token:
        children_indices = find_all_children(i, current_level, result_list)

        for child_index in sorted(children_indices):
            if child_index not in nodes_to_remove:
                children_texts.append(result_list[child_index].get('text', ''))
                nodes_to_remove.add(child_index)  # 标记子节点为待删除

        # 将子节点文本合并到父节点
        result_list[i]['text'] = merged_text

for index in sorted(nodes_to_remove, reverse=True):
    result_list.pop(index)  # 从后向前删除,避免索引移位

两次逆序的原因:

  1. 外层循环从后往前:先处理叶子节点再处理父节点。如果叶子节点先被合并掉,父节点处理时就不需要再考虑这些叶子了
  2. 最后删除时从后往前sorted(nodes_to_remove, reverse=True)):Python 列表删除时,删除靠前的元素会让后面所有元素的索引减 1。从后往前删除,已删除元素不影响待删除元素的索引。

3.4 md_to_tree 中的字段顺序控制

if if_add_node_summary == 'yes':
    tree_structure = format_structure(tree_structure,
        order=['title', 'node_id', 'summary', 'prefix_summary', 'text', 'line_num', 'nodes'])
    await generate_summaries_for_structure_md(...)
    if if_add_node_text == 'no':
        tree_structure = format_structure(tree_structure,
            order=['title', 'node_id', 'summary', 'prefix_summary', 'line_num', 'nodes'])
else:
    if if_add_node_text == 'yes':
        tree_structure = format_structure(tree_structure,
            order=['title', 'node_id', 'summary', 'prefix_summary', 'text', 'line_num', 'nodes'])
    else:
        tree_structure = format_structure(tree_structure,
            order=['title', 'node_id', 'summary', 'prefix_summary', 'line_num', 'nodes'])

format_structure 的作用:重新排列字典 key 的顺序,并删除空的 nodes 数组(叶节点不带 nodes 字段)。这是纯 UI 层面的处理,让输出 JSON 更整洁、可读。

叶节点 vs 分支节点的摘要字段区分(在 generate_summaries_for_structure_md 中):

if not node.get('nodes'):
    node['summary'] = summary        # 叶节点:summary
else:
    node['prefix_summary'] = summary # 分支节点:prefix_summary

设计意图:叶节点 summary 代表该节点内容的摘要;分支节点 prefix_summary 代表该节点标题下所有内容(含子节点)的总体摘要,作为检索时的"章节简介"。


四、关键数据流追踪

4.1 从 PDF 到 JSON 树的完整数据变换

输入:PDF 文件路径

step 1: get_page_tokens()
  → list[tuple[str, int]]
  → [(页1文本, 页1token数), (页2文本, 页2token数), ...]

step 2: check_toc()
  → {'toc_content': str | None, 'toc_page_list': list[int], 'page_index_given_in_toc': 'yes'|'no'}

step 3: process_toc_with_page_numbers() (以此路径为例)
  step 3a: toc_transformer()
    → [{'structure': '1', 'title': '...', 'page': 5}, {'structure': '1.1', ...}]  (含 page 字段)
  step 3b: toc_index_extractor()
    → [{'structure': '1', 'title': '...', 'physical_index': '<physical_index_8>'}, ...]
  step 3c: extract_matching_page_pairs()
    → [{'title': '...', 'page': 5, 'physical_index': 8}, ...]
  step 3d: calculate_page_offset()
    → 3  (整数偏移量)
  step 3e: add_page_offset_to_toc_json()
    → [{'structure': '1', 'title': '...', 'physical_index': 8}, ...]  (page 变为 physical_index)

step 4: validate_and_truncate_physical_indices()
  → 过滤掉 physical_index > 文档总页数 的条目

step 5: verify_toc() → fix_incorrect_toc_with_retries()
  → 同 step 3 的格式,但 physical_index 更准确

step 6: add_preface_if_needed()
  → 如果第一节不从第1页开始,插入 Preface 节点

step 7: check_title_appearance_in_start_concurrent()
  → 每个条目增加 'appear_start': 'yes'|'no'

step 8: post_processing()
  → 将 physical_index 拆分为 start_index + end_index
  → 调用 list_to_tree()
  → list[dict] (树形结构,含 title/start_index/end_index/nodes)

step 9: process_large_node_recursively()
  → 超限节点被递归细分,挂载子节点

step 10: write_node_id() / add_node_text() / generate_summaries_for_structure()
  → 最终输出:{'doc_name': str, 'structure': list[节点树]}

五、隐含假设与已知限制

5.1 硬编码假设

位置假设风险
check_title_appearance_in_start_concurrent line 88page_list[item['physical_index'] - 1]:start_index=1递归处理子节点时索引错误
find_toc_pagesstart_page_index=0TOC 在文档最前面部分文档 TOC 在文档末尾(如某些法律文书)
extract_toc_contentlen(chat_history) > 5注释说10次,实际约1-2次超长 TOC 可能截断
page_index_main:默认 PyPDF2文字 PDF扫描件无法处理
verify_toclast_physical_index < len(page_list)/2有效索引应覆盖超过一半文档对后半部分内容很少的文档(大量附录)误判

5.2 并发安全性

所有 async 函数通过 asyncio.gather() 并发执行,共享 page_list 等数据结构。由于 Python GIL 和纯 IO-bound 操作(等待 API 响应),不存在数据竞争问题。但如果未来引入真正的并行(multiprocessing),page_list 需要额外保护。

5.3 内存使用

一个 500 页 PDF,每页平均 500 个 token:

  • page_list:500 个 (str, int) 元组,约 500×2000字节 ≈ 1MB
  • 所有页面文本在 get_page_tokens 时一次性加载到内存
  • process_no_toc 构建 group_texts 时再次复制页面文本
  • 峰值内存约 3-5× 原始 PDF 大小

本文覆盖 page_index.py(1143行)、page_index_md.py(338行)、utils.py(712行)的每一个函数 上接:01-PageIndex-深度解析.md02-PageIndex-检索与实战.md