PageIndex 技术拆解:一个无向量推理检索系统的完整实现

32 阅读13分钟

PageIndex 技术拆解:一个无向量推理检索系统的完整实现

传统向量 RAG 的检索精度在专业长文档场景下遇到瓶颈。PageIndex(GitHub)提出了一种替代范式:放弃向量库和切块,改用层级树索引 + LLM 推理搜索。本文从源码层面拆解其技术实现——入口点、调用链、核心算法、数据结构和设计决策。

问题域分析:向量检索的精度边界

向量 RAG 的工作流:文档 → 固定大小 chunk → 嵌入模型 → 向量库 → top-K 相似度搜索 → LLM 合成答案。这个管线在大规模文档集的快速召回上表现良好,但在需要领域推理的长文档上存在结构性局限:

  • similarity ≠ relevance:语义相似度分数无法区分"看起来像"和"真正相关",对需要多步推理的专业问题尤其致命
  • chunk 破坏层级:固定大小切割丢失目录→章节→小节的自然结构,跨节引用、表格延续被切断
  • 检索黑箱:向量搜索无法解释"为什么返回这几块",合规审计场景需要可追溯的检索路径
  • 上下文腐化:超长文档多轮对话时,无关内容逐渐稀释有效上下文

vector-vs-tree.png

这不是向量 RAG 的设计缺陷——它本来就是检索工具。局限是场景性的:当你需要相关性而非相似性、需要可解释检索路径、处理有自然层级结构的长文档时,向量搜索遇到了精度边界。

技术范式:树索引 + 推理检索

PageIndex 的核心范式来自 AlphaGo 的类比:不是穷举所有可能,而是在树结构上推理搜索。具体实现:

  1. 生成阶段:将 PDF/Markdown 文档转换为层级树结构索引(类似目录但优化给 LLM 使用)
  2. 检索阶段:LLM 在树索引上推理搜索,自主决定看哪个章节、取哪些页面,模拟人类专家的检索策略

两个趋势使这个范式可行:LLM 推理能力增强到能做多步判断(page_index.py:930 verify_toc 就是 LLM 做验证推理的实例),Agentic AI 的 tool-calling 让 LLM 可以自主调用检索工具循环。

Mafin 2.5 在 FinanceBench 上 98.7% 的实测结果验证了这个范式的精度优势。

项目架构

属性
类型CLI 工具 + Python 库 + Agent 服务
语言Python 3.12+
核心依赖openai SDK(LLM 调用)、PyPDF2(PDF 解析)、pymupdf(备选解析器)
声明但未启用litellm(requirements.txt)、langgraph(pyproject.toml)
项目路径projects/PageIndex/source/

project-structure.png 核心包 pageindex/ 的模块依赖方向:

utils.py ←── page_index.py ←── client.py
utils.py ←── page_index_md.py ←── client.py
utils.py ←── retrieve.py ←── client.py

utils.py 是共享基础设施(llm_completion/llm_acompletionTokenCounterConfigLoaderlist_to_treeremove_fields),被所有核心模块引用。client.py 是 Facade 入口,对调用者隐藏 PDF/MD 差异。

入口点与功能清单

#入口文件:行号输入输出功能域
1page_index_mainpage_index.py:1105PDF path/BytesIO + config{doc_name, structure} dict索引/处理
2page_indexpage_index.py:1219PDF path + 8个可选参数{doc_name, structure} dict索引/处理(公开 API 包装)
3md_to_treepage_index_md.py:243MD path + config{doc_name, structure} dict索引/处理
4PageIndexClient.__init__client.py:31model + workspacePageIndexClient 实例管理/初始化
5PageIndexClient.indexclient.py:53file_path + modedoc_id (UUID)索引/CRUD
6get_documentretrieve.py:81documents dict + doc_idJSON str检索/查询
7get_document_structureretrieve.py:100documents dict + doc_idJSON str (tree, no text)检索/查询
8get_page_contentretrieve.py:110documents dict + doc_id + pagesJSON str检索/查询
9query_agentagentic_vectorless_rag_demo.py:55client + doc_id + promptstr (answer)集成/交互
10PageIndexQAAgent.runqa_agent.py:383questions CSVCSV + JSON集成/评估

