资讯详情

资讯详情

PageIndex:用树结构索引替代向量数据库,提升RAG长文档检索准确率

1. 从向量数据库的困境说起PageIndex 到底想解决什么问题做过 RAG 项目的人大概都有过这种体验文档切了几百上千个 chunk灌进向量数据库用户问一个稍微需要跨段落推理的问题检索回来的片段要么答非所问要么缺胳膊少腿。你调 top_k、换 embedding 模型、加 rerank折腾一圈发现效果提升有限。问题的根子不在检索算法而在于向量相似度这个度量本身丢掉了文档的结构信息。PageIndex 这个项目就是冲着这个痛点来的。它做的事情可以一句话概括用文档自身的层级结构章节、小节、段落来组织索引让 LLM 像人翻书一样先看目录定位章节再逐层下钻到具体内容。这跟传统 RAG 把文档打碎成扁平 chunk 再靠向量召回是两条完全不同的路子。我第一次接触这个思路是在一个合同审查的场景里。一份 80 页的采购合同用户问“违约责任条款里关于延迟交付的赔偿上限是多少”。传统向量检索返回的是几个包含“违约”“赔偿”“延迟”字样的片段但赔偿上限这个数字可能出现在附件里跟主条款隔了十几页向量相似度根本召不回来。而如果文档有清晰的章节树先定位到“违约责任”这一章再在章内找“延迟交付”小节命中率会高得多。PageIndex 适合谁我认为三类人最该关注一是正在做 RAG 但被检索质量卡住的工程师二是需要处理长文档、结构化文档合同、论文、技术手册、财报的开发者三是对 LLM 应用架构感兴趣、想了解“不依赖向量数据库的 RAG 怎么做”的技术决策者。它不要求你精通向量数学但需要你对文档解析和 LLM 调用有基本概念。注意PageIndex 不是要取代向量数据库而是提供另一种索引范式。两者可以互补比如用 PageIndex 做粗定位用向量检索做细召回。2. 核心设计思路拆解为什么是“树”而不是“向量”2.1 向量检索的先天缺陷与 PageIndex 的应对向量检索的本质是把文本映射到高维空间的一个点用余弦相似度或内积衡量“语义接近程度”。这个机制在短文本、单跳问答上表现很好但遇到长文档就暴露三个问题。第一chunk 边界割裂语义。你按 512 token 切分一个完整的论证可能被切成两半前半段在 chunk A后半段在 chunk B检索时只召回 ALLM 拿到半截话自然答不准。第二相似度不等于相关性。用户问“第三章第二节提到的实验方法”向量检索可能召回一堆讲“实验方法”的段落但未必是第三章第二节那个。第三无法表达层级和顺序。文档的“第几章第几节”这种结构信息在扁平向量空间里完全丢失。PageIndex 的应对策略很直接把文档解析成一棵树每个节点是一个语义单元章、节、段节点之间保留父子关系和顺序关系。检索时不是算相似度而是让 LLM 读树的摘要判断该往哪个分支走。这就像你在图书馆找书不是靠“这本书和那本书内容像”来定位而是先看分类号再找书架再翻目录。2.2 树结构索引的构建逻辑构建这棵树的过程我把它拆成四步。第一步是文档解析。PDF、Word、Markdown 各有各的解析方式。PDF 最麻烦因为它是排版格式不是语义格式需要借助版面分析工具识别标题层级。Markdown 最友好#、##、###天然就是层级。我实测下来对于技术文档用 Markdown 源文件构建 PageIndex 的效果最好因为标题层级是作者显式标注的不需要猜测。第二步是节点摘要生成。每个节点尤其是非叶子节点需要一段摘要描述这个节点讲了什么。这段摘要由 LLM 生成输入是该节点下的所有文本输出是 100-200 字的概括。摘要的作用是让检索时的 LLM 能快速判断“这个分支跟问题相关吗”而不需要读全文。第三步是树结构组装。把解析出的标题和段落按层级关系挂到树上。这里有个关键决策叶子节点放什么。我的做法是叶子节点放最小语义单元通常是一个段落或一个小节大小控制在 200-500 字。太小了摘要没意义太大了检索粒度不够。第四步是索引持久化。树结构可以存成 JSON也可以存进图数据库。JSON 的好处是简单、可读、方便调试。我一开始用 JSON后来节点多了超过 5000 个查询变慢换成了 SQLite 存节点表加邻接表性能好了不少。2.3 检索流程LLM 如何“翻目录”检索阶段是 PageIndex 最有意思的地方。用户提问后系统不是去算向量相似度而是从根节点开始让 LLM 逐层选择该进入哪个子节点。具体流程根节点下有若干章每章有摘要。把用户问题和所有章的摘要一起给 LLM问“要回答这个问题应该看哪一章”。LLM 返回章号进入该章。该章下有若干节再把问题和节的摘要给 LLM继续下钻。直到到达叶子节点把叶子节点的全文返回给 LLM 生成答案。这个过程听起来慢因为每层都要调一次 LLM。但实际上树的深度通常只有 3-4 层每层候选节点也就 5-10 个token 消耗可控。而且可以并行化同一层的多个分支可以同时判断。我实测下来一棵 500 节点的树检索一次大约 3-5 次 LLM 调用延迟在 2-4 秒比很多 rerank 方案还快。提示如果树很宽某层有几十个节点可以先用关键词过滤缩小候选集再让 LLM 判断避免 token 爆炸。3. 实操落地从零搭建一个 PageIndex 原型3.1 环境准备与依赖选型我用的技术栈是 Python 3.10 LangChain只用了文档加载部分 OpenAI API也可以用本地模型。为什么选 LangChain因为它的 DocumentLoader 生态成熟PDF、Word、Markdown 都有现成加载器省得自己写解析。但 PageIndex 的核心逻辑不依赖 LangChain你完全可以手写。依赖清单pip install langchain langchain-community openai tiktoken pypdf如果你用本地模型把 openai 换成 ollama 或 vllm 的客户端即可。我试过用 Ollama 跑 Llama 3 8B 做摘要生成效果比 GPT-4 差一截但成本低很多适合原型验证。3.2 文档解析与树结构构建先看 Markdown 文档的解析。假设你有一份技术手册结构如下# 第一章 概述 ## 1.1 背景 ## 1.2 目标 # 第二章 架构 ## 2.1 组件 ### 2.1.1 存储层 ### 2.1.2 计算层解析代码的核心逻辑是维护一个栈遇到#就压栈遇到##就弹出比它层级深的节点再压栈。这样能保证树的父子关系正确。import re def parse_markdown(text): lines text.split(\n) root {title: root, level: 0, content: , children: []} stack [root] current_content [] for line in lines: match re.match(r^(#{1,6})\s(.*), line) if match: # 先把当前积累的内容挂到栈顶节点 if current_content: stack[-1][content] \n.join(current_content) current_content [] level len(match.group(1)) title match.group(2) node {title: title, level: level, content: , children: []} # 弹出层级 当前层级的节点 while len(stack) 1 and stack[-1][level] level: stack.pop() stack[-1][children].append(node) stack.append(node) else: current_content.append(line) if current_content: stack[-1][content] \n.join(current_content) return rootPDF 解析要复杂得多。我的经验是先用 pypdf 提取文本再用正则匹配“第X章”“1.1”这类标题模式。如果 PDF 有书签outline直接读书签最准。没有书签的话可以借助版面分析库如pdfplumber获取字体大小和加粗信息字体大且加粗的行大概率是标题。3.3 节点摘要生成与索引持久化摘要生成我用的是批量调用把同一层的所有节点内容拼成一个 prompt让 LLM 一次性输出所有摘要。这样比逐个调用省 token也省时间。def generate_summaries(nodes, client): prompt 为以下每个章节生成一句话摘要格式为章节标题|||摘要\n\n for node in nodes: prompt f{node[title]}|||{node[content][:2000]}\n\n response client.chat.completions.create( modelgpt-4o-mini, messages[{role: user, content: prompt}] ) # 解析响应回填到节点 for line in response.choices[0].message.content.split(\n): if ||| in line: title, summary line.split(|||, 1) for node in nodes: if node[title] title.strip(): node[summary] summary.strip()持久化我推荐用 SQLite建两张表nodes存节点基本信息edges存父子关系。查询时用递归 CTE 可以快速拿到某节点的所有祖先或后代。CREATE TABLE nodes ( id INTEGER PRIMARY KEY, title TEXT, content TEXT, summary TEXT, level INTEGER ); CREATE TABLE edges ( parent_id INTEGER, child_id INTEGER, order_index INTEGER );3.4 检索与答案生成检索函数的核心是递归下钻def retrieve(query, node, client, path[]): if not node[children]: return node, path # 构造候选列表 candidates [] for child in node[children]: candidates.append(f{child[title]}: {child.get(summary, )}) prompt f问题{query}\n\n可选章节\n \n.join(candidates) prompt \n\n请只返回最相关章节的标题。 response client.chat.completions.create( modelgpt-4o-mini, messages[{role: user, content: prompt}] ) selected_title response.choices[0].message.content.strip() for child in node[children]: if child[title] in selected_title: return retrieve(query, child, client, path [child[title]]) # 没匹配到就返回当前节点 return node, path拿到叶子节点后把它的 content 和 query 一起给 LLM 生成最终答案。如果叶子节点内容太长可以在叶子内部再做一次向量检索或关键词检索做细粒度召回。注意LLM 选择节点时可能返回不存在的标题要做好容错比如用模糊匹配或让 LLM 返回序号而不是标题。4. 踩坑记录与常见问题排查4.1 树构建阶段的典型问题问题一PDF 标题识别错误。很多 PDF 的标题不是用样式标记的而是靠字号和位置。我遇到过一份文档正文里加粗的术语被误判成标题导致树结构里多出一堆无意义的节点。解决办法是加约束标题必须满足“字号大于正文平均字号”且“独占一行”且“长度小于 50 字”。三个条件同时满足才认定为标题。问题二节点内容为空。有些文档的章节标题下直接跟子标题没有正文。这种节点在摘要生成时会报错。我的处理是如果节点内容为空但有子节点摘要就用子节点摘要的拼接。问题三层级跳跃。Markdown 里从#直接跳到###中间没有##。解析时栈会出问题。我的做法是自动补一个虚拟的##节点标题叫“未命名小节”内容为空。4.2 检索阶段的性能与准确率平衡检索阶段最大的坑是LLM 选错分支。我遇到过 LLM 被摘要里的关键词误导选了不相关的章节。比如问“缓存策略”摘要里提到“缓存”的章节有好几个LLM 可能选了讲“浏览器缓存”的而不是“服务端缓存”的。改进方法有三个。一是在摘要里加入章节路径比如“第二章 架构 2.1 组件 2.1.1 存储层”让 LLM 有上下文。二是让 LLM 返回 top-2 分支两个分支都下钻最后合并结果。三是在 prompt 里加入否定示例告诉 LLM“如果多个章节都提到关键词选择层级更深的那个”。另一个坑是树太深导致 LLM 调用次数过多。我见过一份文档有 6 层结构检索一次要调 6 次 LLM延迟 8 秒以上。解决办法是设置最大深度比如只下钻到第 4 层第 4 层以下用关键词检索代替 LLM 判断。4.3 常见问题速查表问题现象可能原因排查方法解决方案检索返回空节点叶子节点内容为空检查解析日志向上回溯到有内容的祖先节点LLM 选错章节摘要关键词误导打印候选摘要加入章节路径返回 top-2检索延迟过高树太深或太宽统计 LLM 调用次数限制深度宽层先关键词过滤答案不完整叶子节点太小检查叶子内容长度合并相邻叶子或调大叶子粒度摘要生成失败节点内容超 token 限制统计节点 token 数截断内容或分段摘要再合并提示调试时把每次 LLM 的输入输出都打到日志里尤其是节点选择的 prompt 和 response。我靠这个日志定位了至少一半的问题。5. 进阶玩法PageIndex 与向量检索、知识图谱的结合5.1 混合检索树定位 向量细召回纯 PageIndex 在叶子节点内部还是靠全文给 LLM如果叶子有 2000 字LLM 可能抓不住重点。我的做法是在叶子节点内部再建一个小型向量索引把叶子切成 200 字的片段检索时先用树定位到叶子再用向量在叶子内召回 top-3 片段。这样既保留了结构信息又有了细粒度召回。实测下来这种混合方案在长文档问答上比纯向量 RAG 的准确率高 20-30 个百分点尤其是在“跨章节推理”类问题上优势明显。5.2 与知识图谱的互补PageIndex 的树结构本质是一种层级化的知识组织但它只表达了“包含关系”不表达“关联关系”。比如“第三章提到的算法 A 在第五章被改进为算法 B”这种跨章节的引用关系树结构表达不了。这时候可以叠加一个知识图谱把实体和关系抽出来检索时同时查树和图合并结果。我试过用 LLM 从每个节点抽取实体和关系存进 Neo4j。查询时先走 PageIndex 定位章节再在图里查相关实体把图查询结果作为补充上下文。这个方案复杂度高适合对准确率要求极高的场景比如法律、医疗。5.3 增量更新与版本管理实际项目中文档会更新PageIndex 需要支持增量重建。我的策略是给每个节点算一个内容哈希更新时对比哈希只重新生成变化节点的摘要。树结构的变化新增章节、删除章节通过对比新旧树的节点路径来识别。版本管理用简单的版本号加时间戳每次重建存一份快照方便回滚。注意增量更新时如果某个章节的内容变了它的所有祖先节点的摘要也可能需要更新因为祖先摘要通常包含子节点信息。我的做法是祖先摘要标记为“脏”下次检索时懒更新。6. 一些个人体会与后续扩展方向PageIndex 这个思路我用了大半年最大的感受是RAG 的瓶颈往往不在检索算法而在文档理解。你把文档结构搞清楚了检索质量自然就上去了。向量数据库是个好工具但它不是万能钥匙尤其面对结构化长文档时显式的层级索引往往比隐式的向量空间更可靠。后续我打算在几个方向继续折腾。一是多模态文档的 PageIndex把图片、表格也纳入树结构表格可以转成 Markdown 再挂到节点上。二是检索路径的可解释性把 LLM 选择分支的过程可视化出来方便调试和向用户展示“为什么返回这个答案”。三是与 Agent 框架结合让 Agent 自己决定什么时候用 PageIndex 检索、什么时候用向量检索、什么时候直接调工具。如果你也在做 RAG 相关的东西被检索质量困扰不妨试试 PageIndex 这个路子。它不一定适合所有场景但对于结构化长文档确实能打开一扇新窗户。我踩过的这些坑希望你能绕过去。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →