1. 从 PageIndex 说起:为什么 RAG 需要一次“索引层”的重构
第一次看到 PageIndex 这个名字,我下意识以为又是一个向量数据库的封装壳子。毕竟这两年 RAG 相关的项目太多了,十个里面有八个是把文档切块、embedding、塞进向量库、检索拼 prompt 这一套。但真正把 PageIndex 拆开看之后,我发现它想解决的问题其实更底层——不是“怎么检索”,而是“检索的索引结构本身该长什么样”。
传统 RAG 的索引是扁平的。你把一篇 50 页的 PDF 切成 500 个 chunk,每个 chunk 变成一个向量点,检索时按余弦相似度捞 top-k。这套逻辑在 demo 阶段很香,但一上生产就露馅:chunk 之间失去了层级关系,文档的章节结构、表格上下文、跨段落指代全部被拍平了。用户问“第三章第二节里那个参数默认值是多少”,向量检索很可能给你捞回一堆语义相近但位置完全不对的碎片。
PageIndex 的核心思路,是把文档的物理结构和语义结构重新组织成一棵可导航的索引树,让检索从“平面撒网”变成“沿树定位”。这个转变听起来简单,但它直接触及了当前 RAG 的几个真实瓶颈:检索精度、上下文完整性、以及多跳推理时的证据链断裂。
这篇文章我会从设计思路、核心机制、实操落地、踩坑排查四个维度,把 PageIndex 这类“结构化索引”方案讲透。适合已经跑通过基础 RAG、但被召回质量和上下文碎片问题折磨过的同学,也适合正在做知识库选型、想搞清楚“向量数据库之外还需要什么”的工程同学。
2. 内容整体设计与思路拆解
2.1 扁平索引的三个硬伤
要理解 PageIndex 为什么这么设计,得先看清楚扁平向量索引到底哪里不够用。我自己在几个企业知识库项目里反复踩过下面这几个坑。
第一个硬伤是语义漂移。一个 chunk 被切出来之后,它脱离了原有的章节标题、表格表头、图表说明。比如一份技术手册里,“默认超时时间为 30 秒”这句话,单独看语义是完整的,但它属于哪个接口、哪个版本、哪个环境,全丢了。向量检索只能匹配到“超时时间”这个语义,却无法保证它匹配的是用户真正想问的那个接口。
第二个硬伤是 top-k 的暴力性。向量检索返回的是固定数量的 chunk,k 取 5 还是 10 全靠拍脑袋。取少了漏证据,取多了塞噪声,而且这些 chunk 之间可能高度冗余——同一段话被切成了三个重叠的块,全被捞回来了。真正有用的那一条可能排在第 12 位,直接被截断。
第三个硬伤是多跳断裂。很多问题需要跨多个段落甚至跨章节才能回答。比如“对比 A 方案和 B 方案在并发场景下的差异”,A 方案在第二章,B 方案在第五章,扁平检索可能只捞到其中一个,或者两个都捞到了但顺序错乱,LLM 拿到一堆无序碎片,推理质量直线下降。
2.2 PageIndex 的树形索引思路
PageIndex 的解法,是把文档先解析成一棵层级化的索引树。根节点是文档本身,往下是章节、小节、段落、句子,每个节点携带自己的语义摘要和位置信息。检索时不是一上来就做向量匹配,而是先做树上的路由定位,再在定位到的子树里做精细检索。
这个思路其实借鉴了传统信息检索里的层次化索引思想,但结合了 LLM 的语义理解能力。具体来说,它做了三件事:
- 结构解析:把 PDF、Markdown、HTML 等格式的文档,还原成带层级的树结构,保留标题、列表、表格、代码块等语义单元。
- 节点摘要:为每个非叶子节点生成一段浓缩的语义摘要,相当于给这棵树的每个分叉口装了一个“路标”。
- 路由检索:查询进来后,先在高层节点上做粗筛,决定往哪个分支走,逐层下沉,最后在叶子节点做向量或关键词精排。
提示:这套逻辑和数据库里的 B+ 树索引有异曲同工之妙——不是全表扫描,而是沿索引路径逐层缩小范围。区别在于,PageIndex 的“键”是语义摘要,而不是数值。
2.3 为什么不是简单地“切得更细”
有人可能会问:既然 chunk 太大丢上下文,那我切小一点不就行了?我实测过,把 chunk 从 512 token 切到 128 token,召回率确实上去了,但上下文完整性崩了。LLM 拿到一堆 128 token 的碎片,拼出来的答案前言不搭后语,因为它看不到完整的论证链条。
PageIndex 的价值在于,它同时保留了粗粒度和细粒度。高层节点提供上下文和定位,叶子节点提供精确证据。检索时两者结合,既不会丢结构,也不会丢细节。这是单纯调 chunk size 做不到的。
2.4 和向量数据库的关系
这里要澄清一个常见误解:PageIndex 不是要取代向量数据库。它更像是架在向量数据库之上的一层索引编排逻辑。底层的 embedding 存储和相似度计算,依然可以交给 Milvus、Qdrant、pgvector 这些成熟方案。PageIndex 负责的是“检索前怎么组织、检索时怎么路由、检索后怎么组装”。
所以选型时不用纠结“要不要换掉向量库”,而是想清楚“我的检索层需不需要一个结构化的导航骨架”。如果文档结构规整、查询复杂度高、对证据链要求严,那这层骨架的收益会非常明显。
3. 核心细节解析与实操要点
3.1 文档解析:结构还原是第一道坎
PageIndex 的效果上限,很大程度上取决于文档解析的质量。如果解析阶段就把章节层级搞乱了,后面的树索引就是空中楼阁。
我一般会按文档类型分开处理:
| 文档类型 | 推荐解析方式 | 关键注意点 |
|---|---|---|
| 结构化 PDF | 基于字体大小和加粗识别标题层级 | 注意页眉页脚干扰,需过滤 |
| Markdown | 直接按 # 层级解析 | 代码块和表格要单独标记 |
| HTML | DOM 树 + 语义标签 | 去掉导航栏、广告等噪声 |
| 扫描件 | OCR + 版面分析 | 表格还原是难点,建议人工校验 |
实操中最容易翻车的是 PDF。很多 PDF 的标题并不是用 heading 标签标记的,而是靠字号和加粗“视觉上”区分。你得写规则去推断:字号大于正文 1.5 倍且加粗的,判定为一级标题;字号大于正文 1.2 倍的,判定为二级标题。这套规则不可能 100% 准,所以我会加一个人工抽检环节,随机抽 10% 的文档看树结构对不对。
注意:表格千万别当普通文本切。表格的语义高度依赖行列关系,一旦被拍平成文本流,表头和数据就错位了。建议把表格整体作为一个节点,内部用结构化格式(如 JSON 或 Markdown 表格)存储。
3.2 节点摘要生成:路标要准但不能太长
每个非叶子节点都需要一段摘要,作用是让检索时能快速判断“这个分支跟查询有没有关系”。摘要的生成我试过几种方案:
- 直接截取前 N 个字符:最省事,但经常截到无关内容,路标失效。
- 抽取式摘要:用 TextRank 之类的算法抽关键句,速度快,但语义连贯性差。
- LLM 生成式摘要:效果最好,但成本高,长文档全量生成摘要可能烧掉不少 token。
我现在的折中方案是:对高层节点(章节级)用 LLM 生成摘要,对低层节点用抽取式摘要。因为高层节点数量少、影响大,值得花成本;低层节点数量多,用轻量方案控制开销。
摘要长度控制在 100 到 150 字比较合适。太短了信息不够,太长了检索时反而引入噪声。生成摘要的 prompt 我会明确要求“保留该章节涉及的核心概念、关键实体和主要结论”,避免生成那种“本章介绍了……”的空话。
3.3 路由检索:逐层下沉的决策逻辑
检索阶段是 PageIndex 最核心的部分。整个流程分三步:
- 粗筛:查询进来后,先和根节点下的所有一级子节点的摘要做相似度计算,选出 top-2 或 top-3 的分支。
- 下沉:进入选中的分支后,再和该分支下的子节点摘要做匹配,继续选 top-2 或 top-3。
- 精排:到达叶子节点后,在叶子节点内部做细粒度的向量检索或关键词匹配,返回最终证据。
这里有个关键参数:每层保留几个分支。保留太多,计算量上去了,而且容易把无关分支带进来;保留太少,可能一开始就选错路,后面全错。我实测下来,每层保留 2 到 3 个分支是比较稳的,既能容错,又不至于爆炸。
另一个细节是回溯机制。如果某一层选中的分支在下一层匹配度都很低,说明可能走错路了,这时候应该回退到上一层,选第二候选分支再试。这个机制能显著降低“一错到底”的概率。
3.4 叶子节点检索:向量还是关键词
到了叶子节点,检索方式可以灵活选择。我的经验是混合检索最稳:向量检索负责语义匹配,BM25 负责关键词精确匹配,两路结果做 RRF 融合。
为什么不能只用向量?因为很多查询包含精确的术语、编号、参数名,这些用向量检索经常匹配不准。比如查“错误码 E5021”,向量检索可能给你返回一堆语义相近但错误码不同的内容,而 BM25 能精确命中。
融合时的权重我一般设成向量 0.6、关键词 0.4,但这个比例要看具体场景。如果文档里术语密度高,关键词权重可以调到 0.5 甚至更高。
4. 实操过程与核心环节实现
4.1 环境准备与依赖安装
先说一下我的实操环境。Python 3.10,主要依赖包括文档解析库、embedding 模型和向量库客户端。
pip install pymupdf markdown beautifulsoup4 pip install sentence-transformers pip install qdrant-client pip install rank-bm25embedding 模型我选的是 BGE-M3,它在中文和英文上的表现都比较均衡,而且支持多粒度输出。如果你追求更轻量,可以用 text-embedding-3-small,但中文场景下 BGE 系列通常更稳。
向量库我用 Qdrant,主要是因为它对 payload 过滤支持得好,方便我们在检索时按节点层级做过滤。Milvus 和 pgvector 也都能用,看你的技术栈。
4.2 文档解析与树结构构建
先定义一个树节点结构:
from dataclasses import dataclass, field from typing import List, Optional @dataclass class IndexNode: node_id: str title: str content: str summary: str = "" level: int = 0 children: List["IndexNode"] = field(default_factory=list) parent_id: Optional[str] = None node_type: str = "section" # section / paragraph / table / code解析 PDF 时,我用 PyMuPDF 提取每个文本块的字体大小和加粗信息,然后按规则推断层级:
import fitz def parse_pdf_to_tree(pdf_path): doc = fitz.open(pdf_path) blocks = [] for page in 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.number }) # 统计正文字号(取出现频率最高的字号) from collections import Counter size_counter = Counter(round(b["size"]) for b in blocks) body_size = size_counter.most_common(1)[0][0] # 按字号推断层级 for b in blocks: if b["size"] > body_size * 1.5 and b["bold"]: b["level"] = 1 elif b["size"] > body_size * 1.2: b["level"] = 2 else: b["level"] = 3 return blocks拿到带层级的块之后,用一个栈来构建树:遇到 level 1 就压栈,level 2 挂到最近的 level 1 下面,以此类推。这一步的逻辑不复杂,但边界情况很多,比如跨页的标题、空标题、连续同级标题等,需要逐个处理。
4.3 节点摘要生成
对章节级节点生成摘要,我用的是本地部署的小模型,避免调用外部 API 带来的延迟和成本:
from transformers import pipeline summarizer = pipeline("summarization", model="uer/t5-base-chinese-cluecorpussmall") def generate_summary(node, max_length=150): if node.node_type == "table": # 表格节点直接取表头和前几行作为摘要 return node.content[:200] text = node.content[:2000] # 截断避免超长 result = summarizer(text, max_length=max_length, min_length=80, do_sample=False) return result[0]["summary_text"]实测下来,t5-base 在中文摘要上够用,但如果文档专业术语多,效果会打折扣。预算允许的话,换成 Qwen 或 ChatGLM 的小尺寸版本会更好。
4.4 索引写入与检索实现
把树节点写入 Qdrant 时,每个节点存一条记录,payload 里带上 node_id、parent_id、level、node_type 这些元信息:
from qdrant_client import QdrantClient from qdrant_client.models import PointStruct, VectorParams, Distance client = QdrantClient("localhost", port=6333) client.recreate_collection( collection_name="page_index", vectors_config=VectorParams(size=1024, distance=Distance.COSINE) ) def index_tree(root, embedder): points = [] stack = [root] while stack: node = stack.pop() vector = embedder.encode(node.summary or node.content[:512]) points.append(PointStruct( id=hash(node.node_id) % (10**9), vector=vector.tolist(), payload={ "node_id": node.node_id, "parent_id": node.parent_id, "level": node.level, "node_type": node.node_type, "title": node.title, "content": node.content[:1000] } )) stack.extend(node.children) client.upsert(collection_name="page_index", points=points)检索时的路由逻辑:
def route_search(query, embedder, top_per_level=3): query_vec = embedder.encode(query).tolist() # 第一层:在 level=1 的节点里粗筛 hits = client.search( collection_name="page_index", query_vector=query_vec, query_filter={"must": [{"key": "level", "match": {"value": 1}}]}, limit=top_per_level ) candidate_parents = [h.payload["node_id"] for h in hits] # 第二层:在选中分支的子节点里继续筛 final_results = [] for parent_id in candidate_parents: sub_hits = client.search( collection_name="page_index", query_vector=query_vec, query_filter={"must": [{"key": "parent_id", "match": {"value": parent_id}}]}, limit=top_per_level ) final_results.extend(sub_hits) # 按分数排序返回 final_results.sort(key=lambda x: x.score, reverse=True) return final_results[:5]这套代码是简化版,实际生产里还要加上回溯、混合检索、去重等逻辑。但核心骨架就是这样,你可以先跑通再逐步加料。
4.5 参数选择与计算过程
几个关键参数的取值我列一下,方便你直接抄:
- chunk 大小:叶子节点内部如果还要切分,建议 256 到 384 token。太小丢上下文,太大检索不准。
- 每层保留分支数:2 到 3。我做过对比实验,取 1 时召回率下降约 15%,取 5 时延迟增加约 40% 但召回率只提升 3%。
- 摘要长度:100 到 150 字。低于 80 字路标信息不足,高于 200 字检索时噪声增加。
- 向量与关键词权重:0.6 / 0.4 起步,根据文档术语密度微调。
这些数字不是拍脑袋来的,是我在三个不同领域的知识库上反复调参后收敛到的区间。你的场景可能不同,但可以从这个起点开始试。
5. 常见问题与排查技巧实录
5.1 检索结果总是差一口气
这是最常见的问题。表现是:明明文档里有答案,但检索就是捞不到,或者捞到了但排序很靠后。
我的排查顺序是这样的:
- 先看解析质量。把索引树打印出来,人工检查章节层级对不对。我遇到过好几次是 PDF 解析把标题识别成了正文,导致整个树结构错位。
- 再看摘要质量。如果高层节点的摘要写得含糊,路由阶段就会选错分支。把摘要单独拿出来读一遍,看能不能准确概括该章节内容。
- 最后看 embedding 模型。如果文档是专业领域(比如医疗、法律),通用 embedding 模型可能力不从心,考虑换领域微调过的模型。
5.2 多跳问题回答不完整
多跳问题的难点在于,证据分散在不同分支里,而路由检索每层只选 top-2 或 top-3,很容易漏掉其中一个分支。
我的解法是并行多路路由:对同一个查询,用不同的摘要粒度或不同的 embedding 模型跑多次路由,把结果合并去重。这样虽然增加了一些计算量,但多跳召回率能提升 20% 以上。
另一个技巧是查询分解。把复杂查询拆成几个子查询,每个子查询独立路由,最后把证据合并。比如“对比 A 和 B 的差异”,拆成“A 的特点”和“B 的特点”两个子查询分别检索。
5.3 表格和图表内容检索不到
表格是 RAG 的老大难。我的处理方式是表格单独建索引:把表格转成 Markdown 格式,表头作为摘要,每一行作为一个可检索单元。检索时如果命中表格节点,把整个表格返回给 LLM,而不是只返回某一行。
图表的话,如果 PDF 里有图注,把图注作为检索入口;如果没有,考虑用多模态模型生成图片描述,再基于描述建索引。这块成本比较高,建议只对关键图表做。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 | 解决手段 |
|---|---|---|---|
| 召回率低 | 解析层级错误 | 打印索引树人工检查 | 修正解析规则,加人工抽检 |
| 排序不准 | 摘要质量差 | 单独阅读节点摘要 | 换更强的摘要模型 |
| 多跳断裂 | 路由分支太少 | 检查每层保留数 | 增大 top_per_level 或并行多路 |
| 表格检索失败 | 表格被拍平 | 检查表格节点类型 | 表格单独结构化建索引 |
| 延迟过高 | 树太深或分支太多 | 统计树深度和节点数 | 控制树深度,剪枝冗余节点 |
| 重复结果多 | chunk 重叠过大 | 检查切分重叠参数 | 降低重叠比例,加去重逻辑 |
5.5 几个我踩过的坑
坑一:摘要生成用了全量文档。一开始我图省事,把整个章节内容丢给 LLM 生成摘要,结果 token 消耗巨大,而且生成的摘要经常跑偏。后来改成只取章节的前 2000 字加结论段,效果反而更好。
坑二:忽略了节点 ID 的稳定性。文档更新后重新建索引,如果节点 ID 变了,之前缓存的检索结果就全失效了。后来我改用“文档路径 + 章节标题 hash”作为节点 ID,保证同一章节在文档更新后 ID 不变。
坑三:检索时没做层级过滤。早期版本我在所有节点上做向量检索,结果叶子节点和高层节点混在一起排序,高层节点的摘要经常压过叶子节点的精确内容。加上 level 过滤后,排序质量明显改善。
提示:建索引时一定要保留原始文档的位置信息(页码、段落号),这样检索到证据后可以回溯原文,方便人工校验和引用标注。
6. 这套方案适合什么场景,不适合什么场景
PageIndex 这类结构化索引方案,不是万能药。我用了大半年,总结下来它最适合的场景有几个特征:文档结构规整、查询复杂度高、对证据链完整性要求严。比如技术手册、法律合同、学术论文、企业规章制度,这些文档本身就有清晰的层级,查询也经常涉及跨章节推理,用树形索引收益很明显。
反过来,如果你的文档本身就是碎片化的,比如聊天记录、零散笔记、短文本集合,那树形索引的价值就有限,老老实实用扁平向量检索可能更划算。另外,如果查询基本都是单跳的事实性问题,比如“XX 的客服电话是多少”,那扁平检索加关键词匹配就够了,上树形索引属于杀鸡用牛刀。
还有一个现实约束是维护成本。树形索引需要文档解析、摘要生成、层级维护,这套流程比单纯的切块 embedding 复杂不少。如果你的文档更新频率很高,每次更新都要重建整棵树,运维压力会比较大。我的做法是增量更新:只重建发生变化的章节子树,其他部分复用已有索引。
最后说一个我个人的判断:RAG 的下一阶段竞争,大概率不在 embedding 模型本身,而在索引结构和检索编排上。向量数据库已经把存储和相似度计算做得很成熟了,真正拉开差距的是“怎么组织知识”和“怎么路由查询”。PageIndex 这类方案代表了一个方向,值得花时间研究。