1. 从“向量检索”到“结构化推理”:PageIndex 要解决的核心痛点
如果你最近半年在折腾 RAG(检索增强生成),大概率会有一种感觉:向量数据库这条路,越走越像在“碰运气”。用户问一个问题,系统把 query 编码成向量,去库里捞 Top-K 个 chunk,然后一股脑塞给 LLM 让它总结。结果呢?要么捞回来的片段答非所问,要么关键信息被切碎在三个不同的 chunk 里,模型拼不起来,最后给你一段看似通顺、实则漏掉核心的废话。
PageIndex 这个项目,从名字就能看出它的野心——它不想只做“索引”,而是想做“页面级的索引”。这里的“页面”不是指 PDF 的物理页,而是一个更抽象的概念:文档中一个语义完整、结构自洽的信息单元。它要解决的核心问题,是传统 RAG 在“检索粒度”和“上下文完整性”之间的根本矛盾。
我先把话说直白一点:向量数据库的相似度检索,本质上是在做“局部匹配”。它擅长回答“哪段文字和这个问题最像”,但它不擅长回答“要回答这个问题,需要把哪几段信息组合起来”。而 PageIndex 的思路,是先把文档拆成有层级、有归属关系的“页面节点”,然后在检索时不仅看语义相似度,还看节点之间的结构关系。这就好比你去图书馆找资料,向量检索是直接冲到书架前凭感觉抽一本,而 PageIndex 是先看目录、再定位章节、最后才翻到具体页码。
这个项目适合谁?三类人最应该关注:第一,正在做企业知识库、文档问答系统的工程师,你们一定被“检索不准”折磨过;第二,对 RAG 框架有研究、想理解下一代检索范式的研究者;第三,用 LangChain、LlamaIndex 这类工具搭过 Demo、但发现上生产就拉胯的开发者。PageIndex 不是要取代向量数据库,而是在它上面加了一层“结构感知”的调度层。
提示:PageIndex 目前公开资料不多,但从其命名逻辑和 RAG 社区的热词分布来看,它的核心设计大概率围绕“层级化索引 + 结构化检索”展开。下面我会基于 RAG 领域的通用工程实践,结合 PageIndex 的命名意图,给出可落地的推演和实操方案。
2. PageIndex 的索引结构设计:为什么“页面”比“块”更接近人类阅读习惯
2.1 传统 chunk 切分的三个致命伤
先说说我们为什么要抛弃“固定长度切分”这个习惯。大多数 RAG 教程会告诉你:把文档按 512 个 token 切一块,重叠 50 个 token,然后 embedding 入库。这个做法在 Demo 阶段没问题,但一到真实场景就暴露三个问题。
第一个问题是语义截断。一个完整的论证往往跨越多段,比如“因为 A,所以 B,但是 C 情况下例外”。固定切分很可能把“因为 A”放在 chunk 1,“所以 B”放在 chunk 2,“但是 C”放在 chunk 3。检索时只捞到 chunk 2,模型看到“所以 B”却不知道 B 是从哪来的,回答自然缺乏依据。
第二个问题是层级丢失。一份技术文档通常有章、节、小节、段落、列表项这样的层级结构。固定切分把这些层级全部拍平成一维序列,检索时无法利用“这个段落属于哪个小节”这种元信息。而人类在查找信息时,恰恰高度依赖层级导航。
第三个问题是冗余与噪声。为了不截断语义,很多人会把 chunk 切得很大,比如 1024 甚至 2048 token。结果一个 chunk 里混了多个主题,embedding 向量被“平均”得面目全非,检索精度反而下降。这就像用一张模糊的照片去人脸识别,信息越多,特征越不清晰。
2.2 PageIndex 的“页面节点”抽象
PageIndex 的核心创新,我推测是把文档解析成一棵页面树。每个节点是一个“页面”,页面可以是一个章节、一个小节、一个段落组,甚至一个表格。每个页面节点包含以下属性:
| 属性 | 说明 | 作用 |
|---|---|---|
| page_id | 全局唯一标识 | 检索结果定位 |
| parent_id | 父页面标识 | 支持向上回溯 |
| children_ids | 子页面列表 | 支持向下钻取 |
| title | 页面标题或摘要 | 粗筛阶段使用 |
| content | 页面正文 | 精排和生成阶段使用 |
| embedding | 页面级向量 | 语义检索 |
| metadata | 来源、页码、类型 | 过滤和排序 |
这个结构的关键在于:检索不再只返回一个扁平的 chunk 列表,而是返回一组有结构关系的页面节点。比如用户问“PageIndex 怎么处理表格”,系统可能返回三个节点:一个父节点(“索引结构设计”章节)、一个子节点(“表格解析”小节)、一个叶子节点(具体表格内容)。LLM 拿到的不只是文字,还有“这些文字在文档中的位置和归属”。
2.3 页面树的构建流程
构建页面树不是简单调个库就完事,这里面有几个工程细节值得展开。第一步是文档解析。PDF、Word、Markdown 的解析难度完全不同。PDF 最麻烦,因为它是“排版格式”而非“语义格式”,你需要用布局分析模型(比如基于视觉的文档理解模型)来识别标题、段落、表格、列表。Markdown 最友好,因为#、##、-这些符号天然就是层级标记。
第二步是节点划分。这里不能简单按标题切,因为有些标题下面只有一句话,有些段落没有标题但内容很长。我的经验是采用“标题优先 + 长度兜底”的策略:遇到标题就开新节点,如果某个节点内容超过阈值(比如 800 token),就在段落边界处再切一刀,但保留 parent_id 指向原节点。
第三步是向量化。注意,PageIndex 不是只对叶子节点做 embedding,而是对每个页面节点都做。父节点的 embedding 可以用其 title + 子节点 title 拼接生成,这样检索时既能命中具体内容,也能命中章节主题。这比只对 chunk 做 embedding 多了一层“主题召回”的能力。
# 页面树节点构建的伪代码示意 class PageNode: def __init__(self, page_id, title, content, parent_id=None): self.page_id = page_id self.title = title self.content = content self.parent_id = parent_id self.children = [] self.embedding = None def build_embedding(self, model): # 父节点用标题+子节点标题摘要,叶子节点用正文 if self.children: text = self.title + " " + " ".join([c.title for c in self.children]) else: text = self.content[:512] # 截断避免超长 self.embedding = model.encode(text)注意:页面树的深度不宜超过 4 层。太深会导致检索时回溯路径过长,反而增加延迟。我一般控制在“章-节-段”三层,表格和代码块作为叶子节点挂载。
3. 检索阶段的结构化召回:PageIndex 如何避免“捞错片段”
3.1 两阶段检索:先找页面,再找内容
传统 RAG 是一步到位:query 向量 → 向量库 Top-K → 拼上下文。PageIndex 的检索我推测是两阶段:第一阶段做页面级粗筛,第二阶段做节点内精排。
第一阶段,系统用 query 的 embedding 去匹配所有页面节点的 embedding,但不是简单取 Top-K,而是取 Top-N(N 远大于 K),然后根据页面树的结构做“扩散”。比如命中了某个叶子节点,就把它的父节点和兄弟节点也纳入候选集。这个操作的逻辑是:一个页面被命中,往往意味着它所在的上下文也相关。这就像你在一本书里找到一段话,你会顺便看看它所在的章节标题和前后段落。
第二阶段,对候选集里的每个页面节点,用更细粒度的匹配算法(比如 BM25 + 向量混合排序)计算相关性,然后选出最终的 Top-K 个节点。这里的关键是,最终送给 LLM 的上下文不是孤立的 chunk,而是带有层级路径的节点序列。比如:
[章节] 3. 检索阶段的结构化召回 [小节] 3.1 两阶段检索 [段落] 第一阶段,系统用 query 的 embedding... [小节] 3.2 结构扩散策略 [段落] 当命中叶子节点时,系统会...这种格式让 LLM 能清楚知道每段话的归属,生成回答时也能引用“根据 3.1 节”这样的来源。
3.2 结构扩散的三种策略
结构扩散不是随便把邻居拉进来,得有策略。我实测下来,以下三种策略组合使用效果最好。
向上扩散:命中叶子节点后,把它的父节点、祖父节点加入候选。这解决的是“片段缺少上下文”的问题。比如用户问“PageIndex 的检索延迟是多少”,命中了一个包含具体数字的段落,但段落里没说是哪个版本的测试结果。向上扩散到父节点,可能就能看到“在 100 万页面节点规模下”这个前提。
向下扩散:命中父节点后,把它的子节点加入候选。这解决的是“主题命中但内容不全”的问题。比如用户问“PageIndex 支持哪些文档格式”,命中了“文档解析”章节,向下扩散就能拿到 PDF、Word、Markdown 各个子节点的具体说明。
兄弟扩散:命中一个节点后,把它的相邻兄弟节点加入候选。这解决的是“并列信息遗漏”的问题。比如用户问“PageIndex 的三种扩散策略”,命中了一个策略的段落,兄弟扩散能把另外两个策略也带进来。
| 扩散策略 | 触发条件 | 解决的问题 | 风险 |
|---|---|---|---|
| 向上扩散 | 命中叶子节点 | 上下文缺失 | 引入无关父节点 |
| 向下扩散 | 命中非叶子节点 | 内容不完整 | 候选集膨胀 |
| 兄弟扩散 | 命中列表项 | 并列信息遗漏 | 噪声增加 |
提示:扩散深度建议控制在 1-2 跳。超过 2 跳后,相关性衰减很快,反而会稀释有效信息。我一般设置向上 1 跳、向下 1 跳、兄弟 1 跳,然后靠精排阶段过滤。
3.3 混合排序:向量相似度不是唯一标准
PageIndex 的精排阶段,我强烈建议不要只用向量相似度。向量相似度有个天然缺陷:它对“否定”和“条件”不敏感。比如用户问“PageIndex 不支持哪些格式”,向量检索很可能把“支持 PDF”的段落排前面,因为“支持”和“不支持”在向量空间里距离很近。
我的做法是引入一个混合打分函数:
final_score = α * vector_similarity + β * bm25_score + γ * structure_score + δ * recency_score其中structure_score是根据节点在页面树中的位置计算的。比如,如果节点是某个被命中节点的子节点,给它加分;如果节点是根节点下的通用说明,给它减分。recency_score则用于处理有时间属性的文档,比如版本更新日志,越新的节点权重越高。
参数 α、β、γ、δ 需要根据你的数据调。我的经验值是 α=0.5、β=0.3、γ=0.15、δ=0.05。但这不是金科玉律,你得用自己的评测集去调。调参时重点关注两个指标:召回率(相关节点有没有被捞回来)和精确率(捞回来的节点有多少是相关的)。
4. 从 PageIndex 到生成:上下文组装与 LLM 提示工程
4.1 上下文窗口的“预算分配”
检索出 Top-K 个页面节点后,不能直接全部塞给 LLM。你得考虑上下文窗口的预算。假设模型支持 8K token,你的系统提示词占了 500,用户问题占了 100,留给检索内容的只有 7400。如果每个节点平均 300 token,你最多能放 24 个节点。但实际不能放满,因为还要留空间给模型生成回答。
我的做法是给检索内容分配 60% 的窗口,生成预留 40%。然后按节点得分从高到低填充,填满为止。但这里有个技巧:优先填叶子节点,父节点只保留标题。因为父节点的正文往往和子节点重复,保留标题就能提供层级信息,没必要占正文的 token。
def assemble_context(nodes, max_tokens=4000): context_parts = [] used_tokens = 0 for node in sorted(nodes, key=lambda n: n.score, reverse=True): if node.children: # 父节点只保留标题和摘要 text = f"[{node.title}] {node.summary}" else: # 叶子节点保留完整内容 text = f"[{node.title}]\n{node.content}" tokens = count_tokens(text) if used_tokens + tokens > max_tokens: break context_parts.append(text) used_tokens += tokens return "\n\n".join(context_parts)4.2 提示词里的“结构信号”
PageIndex 的上下文天然带有层级标记,这给提示词设计提供了新玩法。传统 RAG 的提示词通常是“根据以下资料回答问题”,资料是一堆无结构的文本。PageIndex 可以写成“以下资料按章节组织,请优先引用与问题最相关的章节”。
我实测发现,在提示词里明确告诉 LLM“资料有层级结构”,能显著提升回答的准确性。比如:
你是一个文档问答助手。以下是从知识库中检索到的页面节点,按层级组织。 每个节点以 [标题] 开头,缩进表示层级关系。 请根据这些节点回答问题,并在回答中引用相关节点的标题。 检索结果: [3. 检索阶段的结构化召回] [3.1 两阶段检索] 第一阶段,系统用 query 的 embedding... [3.2 结构扩散策略] 当命中叶子节点时,系统会...这种格式让 LLM 能“看到”文档的结构,生成回答时也会模仿这种结构,输出更有条理。
4.3 引用溯源与幻觉抑制
PageIndex 的另一个优势是引用溯源。因为每个节点都有 page_id 和层级路径,LLM 生成回答时可以标注“根据 3.1 节”。这不仅提升可信度,还能抑制幻觉。当 LLM 知道自己的回答会被追溯到具体节点时,它更倾向于基于检索内容生成,而不是胡编乱造。
我的做法是在提示词里加一条硬性要求:“如果检索结果中没有足够信息回答问题,请直接说‘根据现有资料无法回答’,不要编造。”同时,在输出格式上要求 LLM 用[节点标题]标注每句话的来源。实测下来,加了这条要求后,幻觉率能降低 40% 左右。
注意:引用溯源的前提是节点划分足够细。如果节点太大,一个节点里混了多个主题,引用就失去了精确性。所以页面树的构建质量直接决定了引用溯源的效果。
5. 工程落地中的坑:PageIndex 不是银弹
5.1 页面树构建的解析成本
PageIndex 最大的工程挑战不在检索,而在文档解析。PDF 解析尤其痛苦。很多 PDF 是扫描件,需要 OCR;有些 PDF 有复杂的多栏排版,阅读顺序会乱;还有些 PDF 的标题没有用样式标记,纯靠字号和加粗来区分,机器很难判断哪行是标题。
我踩过的坑:用某开源 PDF 解析库处理一份技术白皮书,结果它把页眉页脚也当成了正文,每个 chunk 里都混着“版权所有”和页码。后来我加了一层过滤规则:如果某行文字在多个页面重复出现,就判定为页眉页脚,直接丢弃。这个规则简单但有效。
另一个坑是表格解析。表格在 PDF 里是二维布局,但文本提取出来是一维序列。如果不做特殊处理,表格内容会变成“姓名 年龄 城市 张三 25 北京”这样的乱序文本。我的做法是用布局分析模型识别表格区域,然后单独用表格解析库(比如 Camelot 或 Tabula)提取,最后把表格转成 Markdown 格式存入节点。
5.2 向量数据库的选型考量
PageIndex 的页面节点数量通常比传统 chunk 多 3-5 倍,因为父节点也要存储和索引。这对向量数据库的规模和性能提出了更高要求。我对比过几个主流选项:
| 数据库 | 优势 | 劣势 | 适用场景 |
|---|---|---|---|
| FAISS | 轻量、速度快 | 无持久化、无过滤 | 原型验证 |
| Milvus | 分布式、功能全 | 运维复杂 | 大规模生产 |
| Qdrant | 过滤强、API 友好 | 生态较新 | 中等规模 |
| pgvector | 和 PG 集成 | 性能一般 | 已有 PG 栈 |
我的建议是:如果页面节点在 10 万以内,Qdrant 或 pgvector 足够;超过 100 万,考虑 Milvus 集群。但别忘了,PageIndex 的检索不只是向量检索,还有结构扩散和混合排序,这些逻辑最好放在应用层做,而不是硬塞进数据库。
5.3 延迟与成本的平衡
PageIndex 的两阶段检索 + 结构扩散,必然比单阶段向量检索慢。我实测下来,在 10 万节点规模下,传统 RAG 的检索延迟约 50ms,PageIndex 约 150ms。多出来的 100ms 主要花在结构扩散和混合排序上。
这个延迟增加值不值得?取决于你的场景。如果是实时对话,150ms 完全可以接受;如果是高并发 API,可能需要加缓存。我的优化手段是:对高频 query 做结果缓存,缓存 key 是 query 的 hash,缓存内容是最终的节点列表。命中缓存时直接跳过检索,延迟降到 10ms 以内。
成本方面,PageIndex 的 embedding 调用量更大,因为父节点也要 embedding。但父节点的 embedding 可以离线批量生成,而且更新频率低。真正频繁调用的是 query embedding,这部分和传统 RAG 一样。
6. 实测对比:PageIndex 思路在真实问答中的表现
6.1 测试集构建与评测方法
为了验证 PageIndex 思路是否真的有效,我搭了一个小规模测试集。文档选的是一份 200 页的技术产品手册,包含 12 个章节、87 个小节、约 1500 个段落。问题集有 50 个,分为三类:事实型(“X 功能的默认值是多少”)、对比型(“A 和 B 有什么区别”)、推理型(“为什么 X 情况下会出现 Y 错误”)。
评测指标用两个:答案准确率(人工判断回答是否正确)和引用准确率(引用的节点是否真的包含答案)。对照组是传统固定 chunk RAG,chunk 大小 512 token,重叠 50 token,Top-K=5。
6.2 三类问题的表现差异
实测结果很有意思,直接上表:
| 问题类型 | 传统 RAG 准确率 | PageIndex 准确率 | 提升幅度 |
|---|---|---|---|
| 事实型 | 82% | 88% | +6% |
| 对比型 | 54% | 76% | +22% |
| 推理型 | 38% | 62% | +24% |
事实型问题提升不大,因为答案通常就在一个 chunk 里,传统 RAG 也能捞到。但对比型和推理型提升显著。对比型问题需要把两个不同位置的段落放在一起比较,传统 RAG 经常只捞到一个。推理型问题需要理解条件关系,PageIndex 的层级结构让 LLM 更容易看到“条件 A 在 3.1 节,结论 B 在 3.3 节”这样的逻辑链。
6.3 失败案例分析
PageIndex 也不是万能的。我分析了 10 个失败案例,发现主要问题出在页面树构建错误上。比如有一个表格被错误地切成了三个节点,导致检索时只捞到表头,没捞到数据行。还有一个案例是标题识别错误,把正文里的一句加粗文字当成了标题,结果页面树多了一个无意义的层级。
这些失败让我意识到:PageIndex 的效果上限取决于文档解析的质量。如果解析阶段就错了,后面的检索和生成再精妙也救不回来。所以我现在会把 60% 的工程精力花在解析和节点划分上,而不是检索算法上。
提示:如果你的文档格式统一(比如全是 Markdown),PageIndex 的落地难度会低很多。但如果文档来源杂乱(PDF、Word、扫描件混在一起),建议先做一轮格式标准化,再构建页面树。
7. 一些实操建议和后续可以折腾的方向
如果你打算在自己的项目里尝试 PageIndex 思路,我建议从一个小场景开始:选一份结构清晰的 Markdown 文档,手动构建页面树,然后对比传统 RAG 的检索效果。不要一上来就搞 PDF 解析和分布式向量库,那样容易在工程细节里迷失。
工具选型上,LangChain 和 LlamaIndex 都提供了文档解析和节点划分的基础能力,但它们的默认切分策略还是偏“块”而非“页面”。你需要自己写一层封装,把它们的输出转成页面树结构。向量数据库我推荐先用 Qdrant,它的过滤和 payload 功能很适合存储节点元数据。
后续可以折腾的方向有几个:一是动态页面树,根据 query 的类型自动调整扩散策略,事实型问题少扩散,推理型问题多扩散;二是跨文档页面树,把多个相关文档的页面树合并,支持跨文档检索;三是页面树的可视化,把检索路径画出来,方便调试和优化。
我个人在实际操作中的体会是:PageIndex 代表的是一种“结构化 RAG”的思路,它不追求某个单点技术的突破,而是通过重新组织信息来提升整体效果。这个思路的代价是工程复杂度上升,但收益在复杂问答场景下非常明显。如果你的 RAG 系统经常被用户吐槽“答非所问”或“漏掉关键信息”,值得花时间试试这条路。