功能域分组:索引/处理(1-3,5)、检索/查询(6-8)、管理/初始化(4)、集成/交互(9-10)。

核心功能实现详解

PDF 树索引生成(page_index.py:1105

这是 PageIndex 最复杂的管线,入口 page_index_main,完整调用链:

pdf-index-pipeline.png

page_index_main(page_index.py:1105)          [入口: PDF path → dict{structure}]get_page_tokens(utils.py:437)            [数据边界: PyPDF2/PyMuPDF 读 PDF → page_list[(text,tokens)]]
  → TokenCounter.reset()                     [清零统计器]tree_parser(page_index.py:1059)          [核心编排器: page_list → toc_tree]check_toc(page_index.py:726)           [交互边界: LLM逐页检测目录页]
      → find_toc_pages → toc_detector_single_page(page_index.py:125) → llm_completion
      → toc_extractor(page_index.py:246) → detect_page_index → llm_completion
    → meta_processor(page_index.py:989)      [分支: 3种mode]
      Mode 1: process_toc_with_page_numbers(page_index.py:652)
        → toc_transformer(page_index.py:297) → llm_completion     [LLM: TOC文本→JSON]
        → toc_index_extractor(page_index.py:267) → llm_completion  [LLM: 标题→physical_index映射]
        → calculate_page_offset(page_index.py:418)                 [算法: 众数法计算页码偏移]
        → add_page_offset_to_toc_json(page_index.py:440)           [应用偏移]
      Mode 2: process_toc_no_page_numbers(page_index.py:627)
        → toc_transformer → llm_completion
        → add_page_number_to_toc(page_index.py:485) → llm_completion [LLM: 为每项补充页码]
      Mode 3: process_no_toc(page_index.py:606)
        → page_list_to_group_text(page_index.py:450)               [算法: 按token上限分组+overlap]
        → generate_toc_init(page_index.py:566) → llm_completion    [LLM: 从首组生成初始TOC]
        → generate_toc_continue(page_index.py:531) → llm_completion [LLM: 续写后续组]
    → validate_and_truncate_physical_indices(page_index.py:1230)   [截断超出范围的页码]
    → verify_toc(page_index.py:930) → check_title_appearance → llm_acompletion
      [分支: accuracy=1.0→通过; accuracy>0.6→修复; accuracy≤0.6→降级]
    → fix_incorrect_toc_with_retries(page_index.py:908)           [最多3次修正]
      → fix_incorrect_toc → single_toc_item_index_fixer(page_index.py:770) → llm_acompletion
    → add_preface_if_needed(utils.py:422)                          [检测并添加前言节点]check_title_appearance_in_start_concurrent(page_index.py:95) → llm_acompletion
    → post_processing(utils.py:483)                                [扁平→树的关键变换]list_to_tree(utils.py:374)                                 [核心算法: structure编码→嵌套树]process_large_node_recursively(page_index.py:1030)           [递归: 大节点→meta_processor(mode='no_toc')]write_node_id(utils.py:182)                                    [DFS分配0001-9999编号]add_node_text(utils.py:602)                                    [按页码范围填充text字段]generate_summaries_for_structure(utils.py:639)                 [异步: 每节点LLM生成summary]generate_doc_description(utils.py:672)                         [LLM: 整文档一句话描述]format_structure(utils.py:690)                                 [重排dict键顺序]
  → 写 report JSON to ./reports/

关键设计决策:meta_processor 三模式降级链page_index.py:989

meta_processor 不是单路径——它根据 TOC 检测结果选择模式,验证准确率低于 0.6 时自动降级到下一模式重试。三种都失败才 raise Exception('Processing failed')。这是容错设计:宁可降级处理也不在单条路径上反复重试。

验证逻辑:verify_tocpage_index.py:930)让 LLM 检查每个 TOC 标题是否出现在对应物理页上。accuracy = 正确项数/总项数。accuracy > 0.6 时调用 fix_incorrect_toc_with_retries(最多3次修正,每次让 LLM 在前后正确项的页码范围内重新定位)。accuracy ≤ 0.6 时直接降级。

核心算法:list_to_treeutils.py:374

输入:扁平 TOC 列表,每项含 structure 字段(如 "1", "1.1", "1.2", "2", "2.1")。算法:将 structure 按点号分割,去掉末段得到父级 structure("1.1" → 父为 "1"),以此建立父子映射,递归构建 nodes 数组。纯计算函数,不调 LLM。

