wth2jhl.online

Back

一、一个让我重新思考 RAG 的项目#

VectifyAI/PageIndex 这个项目我第一次打开 README 是被一行字砸到的:

“Vector-based RAG retrieves by semantic similarity. But similarity ≠ relevance — what retrieval actually needs is relevance, and relevance requires reasoning.”

这句话把我过去两年做的所有 RAG 都「打回原形」了一遍。我之前搭的所有 RAG 流程——chunk → embed → 存进向量库 → 相似度 top-k → 拼 prompt——全都在用「相似度」硬撑「相关性」。这两件事差多远?

举一个真实例子:

  • 文档里讲「2023 年运营利润率是 24.5%」
  • 用户问「What was the 2023 operating margin?」
  • 向量检索会找「2023」+「operating」+「margin」这三个词最相似的段落——但「operating margin」是个组合语义,三个关键词分散在不同段落里,每个段落相似度都还行,但没有一个段落同时含这三个词的语义
  • 向量检索返回一堆”看起来都沾边”的段落,但没有一个是答案

PageIndex 干的事情就是:把向量索引整个拆掉,换成「一棵文档树」,让 LLM 像人一样翻目录、找章节、读全文。

我读完整个仓库(12000+ 行 Python)后,觉得这事的本质是:

把 RAG 从「信息检索」(information retrieval)问题,重新定位成「树搜索 + 推理」(tree search + reasoning)问题。

这两个问题的工程范式完全不同。前者用向量数据库 + 相似度算法;后者用一棵树 + LLM agent。


二、先看项目长什么样#

git clone 下来 59MB(主要是 cookbook/ 里的 ipynb + 测试 fixture + PDF),核心代码在 pageindex/:

pyproject.toml 里只有 2 个非标准库依赖:requests 和 urllib3。这告诉你:PageIndex 的整个 LLM 调用全是裸 HTTP 请求,不绑定 OpenAI / Anthropic SDK——任何「OpenAI-compatible」API 都能用。

整个仓库 538 个 commit,GitHub Trending #1 一段时间过(Trendshift badge 还在 README 里)。当前版本 0.2.10,License MIT,作者 Ray(ray@vectify.ai)。


三、核心论据:为什么相似度 ≠ 相关性#

我把 README 的对比表抄下来,因为这是 PageIndex 整个立项的根:

Vector RAGPageIndex
Index向量索引文档树索引
Retrieval语义相似度搜索LLM reasoning over the tree
Result不透明、“vibe retrieval”显式 traceable,每个答案带页码
Context只有 query embedding完整上下文:对话历史 + 领域知识

「不透明」这一点是向量 RAG 真正的痛点。生产环境里,用户问”我的退款为什么还没到账”,向量检索可能返回:

  • 「退款处理流程」(相似度 0.82)
  • 「退款时效说明」(相似度 0.79)
  • 「申请退款的条件」(相似度 0.76)

这三个都是「相关文档」的相关段落——但没有一个真正回答了用户的问题。向量检索告诉你「这些文档沾边」,但不告诉你「答案在哪一页」。

PageIndex 的做法是:让 LLM 沿着树走,每一步决定「进哪个子节点 / 读哪一页 / 换分支」。LLM reasoning 的每一步都是「可追溯的思考过程」,最终答案带「Page 47, 段落 3」这种精确引用。

这种范式的工程成本是:每次查询都要消耗 LLM tokens reasoning 路径。所以 PageIndex 才要优化树——树越精准,reasoning 路径越短,tokens 越省。


四、两阶段流程:Index + Retrieve#

我把 PageIndex 整个流程画成一张图,把核心心智和代码模块对应起来:

PageIndex 两阶段流程:Index(建树) + Retrieve(Reasoning)

这张图把 PageIndex 的工程结构讲清楚了。让我顺着图一步步讲。

4.1 阶段 ①:Index(建树)#

这一阶段只在文档首次入库时跑一次,跑完生成一个 tree.json,之后所有查询都复用它。

Step ① 解析器(page_index_md.py + page_index_classic.py):

  • Markdown 版用正则匹配 # / ## / ### 标题(extract_nodes_from_markdown),跳过代码块里的伪标题,提取 node_title / line_num / level
  • PDF 版用 PyMuPDF 提页文本 + 检测 Title/TOC,处理跨页段落

Step ② 树结构生成:

  • 把所有节点递归嵌套,生成 {title, level, page_range, text, nodes: [...]} 的树形 JSON
  • 关键点:每个节点带 text 字段(原始内容)和 page_range(页码范围)

