02-PageIndex-检索与实战
PageIndex 检索与实战——深度解析
本文是
01-PageIndex-深度解析.md的续篇,聚焦于索引产物的使用方式、多文档检索策略、真实输出数据结构,以及从源码和测试结果中提炼出的工程实战经验。
一、PageIndex 的两个阶段
理解 PageIndex 需要明确区分两个阶段:
阶段一:索引构建(Indexing)
输入:PDF / Markdown
输出:JSON 树(包含结构 + 摘要)
消耗:多次 LLM 调用(一次性成本)
对应:page_index.py / page_index_md.py
阶段二:检索使用(Retrieval)
输入:用户查询 + JSON 树
输出:命中的节点 ID → 读取对应页面内容
消耗:1-2 次 LLM 调用
对应: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_description | LLM 生成的文档整体描述 | 配置项(默认 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.json | 3.1 KB | 短文档,结构简单 |
2023-annual-report_structure.json | 14 KB | 标准年报格式 |
PRML_structure.json | 50 KB | 学术教材,章节细分深 |
q1-fy25-earnings_structure.json | 49 KB | 财报,节点摘要详细 |
Regulation Best Interest_proposed rule_structure.json | 130 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 的工程含义:
- 要求 thinking 字段:强制 LLM 先推理再给答案(Chain-of-Thought),提高定位准确率
- node_id 而非标题:避免歧义(同名章节),精确定位到树节点
- "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 公式:
- :该文档命中的 chunk 数量
- :第 n 个 chunk 的向量相似度得分
- 分母:惩罚大文档,防止长文档因 chunk 多而得分虚高
为什么用 而非 ?
如果分母是 ,得分变为均值,对短文档和长文档一视同仁。 如果分母是 ,长文档需要更多高分 chunk 才能赢过短文档,避免文档越长越容易被召回的偏差。
本质上这是一个召回率与精确率的 trade-off 调节参数, 是经验值,可根据业务场景调整指数。
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)
工程观察:
- 快速迭代:两个 Beta 版本相隔不到三周,说明项目处于活跃开发期
- 字段重命名:
child_nodes → nodes是简化 API 的好决策,但下游用户需适配 - 回归 bug:
start_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.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
| 维度 | LlamaIndex | PageIndex |
|---|---|---|
| 定位 | 通用 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:
文档 → 分块 → 向量化 → 向量库
查询 → 向量化 → 相似度检索 → 拼接 Context → LLM
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:取所有偏移候选值的众数
例:
章节 A → TOC:25页, 物理:28 → 偏移=3
章节 B → TOC:47页, 物理:50 → 偏移=3
章节 C → TOC:103页, 物理:106 → 偏移=3
附录 D → TOC: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 使用点分层级表示(1、1.1、1.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",
...
}
]
}
字段说明:
| 字段 | 类型 | 说明 |
|---|---|---|
title | string | 章节标题(来自文档) |
node_id | string | 层级编号(0001.0002 格式) |
physical_start_index | int | 在 PDF 中的起始物理页索引(0-based) |
physical_end_index | int | 结束物理页索引 |
summary | string | LLM 生成的节点内容摘要 |
prefix_summary | string | 分支节点的整体摘要(含子节点) |
text | string | 原始文本(可选,默认不输出) |
children | array | 子节点列表 |
八、配置系统
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 的本质区别
| 维度 | 向量 RAG | PageIndex |
|---|---|---|
| 检索方式 | 相似度匹配(数学) | 树上推理(语言) |
| 结构感知 | 无 | 有(层次树) |
| 可解释性 | 低("为什么相似?") | 高("第3章第2节") |
| 适用文档 | 结构弱、知识密集 | 结构强(报告、书籍) |
| 维护成本 | 需维护向量库 | 需重新索引(文档更新时) |
| 首次处理 | 快(嵌入计算) | 慢(多次 LLM 调用) |
11.4 适用场景
最适合 PageIndex 的场景:
- 金融文档(年报、招股说明书、监管文件)
- 法律合同(条款引用精确)
- 学术教材(章节结构明确)
- 技术手册(结构化规范)
不太适合的场景:
- 无结构的纯文本(新闻、博客)
- 需要跨文档检索的知识库(向量 RAG 更擅长)
- 实时性要求高的场景(首次索引 LLM 调用多)
十二、延伸思考
12.1 为什么能在金融文档上取得 98.7%?
金融文档(如 10-K 年报)有极其规范的结构:
- 目录必然存在且格式统一
- 章节编号清晰(Item 1, Item 1A, ...)
- 页码与物理页偏差固定(通常只有封面几页)
这正是 PageIndex 三路分支中最优路径的适用条件。
12.2 潜在改进方向
- 混合检索:PageIndex 树用于粗定位,向量检索用于节点内精确定位
- 增量更新:文档部分更新时,只重新索引变化的章节
- 多文档关联:在树节点间建立跨文档引用关系
- 视觉 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 字段才是层级路径。
设计优势:
- 唯一性保证:顺序 ID 无论树结构如何都不冲突
- 引用简单:用户说"节点 0042"比"节点 3.2.1.4"更简洁
- 稳定性:树结构变化时(如新增章节),只有受影响节点的 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_numbers | process_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-2 | toc_transformer |
| 转换完整性检查 | 1-2 | |
| 物理索引提取 | 1 | toc_index_extractor |
| 页码检测 | 1 | detect_page_index |
| 节点验证 | ~10 | verify_toc,抽样约30% |
| 修复(假设2个错误) | 2 | fix_incorrect_toc |
| 修复后验证 | 2 | check_title_appearance |
| 节点开头检查 | 30 | check_title_appearance_in_start,全并发 |
| 摘要生成 | 30 | 每个节点一次,全并发 |
| 文档描述 | 1(可选) | |
| 合计 | ~90-100 |
成本估算(GPT-4o,2024年价格):
- 每次调用平均约 2000 输入 + 500 输出 token
- 约 90 次 × (2000×0.01) / 1000 ≈ $0.90
不含摘要:约 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_API | content (str) | 普通调用,不关心截断 |
ChatGPT_API_with_finish_reason | (content, reason) | 需要检测输出是否被截断(TOC 提取) |
ChatGPT_API_async | content (awaitable) | 并发调用场景 |
关键细节:
-
temperature=0的意义:确定性输出,同样 prompt 每次返回相同结果。对结构解析至关重要——"第3节"不能一次返回"3"下次返回"三"。 -
chat_history的副作用 bug:messages = chat_history # 这是引用,不是复制! messages.append(...) # 会修改原始 chat_history 列表!这是一个潜在 bug:如果调用方在循环中复用同一个
chat_history列表,每次调用都会追加消息,导致历史记录无限增长。实际使用中chat_history仅在extract_toc_content和toc_transformer的续写逻辑里用到,每次都新建列表,所以暂时没有问题。 -
client = openai.OpenAI(api_key=...)在循环内部:同步版本每次 retry 都重建 client 对象,有轻微开销。异步版本用async with正确管理了连接池。 -
错误处理粒度粗:
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 错误 |
两个失败场景:
-
end_idx = content.rfind("```")找到最后一个反引号对,如果 LLM 输出多个代码块,可能截取错误范围。例如:Here's the result: ```json {"a": 1} ``` Note: ```this is not JSON```此时
rfind("```")找到最后一个,content[start_idx:end_idx]会包含中间的非 JSON 内容。 -
无法修复嵌套 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 位(0001、0042),支持最多 9999 个节点if 'nodes' in key:字符串包含检测,能匹配"nodes"、"child_nodes"等任意含nodes的字段名for key in list(data.keys()):list()复制键列表,避免在迭代时修改字典(虽然此处只读不修改,但防御性编程)- node_id 穿透返回:通过返回值传递计数器,保证全树唯一性,而不用全局变量
与 Markdown 树的差异:page_index_md.py 的 build_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 的实际差异:
| 特性 | PyPDF2 | PyMuPDF (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_page → find_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 - None 抛 TypeError,捕获后跳过这对,不影响众数计算。
众数算法的局限:
如果偏移量出现平票(如各有 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, ...)
...
多轮检测的场景:
某些文档有两个目录:
- 前几页:大纲式目录(无页码)
- 稍后几页:详细目录(有页码)
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_processor 中 accuracy == 0 且 len(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())
摘要生成的"借用文本"逻辑:
- 用户不需要
text(if_add_node_text == 'no') - 但摘要生成需要
text作为输入 - 所以临时添加 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)
代码块检测的边界情况:
- 反引号数量:只检测
```(三个反引号),不处理(单个)或````(四个) - 嵌套代码块:代码块内的代码块会导致
in_code_block状态错误地切换 ~~~风格的代码块完全不处理
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) # 从后向前删除,避免索引移位
两次逆序的原因:
- 外层循环从后往前:先处理叶子节点再处理父节点。如果叶子节点先被合并掉,父节点处理时就不需要再考虑这些叶子了
- 最后删除时从后往前(
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 88 | page_list[item['physical_index'] - 1]:start_index=1 | 递归处理子节点时索引错误 |
find_toc_pages:start_page_index=0 | TOC 在文档最前面 | 部分文档 TOC 在文档末尾(如某些法律文书) |
extract_toc_content:len(chat_history) > 5 | 注释说10次,实际约1-2次 | 超长 TOC 可能截断 |
page_index_main:默认 PyPDF2 | 文字 PDF | 扫描件无法处理 |
verify_toc:last_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-深度解析.md、02-PageIndex-检索与实战.md