1. 从“向量数据库依赖症”说起:PageIndex 到底想解决什么
做 RAG 的人大概都有过这种体验:文档切了一堆 chunk,灌进向量数据库,用户问一个问题,检索回来的片段看着都沾点边,但拼起来就是答不到点子上。尤其是那种几十页的合同、上百页的技术手册、带大量表格和层级结构的年报,向量检索经常把“第三章第二节的例外条款”和“附录里的通用说明”混在一起召回,模型拿到一堆语义相似但逻辑不相关的碎片,生成出来的答案自然飘。
PageIndex 这个项目,核心思路就是冲着这个痛点去的。它不把文档当成一堆扁平的文本块,而是先给文档建一棵“目录树”,让检索发生在树结构上,而不是在向量空间里做近似最近邻。你可以把它理解成:以前是在一堆散落的书页里找关键词,现在是先看目录,定位到章节,再翻到具体那一页。这个差别在长文档、强结构文档场景下非常明显。
这个项目适合谁?如果你正在做企业知识库、法律文书检索、技术文档问答、研报分析这类“文档本身有明确层级”的 RAG 应用,PageIndex 的思路值得你花时间研究。如果你只是做简单的 FAQ 问答,文档都是短文本,那向量检索够用,不必过度设计。但只要你遇到过“检索回来的内容对不上问题”的情况,这篇文章里的东西就能帮到你。
需要先说明一点:PageIndex 目前公开的资料不算特别多,很多细节需要结合 RAG 领域的通用实践来补全。下面我会基于项目标题、相关热词以及 RAG 工程的常见做法,把它的设计逻辑、实操要点和踩坑经验尽量讲透。涉及具体实现的地方,我会明确标注哪些是项目本身的思路,哪些是我基于经验补充的合理推断。
2. PageIndex 的核心设计逻辑拆解
2.1 为什么“树结构”比“扁平 chunk”更适合长文档
先说一个基本事实:向量数据库做检索,本质是把文本映射到高维空间,然后算余弦相似度。这个机制对“语义相似”很敏感,但对“逻辑归属”几乎无感。举个例子,一份产品说明书里,“安全警告”这个章节和“故障排查”章节可能都提到“高温环境”,向量检索会把两边的片段都召回,但用户问的是“高温环境下能不能用”,正确答案在安全警告里,故障排查里的那段只是顺带提了一句。模型拿到混合上下文,很容易被带偏。
PageIndex 的做法是先把文档解析成树:根节点是文档标题,一级节点是章,二级节点是节,叶子节点是具体段落或表格。检索的时候,先在这棵树上做“路由”——判断用户的问题应该落到哪个分支,然后只在该分支下做细粒度检索。这样做的好处有三个:一是缩小了检索范围,召回精度更高;二是保留了层级上下文,模型知道这段内容属于哪一章哪一节,理解更准确;三是可以支持“先看目录再深入”的多步检索,模拟人类查阅文档的习惯。
这个思路和热词里提到的“ontology rag”有相通之处,都是给文档加一层结构化语义。区别在于 ontology 更强调领域本体和概念关系,PageIndex 更聚焦文档自身的层级结构。两者可以结合使用,但 PageIndex 的切入点更轻量,不需要预先定义复杂的本体。
2.2 树是怎么建出来的:解析、切分与节点生成
建树的第一步是文档解析。PDF、Word、Markdown、HTML 这些格式,解析难度差别很大。PDF 最麻烦,因为它是排版格式,不是语义格式,标题和正文在底层可能都是文本块,需要靠字体大小、加粗、位置等特征来推断层级。PageIndex 这类项目通常会依赖一些成熟的解析库,比如 Python 生态里的 PyMuPDF、pdfplumber,或者用专门做文档解析的模型来识别标题层级。
解析完之后是切分。这里的关键是:切分单位不是固定长度的 token 窗口,而是语义完整的节点。一个三级标题下的所有内容,如果不超过某个阈值(比如 2000 token),就作为一个叶子节点;如果超了,再按段落或子标题继续往下分。这样每个节点都是“一个完整的意思单元”,而不是被硬切断的半句话。
节点生成的时候,通常会给每个节点生成一个摘要或关键词列表,用于后续的路由判断。这个摘要可以用 LLM 来生成,也可以用传统的 TextRank 之类的方法抽取。用 LLM 生成的好处是语义质量高,坏处是成本高、有延迟。实际项目里,我见过用轻量模型做摘要、用大模型做最终生成的组合方案,平衡效果和成本。
注意:建树是一次性成本,但树的质量直接决定后续检索的上限。如果解析阶段把标题层级搞错了,后面怎么调都救不回来。建议在解析环节多花时间做验证,尤其是 PDF 文档,最好人工抽查几份,确认层级还原准确。
2.3 检索阶段:路由、召回与重排的三段式
PageIndex 的检索流程,我理解大致分三步。第一步是路由:把用户 query 和树上的节点摘要做匹配,判断应该进入哪个分支。这一步可以用向量相似度,也可以用 LLM 直接做分类。用 LLM 的好处是能处理复杂的意图,比如“对比第三章和第五章的方案差异”这种需要跨分支的问题;坏处是慢。实际落地时,常见做法是先用向量做粗筛,再用 LLM 做精排。
第二步是召回:在选定的分支下,对叶子节点做细粒度检索。这里可以用向量检索,也可以用 BM25 这类关键词检索,或者两者混合。混合检索在 RAG 里已经是比较成熟的实践了,热词里提到的“rag检索增强”和“rag瓶颈”很多都跟召回质量有关。PageIndex 的优势在于,它的召回是在一个已经缩小了的候选集里做的,所以即使检索算法本身一般,最终效果也不会太差。
第三步是重排:把召回的节点按相关性排序,取 top-k 送给生成模型。重排可以用交叉编码器,也可以用 LLM 打分。这一步在向量检索里也很常见,但 PageIndex 的重排可以额外利用层级信息,比如优先选择层级更深的节点(更具体),或者优先选择与路由路径一致的节点。
2.4 和向量数据库的关系:不是替代,是互补
看到“PageIndex”和“向量数据库”同时出现在热词里,很多人会误以为这是要取代向量数据库。我的理解是:PageIndex 不排斥向量数据库,而是把向量检索放在一个更结构化的框架里使用。你完全可以在每个叶子节点上存向量,检索时先路由再算相似度。向量数据库在这里的角色是“节点内的检索加速器”,而不是“全局的检索入口”。
这个思路对“向量数据库选型”也有影响。如果你用 PageIndex 的思路,对向量数据库的极致性能要求会降低,因为检索范围被树结构限制住了。反而对元数据过滤、多条件查询的能力要求更高,因为你需要按节点路径、层级深度等条件筛选。选型时可以优先考虑支持丰富过滤条件的方案,而不是单纯看 QPS 和召回率。
3. 动手实现:从零搭一个 PageIndex 风格的检索流程
3.1 文档解析与树结构定义
先定义树节点的数据结构。用 Python 的话,可以简单用一个 dataclass 来表示:
from dataclasses import dataclass, field from typing import List, Optional @dataclass class TreeNode: node_id: str title: str content: str level: int parent_id: Optional[str] = None children: List[str] = field(default_factory=list) summary: str = "" keywords: List[str] = field(default_factory=list)这个结构里,level表示层级深度,parent_id和children维护树关系,summary和keywords用于路由。实际存储时,可以把节点存成扁平列表,用 id 关联,也可以直接存嵌套结构。扁平列表更方便做检索和更新。
解析 PDF 的时候,我一般用 PyMuPDF 提取文本块,然后根据字体大小和加粗程度判断标题层级。具体做法是:先统计全文的字体大小分布,找出几个明显的峰值,最大的通常是文档标题,次大的是一级标题,以此类推。这个方法不是百分百准确,但对结构规范的文档效果不错。如果文档结构混乱,可以考虑用 LLM 来做层级识别,把前几页的文本送给模型,让它输出目录结构。
import fitz # PyMuPDF def extract_blocks(pdf_path): doc = fitz.open(pdf_path) blocks = [] for page_num, page in enumerate(doc): for block in page.get_text("dict")["blocks"]: if "lines" not in block: continue for line in block["lines"]: for span in line["spans"]: blocks.append({ "text": span["text"], "size": span["size"], "bold": "Bold" in span["font"], "page": page_num }) return blocks拿到 blocks 之后,按字体大小聚类,给每个 cluster 分配一个层级。然后遍历 blocks,遇到标题就开新节点,遇到正文就追加到当前节点。这个逻辑写起来不复杂,但边界情况很多,比如跨页的段落、表格里的文字、页眉页脚等,需要逐一处理。
3.2 节点摘要生成与路由索引构建
树建好之后,给每个非叶子节点生成摘要。摘要的作用是让路由阶段能快速判断“这个问题该去哪个分支”。摘要不需要太长,一两句话概括该节点覆盖的内容即可。用 LLM 生成的话,prompt 可以这样写:
SUMMARY_PROMPT = """请用一句话概括以下文档章节的主要内容,用于检索路由。 章节标题:{title} 章节内容:{content} 概括:"""生成完摘要后,把所有节点的摘要向量化,存进向量索引。路由时,先算 query 和所有节点摘要的相似度,取 top-3 分支,然后在这三个分支下继续往下路由,直到叶子节点。这个过程可以递归进行,也可以一次性算完所有层级的相似度再综合判断。
实操心得:路由阶段不要只依赖向量相似度。我试过在摘要里加入节点路径信息(比如“第三章 > 第二节 > 安全警告”),检索时把 query 和路径也做一次匹配,效果比纯摘要匹配好不少。因为用户的问题里经常包含章节相关的词,比如“第三章里提到的...”,路径匹配能直接命中。
3.3 叶子节点检索与上下文组装
到了叶子节点这一层,就可以用常规的 RAG 检索了。每个叶子节点的 content 可以进一步切成更小的 chunk,也可以整个作为检索单元。如果叶子节点比较长(超过 1000 token),建议再切一次,但切的时候要保留节点路径作为元数据,这样召回后能知道这段内容属于哪个章节。
检索时,我一般用混合策略:向量检索取 top-10,BM25 取 top-10,然后合并去重,再用一个轻量重排模型(比如 bge-reranker)排个序,取 top-5 送给生成模型。生成模型的 prompt 里,除了召回的内容,还会带上节点路径,让模型知道每段内容的出处。
RAG_PROMPT = """基于以下文档内容回答问题。每个片段都标注了所属章节,请结合章节结构理解内容。 {context} 问题:{query} 回答:"""上下文组装的时候,有个细节值得注意:如果召回的多个片段来自同一章节,可以把它们合并成一个上下文块,减少重复的路径标注。如果来自不同章节,按层级顺序排列,让模型能看出内容之间的逻辑关系。
3.4 参数选择与性能权衡
建树和检索涉及不少参数,选错了效果差很多。下面这张表是我在实际项目中总结的参考值:
| 参数 | 建议值 | 说明 |
|---|---|---|
| 叶子节点最大 token 数 | 800-1200 | 太短丢失上下文,太长检索粒度粗 |
| 路由 top-k 分支 | 2-3 | 太少可能漏掉正确分支,太多失去路由意义 |
| 叶子检索 top-k | 5-10 | 根据生成模型上下文窗口调整 |
| 重排后保留数 | 3-5 | 太多引入噪声,太少可能漏关键信息 |
| 摘要最大 token 数 | 100-150 | 够用即可,太长增加路由计算量 |
这些值不是固定的,需要根据文档类型和 query 特点调。比如法律合同这种条款密集的文档,叶子节点可以小一点,500-800 token,保证每个条款独立;技术手册可以大一点,1000-1500 token,因为很多概念需要前后文一起理解。
4. 实操中容易踩的坑与排查思路
4.1 解析阶段:标题层级识别错误
这是最常见的坑。PDF 里的标题识别,靠字体大小和加粗有时候会翻车。比如有些文档用颜色区分标题,字体大小和正文一样;有些文档标题是图片,根本提取不出文字。我遇到过一份研报,一级标题和正文的字体大小只差 0.5pt,聚类的时候直接混在一起了。
排查方法:把解析出来的树结构打印出来,人工看前 20 个节点,确认层级是否正确。如果发现某几个标题被漏掉了,可以调整聚类阈值,或者加一些规则,比如“以数字开头的短行优先判断为标题”。如果文档里标题是图片,那就只能上 OCR 了,或者用多模态模型直接读页面。
另一个坑是表格。PDF 里的表格提取出来经常是乱的,行列对不上。如果文档里表格很多,建议用专门的表格提取工具,比如 Camelot 或 Tabula,把表格转成结构化数据再放进节点内容里。不然模型拿到一堆错位的文字,生成出来的答案也是错的。
4.2 路由阶段:分支选择偏差
路由阶段如果选错了分支,后面怎么检索都是白搭。常见的原因是摘要质量不高,或者 query 和摘要的表述差异太大。比如用户问“这个产品在极端温度下能用吗”,摘要写的是“环境适应性说明”,向量相似度可能不高,路由就跑到别的分支去了。
解决办法有几个:一是摘要生成时让模型尽量覆盖多种表述,比如“环境适应性(包括温度、湿度、海拔等)”;二是在路由阶段加入关键词匹配作为补充,把 query 里的关键词和节点关键词做交集;三是保留一个“兜底分支”,如果所有分支的相似度都低于阈值,就在全文档范围内做一次扁平检索,避免完全漏掉。
注意:路由阶段不要设太高的相似度阈值。我见过有人把阈值设到 0.8,结果大部分 query 都触发兜底逻辑,等于没做路由。一般 0.5-0.6 比较合适,具体看 embedding 模型的分布。
4.3 检索阶段:召回内容不完整
有时候路由对了,但叶子节点检索召回的内容不完整。比如答案分散在三个段落里,但只召回了两个。这种情况通常是 chunk 切分的问题,把完整的意思切断了。解决方法是调整切分策略,尽量按语义边界切,比如按句子或段落切,而不是按固定 token 数切。
还有一个原因是检索算法本身的局限。向量检索对“精确匹配”不敏感,比如用户问“型号 X200 的电池续航”,向量检索可能召回一堆讲电池的内容,但没召回专门讲 X200 的那段。这时候 BM25 就能补上,因为 X200 这个关键词在 BM25 里权重很高。所以混合检索不是可选项,是必选项。
4.4 生成阶段:模型忽略结构信息
即使你把节点路径放进了 prompt,模型也可能忽略它,直接根据内容生成答案。尤其是当召回内容里有矛盾信息时,模型可能选错。比如第三章说“支持”,第五章说“不支持”,模型可能随便选一个。
要解决这个问题,可以在 prompt 里明确要求模型“优先参考层级更深的节点”或者“如果不同章节有冲突,以更具体的章节为准”。另外,可以在上下文组装时,把层级信息用更醒目的方式标注,比如用缩进或者加粗路径。实测下来,把路径放在每段内容前面,比放在后面效果好。
4.5 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 | 解决建议 |
|---|---|---|---|
| 检索结果与问题无关 | 路由分支选错 | 检查 query 与节点摘要的相似度 | 调整摘要生成 prompt,加入关键词匹配 |
| 答案不完整 | 叶子节点切分过碎 | 查看召回片段的边界 | 按语义边界切分,增大叶子节点粒度 |
| 答案与文档矛盾 | 召回内容冲突 | 检查是否有跨章节矛盾 | prompt 中明确层级优先级 |
| 响应速度慢 | 路由层级太深 | 统计路由递归次数 | 限制路由深度,或并行计算各层相似度 |
| 表格内容错乱 | PDF 表格提取失败 | 检查表格区域的文本 | 用专用表格提取工具预处理 |
5. 关于 PageIndex 与 RAG 生态的一些个人观察
PageIndex 这个思路,本质上是在 RAG 的“检索”环节做文章,而且是从文档结构入手,而不是从模型或算法入手。这让我想起热词里提到的“rag瓶颈”——很多人觉得 RAG 的瓶颈在生成模型,其实检索才是大头。生成模型再强,喂给它的上下文不对,它也变不出正确答案。PageIndex 把检索的精度问题往前推了一步,在文档解析和结构建模阶段就解决了一部分。
和 LangChain、LlamaIndex 这些 RAG 框架相比,PageIndex 更像是一个“检索策略”而不是“全流程框架”。你完全可以在 LlamaIndex 里实现类似的路由逻辑,用它的 node parser 建树,用 query engine 做路由。热词里提到的“langchain4j easy rag”也是类似的东西,只是语言生态不同。关键不在于用哪个框架,而在于你有没有意识到“扁平检索”的局限,以及愿不愿意花时间把文档结构利用起来。
另外,PageIndex 和“知识图谱”也不是一回事。知识图谱是把文档里的实体和关系抽出来,建一个语义网络;PageIndex 是保留文档原有的层级结构,建一棵目录树。前者更适合“实体关系推理”类的问题,后者更适合“文档内容定位”类的问题。两者可以结合,比如在叶子节点上再抽实体关系,但那是另一个层面的工作了。
最后说一个我自己的体会:做 RAG 项目,最花时间的往往不是模型调优,而是数据预处理。文档解析、切分、建索引,这些“脏活累活”决定了系统的上限。PageIndex 的价值在于它把“结构”这个维度显式地引入了检索流程,而不是让向量数据库去隐式地学。如果你手头的文档有明确的章节结构,不妨试试这个思路,哪怕只是简单地按标题切分再检索,效果也可能比纯向量检索好一截。