Step ③ 树优化(tree_optimize.py,1008 行):

  • 这是 PageIndex 最硬的一段代码——把初版树重塑成最适合 reasoning 检索的形态
  • 核心操作是 merge(确定性,无 LLM)和 expand(LLM 提议子节)
  • merge/expand 的判定标准是下一节我要展开的「cost model」

Step ④ Summary:

  • 每个节点并行调用 LLM 生成 summary(本节内容)和 prefix_summary(本节路径的意图)
  • 摘要控制在 200 token 以内,过长会被压缩

整体成本:$0.001/页 + 13 秒到 4.5 分钟(README benchmark,9 到 1098 页文档实测)。

4.2 中间产物:tree.json#

tree.json 是 PageIndex 整个架构的核心:

每个节点同时存 4 件事:title、summary(LLM 总结)、prefix_summary(父节点摘要)、page_range。这 4 件事是 reasoning agent 做决策的全部信息。

4.3 阶段 ②:Retrieve(Reasoning)#

这一阶段每个查询都要跑。流程是:

用户查询 → LLM Agent 进入 tree.json 根节点
       → 看当前节点 title + summary + child_titles
       → 决定:进子节点?读当前节点的 text?换分支?
       → 循环调用工具,直到 evidence 充足
       → 拼出最终答案 + 页码引用
plaintext

local_chat.py 是这一阶段的主入口,1545 行,实现了:

  • 多轮 reasoning——支持 conversational context(用户连续追问时,history 进 prompt)
  • 流式输出(chat_stream.py,69 行)——把 agent 的工具调用过程实时吐给前端
  • Tool 契约(agent_tools.py,1777 行)——和云端 MCP server 完全对齐的工具定义,这样本地客户端和云端调用无缝切换

关键心智:每个查询都重跑 reasoning,而不是”算一次相似度”。这意味着 PageIndex 的查询成本和向量 RAG 不可比——但因为 Index 一次可以复用,而且 reasoning 只读”路径上的页”而非全文,长文档场景下反而更便宜。

README 给了一组实测数据:

文档长度原生 PDF 输入 vs PageIndex
52 页2.1× 贵
80 页3.4× 贵
198 页7.8× 贵
420 页16.6× 贵
805 页塞不进 context window

这就是 PageIndex 的商业卖点:文档越长,PageIndex 越便宜——因为它只读 reasoning 路径上的页,而原生 PDF 输入得把整篇丢给 LLM。


五、PageIndex 最硬的一段代码:文档树的 cost model#

这是 PageIndex 区别于所有其他”RAG 优化”的根本点——它把整棵树当成搜索引擎来优化,而不是当成「embedding 索引 + 排名函数」。

tree_optimize.py 头 30 行的注释里,作者 Ray 把整个模型定义得非常清楚:

我把这套模型画成下面这张图,把每个公式和它的工程意义对应起来:

PageIndex 文档树成本模型:S(v) / R(v) / tree_cost(v)

5.1 三个原始量#

  • S(v) = 节点 v 子树的总页数(包含 v 自己 + 所有后代节点覆盖的页)。这是「如果把 v collapse,代价是多少」的度量。
  • R(v) = 1 page——这是「访问 v 读 routing 信息」的成本。读 title + summary + child descriptions,粗略按 1 页算。这是个常数,不是变量。
  • S_residual(v) = v 自己”独占”的页数——子节点覆盖范围之外的页。这些页只能通过读 v 自己才能拿到,不能通过子节点路由。

5.2 决策 1:expand(展开 collapsed 节点)#

trigger:       S(v) > TRIGGER_PAGES        (默认 5 页)
collapse_cost = S(v)
expand_cost   = R(v) + max(S_residual(v), max_i S(c_i))
expand iff     expand_cost < collapse_cost                  (ties keep collapsed)
plaintext

心智:一个 collapsed 节点,如果子树跨度超过 5 页(TRIGGER_PAGES),就有「展开价值」。展开成本 = 读 v 自己(1 页)+ 走进最贵子分支(max_i S(c_i))或读 v 独占的残差页(S_residual(v))。取 max 是因为 reasoning agent 会沿”最贵分支”找——它会沿树搜索直到找到答案,最坏情况是遍历最贵的那一支。

如果展开比扫一遍还便宜,就展开——这是个 1 行代码就能写的判定逻辑,但背后的工程意义是把「AI 在树上绕弯路」变成可优化的问题。

