news 2026/10/4 21:47:01

PageIndex 实战:用页码级索引提升 RAG 检索命中率

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
PageIndex 实战:用页码级索引提升 RAG 检索命中率

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_idstring文档唯一标识
chunk_idstring块唯一标识
page_startint起始页码
page_endint结束页码
section_pathlist章节路径,如 ["第3章", "3.2节", "3.2.1"]
chunk_typeenum正文/标题/表格/脚注/代码
prev_chunkstring前一个块的 chunk_id
next_chunkstring后一个块的 chunk_id
parent_chunkstring父级块(如小节标题)的 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-small
  • chromadb或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 相关的项目,欢迎交流踩坑经验。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/4 21:42:18

Luatools for macOS:专为苹果系统深度适配的LuatOS开发工具

1. 项目概述&#xff1a;为什么 macOS 用户需要专属的 Luatools&#xff1f; 在嵌入式开发圈里&#xff0c;合宙的 LuatOS 是个特别的存在——它用 Lua 脚本语言把 ESP32、Air101、Air103 这类资源受限的 MCU 变成了“会写脚本的智能小工”。你不用再啃 C 语言寄存器手册&…

作者头像 李华
网站建设 2026/10/4 21:41:36

omofun动漫|安卓安装|官网入口和追番入门

第一次接触 OmoFun动漫&#xff0c;可以先把它看作一处面向动画爱好者的内容入口&#xff1a;打开后&#xff0c;不必急着寻找某一部作品&#xff0c;不妨先浏览首页推荐、分类栏目与专题信息&#xff0c;了解平台的页面布局&#xff0c;再按自己的兴趣逐步筛选。不同版本的界面…

作者头像 李华
网站建设 2026/10/4 21:34:30

PHP遗留系统迁移实战:Go与Java混合架构选型与踩坑复盘

接手这套系统的那天&#xff0c;我就知道自己接了个烫手山芋&#xff1a;一个运行了快八年的PHP单体应用&#xff0c;登录模块和报表模块挤在同一个6000行的类里&#xff0c;控制器里直接拼SQL&#xff0c;session还是默认的本地文件存储。我原本只是加一个简单的验证码开关&am…

作者头像 李华
网站建设 2026/10/4 21:32:54

插件系统开发实战:plugin.json配置、TypeScript SDK与激活失败排查

1. 插件系统到底解决了什么问题第一次接触 "plugins" 这个概念&#xff0c;很多人会以为它只是"给软件加功能"这么简单。但真正在工程里用过插件体系的人都知道&#xff0c;它解决的其实是扩展性与解耦这对老矛盾。一个工具如果把所有功能都写死在核心代码…

作者头像 李华
网站建设 2026/10/4 21:28:27

UltraEdit绿色版右键菜单带图标:TaoToken场景下的注册表配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华