大节点递归拆分:process_large_node_recursivelypage_index.py:1030

条件:end_index - start_index > max_page_num_each_node AND token > max_token_num_each_node。对大节点递归调用 meta_processor(mode='process_no_toc') 重新生成子树,用 asyncio.gather 并发处理所有子节点。

Markdown 树索引生成(page_index_md.py:243

MD 路径比 PDF 简单得多,因为标题层级是确定的(# 数量),不需要 LLM 做 TOC 检测。

md_to_tree(page_index_md.py:243)                [入口: MD path → dict{structure}]open(md_path).read()                         [数据边界: 读 MD 文件]extract_nodes_from_markdown(page_index_md.py:32)
    [正则: r'^(#{1,6})\s+(.+)$' 逐行匹配 # 标题]
    [跳过代码块: 三反引号 in_code_block 标志切换]
    → 输出: (node_list[{node_title, line_num}], markdown_lines)
  → extract_node_text_content(page_index_md.py:62)
    [每节点: 标题行到下一标题行之间的 lines 切片 → text 字段]
    → 输出: all_nodes[{title, line_num, level, text}]
  → [可选] update_node_list_with_text_token_count(page_index_md.py:89)
    → find_all_children → count_tokens(utils.py:64) [len(text)//4 粗估][可选] tree_thinning_for_index(page_index_md.py:135)
    [条件: parent total_tokens < min_token_threshold → 合并子节点到父节点]
    → 输出: 精简后的 node_list
  → build_tree_from_nodes(page_index_md.py:190)  [核心算法: 栈构建嵌套树]
  → write_node_id(utils.py:182)                  [DFS编号]
  → [可选] generate_summaries_for_structure_md(page_index_md.py:19)
    → structure_to_list(utils.py:209)             [树扁平化]
    → get_node_summary → [tokens < threshold: 返回原文 | ≥ threshold: llm_acompletion]
  → [可选] generate_doc_description(utils.py:672) → llm_completion
  → format_structure → dict{doc_name, structure}

核心算法:build_tree_from_nodespage_index_md.py:190

栈算法:遍历扁平节点列表,弹出栈直到栈顶 level < current_level,当前节点成为栈顶的子节点(栈非空时)或根节点(栈空时),然后推入 (tree_node, level)。O(n) 时间,O(depth) 空间。

PageIndexClient 检索管线(client.py

PageIndexClient 是 Facade 入口,对调用者隐藏 PDF/MD 差异,提供 4 个方法:

index(file_path, mode)client.py:53

index(client.py:53) → [PDF] page_index(page_index.py:1219) → page_index_main → doc_dict
                   → [MD] md_to_tree(page_index_md.py:243) → doc_dict
                   → PyPDF2.PdfReader 提取 per-page text (client.py:79-82) [PDF专用]
                   → self.documents[doc_id] = doc_dict (client.py:84)
                   → _save_doc(client.py:155)                          [持久化]remove_fields(doc['structure'], ['text'])       [PDF: 剔除text节省存储]
                     → json.dump → workspace/{doc_id}.json            [写文件]
                     → _save_meta → _meta.json                        [轻量索引]
                     → pop structure/pages from memory                [释放大字段]
                   → return doc_id

get_document(doc_id)client.py:218

get_document → retrieve.get_document(retrieve.py:81)
  → documents.get(doc_id)           [分支: 不存在→error JSON]_count_pages(retrieve.py:27)    [三级缓存: page_count→pages→PyPDF2]
  → json.dumps → JSON metadata

get_document_structure(doc_id)client.py:222

get_document_structure → _ensure_doc_loaded(client.py:206) → _read_json → 恢复structure/pages
  → retrieve.get_document_structure(retrieve.py:100)
  → remove_fields(structure, ['text'])       [递归剔除text字段节省token]
  → json.dumps → JSON tree (无text)

get_page_content(doc_id, pages)client.py:228

get_page_content → _ensure_doc_loaded → 恢复structure/pages
  → retrieve.get_page_content(retrieve.py:110)
  → _parse_pages(retrieve.py:12)             ["5-7"→range, "3,8"→list, "12"→single][PDF] _get_pdf_page_content(retrieve.py:36)
    [二级缓存: cached pages→内存映射 | 无缓存→PyPDF2读PDF文件][MD] _get_md_page_content(retrieve.py:56)
    [递归遍历structure树, 匹配line_num在范围内的节点]
  → json.dumps → JSON [{page, content}]

惰性持久化机制:_ensure_doc_loadedclient.py:206

index() 完成后从内存释放 structurepages 大字段到磁盘 JSON。检索时 _ensure_doc_loadedworkspace/{doc_id}.json 恢复到内存。这是节省内存的代价换取:大规模文档集不会因为全量缓存耗尽内存。

Agentic Vectorless RAG(agentic_vectorless_rag_demo.py:55

data-flow.png

query_agent(agentic_vectorless_rag_demo.py:55)
  → 定义 3 个 function_tool (line 62-79):
    - get_document()client.get_document(doc_id)
    - get_document_structure()client.get_document_structure(doc_id)
    - get_page_content(pages)client.get_page_content(doc_id, pages)
  → Agent(name, instructions, tools, model) (line 81-87)     [OpenAI Agents SDK]
  → Runner.run_streamed(agent, prompt) (line 90)              [交互边界: LLM API]
    → Agent 自主推理循环 (OpenAI SDK 内部):
      1. 发送 prompt + system_prompt 到 LLM                 [交互边界]
      2. LLM 返回: 文本回复 OR tool_call 指令               [分支: 是否需要工具]
      3. 若 tool_call → 执行对应 function_tool → 结果回传 LLM
      4. 重复直到 LLM 返回最终文本答案
  → return streamed_run.final_output (str)

这是 ReAct 模式的实现:LLM 推理 → 工具行动 → 观察结果 → 继续推理。LLM 不是机械取 top-K,而是自主决定"我先看目录结构,再取第 5-8 页内容"。模拟人类专家的检索策略——先定位再读取。

数据结构与流向

核心数据结构

tree node(PDF/MD 管线最终输出):递归嵌套 dict,字段:

字段类型含义生成位置
titlestr章节标题toc_transformer/LLM 或 #标题
node_idstr4位零填充编号 (0001-9999)write_node_id(utils.py:182)
start_indexint起始页码(PDF)/行号(MD)post_processing(utils.py:483)
end_indexint结束页码(PDF)/行号(MD)post_processing(utils.py:483)
summarystr一句话摘要generate_node_summary(utils.py:628)/LLM
textstr原文内容add_node_text(utils.py:602)
nodeslist[dict]子节点数组list_to_tree(utils.py:374)

TOC item(中间态,扁平):{structure: "1.2.3", title, physical_index}structure 编码用于 list_to_tree 确定父子关系。

page_list(输入载体):list[tuple(page_text: str, token_length: int)]。每页文本+count_tokens粗估。

agentic-rag-loop.png

管线间数据衔接

衔接点上游输出下游输入数据形状变化
get_page_tokensstr(PDF path)page_list[(text,tokens)]PDF 二进制 → 每页文本+token估算
meta_processorpage_listflat TOC [{structure,title,physical_index}]页面文本 → LLM 结构化输出
post_processinglist_to_treeflat TOC itemstree nodes (nested dict)扁平列表 → 递归嵌套树
_save_docdoc_dict (in memory){doc_id}.json + _meta.json内存对象 → JSON文件(PDF去text)
_ensure_doc_loaded{doc_id}.jsondoc_dict (in memory)JSON文件 → 内存对象(惰性恢复)
remove_fieldsstructure (with text)structure (no text)递归遍历剔除text字段省token
_parse_pages"5-7"[5, 6, 7]字符串 → 整数页码列表

持久化方式

操作格式数据内容
workspace/{doc_id}.json单文档完整索引(PDF structure去text)
workspace/_meta.json全文档轻量元数据索引(doc_id→type/name/description)
results/*_structure.json索管线最终输出
reports/*_report.json处理统计(page数/token/耗时/LLM调用量)
PDF file / BytesIOPyPDF2/PyMuPDF 提取文本
MD fileopen().read() 全文本
workspace/_meta.json恢复 documents dict 索引

设计决策分析

决策选择分析
LLM 调用方式OpenAI SDK 直连简单直接;litellm 声明但未启用,说明当前只用一个 endpoint
配置管理硬编码 ConfigLoader 替代 YAML部署简洁但 config.yaml 和 pyyaml 成为遗留物
PDF 解析默认 PyPDF2,可选 PyMuPDFPyPDF2 纯 Python 无编译依赖;PyMuPDF 精度更高但需编译
TOC 处理3级降级链容错核心:accuracy<0.6 自动降级,三种都失败才报错
TOC 验证全量验证+重试修正(最多3次)可验证优于盲目信任;但重试后仍不准确则降级
大节点处理asyncio.gather 递归异步拆分并行效率;但递归 meta_processor 调用意味着大文档 token 成本高
检索 APIPageIndexClient Facade隐藏 PDF/MD 差异,3 个工具方法统一接口
持久化Workspace JSON+_meta+惰性加载节省内存;但 _save_doc 后内存释放意味着检索需要磁盘 I/O
Agentic QAOpenAI function calling成熟 API;langgraph 声明但未使用

实现缺陷与测试缺口

代码层面缺陷

类型内容位置
安全API Key 硬编码5处utils.py:17, client.py:35, chat_agent.py:154, qa_agent.py:84, eval_agent.py:110
安全.env 文件含 API Key 在源码中source/.env
错误处理llm_completion 10次重试后返回空字符串而非抛异常utils.py:99-102, utils.py:130-132
错误处理extract_json 解析失败返回空 {}utils.py:175-177
错误处理bare except 捕获所有异常含 KeyboardInterruptpage_index_md.py:7, utils.py:175
精度count_tokenslen(text)//4 粗估utils.py:64-68
设计ConfigLoader 禁用 YAML 读取改硬编码utils.py:704-716
质量QA 答案判定 len(answer.strip()) > 10qa_agent.py:402
设计Chat Agent 工具循环上限 10 次硬编码chat_agent.py:180
遗留litellmlanggraph 声明未启用pyproject.toml, requirements.txt
遗留pyproject.toml description 未填写pyproject.toml:4

静默失败风险

最危险的缺陷是 llm_completionextract_json 的静默失败模式。当 LLM 调用10次全部失败后返回空字符串,extract_json 解析失败返回空 dict,下游代码会继续处理空结果而不会意识到上游失败了。meta_processorverify_toc 检查 accuracy 依赖这些函数的正确返回——如果验证本身基于空数据运行,accuracy 计算可能产生错误判断。

测试缺口

整个项目零测试文件。高风险未测试功能:

功能入口风险
TOC 检测(toc_detector_single_page)page_index.py:125高 — LLM JSON 解析失败静默返回 'no'
meta_processor 三模式 fallbackpage_index.py:989高 — accuracy<0.6 三种都失败则 raise Exception
fix_incorrect_toc_with_retriespage_index.py:908中 — 3次修正可能仍留 incorrect 结果
llm_completion 重试与错误处理utils.py:71高 — 10次重试后返回空字符串影响所有 LLM 调用链
validate_and_truncate_physical_indicespage_index.py:1230中 — 防止 TOC 引用不存在页码
PageIndexClient workspace 持久化client.py中 — 跨会话数据一致性
Markdown 标题解析page_index_md.py:32中 — 代码块内 # 误解析

能力边界

能做:PDF/MD 层级树索引、TOC 三模式检测与降级、验证修复循环、大节点递归拆分、节点摘要生成、Agentic QA 检索、workspace 惰性持久化、token 统计。

不能做:扫描版/图片 PDF(PyPDF2 extract_text 返回空)、Word/HTML/EPUB 格式(client.py:123 ValueError)、图片/表格语义提取、非 # 标题 Markdown、代码块内 # 防误解析。

可配置边界

配置项默认值约束
model硬编码可通过 PageIndexClient 传入覆盖
max_page_num_each_node10递归拆分阈值
max_token_num_each_node20000递归拆分阈值
toc_check_page_num20TOC 检测扫描范围
temperature0硬编码不可配置
max_retries (LLM)10硬编码不可配置

PageIndex 适合:有自然层级结构的长文档(金融/法律/学术)、需要可解释检索路径的合规场景、不需要向量库的简化部署。不适合:扫描版 PDF、需要图表语义提取、需要极低检索延迟的场景。