5.3 决策 2:merge(合掉已展开的子树)#

merge_cost    = S(v)
tree_cost(v)  = S(v)                                           if v is a frontier node
              = R(v) + max(S_residual(v), max_c tree_cost(c))  if v is expanded
merge iff      merge_cost <= tree_cost(v)                       (ties merge)
plaintext

心智:一个已展开的节点 v,如果合掉它(merge_cost = S(v),扫一遍)比保留子树(tree_cost(v),走分支)更便宜,就合掉。

tree_cost(v) 的递归式是整个模型最硬的部分:

tree_cost(v) = max over frontier u [ d(v, u) + S(u) ]
plaintext

「沿树最坏路径」= 找 frontier 节点 u,让 distance(v,u) + S(u) 最大。这个距离是 reasoning agent 从 v 走到 u 要读多少个父节点的 routing 信息(R(v))。

作者在代码注释里特别强调了一件事:

“A node with residual pages contributes a virtual frontier entry one hop below itself. The maximum is over every branch - the deepest leaf need not be the most expensive one.”

深的叶子不一定是最贵的分支——残差页多、内部残差累积的中间节点反而可能更贵。这点和算法课的「最大子树和」是同一个心智。

5.4 merge 为什么不需要 LLM#

merge 决策的所有输入(S(v) / R(v) / tree_cost(v))都是纯计算量,不需要读内容。所以 merge 是确定性的、不需要 LLM、跑起来几毫秒。

而 expand 需要 LLM,因为要提议子节——「这一节应该怎么切」。expand 用 LLM 在 prompt 里给出当前 section 的全文 + 一个 JSON schema,LLM 返回子节列表。

5.5 same-page dedup 是个隐藏优化#

代码注释里还藏了一个细节:merge_same_page()——「frontier siblings covering identical pages cannot be told apart, 因为 retrieval 是 page-granular 的——an agent routed to any of them reads the same text, and because the leaf summary prompt sees only that text, their summaries come back near-identical」。

翻译:如果两个兄弟叶子覆盖同一组页,它们的 summary 会趋同,合并掉就完事。这是「省 LLM 推理成本」的隐藏优化。


六、Reasoning agent 怎么走树#

local_chat.py 和 agent_tools.py 共同实现了「LLM 沿树 reasoning」这件事。我把这块读完后总结成三段心智:

6.1 工具契约和云端 MCP 对齐#

agent_tools.py 里有一个 TOOL_CONTRACT 字典,定义了 5 个核心工具:

TOOL_CONTRACT = {
    "browse_documents": { ... },  # 主检索工具:列出文档、搜索、按 folder 钻取
    "search_documents":  { ... },  # 升级路径:向量相似度,只在 browse 失败后用
    "locate":            { ... },  # 给定关键词,定位所在章节
    "summarize":         { ... },  # 总结节点
    "extract":           { ... },  # 提取字段
}
python

关键设计:这套契约和云端 MCP server 完全一致。本地模式和云模式共用同一个 client(client.py,2709 行),只是底层实现不同——本地模式读 tree.json,云模式调云 MCP。这意味着 agent prompt 在两种模式下可以无缝切换。

6.2 走树是 reasoning,不是匹配#

agent_tools.py 的 browse_documents 注释里有一段非常硬的工程判断:

“Primary document retrieval tool. After orienting with get_folder_structure() (when available), use this for all document-related questions… Do NOT jump to search_documents() first — it is an escalation path, only after browse_documents(sort=‘relevance’) has failed.”

工程判断:browse_documents(走树)是首选,search_documents(向量)只是「升级路径」——只在 browse 失败时升级。这反向很多 RAG 项目的默认行为(默认用向量,关键词当 fallback)。

这是因为——对长文档,reasoning 找答案的召回率 100% 优于相似度。相似度召回看 embedding 模型的语义理解;reasoning 召回看 LLM 的语义理解——后者明显更强。

6.3 永远不抛异常#

agent_tools.py 头注释有一段非常工程化的设计哲学:

“Tools never raise: every outcome, including errors, is returned as the same JSON envelope the cloud emits ({“success”: true, …} / {“error”: …}) — arguments outside a pruned local signature come back as that envelope too…”

翻译:工具永远不抛异常,所有结果(包括错误)都用云端 emit 的同一种 JSON 包装返回。这是为了让 agent prompt 在本地模式和云模式下能复用同一套 error handling 逻辑。

