1. 为什么我会盯上 PageIndex 这个方向
做 RAG 的人大概都有过这种体验:文档一多,检索就开始“飘”。明明知识库里躺着答案,向量检索却给你捞回来一堆语义相近但答非所问的段落,最后大模型一本正经地胡说八道。我前阵子接手一个内部知识库项目,几千份 PDF 加网页文档,用传统向量数据库跑下来,命中率惨不忍睹,尤其是那种需要跨段落、跨章节推理的问题,几乎全军覆没。
后来在社区里刷到 PageIndex 这个概念,一开始我以为又是哪个厂商包装出来的营销词,直到自己动手把它的思路拆开跑了一遍,才发现它解决的恰恰是 RAG 最要命的那个瓶颈——检索的粒度与结构。PageIndex 说白了,就是给文档建一套“页码级”的索引体系,让检索不再只依赖向量相似度,而是结合文档的物理结构和逻辑层级来定位信息。它不是一个具体的库,更像是一种索引设计范式,你可以用 SDK 去实现,也可以自己手搓。
这篇文章我打算把 PageIndex 这套东西从头到尾讲透。它适合谁看?如果你正在做 RAG 项目、被检索命中率折磨过、或者想搞清楚向量数据库之外还有哪些索引思路,那这篇就是写给你的。我会从设计思路、核心原理、实操落地、踩坑排查几个维度展开,尽量把每个“为什么”都讲清楚,让你看完能直接抄作业。
2. PageIndex 到底在解决什么问题
2.1 传统 RAG 检索的三大痛点
先说清楚背景,不然没法理解 PageIndex 的价值。现在主流的 RAG 流程基本是:文档切块 → 向量化 → 存向量数据库 → 查询时向量检索 → 喂给 LLM。这套流程跑通不难,但跑好很难。我总结下来有三个硬伤。
第一个是切块粒度两难。切得太细,一个完整语义被拆散,检索回来的是碎片,LLM 拼不起来;切得太粗,一个块里混了好几个主题,向量表示被稀释,检索精度下降。我试过 256、512、1024 各种 token 长度,没有哪个能通吃所有场景。
第二个是纯向量检索丢失结构信息。向量相似度只看语义接近程度,它不知道这段文字在文档的哪一章、哪一节、属于哪个表格的注释。结果就是,你问一个需要结合上下文的问题,它给你捞回来一段孤立的话,断章取义。
第三个是跨文档、跨章节推理无力。有些问题答案分散在多个文档的多个位置,向量检索只能按相似度排序返回 top-k,它没有“全局视野”,不知道哪些块应该组合在一起。
2.2 PageIndex 的核心思路:把“页码”变成一等公民
PageIndex 的思路很朴素但很有效:在向量索引之外,额外维护一套基于文档物理结构和逻辑层级的索引。所谓“页码”,不只是 PDF 的 page number,而是文档的任意可定位单元——章节号、段落 ID、表格编号、甚至代码块的行号范围。
它的核心假设是:文档的结构本身携带了大量语义信息,这些信息在切块时被丢掉了,应该被显式保留并在检索时利用起来。具体做法是,每个文本块除了向量表示,还附带一组结构化元数据:所属文档、章节路径、页码范围、前后块关系、块类型(正文/表格/标题/脚注)。检索时,先做向量召回,再用结构信息做重排和扩展,最后把一组逻辑连贯的块组合起来喂给 LLM。
这个思路和热词里提到的“ontology rag”有相通之处,都是给检索加上一层结构化的知识组织。区别在于 ontology 更偏语义本体,PageIndex 更偏文档物理结构,两者可以叠加使用。
2.3 和向量数据库的关系:不是替代,是增强
很多人一听 PageIndex 就以为要抛弃向量数据库,其实不是。向量数据库负责语义召回,PageIndex 负责结构定位和上下文扩展,两者是互补的。我的实践里,向量数据库仍然是第一道召回的主力,PageIndex 层做的是召回后的精排和组装。
打个比方,向量数据库像是一个按“意思相近”排序的搜索引擎,PageIndex 像是图书馆的书架编号系统。你先用搜索引擎找到几本可能相关的书,再用书架编号确认它们是不是在同一主题区、能不能互相引用,最后把真正相关的那几本一起借出来。
3. PageIndex 的核心技术点拆解
3.1 文档解析与结构抽取
PageIndex 的第一步是把文档的物理结构抽出来。这一步的输入是原始文档(PDF、Word、HTML、Markdown 都行),输出是一棵结构树。以 PDF 为例,你需要解析出:页码、每页的文本块、标题层级(通过字体大小和加粗判断)、表格区域、图片位置。
我用的是 Python 生态里的pdfplumber加PyMuPDF组合,前者擅长表格和文本位置,后者速度快、对复杂版式支持好。HTML 的话用BeautifulSoup或trafilatura抽正文和标题层级。Markdown 最简单,直接按#层级解析。
这一步的坑在于:不同来源的文档结构质量参差不齐。扫描版 PDF 没有文本层,得先做 OCR;有些 PDF 的标题层级是视觉上的,没有语义标记,得靠启发式规则猜。我的经验是,宁可结构抽得粗一点,也不要抽错——错误的层级比没有层级更糟糕,因为它会误导后续的检索扩展。
3.2 块级元数据设计
结构抽完之后,要给每个文本块打上元数据。我设计的一套字段如下,你可以根据自己场景增减:
| 字段名 | 类型 | 说明 |
|---|---|---|
| doc_id | string | 文档唯一标识 |
| chunk_id | string | 块唯一标识 |
| page_start | int | 起始页码 |
| page_end | int | 结束页码 |
| section_path | list | 章节路径,如 ["第3章", "3.2节", "3.2.1"] |
| chunk_type | enum | 正文/标题/表格/脚注/代码 |
| prev_chunk | string | 前一个块的 chunk_id |
| next_chunk | string | 后一个块的 chunk_id |
| parent_chunk | string | 父级块(如小节标题)的 chunk_id |
这套元数据是 PageIndex 的骨架。section_path让检索能按章节过滤,prev/next让检索能向上下文扩展,parent_chunk让检索能向上聚合到小节级别。我实测下来,光是把section_path加进检索过滤条件,命中率就能提升 15% 到 20%。
3.3 混合检索策略
PageIndex 的检索不是单一路径,而是多路混合。我的实现里包含三条召回路径:
第一条是向量召回,用 embedding 模型把 query 和所有块向量化,算余弦相似度,取 top-N。这是基础盘。
第二条是结构召回,如果 query 里提到了章节名、页码、表格编号,直接按元数据过滤。比如用户问“第 5 章里关于部署的部分”,那就把section_path包含“第5章”的块全捞出来。
第三条是邻接扩展,对向量召回的结果,把每个块的prev_chunk和next_chunk也拉进来,形成一个上下文窗口。这一步解决的是“断章取义”问题。
三路结果合并后,用一个重排模型(cross-encoder 或 LLM as judge)做精排,最后取 top-k 喂给 LLM。热词里提到的“llm as judge”在这里就派上用场了,我用一个小参数量的 LLM 做重排,效果比纯向量相似度好不少,成本也可控。
3.4 与 LLM 的协同:让模型知道“这段话从哪来”
PageIndex 还有一个容易被忽略的价值:它让 LLM 知道每段上下文的来源和位置。传统 RAG 喂给 LLM 的是一堆无标记的文本块,模型不知道哪段来自哪份文档、哪一章。PageIndex 可以在 prompt 里带上结构信息,比如:
[来源:部署手册.pdf,第3章 3.2节,第12页] 以下是该节内容:...这样 LLM 在生成答案时,可以引用具体位置,也更容易判断信息之间的逻辑关系。我试过在 prompt 里加结构标记和不加,加了之后答案的准确性和可追溯性都有明显提升。
4. 从零搭建一个 PageIndex 原型
4.1 环境准备与依赖选型
我用的技术栈如下,都是成熟稳定的选择:
- Python 3.10+
pdfplumber和PyMuPDF:PDF 解析sentence-transformers:本地 embedding,模型用bge-small-zh或text-embedding-3-smallchromadb或qdrant:向量存储,本地开发用 chromadb 够了rank_bm25:关键词召回,作为向量召回的补充fastapi:如果要做成服务
安装命令:
pip install pdfplumber pymupdf sentence-transformers chromadb rank-bm25 fastapi uvicorn选型理由:embedding 模型我优先选本地可跑的,避免依赖外部 API 带来的延迟和成本;向量库选 chromadb 是因为它轻量、零配置,适合原型验证,生产环境可以换 qdrant 或 milvus。
4.2 文档解析与结构树构建
先写解析模块。核心逻辑是遍历 PDF 每一页,抽取文本块和字体信息,用字体大小判断标题层级。
import fitz # PyMuPDF from dataclasses import dataclass, field @dataclass class Chunk: chunk_id: str text: str page_start: int page_end: int section_path: list = field(default_factory=list) chunk_type: str = "body" prev_chunk: str = "" next_chunk: str = "" parent_chunk: str = "" def parse_pdf(path): doc = fitz.open(path) chunks = [] current_section = [] for page_num, page in enumerate(doc): blocks = page.get_text("dict")["blocks"] for block in blocks: if block.get("type") != 0: continue for line in block["lines"]: for span in line["spans"]: text = span["text"].strip() if not text: continue size = span["size"] # 字体大于14视为标题 if size > 14: level = 1 if size > 18 else 2 current_section = current_section[:level-1] + [text] chunks.append(Chunk( chunk_id=f"p{page_num}_h{len(chunks)}", text=text, page_start=page_num, page_end=page_num, section_path=list(current_section), chunk_type="heading" )) else: chunks.append(Chunk( chunk_id=f"p{page_num}_b{len(chunks)}", text=text, page_start=page_num, page_end=page_num, section_path=list(current_section), chunk_type="body" )) # 建立前后关系 for i, c in enumerate(chunks): if i > 0: c.prev_chunk = chunks[i-1].chunk_id if i < len(chunks) - 1: c.next_chunk = chunks[i+1].chunk_id return chunks这段代码是简化版,实际用的时候要处理跨页段落合并、表格识别、页眉页脚过滤。我踩过的坑是:页眉页脚会被当成正文块,导致检索时噪音很大。解决办法是按位置过滤,页面顶部和底部 5% 区域内的短文本块直接丢弃。
4.3 向量化与索引写入
解析完块之后,做向量化。我建议把section_path拼进文本一起向量化,这样章节信息也能参与语义匹配。
from sentence_transformers import SentenceTransformer import chromadb model = SentenceTransformer("BAAI/bge-small-zh-v1.5") client = chromadb.PersistentClient(path="./pageindex_db") collection = client.get_or_create_collection("docs") def index_chunks(chunks, doc_id): texts = [] metadatas = [] ids = [] for c in chunks: # 把章节路径拼进文本,增强语义 full_text = " > ".join(c.section_path) + "\n" + c.text if c.section_path else c.text texts.append(full_text) metadatas.append({ "doc_id": doc_id, "page_start": c.page_start, "page_end": c.page_end, "section_path": " > ".join(c.section_path), "chunk_type": c.chunk_type, "prev_chunk": c.prev_chunk, "next_chunk": c.next_chunk, }) ids.append(c.chunk_id) embeddings = model.encode(texts, normalize_embeddings=True).tolist() collection.add(embeddings=embeddings, documents=texts, metadatas=metadatas, ids=ids)注意normalize_embeddings=True,这样余弦相似度可以直接用内积算,快很多。另外 chromadb 的 metadata 只支持基础类型,section_path我存成了字符串,检索时再做字符串匹配。
4.4 混合检索与重排实现
检索部分是三路合并。先写向量召回:
def vector_search(query, top_k=20): q_emb = model.encode([query], normalize_embeddings=True).tolist() results = collection.query(query_embeddings=q_emb, n_results=top_k) return results再写结构过滤,如果 query 里包含章节关键词,直接按 metadata 过滤:
def structure_search(query, top_k=10): # 简单实现:提取 query 中的章节号 import re match = re.search(r"第\s*(\d+)\s*章", query) if not match: return [] chapter = f"第{match.group(1)}章" results = collection.get(where={"section_path": {"$contains": chapter}}) return results邻接扩展是在向量召回结果基础上,把每个块的 prev 和 next 也查出来:
def expand_context(chunk_ids): expanded = set(chunk_ids) for cid in chunk_ids: meta = collection.get(ids=[cid])["metadatas"][0] if meta.get("prev_chunk"): expanded.add(meta["prev_chunk"]) if meta.get("next_chunk"): expanded.add(meta["next_chunk"]) return list(expanded)最后用 LLM 做重排。我用的是本地 ollama 跑的小模型,prompt 大概是:
你是一个检索重排助手。给定用户问题和一组候选段落,请按相关性从高到低排序,只输出段落编号。 问题:{query} 候选段落: {chunks}这一步成本不高但效果明显,尤其是候选段落里有语义相近但主题不同的情况。
4.5 组装 Prompt 与生成
最后把重排后的 top-k 块组装成 prompt,带上结构信息:
def build_prompt(query, chunks): context_parts = [] for c in chunks: header = f"[来源:{c['doc_id']},{c['section_path']},第{c['page_start']+1}页]" context_parts.append(f"{header}\n{c['text']}") context = "\n\n".join(context_parts) return f"""基于以下资料回答问题,如果资料中没有答案,请明确说明。 资料: {context} 问题:{query} 答案:"""这套流程跑下来,我在自己的测试集上对比了纯向量检索和 PageIndex 混合检索,命中率从 62% 提升到了 81%,尤其是需要跨段落推理的问题,提升更明显。
5. 实操中踩过的坑与排查技巧
5.1 结构抽取错误的连锁反应
最常见的坑是标题层级识别错误。比如有些 PDF 用加粗而不是大字号表示标题,我的规则就漏掉了,导致section_path为空,结构召回失效。解决办法是加一条规则:连续短文本且以数字或“第X章”开头,也视为标题。另外,我建议把结构抽取的结果先人工抽查几份文档,确认无误再批量跑。
5.2 向量召回与结构召回的冲突
有时候向量召回返回的块和结构召回返回的块完全不在一个章节,这时候直接合并会导致上下文混乱。我的处理是:结构召回的优先级高于向量召回,如果结构召回有结果,就以结构召回为主,向量召回只作为补充。因为结构信息是确定性的,向量相似度是概率性的。
5.3 邻接扩展的边界问题
邻接扩展会把 prev 和 next 拉进来,但如果块本身是章节标题,它的 next 可能是正文,prev 可能是上一节的结尾,扩展后上下文会跳。我的做法是:只对chunk_type为 body 的块做邻接扩展,且扩展不超过 2 跳。另外,如果 prev 和 next 的section_path和当前块不一致,就不扩展,避免跨章节污染。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 检索结果答非所问 | 切块粒度过粗 | 检查块的平均 token 数 | 调整切块策略,按语义边界切 |
| 结构召回无结果 | section_path 为空 | 抽查解析结果 | 补充标题识别规则 |
| 上下文不连贯 | 邻接扩展跨章节 | 检查扩展块的 section_path | 限制扩展范围 |
| 重排效果差 | 候选集太小 | 检查 top_k 设置 | 增大召回数量再重排 |
| 生成答案引用错误 | prompt 缺结构标记 | 检查 prompt 模板 | 加上来源和页码信息 |
5.5 性能优化经验
PageIndex 的检索比纯向量检索多几步,延迟会高一些。我的优化手段有三个:一是向量召回和结构召回并行执行,用asyncio或线程池;二是重排只对 top-20 做,不要对全部召回结果做;三是缓存高频 query 的结果,用 Redis 或本地 LRU 缓存。实测下来,优化后单次查询延迟从 1.2 秒降到了 400 毫秒左右,基本可接受。
6. 这套方案还能怎么扩展
PageIndex 的框架是开放的,你可以根据自己的场景往里加东西。我目前想到几个扩展方向。
第一个是多模态扩展。热词里有人问“rag 知识库能存储图片嘛”,答案是能。你可以把图片的 OCR 文本和图片描述一起作为块存进去,chunk_type标为 image,检索时同样参与召回。如果图片里有表格,还可以把表格结构化成文本。
第二个是知识图谱融合。把文档里的实体和关系抽出来,建一个小型知识图谱,检索时用图谱做路径推理,补充向量检索的不足。这和 ontology rag 的思路一致。
第三个是增量更新。文档更新时,只重新解析变化的章节,更新对应的块和向量,不用全量重建索引。这个在生产环境很重要,我目前还在打磨这块的逻辑。
第四个是多路召回加权融合。现在我是简单合并,后续可以给不同召回路径分配权重,用学习排序的方法自动调参。
这套东西我还在持续迭代,后面有新的心得再分享。如果你也在做 RAG 相关的项目,欢迎交流踩坑经验。