PageIndex:用推理树替代向量检索,解决RAG结构性缺陷
1. 从向量数据库的困境说起PageIndex 到底想解决什么问题做过 RAG 项目的人大概都有过这种体验文档切了一堆 chunk灌进向量数据库用户问一个问题检索出来七八个片段看着都沾点边拼到 prompt 里让 LLM 回答结果要么答非所问要么把几个不相关的段落硬凑在一起幻觉严重。你调 embedding 模型、调 chunk size、调 top_k折腾一圈发现效果提升有限。这不是你调参不够努力而是向量检索这套范式本身有结构性缺陷。向量数据库的核心逻辑是把文本映射到高维空间用余弦相似度找“语义相近”的片段。听起来很合理但它丢掉了两样东西——结构和推理。一份 PDF 报告有章节层级一份合同有条款编号一份技术文档有目录树这些结构信息在切 chunk 的那一刻就被碾平了。向量检索找回来的是“语义相似的碎片”而不是“逻辑上该看的那一段”。更麻烦的是当用户的问题需要跨多个章节综合推理时向量检索几乎无能为力因为它本质上是一次性的相似度匹配没有“先定位、再深入、再回溯”的过程。PageIndex 这个项目就是冲着这个痛点来的。它的核心主张很明确用推理代替相似度用树结构代替扁平向量。简单说它不把文档切碎灌进向量库而是先把文档解析成一棵层级树类似书籍的目录结构检索时让 LLM 在这棵树上做推理导航——先看顶层目录判断该进哪个分支再逐层深入直到定位到真正相关的那一节。这个过程更接近人类查阅资料的方式先翻目录再找章节最后精读段落。这个思路对几类人特别有价值。一是做企业知识库的开发者文档量大、结构复杂、对准确性要求高向量检索的“差不多就行”满足不了二是做长文档问答的团队比如法律合同、研报、技术手册这些场景里结构就是语义的一部分三是RAG 教程学习者如果你已经会搭基础的向量 RAG想理解下一代检索范式长什么样PageIndex 是一个很好的切入点。它不依赖向量数据库不需要 embedding 模型核心依赖是 LLM 的推理能力和一套文档树构建逻辑这让它在一众 RAG 框架里显得很不一样。我接下来会从设计思路、核心机制、实操落地、踩坑排查几个层面把这个项目拆开讲透。不管你是想直接用它还是想借鉴它的思路改造自己的 RAG 管线应该都能拿到点东西。2. PageIndex 的整体设计与思路拆解2.1 为什么放弃向量检索相似度匹配的三个硬伤要理解 PageIndex 的设计得先搞清楚向量检索到底哪里不行。我把它的问题归纳成三条都是实际项目里反复撞墙撞出来的。第一语义相似不等于逻辑相关。用户问“这份合同里乙方违约的赔偿上限是多少”向量检索可能召回一段讲“甲方违约责任”的文字因为两段话都包含“违约”“赔偿”“责任”这些词embedding 空间里距离很近。但用户要的是乙方那一条甲方的内容再相似也是错的。向量检索没有“主体区分”能力它只看整体语义分布。第二跨段落推理是盲区。很多问题的答案不是集中在某一段而是分散在多个章节里需要综合。比如“对比第三章和第五章提到的两种方案的成本差异”向量检索会分别召回第三章和第五章的片段但它不知道这两段需要被“对比”也不知道对比的维度是什么。它只是把相似的片段堆给你综合推理的活儿全甩给 LLM而 LLM 拿到的上下文是碎片化的推理质量自然打折。第三chunk 边界是人为的会切断语义。一份文档按 512 token 切切到哪里全看字数可能正好把一个完整的论证切成两半。前半段在 chunk A后半段在 chunk B检索时只召回 AB 丢了答案就不完整。你调 overlap 能缓解但治标不治本overlap 大了又引入冗余挤占上下文窗口。PageIndex 的应对策略是釜底抽薪不切 chunk不建向量索引改用文档树 LLM 推理导航。文档树保留了原始结构每个节点是一个完整的章节或段落不存在人为切断的问题检索时 LLM 沿着树逐层判断天然支持跨节点推理而且因为节点是结构化的LLM 能清楚知道“我现在看的是第三章第二节”主体和层级关系一目了然。2.2 文档树是怎么构建的从原始文档到层级结构PageIndex 的第一步是把输入文档转成一棵层级树hierarchical tree。这棵树的结构类似文档根节点 ├── 第一章 │ ├── 1.1 节 │ │ ├── 段落1 │ │ └── 段落2 │ └── 1.2 节 ├── 第二章 │ ├── 2.1 节 │ └── 2.2 节每个节点携带的信息包括标题这一节讲什么、摘要LLM 生成的内容概括、原文该节点的完整文本、子节点列表。构建过程大致分三步。第一步是结构解析。如果输入是 Markdown直接按标题层级解析#是一级##是二级天然成树。如果是 PDF就得先做版面分析识别标题、正文、表格、页眉页脚这一步通常借助 PDF 解析库比如 PyMuPDF、pdfplumber或者专门的文档解析服务。如果是纯文本就得靠 LLM 或规则来推断章节边界难度最大。第二步是节点摘要生成。对每个节点调 LLM 生成一段简短摘要说明这一节的核心内容。这个摘要非常关键它是后续检索导航的“路标”。摘要质量直接决定检索准确率所以 prompt 要设计好要求 LLM 提炼主题而非复述细节。第三步是树结构校验。检查有没有孤立的节点、层级错乱、空节点必要时做合并或拆分。这一步容易被忽略但实际项目里文档格式千奇百怪不校验的话树会歪掉检索时 LLM 在歪树上导航结果可想而知。提示文档树的质量是 PageIndex 效果的天花板。如果原始文档结构混乱比如扫描版 PDF、无标题的纯文本树建不好后面检索再聪明也白搭。这类文档建议先做预处理或者退回向量方案。2.3 推理式检索LLM 如何在树上“找路”树建好之后检索阶段就是 PageIndex 最有意思的部分。它不查向量库而是让 LLM 在树上做推理导航reasoning-based retrieval。流程是这样的用户提问后系统把根节点的子节点列表也就是顶层目录每个节点带标题和摘要喂给 LLM问它“根据这个问题应该进入哪个分支”LLM 判断后系统进入该分支再把该分支的子节点列表喂给 LLM继续判断如此逐层深入直到到达叶子节点或 LLM 认为已经找到足够信息。这个过程可以类比成在图书馆找书你先看分类目录顶层判断该去哪个区到了那个区看书架标签中层判断该拿哪一排最后在那一排里挑出具体的书叶子节点。每一步都是基于当前层级的摘要做判断而不是一次性匹配所有内容。这种方式的优势很明显。第一上下文精准。LLM 每一步只看到当前层级的几个选项不会被无关内容干扰判断质量高。第二支持多跳推理。如果一个问题需要综合多个分支LLM 可以在导航过程中标记“这个分支相关先记下”最后把多个分支的内容汇总。第三可解释。检索路径是显式的你能看到 LLM 为什么进了这个分支、跳过了那个分支出问题时好排查。当然代价是多次 LLM 调用。树有几层就要调几次如果树很深比如五六层延迟和成本都会上去。所以 PageIndex 在实践中通常会做优化比如限制树深度、合并浅层节点、缓存中间判断结果。这些优化后面实操部分会细讲。3. 核心细节解析与实操要点3.1 节点摘要的生成策略路标要准不能含糊节点摘要是 PageIndex 检索导航的“路标”它的质量直接决定 LLM 能不能找对路。我踩过的坑是一开始让 LLM 生成摘要时要求“详细概括”结果摘要写得太长太细反而干扰判断——LLM 看到一堆细节抓不住这一节的主题。后来改成限制字数 强制提炼主题效果好很多。具体做法是prompt 里明确要求用一到两句话说明这一节的核心主题不要罗列细节不要复述原文要能让读者一眼判断“这一节讲的是不是我要找的东西”。比如一个讲“数据库索引原理”的章节好的摘要是“介绍 B 树索引的结构与查询流程”而不是“本章首先介绍了索引的基本概念然后讲解了 B 树的节点结构包括……”。另一个要点是摘要要包含区分性信息。如果文档里有多个章节都讲“配置”摘要就得说清楚各自配置的是什么——是“数据库连接配置”还是“日志级别配置”。否则 LLM 在导航时看到两个都叫“配置”的节点没法判断该进哪个。注意摘要生成是一次性成本建树时做完就固定了。所以这一步值得多花点时间调 prompt甚至可以人工抽检几个节点确认摘要质量。建树质量差后面检索全白费。3.2 树深度与节点粒度的权衡太深太浅都不行树的深度和节点粒度是一对矛盾。树太深层级多检索时要调很多次 LLM延迟高、成本大树太浅层级少每个节点包含的内容太多摘要概括不过来导航精度下降。节点粒度太细叶子节点只有一两句话检索到了也没多少信息粒度太粗一个节点几千字LLM 读不完也抓不住重点。我的经验是树深度控制在 3 到 4 层比较合适对应“章-节-小节-段落”这种常见结构。叶子节点的文本量控制在 200 到 500 字这样 LLM 能完整读完信息量也够。如果原始文档层级超过 4 层可以考虑把最底下两层合并或者把过细的节点向上归并。具体操作上建树时可以设一个最大深度参数和最小节点字数参数。超过最大深度的节点把内容合并到父节点小于最小字数的节点尝试和兄弟节点合并。这两个参数没有绝对最优值得根据你的文档特点调。技术文档通常层级清晰可以深一点散文类文档结构松散浅一点更好。3.3 检索时的 prompt 设计让 LLM 做判断而非生成检索导航阶段的 prompt 和普通问答 prompt 完全不同。这里不需要 LLM 生成答案只需要它做选择题给定当前节点的子节点列表和用户问题判断该进入哪个或哪几个子节点。prompt 的关键要素有三个。一是明确任务告诉 LLM“你的任务是从下列选项中选出与问题最相关的分支不要回答问题本身”。二是提供选项把子节点的标题和摘要列出来编号。三是要求输出格式让 LLM 返回选中的编号和简短理由方便程序解析。我试过让 LLM 直接返回节点名称结果它有时候会改写名称导致匹配失败。后来改成返回编号稳定多了。另外允许 LLM 返回多个分支也很重要因为有些问题确实需要跨分支综合强制单选会丢信息。可以设一个上限比如最多选 3 个避免它贪多。还有一个细节在 prompt 里加入“如果当前层级没有相关分支返回 NONE”。这样 LLM 在走错路时能及时止损而不是硬着头皮往下走。实际测试中这个机制能减少不少无效检索。3.4 与向量检索的混合方案不是非此即彼虽然 PageIndex 主打推理检索但实际项目里纯推理检索和纯向量检索各有短板混合方案往往更稳。推理检索擅长结构清晰、需要逻辑导航的场景但对“模糊语义匹配”不如向量检索——比如用户问一个很口语化的问题和文档里的正式表述差很远向量检索可能靠语义相似度召回推理检索却因为摘要里没有对应词汇而错过。混合的做法是先用向量检索做粗筛召回一批候选节点再用推理检索在候选节点所在的子树里做精细导航。或者反过来先用推理检索定位到相关章节再在该章节内部用向量检索找具体段落。两种顺序都行看你的文档特点和查询类型。我自己的项目里用的是第一种向量检索召回 top 20 个 chunk映射回它们所属的树节点然后从这些节点的最近公共祖先开始做推理导航。这样既保留了向量检索的召回能力又用树结构做了精排效果比单用任何一种都好。4. 实操过程与核心环节实现4.1 环境准备与依赖安装PageIndex 本身是一个思路和框架具体实现可以自己写也可以用现成的库。我这里给一个基于 Python 的最小实现方案依赖尽量少方便你快速跑通。核心依赖就三个文档解析库处理 PDF/Markdown、LLM 调用库调 OpenAI 或本地模型、树结构管理用 Python 内置的 dict 或 dataclass 就够。如果你用 OpenAI 的 API装openai包如果用本地模型装ollama或transformers。文档解析方面Markdown 用markdown库PDF 用pymupdf。pip install openai pymupdf markdown如果你打算用本地 LLM比如通过 Ollama 跑 Llama 3 或 Qwen那就pip install ollama ollama pull qwen2.5:7b选本地模型的好处是数据不出内网适合企业场景坏处是推理质量可能不如 GPT-4 级别建树和导航的准确率会打折扣。我的建议是建树阶段用强模型一次性成本值得投入检索导航阶段可以用稍弱的模型多次调用成本敏感两者可以分开配置。4.2 文档解析与树构建的完整代码先定义一个树节点的数据结构from dataclasses import dataclass, field from typing import List, Optional dataclass class TreeNode: title: str summary: str content: str children: List[TreeNode] field(default_factorylist) parent: Optional[TreeNode] None node_id: str 然后写 Markdown 解析函数把标题层级转成树import re def parse_markdown_to_tree(md_text: str) - TreeNode: lines md_text.split(\n) root TreeNode(titleROOT) stack [(root, 0)] # (node, heading_level) for line in lines: match re.match(r^(#{1,6})\s(.*), line) if match: level len(match.group(1)) title match.group(2).strip() node TreeNode(titletitle) while stack and stack[-1][1] level: stack.pop() parent stack[-1][0] node.parent parent parent.children.append(node) stack.append((node, level)) else: if stack: stack[-1][0].content line \n return root这段代码的核心逻辑是用一个栈维护当前路径。遇到标题时弹出所有层级大于等于当前层级的节点剩下的栈顶就是父节点。这是处理层级结构的经典写法比递归解析更直观。树建好后遍历每个节点生成摘要def generate_summaries(node: TreeNode, llm_client): if node.content.strip(): prompt f用一到两句话概括以下内容的主题不要罗列细节 {node.content[:2000]} node.summary llm_client.chat(prompt) for child in node.children: generate_summaries(child, llm_client)注意这里截断了 content 到 2000 字避免 prompt 过长。如果节点内容确实很长可以先分段摘要再合并但大多数情况下 2000 字足够 LLM 抓住主题了。4.3 推理检索的完整实现检索函数的核心是递归导航def reasoning_retrieve(root: TreeNode, query: str, llm_client, max_depth4): current root path [] for depth in range(max_depth): if not current.children: break options \n.join( f{i1}. {c.title}{c.summary} for i, c in enumerate(current.children) ) prompt f用户问题{query} 当前可选的子章节 {options} 请选出与问题最相关的章节编号可多选最多3个 如果都不相关返回 NONE。只返回编号和简短理由。 response llm_client.chat(prompt) selected parse_selection(response, len(current.children)) if not selected: break path.append((current, selected)) # 简化处理只走第一个选中的分支 current current.children[selected[0] - 1] return current, pathparse_selection负责从 LLM 返回的文本里提取编号用正则匹配数字即可。实际项目里要做健壮性处理比如 LLM 返回格式不对时重试或降级。检索到目标节点后把该节点的 content 和路径上各节点的摘要拼成上下文喂给 LLM 生成最终答案。上下文里带上路径信息“根据第三章第二节的内容……”能让答案更有依据。4.4 参数选择与性能调优几个关键参数需要根据实际情况调参数建议值说明树最大深度3-4 层太深调用次数多太浅导航不准叶子节点字数200-500 字太少信息不足太多 LLM 抓不住重点摘要字数30-80 字一到两句话突出主题每层最多选分支2-3 个平衡召回和成本检索最大轮次4 次对应树深度防止死循环性能方面建树是一次性成本一份 100 页的文档大概需要几十次 LLM 调用用 GPT-4 级别模型也就几块钱。检索是每次查询都发生的树深 3 层就是 3 次 LLM 调用加上最终答案生成 1 次共 4 次。如果 QPS 高这个成本要算清楚。优化手段包括缓存常见查询的检索路径、用更小的模型做导航、把浅层节点的判断结果预计算好。提示如果你的文档更新频繁建树成本会累积。可以考虑增量更新——只重建变化的部分其余节点复用。这需要给节点加版本标记实现起来稍复杂但长期看省不少钱。5. 常见问题与排查技巧实录5.1 检索走错分支怎么办这是最常见的问题。LLM 在某一层选错了分支后面越走越偏最后召回的内容完全不相关。排查思路是打印检索路径看它在哪一层拐错了弯。如果错在顶层通常是摘要质量不行——顶层摘要太笼统LLM 分不清。解决办法是重新生成顶层摘要要求更具体。如果错在中层可能是选项太多LLM 挑花了眼。可以试试减少每层节点数把不相关的节点提前剪掉。如果错在底层往往是叶子节点粒度过粗摘要覆盖不全LLM 判断依据不足。还有一个隐蔽原因用户问题本身有歧义。比如“配置怎么改”没说是哪个配置。这种情况可以在检索前加一步查询改写让 LLM 先把问题补全成“数据库连接配置怎么改”再去做导航。5.2 树构建失败或结构错乱PDF 解析是最容易出问题的环节。扫描版 PDF 没有文字层解析出来全是空白双栏排版的 PDF解析顺序会乱表格和图片混排的文字提取经常错位。这些情况 PageIndex 的树建出来就是歪的。应对策略分三档。轻度问题比如页眉页脚混入正文用正则清洗掉。中度问题双栏排版用版面分析库如layoutparser先做区域划分再提取。重度问题扫描版只能上 OCR但 OCR 出来的文本没有标题层级得靠 LLM 推断章节结构准确率会下降。Markdown 和纯文本相对好处理但也要注意有些 Markdown 用**加粗**当标题而不是#解析时要兼容。纯文本没有标题标记得靠 LLM 或规则比如空行、编号来切分效果不稳定建议尽量用结构化格式。5.3 LLM 返回格式不符合预期导航阶段 LLM 应该返回编号但它有时候会返回一堆解释文字或者编号格式不对比如返回“第一项”而不是“1”。这会导致程序解析失败。解决办法有三层。第一层prompt 里明确要求“只返回编号不要其他文字”并给示例。第二层解析时用宽松正则匹配各种可能的编号形式阿拉伯数字、中文数字、带括号的。第三层解析失败时重试一次重试还失败就降级——比如默认选第一个分支或者返回当前节点内容让 LLM 直接回答。我实测下来加了 few-shot 示例后格式合规率能从 70% 提到 95% 以上。所以别省这点 prompt 长度值得。5.4 常见问题速查表问题现象可能原因排查方向解决手段检索结果不相关摘要质量差检查出错层级的摘要重新生成摘要要求更具体检索路径过深树太深看树深度合并浅层节点限制最大深度建树失败文档格式问题检查解析后的文本换解析库或预处理文档LLM 返回格式错prompt 不明确看 LLM 原始输出加 few-shot 示例宽松解析响应太慢LLM 调用次数多统计调用次数缓存路径用更小模型导航跨章节问题答不全单选分支看是否只走了一条路允许多选汇总多分支内容5.5 几个我踩过的坑坑一摘要生成时用了太长的上下文。一开始我把整个节点内容几千字都塞给 LLM 生成摘要结果它把细节都写进去了摘要变成小作文。后来限制输入 2000 字并要求“只讲主题”才正常。坑二忘了处理空节点。有些章节只有标题没有内容建树时生成了空节点检索时 LLM 看到空摘要一脸懵。后来在建树阶段加了过滤空节点直接删掉。坑三检索路径没有缓存。同一个问题反复问每次都重新导航浪费 LLM 调用。后来加了一层查询缓存相同或相似的问题直接复用路径成本降了不少。坑四低估了文档更新的成本。文档一改就得重建整棵树如果文档大、更新频繁LLM 费用会失控。后来改成增量更新只重建受影响的子树才把成本压下来。坑五导航 prompt 里没给“退出”选项。LLM 有时候明明发现当前分支不相关还是硬选一个因为 prompt 里没告诉它可以返回 NONE。加上这个选项后无效检索少了很多。6. 这套方案适合什么场景不适合什么场景PageIndex 的思路不是万能的它有明确的适用边界。适合的场景文档结构清晰有章节层级、查询需要逻辑推理或跨章节综合、对准确性要求高、能接受一定的 LLM 调用成本。典型如法律合同审查、技术手册问答、研报分析、企业制度查询。不太适合的场景文档本身没有结构比如聊天记录、零散笔记、查询以模糊语义匹配为主比如“找一段和这个意思相近的话”、QPS 极高且成本敏感、文档更新极其频繁。这些场景下向量检索或者混合方案可能更划算。我个人的体会是PageIndex 最大的价值不在于它替代了向量数据库而在于它把检索从“匹配问题”重新定义成了“推理问题”。这个视角转换本身就很有启发——当你发现向量检索效果上不去时也许该想的不是怎么调 embedding而是换一种检索范式。实际项目里我倾向于把 PageIndex 和向量检索结合用各取所长而不是非此即彼。毕竟工程上没有银弹只有权衡。