只有云端是例外:401/403、429/5xx、不可达 server、RATE_LIMITED / USAGE_LIMIT_REACHED——这四种情况会重新抛出 PageIndexAPIError,让上层决定如何重试。


七、横向对比:PageIndex 和其他 RAG 方案的差异#

我把 PageIndex 和主流 RAG 方案做了横向比较:

方案索引检索优势劣势
Vector RAG (LangChain + Pinecone)embedding + HNSW余弦相似度 top-k通用、快、便宜召回率对组合语义差、不透明
PageIndex文档树 + 节点摘要LLM reasoning over tree可追溯、对长文档召回率高每次查询消耗 LLM tokens
BM25倒排索引词频统计零依赖、解释性强不理解语义、对手需要
ColBERT / SPLADElate interactiontoken 级匹配比 BM25 强,比向量省工程复杂、需要 GPU
HyDEembedding + 假设文档假设检索解决零样本问题多一次 LLM 调用
GraphRAG实体关系图图遍历跨文档关系强建图贵、不适合长单文档

PageIndex 的定位非常清楚:「长单文档 + 高准确性 + 可追溯」三个诉求同时满足。这三个诉求和场景其他方案的对比:

  • 长单文档:Vector RAG 弱(chunk 切断语义),PageIndex 强(按章节自然切分)
  • 高准确性:Vector RAG 弱(top-k 召回不稳),PageIndex 强(reasoning 召回)
  • 可追溯:Vector RAG 弱(黑盒向量),PageIndex 强(答案带页码)

FinanceBench 的数字是个硬证据:PageIndex 98.7% vs Vector RAG 50%。这是真实金融文档 QA benchmark,不是 toy 数据。

7.1 PageIndex 不适合什么场景#

我读完代码后的判断,PageIndex 在以下场景下不划算:

  1. 短文档(< 10 页):reasoning 成本超过直接读全文
  2. FAQ 类简单问答:向量检索够快,reasoning 是 overkill
  3. 需要 sub-100ms 响应:每次查询至少一次 LLM round-trip,延迟天然大
  4. 多文档无结构化目录:PageIndex 假设文档有 TOC,如果是 wiki / 论坛爬下来的没有目录的文本,得先建 TOC

如果你有长文档 + 高准确 + 可解释这三个诉求,PageIndex 是当前唯一一个把这三个同时搞定的开源方案。


八、读源码学到的 5 件事#

8.1 把”信息检索”重新定位为”树搜索 + reasoning”#

这是 PageIndex 给我最大的冲击。它不是在「向量检索」上加技巧,而是把问题换了一个范式。这种”换范式 > 优化”的工程判断,在很多领域都是值得学习的——

  • 数据库从行存改成列存,查询从「全表扫」变成「列扫描 + SIMD」
  • 编译从解释执行改成 JIT,计算从「逐条指令」变成「trace 优化」
  • ML 推理从 dense retrieval 改成 late interaction,匹配从「整体相似」变成「token 级」

范式对了,优化才有意义。

8.2 文档树 cost model:工程优化的硬核#

tree_optimize.py 的成本模型把”搜索代价”量化成 page 单位,然后用 merge/expand 两个操作全局优化——这是把 ML/IR 工程师的工作降维到”纯算法可解”的问题。

我能直接复用这条思路的两个地方:

  • 代码搜索:把代码库按目录建树,用 cost model 决定哪些目录该 merge 哪些该 expand
  • 知识库问答:企业 wiki 按章节建树,用 cost model 优化,reasoning 检索走能大幅提升问答准确率

8.3 tool 契约 + 云端/本地是同款 = 工程体验要小#

TOOL_CONTRACT 字典 + client.py 的双重实现,是关键设计哲学:本地/云模式共享 agent 行为代码,只动到底层 IO。这让本地路径天然就有一个”云端对照逻辑”——任何本地能跑的工具,云端都有等价物。

这种”接口一致性 + 实现多态性”在大型系统里非常重要——否则本地和云会越走越远。

8.4 “永远不抛异常” 是个被低估的设计#

agent_tools.py 把所有错误(参数错误、解析错误、内部错误)都包装成和云端 emit 的同一种 JSON 包装返回,只有云端特定错误才重新抛——这是为了让 agent prompt 的 error handling 逻辑能在本地/云端复用。

如果本地抛 KeyError,云端抛 HTTPError,agent 就得写两套 error handling,逻辑会发散。统一返回包装,本质是给 agent 一个稳定的 interface。

8.5 摘要的「双层」设计:summary + prefix_summary#

