PageIndex 实战:用结构感知索引突破 RAG 召回率瓶颈

发布时间:2026/10/4 16:07:23
PageIndex 实战:用结构感知索引突破 RAG 召回率瓶颈 1. 从向量检索到结构推理PageIndex 要解决的真问题RAG 这套东西做过的人都知道Demo 跑通只要一个下午真正上线之后被业务方追着问为什么这个答案不对才是常态。绝大多数 RAG 系统的检索层都是同一套逻辑把文档切块、做 embedding、塞进向量数据库、按余弦相似度召回 Top-K。这套流程在事实型问答上表现还行但一旦用户的问题需要跨段落推理、需要理解文档的层级结构、需要把散落在三个章节里的信息拼起来向量检索就开始露怯了。PageIndex 这个标题本身就点出了它的核心主张以页或文档结构为索引单位而不是以固定长度的 chunk 为索引单位。这不是简单的切块粒度调整而是对 RAG 检索范式的一次重新思考。结合热词里反复出现的rag瓶颈、rag hit rate、ontology rag、wiki和rag可以判断出这个项目瞄准的正是当前 RAG 最痛的那几个点召回率上不去、上下文碎片化、结构信息丢失。我先把话说在前面PageIndex 不是一个替代向量数据库的方案它更像是在向量检索之上加了一层结构感知的索引层。你可以把它理解成给 RAG 系统装了一个目录大脑——当用户提问时系统先判断这个问题应该去文档的哪个部分找答案而不是盲目地在整个向量空间里做相似度匹配。适合读这篇内容的人有三类一是正在做 RAG 项目、被召回率折磨的工程师二是想理解 RAG 演进方向、评估技术选型的架构师三是刚入门 RAG、想跳过那些教程里不讲的坑的开发者。我会从原理、实现、踩坑、调优四个维度把 PageIndex 这套思路讲透代码和配置都能直接抄。2. PageIndex 的核心机制为什么按页索引比按块索引更接近人类找信息的方式2.1 向量检索的先天缺陷chunk 是无结构的先说说为什么传统 RAG 会卡在召回率上。假设你有一份 50 页的技术白皮书用 512 token 的固定窗口切块得到大概 200 个 chunk。用户问第三章提到的那个性能优化方案和第五章的架构设计之间有什么关联这个问题在向量空间里几乎无法被正确召回。因为第三章的性能优化和第五章的架构设计分别落在不同的 chunk 里而查询语句的 embedding 和任何一个单独 chunk 的 embedding 相似度都不高——查询本身携带的是跨块的关系信息而 chunk 的 embedding 只编码了局部语义。这就是热词里说的rag瓶颈检索层丢失了文档的结构信息。人类找信息的方式完全不同。我们拿到一份文档先看目录判断这个问题应该在哪一章然后翻到那一章再在章节内部定位具体段落。这个过程是层级化的、结构驱动的而不是扁平化的相似度匹配。2.2 PageIndex 的索引结构树形节点 语义摘要PageIndex 的核心数据结构是一棵文档树。每个节点对应文档的一个结构单元——可以是一章、一节、一个子节甚至是一个表格或图表。每个节点存储三类信息结构元数据标题、层级、页码范围、父子关系语义摘要用 LLM 对该节点内容生成的浓缩描述通常 100-200 字原始内容引用指向实际文本的指针检索命中后再加载检索时系统不是直接拿 query 去匹配所有 chunk而是先在树的摘要层做路由从根节点开始逐层判断这个问题应该往哪个子节点走直到定位到最相关的叶子节点再加载该节点的原始内容作为上下文。这个过程的本质是把扁平检索变成了层级路由。打个比方传统 RAG 像是在一个没有目录的图书馆里靠书名相似度找书PageIndex 像是先看分类架、再看书架、最后看具体书脊每一步都在缩小范围。2.3 摘要生成的关键让 LLM 做结构化压缩而不是内容改写节点摘要的质量直接决定路由准确率。我实测下来摘要生成有几个必须注意的点第一摘要必须保留该节点与其他节点的区分性信息。如果两个章节的摘要都写成本章介绍了系统设计相关内容路由就废了。prompt 里要明确要求 LLM 提取本章独有的主题词和关键结论。第二摘要长度要控制。太短丢失信息太长增加路由时的 token 消耗。我的经验值是每个节点摘要 80-150 字根节点可以到 200 字。第三摘要要包含这个节点能回答什么问题的暗示。比如一个讲数据库选型的章节摘要里应该出现适合回答为什么选 A 不选 B、A 和 B 的性能对比这类元信息。这能显著提升路由命中率。下面是我实际用的摘要生成 prompt 模板SUMMARY_PROMPT 你是一个文档结构分析助手。请为以下文档片段生成一个结构化摘要。 要求 1. 用 80-150 字概括该片段的核心主题和关键结论 2. 明确列出该片段能回答的 2-3 类问题 3. 保留该片段独有的术语、数据、结论避免泛化表述 4. 如果片段包含表格或图表说明其内容 文档片段标题{title} 文档片段内容 {content} 输出格式 主题[一句话主题] 关键结论[2-3 条] 可回答问题[问题类型列表] 2.4 路由检索的两种实现路径PageIndex 的路由检索有两种落地方式各有取舍方式原理优点缺点适用场景LLM 路由每层用 LLM 判断走哪个子节点准确率高能处理复杂语义延迟高token 消耗大文档层级浅、查询复杂向量路由节点摘要做 embedding用向量相似度选子节点快成本低对摘要质量敏感文档层级深、查询量大混合路由先用向量粗筛 Top-3 子节点再用 LLM 精选平衡准确率和成本实现复杂度高生产环境推荐我个人的建议是文档层级不超过 3 层、日均查询量低于 1 万次直接用 LLM 路由超过这个规模上混合路由。纯向量路由我不太推荐因为节点摘要的 embedding 区分度往往不如原始 chunk容易在层级间跳错。3. 把 PageIndex 跑起来从文档解析到检索服务的完整链路3.1 文档解析结构信息的提取比文本提取更重要PageIndex 的第一步是把原始文档PDF、Word、Markdown、HTML解析成结构化的树。这一步的坑最多我逐个说。PDF 解析是重灾区。PDF 本质上没有章节概念只有页面和文本框。你需要用PyMuPDF或pdfplumber提取文本然后靠字体大小、加粗、编号模式来推断层级。我的做法是import fitz # PyMuPDF def extract_structure(pdf_path): doc fitz.open(pdf_path) nodes [] for page_num, page in enumerate(doc): blocks page.get_text(dict)[blocks] for block in blocks: if lines not in block: continue for line in block[lines]: for span in line[spans]: # 根据字号和加粗判断是否为标题 font_size span[size] is_bold Bold in span[font] text span[text].strip() if not text: continue # 字号大于正文 1.2 倍且加粗判定为标题 if font_size 12 and is_bold: level 1 if font_size 16 else 2 nodes.append({ type: heading, level: level, text: text, page: page_num }) else: nodes.append({ type: content, text: text, page: page_num }) return nodes注意字号阈值 12 和 16 不是通用的必须针对你的文档集调。我建议先跑一遍统计所有文档的字号分布找出正文和标题的分界点。Markdown 和 HTML好办得多直接按#层级或 DOM 树解析即可。但要注意很多 Markdown 文档的标题层级是乱的比如从#直接跳到###需要做一次层级修复。Word 文档用python-docx通过paragraph.style.name判断 Heading 1/2/3这个比较可靠。3.2 树的构建父子关系的确定与节点合并解析出来的扁平节点列表需要组装成树。核心逻辑是维护一个栈def build_tree(flat_nodes): root {level: 0, title: ROOT, children: [], content: []} stack [root] for node in flat_nodes: if node[type] heading: # 弹出栈中层级 当前层级的节点 while len(stack) 1 and stack[-1][level] node[level]: stack.pop() new_node { level: node[level], title: node[text], children: [], content: [] } stack[-1][children].append(new_node) stack.append(new_node) else: stack[-1][content].append(node[text]) return root这里有个经验过小的节点要合并过大的节点要拆分。如果一个叶子节点的内容少于 200 字把它合并到父节点如果超过 3000 字考虑按段落再拆一层。节点粒度直接影响路由准确率和上下文质量。3.3 摘要生成批量调用 LLM 的工程细节树建好之后对每个节点生成摘要。这一步是 token 消耗大户工程上要注意并发控制用asynciosemaphore控制并发数我一般设 5-10太高会被限流失败重试LLM 调用失败是常态要有指数退避重试缓存节点内容的 hash 作为 key摘要存本地避免重复生成增量更新文档更新时只重新生成变化节点的摘要import asyncio import hashlib from functools import lru_cache async def generate_summaries(tree, llm_client, concurrency8): semaphore asyncio.Semaphore(concurrency) cache {} async def process_node(node): async with semaphore: content \n.join(node[content]) if not content.strip(): node[summary] node[title] return cache_key hashlib.md5(content.encode()).hexdigest() if cache_key in cache: node[summary] cache[cache_key] return prompt SUMMARY_PROMPT.format( titlenode[title], contentcontent[:3000] ) for attempt in range(3): try: summary await llm_client.complete(prompt) node[summary] summary cache[cache_key] summary break except Exception as e: if attempt 2: node[summary] node[title] await asyncio.sleep(2 ** attempt) for child in node[children]: await process_node(child) await process_node(tree) return tree3.4 检索服务路由 加载的两段式设计检索接口分两段第一段是路由输入 query输出命中的叶子节点列表第二段是加载把命中节点的原始内容拼成上下文返回。async def route_query(query, tree, llm_client, max_depth3): current tree path [current] for _ in range(max_depth): if not current[children]: break # 构造候选列表 candidates \n.join([ f[{i}] {child[title]}: {child[summary]} for i, child in enumerate(current[children]) ]) prompt f用户问题{query} 请从以下章节中选择最相关的 1-2 个只返回编号逗号分隔 {candidates} result await llm_client.complete(prompt) indices parse_indices(result) if not indices: break # 取第一个命中的子节点继续深入 current current[children][indices[0]] path.append(current) return path路由深度一般设 3 层就够了。再深的话LLM 在每层的判断误差会累积反而降低准确率。4. 实测中的坑PageIndex 不是银弹这些问题你必须提前知道4.1 摘要质量决定上限而摘要质量很难量化我踩过的最大坑是摘要生成得好不好在路由出错之前你根本不知道。等发现召回率低的时候你面对的是几百个节点摘要根本不知道是哪个环节出了问题。我的解决方案是建一个路由评估集人工标注 50-100 个 query 对应的正确节点路径每次改摘要 prompt 或换模型都跑一遍评估集看路由准确率的变化。这个投入是值得的没有评估集你就是在盲调。评估指标用两个路径准确率路由到的叶子节点是否包含答案和路径效率路由深度是否合理有没有绕远路。4.2 层级过深时 LLM 路由的误差累积假设每层路由准确率是 90%3 层下来整体准确率只有 0.9³ 72.9%。这是 PageIndex 最致命的弱点。缓解办法有三个控制树深度超过 3 层的文档考虑把中间层扁平化每层多选每层不只选 1 个选 Top-2 或 Top-3最后合并结果叶子节点直接向量检索路由到第 2 层后对该层下所有叶子节点做向量检索而不是继续 LLM 路由我现在生产环境用的是第三种LLM 路由 2 层 向量检索兜底。实测下来整体召回率比纯 LLM 路由高 15 个百分点延迟还降了 40%。4.3 表格和图表的处理是另一个深坑文档里的表格和图表用文本提取往往得到一堆乱码。我的处理方式是表格用camelot或pdfplumber的表格提取功能转成 Markdown 表格作为独立节点图表提取图注caption用多模态模型生成描述作为节点摘要公式用pix2tex转 LaTeX保留在内容里这些特殊节点在路由时要单独标记因为它们的摘要和普通文本节点的摘要分布不一样混在一起会影响路由判断。4.4 增量更新时的树结构一致性文档更新后如果章节结构变了比如新增了一节整棵树的节点 ID 会变之前缓存的摘要和评估集全部失效。我的做法是给每个节点生成一个基于标题路径的稳定 ID比如chapter1/section2/subsection3这样只要标题不变ID 就不变缓存可以复用。5. 调优实战把 PageIndex 的召回率从 68% 拉到 91% 的五个动作5.1 动作一摘要 prompt 加入负样本对比最初的摘要 prompt 只让 LLM 概括内容结果很多摘要长得差不多。后来我在 prompt 里加了一句请确保摘要能区分本章节与其他章节避免使用本章介绍了...这类通用表述。 同时给 LLM 展示同级的其他章节标题作为对比。这一改路由准确率直接涨了 8 个百分点。5.2 动作二路由时带上已走过的路径LLM 在路由时如果不知道自己在树的哪个位置容易做出局部最优但全局错误的判断。我在路由 prompt 里加上了当前路径当前路径根 第三章 系统设计 3.2 存储层 用户问题... 候选子章节...这样 LLM 能结合上下文判断避免跳回已经排除的分支。5.3 动作三叶子节点做二次向量精排路由到叶子节点后如果该节点内容超过 2000 字直接全部塞进上下文会浪费 token 且引入噪声。我的做法是在叶子节点内部再做一次向量检索取 Top-3 段落。这一步把上下文精确率从 72% 提到了 88%。5.4 动作四用 query 改写提升路由命中用户的原始 query 往往口语化、有指代。在路由前先做一次 query 改写补全指代、明确意图。比如那个方案怎么样改写成第三章提到的存储方案在性能上表现如何。这一步用一个小模型7B 级别就够了延迟增加 200ms 左右但路由准确率提升明显。5.5 动作五建立路由失败的反馈闭环每次路由结果和最终答案质量做关联如果答案质量差回溯是不是路由错了。把路由错误的 case 加入评估集定期重新评估。这个闭环跑起来之后系统会自己进化。下面是我最终生产环境的配置参数可以直接参考参数值说明树最大深度3超过则扁平化节点摘要长度80-150 字根节点 200 字路由层数2 层 LLM 向量兜底平衡准确率和延迟每层候选数Top-2避免漏选叶子节点内精排Top-3 段落控制上下文长度并发摘要生成8避免限流路由超时3s超时降级到向量检索6. PageIndex 与现有 RAG 栈的集成方式6.1 和向量数据库的关系互补而非替代很多人误以为 PageIndex 要取代向量数据库其实不是。PageIndex 负责结构路由向量数据库负责节点内精排和兜底检索。两者是上下游关系。我的集成方式是PageIndex 路由到叶子节点后把该节点的内容切片存入向量数据库如果还没存的话然后用 query 做一次局部向量检索。这样既保留了结构信息又利用了向量检索的语义匹配能力。6.2 和 LangChain / LlamaIndex 的对接如果你用 LangChain可以把 PageIndex 封装成一个BaseRetrieverfrom langchain.schema import BaseRetriever, Document class PageIndexRetriever(BaseRetriever): tree: dict llm_client: object def _get_relevant_documents(self, query: str): path asyncio.run(route_query(query, self.tree, self.llm_client)) leaf path[-1] content \n.join(leaf[content]) return [Document( page_contentcontent, metadata{path: /.join(n[title] for n in path)} )]LlamaIndex 的话实现一个自定义NodePostprocessor或者在RetrieverQueryEngine前面加一层路由。6.3 和 Agent 的结合把 PageIndex 当成一个工具在 Agent 场景下PageIndex 可以封装成一个文档导航工具。Agent 先调用这个工具确定去哪个章节再调用内容读取工具获取具体内容。这样 Agent 的推理过程更接近人类查阅文档的方式比一次性塞入大量上下文效果更好。热词里提到的rag智能体、ontology rag本质上都是这个思路的延伸让检索过程具备推理能力而不是一次性的相似度匹配。7. 一些不那么显然的经验PageIndex 这套方案我从原型到生产跑了大概四个月有几个体会是文档里不会写的。第一文档质量比算法重要。如果原始文档本身结构混乱、标题层级不一致再好的路由算法也救不了。我在项目前期花了整整两周做文档规范化把标题层级统一、把扫描件做 OCR、把表格转结构化。这两周的投入比后面调 prompt 一个月的收益都大。第二不要追求 100% 的路由准确率。路由到 90% 准确率之后边际收益急剧下降。剩下的 10% 用向量兜底 多路召回合并来解决比死磕路由算法划算得多。第三评估集要持续维护。我一开始建了 50 条评估集跑了一个月就发现覆盖不全。后来改成每周从线上真实 query 里采样 20 条人工标注后加入评估集。现在评估集有 300 多条基本能覆盖各种查询类型。第四延迟和准确率的权衡要看业务。如果是离线批处理场景可以上最重的方案如果是在线问答路由延迟必须控制在 1 秒内那就得用向量路由 小模型。没有通用最优解只有适合你业务的解。最后分享一个我常用的小技巧在路由 prompt 里加一句如果不确定返回所有可能相关的章节编号让 LLM 在犹豫时多选而不是硬选一个。这个改动让我的漏召回率降了一半代价是上下文长度增加 30% 左右但换来的是答案质量的显著提升。对于大多数业务场景这个 trade-off 是值得的。