
1. 为什么我要认真聊聊 PageIndex 这个东西RAG 这个词在过去一年多里被反复咀嚼几乎每个做 LLM 应用的人都在搭自己的知识库。但真正落地过几个项目之后你会发现向量数据库那套切片-嵌入-相似度检索的流水线在遇到长文档、结构化文档、需要跨段落推理的问题时命中率会掉得让人怀疑人生。我去年做过一个技术手册问答的项目用户问第三章提到的那个参数在附录里怎么配置向量检索返回的全是第三章的碎片附录压根没被召回——因为切片之后章节之间的层级关系彻底丢了。PageIndex 就是在这个背景下进入我视野的。它不是一个向量数据库也不是一个 RAG 框架的封装而是一种用文档自身的层级结构来组织检索的思路。简单说它把文档当成一棵树而不是一堆碎片。你问一个问题它先在树的节点上做推理决定该往哪个分支走再逐层下钻到具体内容。这个思路和传统 RAG 最大的区别在于检索的单位从文本块变成了文档节点检索的方式从向量相似度变成了结构推理。这篇文章适合谁看如果你正在做 RAG 项目、被召回率折磨过、或者单纯好奇不用向量数据库能不能做检索那接下来的内容应该对你有用。我会从设计思路、核心机制、实操落地、踩坑排查几个维度把 PageIndex 拆开讲清楚尽量做到你看完能自己动手复现一套。2. PageIndex 的整体设计与思路拆解2.1 传统 RAG 的瓶颈到底卡在哪先把问题说透不然没法理解 PageIndex 为什么要这么设计。传统 RAG 的流程大概是文档切块 → 每块做 embedding → 存进向量数据库 → 查询时把 query 也 embedding → 算余弦相似度 → 取 Top-K → 塞进 LLM 的上下文。这套流程在短文本、语义独立的场景下没问题但有几个硬伤第一个硬伤是切片破坏了结构。一份 200 页的技术文档你按 512 token 切切完之后3.2 节和3.2 节下面的子条目可能被分到不同的块里甚至一个表格被拦腰截断。检索的时候你拿到的是一堆语义碎片LLM 拼起来经常答非所问。第二个硬伤是相似度不等于相关性。向量相似度衡量的是语义接近程度但用户的问题往往需要的是逻辑上相关而不是语义上相似。比如问这个错误码怎么解决向量检索可能召回一堆提到这个错误码的地方但真正有用的那条解决步骤可能因为措辞不同而排在后面。第三个硬伤是Top-K 的 K 很难调。K 太小召回不全K 太大噪声淹没信号还浪费上下文窗口。而且这个 K 是全局固定的但不同问题的合适召回量其实差别很大。我自己的经验是向量检索在事实型问答上表现还行一旦涉及跨章节推理层级定位多跳查询命中率会断崖式下跌。这不是调参能解决的是范式的问题。2.2 PageIndex 的核心思路把文档当树把检索当推理PageIndex 的做法是在索引阶段就把文档解析成一棵层级树。根节点是文档标题往下是章节、小节、段落叶子节点是具体的文本内容。每个节点带自己的标题、摘要、页码范围这些元信息。注意这里不一定要做 embedding节点之间的关系是靠结构而不是向量来维系的。查询的时候流程变成LLM 拿到用户问题先看根节点的子节点列表也就是各章节标题和摘要推理这个问题最可能在哪一章然后进入那一章再看它的子节点继续推理一层层往下走直到定位到具体的叶子节点把那个节点的内容返回。这个思路有几个明显的好处。第一检索有了解释性——你能看到 LLM 是怎么一步步定位的哪一步走错了可以针对性优化。第二上下文利用率高——返回的是完整的、结构化的段落而不是被切碎的片段。第三天然支持多跳——因为树本身就有层级跨章节的问题可以通过回溯父节点来处理。2.3 为什么这个方案值得认真对待有人可能会问这不就是给文档建个目录然后让 LLM 按目录找吗听起来很简单啊。但真正做过的人知道难点在于怎么把任意格式的文档稳定地解析成树以及怎么让 LLM 在每一层做出靠谱的路由决策。PageIndex 的价值在于它把这两件事都工程化了。文档解析这块它要处理 PDF 的排版、Word 的样式、Markdown 的标题层级还要识别表格、图片说明、脚注这些特殊元素。路由决策这块它要设计合适的节点摘要、控制每层给 LLM 的候选数量、处理 LLM 选错分支时的回退逻辑。和向量数据库方案对比一下差异更清楚维度向量数据库 RAGPageIndex 结构检索索引单位固定长度文本块文档层级节点检索依据向量相似度LLM 结构推理可解释性低黑盒打分高路径可追溯长文档表现随长度下降明显相对稳定多跳推理需要额外机制树结构天然支持冷启动成本需要 embedding 模型需要 LLM 调用增量更新重新嵌入即可需要维护树结构这张表不是要说谁替代谁而是说它们适合的场景不同。事实型、短文本、海量并发的场景向量库更划算长文档、结构化、需要推理的场景PageIndex 这类方案更合适。3. 核心细节解析与实操要点3.1 文档解析怎么把一份 PDF 变成一棵树这是整个流程里最脏最累的活也是最容易翻车的地方。我试过几种方案踩过的坑可以写一篇长文这里挑关键的讲。第一步是提取结构信息。PDF 本身不存层级你看到的标题加粗、字号变大在 PDF 里可能只是一段带样式的文本。所以要用能识别版式的解析器比如 PyMuPDF 配合字体大小和位置信息来推断标题层级。经验值是正文一般 10-12pt一级标题 18-22pt二级标题 14-16pt通过字号聚类能大致分出层级。第二步是处理跨页和跨栏。技术文档经常有双栏排版直接按阅读顺序提取会把左右栏混在一起。我的做法是先按 x 坐标把页面切成左右两半各自按 y 坐标排序再合并。跨页的段落要判断是否连续通常看上一页末尾和下一页开头是否有断句特征。第三步是识别特殊元素。表格、代码块、图注这些不能当普通段落处理。表格建议单独抽出来存成结构化数据代码块要保留缩进图注要关联到对应的图。这些元素在树里应该有自己的节点类型检索时区别对待。# 一个简化的层级推断逻辑示意 import fitz # PyMuPDF def infer_heading_level(span, body_size11.0): size span[size] if size body_size * 1.8: return 1 elif size body_size * 1.4: return 2 elif size body_size * 1.15: return 3 return 0 # 正文 def build_tree(doc): tree {title: doc.name, children: []} stack [(0, tree)] for page in doc: blocks page.get_text(dict)[blocks] for block in blocks: for line in block.get(lines, []): for span in line[spans]: level infer_heading_level(span) if level 0: node {title: span[text], children: []} while stack and stack[-1][0] level: stack.pop() stack[-1][1][children].append(node) stack.append((level, node)) return tree这段代码只是示意真实场景要复杂得多但核心逻辑就是用字号和位置推断层级用栈维护当前路径。实操心得解析阶段一定要保留原始页码和坐标信息。后面排查为什么这个问题没被召回的时候你需要知道每个节点对应原文的哪一页否则根本没法定位问题。3.2 节点摘要让 LLM 能看懂每个分支树建好之后每个节点只有一个标题信息量不够 LLM 做路由决策。所以需要给每个节点生成一段摘要概括这个节点下面讲了什么。摘要的质量直接决定检索准确率。生成摘要的方式有两种。一种是自底向上先给叶子节点生成摘要再往上聚合。另一种是自顶向下先看整个章节的内容生成摘要再分给子节点。我实测下来自底向上更稳因为叶子节点的内容具体摘要不容易跑偏。摘要的长度要控制。太短信息不够太长浪费上下文。我的经验是每个节点摘要控制在 50-100 字包含这个节点的核心主题和关键实体。比如一个讲错误处理的章节摘要里要出现错误码重试降级这些词这样用户问相关问题的时候LLM 才能匹配上。这里有个细节摘要里要保留原文的关键术语不要做同义替换。因为用户提问用的词往往和文档里的术语一致如果你把鉴权改写成身份验证反而可能匹配不上。3.3 路由推理LLM 在每一层怎么选这是 PageIndex 最核心的机制。查询时LLM 拿到当前节点的子节点列表标题摘要以及用户的问题输出应该进入哪个子节点。如果当前节点是叶子就直接返回内容。路由的 prompt 设计很关键。我试过几种写法最后稳定下来的结构是先给 LLM 说明任务你是一个文档导航助手再给候选节点列表再给用户问题最后要求输出节点编号和理由。要求输出理由这一步很重要一方面能提升准确率让 LLM 想清楚再答另一方面方便排查。ROUTE_PROMPT 你是一个文档导航助手。用户有一个问题你需要从下面的章节列表中选择最可能包含答案的章节。 章节列表 {node_list} 用户问题{query} 请输出你选择的章节编号以及选择理由。格式 选择编号 理由一句话理由 候选节点的数量要控制。一层如果有 50 个子节点LLM 很容易选错。我的做法是如果一层超过 15 个节点先用一个粗筛步骤可以用关键词匹配或者轻量 embedding缩到 10 个以内再让 LLM 精挑。还有一个坑LLM 可能选一个明显不相关的节点。这时候要有回退机制。我的做法是让 LLM 在都不相关的时候输出无匹配然后回到父节点换一个分支继续。这个回退逻辑要设最大深度防止死循环。3.4 和向量检索的混合策略纯结构检索有个弱点如果用户的问题和文档的章节标题措辞差异很大LLM 在顶层就可能选错分支。这时候可以引入向量检索做辅助。我的混合策略是这样的在每一层路由时除了给 LLM 节点列表还额外用向量检索召回几个语义最接近的节点作为补充候选。这样既保留了结构推理的可解释性又补上了语义匹配的召回能力。实测下来混合策略的命中率比纯结构检索高 15-20 个百分点比纯向量检索高 30 个百分点以上。4. 实操过程与核心环节实现4.1 环境准备与依赖选型先说技术栈。文档解析我用 PyMuPDF快、稳定、能拿到版式信息树结构用 Python 的 dict 或者 dataclass 都行LLM 调用看你的场景——本地跑用 Ollama云端用各家 API 都可以。如果你想要更省事的方案LangChain4j 的 Easy RAG 里也有类似的结构化检索组件但灵活性不如自己搭。依赖清单大概是这样pip install pymupdf python-docx markdown beautifulsoup4 pip install ollama # 如果本地跑模型 pip install numpy scikit-learn # 如果要做轻量向量辅助模型选择上路由推理不需要特别强的模型7B 级别的指令微调模型就够用。我用 Ollama 跑 qwen2.5:7b路由准确率能到 85% 以上。如果预算充足上更大的模型当然更好但边际收益递减明显。4.2 完整流程的代码骨架把整个流程串起来大概是这么几个阶段class PageIndex: def __init__(self, llm_client): self.llm llm_client self.tree None def build(self, doc_path): # 阶段一解析文档 raw self.parse_document(doc_path) # 阶段二构建层级树 self.tree self.build_tree(raw) # 阶段三生成节点摘要 self.generate_summaries(self.tree) return self.tree def query(self, question, max_depth5): node self.tree path [node[title]] for _ in range(max_depth): if not node.get(children): return node.get(content, ), path chosen self.route(question, node[children]) if chosen is None: return None, path # 无匹配 node chosen path.append(node[title]) return node.get(content, ), path def route(self, question, candidates): node_list \n.join( f{i}. {c[title]}: {c.get(summary, )} for i, c in enumerate(candidates) ) prompt ROUTE_PROMPT.format(node_listnode_list, queryquestion) resp self.llm.generate(prompt) idx self.parse_choice(resp) return candidates[idx] if idx is not None else None这个骨架跑通之后你会发现大部分工作量在parse_document和generate_summaries这两个方法上。解析的健壮性决定了整个系统的上限。4.3 参数选择与调优记录几个关键参数我记录一下实测值供参考切片粒度叶子节点的内容长度建议控制在 300-800 字。太短信息不全太长 LLM 处理慢。如果原文段落超过 800 字考虑按语义再切一层。摘要长度50-100 字。我试过 30 字信息不够试过 200 字路由时上下文太长反而干扰判断。每层候选数10-15 个。超过 15 个先粗筛。最大深度5 层。一般文档 3-4 层就够了5 层是保险值。路由重试次数2 次。第一次选错回退到父节点换分支再错就返回无匹配。这些值不是绝对的不同文档类型要微调。技术手册层级深深度可以设大一点新闻稿层级浅3 层足够。4.4 一个完整的查询实例拿一份产品手册举例。用户问设备离线超过 24 小时会怎样第一层根节点下有产品概述安装指南功能说明故障处理附录五个子节点。LLM 判断这个问题和功能说明或故障处理相关选了功能说明。第二层功能说明下有设备管理数据同步告警机制离线策略四个子节点。LLM 选了离线策略。第三层离线策略下有三个叶子节点分别是短期离线1小时中期离线1-24小时长期离线24小时。LLM 选了第三个。返回长期离线节点的完整内容里面写着设备离线超过 24 小时后本地缓存的数据将停止同步重新上线后需要手动触发全量同步。整个路径清晰可追溯。如果答案不对你能立刻看出是哪一层选错了针对性优化那一层的摘要或候选列表。5. 常见问题与排查技巧实录5.1 路由选错分支怎么办这是最高频的问题。排查思路分三步第一步看是不是摘要质量问题。把选错的那一层的所有节点摘要打出来人工判断如果我是 LLM看到这些摘要会选哪个。如果人工也觉得该选另一个说明摘要没写好要重新生成。第二步看是不是候选太多。如果一层有 20 多个节点LLM 注意力分散容易选错。加粗筛步骤缩到 10 个以内。第三步看是不是问题本身有歧义。有些问题确实跨多个章节LLM 选哪个都不算错。这种情况要在 prompt 里允许 LLM 输出多个候选然后并行下钻最后合并结果。5.2 文档解析出来的树结构错乱常见表现是标题层级识别错误把正文当成了标题或者把二级标题当成了一级。原因通常是字号聚类没做好。我的排查方法是把解析出来的树打印出来和原文目录对比。如果差异大回去看字号分布。有时候文档里混用了多种字号比如正文有 10pt 和 11pt 两种标题有 16pt 和 18pt 两种聚类的时候要留够间隔。还有一个坑有些 PDF 是扫描件根本没有文本层PyMuPDF 提取出来是空的。这种情况要先做 OCR但 OCR 出来的版式信息不可靠层级推断会更难。我的建议是扫描件单独走一套流程用 OCR 的置信度和位置信息来辅助判断。5.3 查询延迟太高PageIndex 的查询延迟主要来自 LLM 调用。每层一次调用5 层就是 5 次如果每次 2 秒总共 10 秒用户体验很差。优化手段有几个。一是缓存路由结果相同或相似的问题直接命中缓存。二是并行化如果某一层有多个候选都相关可以并行下钻。三是用小模型做路由7B 模型的路由准确率和大模型差距不大但速度快好几倍。四是预生成摘要把摘要生成放在索引阶段查询时直接用。我实测下来用 7B 模型 缓存平均查询延迟能压到 3 秒以内基本可接受。5.4 常见问题速查表问题现象可能原因排查方向解决手段路由选错分支摘要质量差/候选过多/问题歧义打印摘要人工判断重写摘要/加粗筛/允许多选树结构错乱字号聚类失败/扫描件无文本层对比原文目录调整聚类阈值/走 OCR 流程查询延迟高LLM 调用次数多统计各层耗时缓存/并行/小模型/预生成召回内容不完整叶子节点切分过细检查叶子内容长度合并过短节点跨章节问题答不全单路径检索局限看问题是否跨分支多路径并行结果合并增量更新困难树结构需重建看更新频率局部重建/版本化5.5 几个我踩过的坑坑一摘要里用了太多同义词。我一开始为了让摘要丰富把鉴权写成身份验证/权限校验/登录检查结果用户问鉴权怎么做的时候LLM 反而匹配不上因为摘要里没有鉴权这个词。后来改成保留原文术语准确率立刻上来了。坑二叶子节点切得太碎。有个文档我按段落切结果一个完整的操作步骤被切成 5 个叶子检索时只返回其中一步用户还得自己拼。后来改成按语义完整的块切一个操作步骤就是一个叶子。坑三忽略了表格和代码块。表格被当普通文本处理行列关系全丢了。代码块被合并成一行缩进没了。后来给这些元素单独建节点类型检索时区别对待效果好很多。坑四没有做无匹配处理。用户问一个文档里根本没有的问题LLM 硬选一个分支返回一堆不相关内容。后来加了无匹配选项LLM 判断都不相关时直接返回空用户体验反而更好。6. 这套方案适合什么场景不适合什么场景聊了这么多实现细节最后说说适用边界。PageIndex 这类结构检索方案最适合的是长文档、强结构、需要推理的场景。技术手册、法律合同、学术论文、产品规格书这些文档本身就有清晰的层级用户的问题往往需要跨章节定位结构检索的优势能充分发挥。不太适合的场景也很明确。海量短文本比如几百万条用户评论建树的意义不大向量检索更划算。实时性要求极高的场景LLM 调用的延迟是硬伤除非你能接受预计算。文档结构极差的场景比如聊天记录、会议纪要层级不明显建树的效果会打折扣。还有一个现实问题成本。索引阶段要给每个节点生成摘要查询阶段要多次调用 LLMtoken 消耗比向量检索高不少。如果你的场景对成本敏感要算清楚账。我的经验是文档量在几千份以内、查询频率不高的场景成本可以接受如果是百万级文档、高并发查询要慎重。混合方案往往是最优解。用向量检索做粗筛用结构检索做精定位两者结合既控制了成本又保证了准确率。这也是我目前在大多数项目里采用的策略。最后分享一个我在实际项目里的小技巧把用户的历史查询路径存下来作为路由的先验。如果某个问题之前被路由到某个分支并且用户反馈满意下次遇到相似问题可以直接优先走那条路径。这个简单的缓存机制能把重复问题的响应速度和准确率都提升一大截。