每个节点同时存 summary(本节内容)和 prefix_summary(父节点摘要)——prefix_summary 来自父节点,用于告诉 agent「这个子节在父节里的角色是什么」。

这是个非常细的工程考量:没有 prefix_summary,子节点的 summary 单独看是合理的,但放进父节点里可能意思完全相反(比如父节是「缺点」,子节 summary 是「稳定」——光看 summary 会以为是优点)。


九、可复用方法论:读”AI 原生应用”源码的正确姿势#

读完 PageIndex,我把读”AI 原生应用”源码的方法论总结成 4 步,和读传统工具源码不一样:

  1. 先看「问题重新定位」的段落——PageIndex 是把 IR 换成了”tree search + reasoning”。这种”换问题”的判断是 AI 原生应用的核心价值。
  2. 看「工具契约」的定义——TOOL_CONTRACT 字典定义了 agent 能干什么。这种契约比具体实现更重要——它定义了 agent 的”操作空间”。
  3. 看「持久化产物」——PageIndex 的 tree.json 是核心产物,所有 reasoning 都基于它。读懂 tree.json = 读懂整个系统。
  4. 看「Cost model / 优化目标」——tree_optimize.py 的 cost model 是 PageIndex 工程价值的根。读懂它,就理解了为什么 PageIndex 比 Vector RAG 强。

这套方法论同样适用于读 OpenAI Function Calling 的应用、读 LangChain 的核心模块、读 LlamaIndex 的 index 抽象。


十、写在最后#

VectifyAI/PageIndex 不是一个”完美”的项目——它依赖 LLM 推理,每次查询都要烧 tokens;它的树优化是工程经验,没有理论保证;它的 MCP 集成要求用户接受云端服务的依赖。但它做对了一件事:

把”为什么相似度 ≠ 相关性”这件事,用 12000 行代码 + 清晰的 cost model + 38.7% vs 50% 的 FinanceBench 数字说清楚了。

这是 2025-2026 年 RAG 领域为数不多真的在重新定位问题的项目。Vector RAG 是 2022 时代的范式,PageIndex 是 2026 时代的范式——前者用算法拟合语义,后者让 LLM 直接 reasoning 语义。

如果你做 RAG,我把它推荐给你。读它能让你想清楚两件事:

  1. 你的文档是「长单文档」还是「短多文档」?
  2. 你的诉求是「top-k 召回」还是「答案带页码」?

参考与引文#

本文涉及的所有引用都来自 VectifyAI/PageIndex 仓库本身,以下是我精读过的核心文件:

  • pageindex/page_index_md.py(345 行)—— Markdown 版树生成(extract_nodes_from_markdown、generate_summaries_for_structure_md)
  • pageindex/page_index_classic.py(1325 行)—— PDF 版树生成(PyMuPDF + 标题检测 + TOC)
  • pageindex/tree_optimize.py(1008 行)—— 核心:cost model + merge / expand / merge_same_page
  • pageindex/local_chat.py(1545 行)—— 本地 Reasoning 主流程 + 多轮历史 + 协议转换
  • pageindex/agent_tools.py(1777 行)—— MCP tool 契约 + 本地/云端一致性
  • pageindex/client.py(2709 行)—— 统一 SDK client(local + cloud)
  • pageindex/utils.py(1295 行)—— 异步 LLM 调用 + token 计数 + 树转 list 等工具
  • README.md —— 完整文档 + benchmark + cost 对比图
  • cookbook/pageindex-flash-demo.ipynb —— PageIndex Flash 快速建树教程

仓库地址:github.com/VectifyAI/PageIndex ↗ 当前版本:0.2.10 / MIT License / 作者 Ray(@vectify.ai)


关于配图:本文两张图都是用 matplotlib 重新画的——img1-tree-cost-model.png 是文档树成本模型,把 tree_optimize.py 头 30 行的 S(v) / R(v) / tree_cost(v) 心智画清楚;img2-pipeline.png 是两阶段流程图,把 page_index_md.py + tree_optimize.py + local_chat.py 三件事串成一张链路图。原 README 里用 PNG / WebP 渲染了 4 张 benchmark 成本图,我的重画目的是把”成本模型”和”两阶段解耦”这两个工程核心可视化讲出来。

深入解析 PageIndex:为什么「相似度」不等于「相关性」
https://wth2jhl.online/blog/pageindex-vectorless-rag-deep-dive/
Author 小吴同学
Published at September 30, 2026