1. 为什么我会盯上 PageIndex 这个方向
第一次看到 PageIndex 这个词,是在翻 RAG 相关的讨论帖时。当时我正在给一个内部知识库做检索增强,文档量大概两千多份,PDF、Word、Markdown 混在一起,向量数据库用的是本地部署的方案。检索效果怎么说呢,能跑,但总感觉差一口气——用户问一个跨章节的问题,召回来的永远是几个孤立的段落,拼在一起逻辑是断的。后来有人提到 PageIndex 这个思路,我才意识到问题可能不在 embedding 模型,而在检索的粒度上。
PageIndex 说白了就是给文档建一套“页码级”的索引结构。传统 RAG 是把文档切成 chunk,每个 chunk 独立向量化,检索时按相似度召回。这套逻辑的毛病在于,chunk 一旦切开,文档原有的层级关系就丢了。PageIndex 想做的事情,是在向量检索之外,额外维护一份文档的物理结构索引——哪一页、哪一节、哪一段,彼此之间是什么关系。检索的时候先定位到页,再在页内做细粒度匹配,最后按文档结构把结果重新组装起来。
这个思路解决的核心问题是 RAG 的“碎片化召回”。你想想,一份技术白皮书,第三章讲架构,第四章讲部署,用户问“部署阶段需要注意哪些架构约束”,传统 RAG 可能只召回第四章的片段,但答案其实横跨两章。PageIndex 的价值就在于,它让检索结果带上了位置信息,LLM 拿到的不再是一堆散装段落,而是一份有页码、有层级、有上下文的“结构化证据包”。
适合谁来参考这套东西?我觉得三类人最需要:一是正在做企业知识库、文档问答的工程师,二是被 RAG 召回质量折磨过的算法同学,三是想从零搭一套本地知识库、又不想被向量数据库选型绕晕的独立开发者。哪怕你用的是 LangChain4j 的 Easy RAG 或者 Ollama 配简易本地知识库,PageIndex 这层结构索引的思路都能直接嫁接上去。
2. PageIndex 的整体设计与思路拆解
2.1 传统 RAG 的瓶颈到底卡在哪
先把问题说透。现在主流的 RAG 流程大概是:文档解析 → 文本切分 → 向量化 → 存入向量数据库 → 查询时向量化 → 相似度检索 → 拼接上下文 → 丢给 LLM。这条链路里,最容易被低估的环节是“文本切分”。很多人默认 chunk size 设个 512 或 1024,overlap 设个 50 就完事了,但实际上切分策略直接决定了召回的上限。
我踩过的坑是这样的:一份产品需求文档,里面有个表格描述了不同角色的权限矩阵。按固定长度切分后,表格被拦腰截断,前半段在 chunk A,后半段在 chunk B。用户问“管理员有哪些权限”,检索只召回了 chunk A,LLM 回答时缺了后半段的信息,结果就是漏答。这不是 embedding 模型不行,是切分把语义单元破坏了。
另一个瓶颈是“多跳问题”。用户的问题往往需要综合多个段落才能回答,但向量检索是单轮召回,它不关心段落之间的逻辑关系。你问“这个功能的实现依赖哪些上游服务”,答案可能散落在架构图说明、接口文档、部署配置三个地方,传统 RAG 很难一次性把这三块都捞回来。
PageIndex 针对的就是这两个痛点。它的核心主张是:文档的物理结构(页码、章节、段落编号)本身就是一种强先验,不应该在切分阶段被丢掉。
2.2 PageIndex 的核心思路:把“页”作为检索锚点
PageIndex 的设计逻辑可以概括成一句话:先定位页,再定位段,最后按结构组装。具体来说,它在传统向量索引之外,额外建了两层索引。
第一层是页面级索引。每份文档解析后,记录每一页的文本内容、页码、所属章节标题。这一层不做细粒度切分,整页作为一个单元。页面级索引的作用是快速缩小检索范围——用户的问题先跟页面级摘要做匹配,锁定最相关的几页。
第二层是页内段落索引。在锁定的页面内部,再按段落或语义块做细粒度向量化。这一层才是真正跟用户 query 做相似度计算的地方。因为范围已经缩小到几页,检索精度会明显提升,而且不会出现跨页语义断裂的问题。
最后是结构组装。检索到的段落带着页码和段落序号,系统按原始文档顺序把它们拼起来,必要时还会把相邻段落一并带上作为上下文。这样 LLM 拿到的是一段连续的、有位置标记的文本,而不是一堆乱序的 chunk。
这个思路跟“ontology RAG”或者“知识图谱增强 RAG”有相似之处,都是想给检索结果加上结构信息。但 PageIndex 更轻量,它不要求你预先建本体或图谱,只需要在文档解析阶段多抽一层页码和章节信息就行。
2.3 为什么选“页”而不是“章节”或“句子”
这里有个选型考量值得展开说。为什么锚点选“页”,而不是更粗的“章节”或更细的“句子”?
章节太粗。一份文档可能只有三五个章节,每个章节几十页,锁定章节后检索范围还是太大,精度提升有限。而且很多文档的章节划分并不均匀,有的章节两页,有的章节五十页,用章节做锚点会导致检索负载严重不均衡。
句子太细。句子级索引的召回精度确实高,但丢失的上下文太多。一个句子单独拿出来,往往歧义很大,而且句子级索引的存储开销和检索开销都会成倍增加。你想想,一份一百页的文档,按句子切可能有两三千个单元,向量数据库的压力不小。
页是一个折中点。一页通常包含几个段落,语义相对完整,又不会太大。而且页码是文档的天然属性,解析阶段就能拿到,不需要额外的语义分割算法。对于 PDF 这类格式,页边界是明确的;对于 Word 和 Markdown,虽然没有严格页码,但可以按“逻辑页”(比如固定字符数或按标题层级)来模拟。
提示:如果你的文档主要是 Markdown 或纯文本,没有物理页码,可以用“标题层级 + 固定长度”来模拟逻辑页。比如每个二级标题下的内容算一页,超过 2000 字再拆成多页。
2.4 跟向量数据库的关系:不是替代,是叠加
很多人一听 PageIndex 就以为要换向量数据库,其实不是。PageIndex 是一层额外的索引结构,它跟向量数据库是叠加关系,不是替代关系。
我的做法是:向量数据库照常用,存页内段落的 embedding;另外用一张轻量级的关系表或文档型存储来维护页面级索引和文档结构树。查询时先查页面索引锁定范围,再带着范围条件去向量数据库做过滤检索。大部分向量数据库都支持 metadata 过滤,你可以把页码作为 metadata 字段存进去,检索时加一个page in [3,4,5]的条件就行。
这样设计的好处是改动成本低。你不需要重构现有的 RAG 管线,只需要在文档解析阶段多抽一层页码信息,在检索阶段多一步页面定位。对于已经在用 LangChain4j 或类似框架的项目,这层逻辑可以封装成一个独立的 Retriever 组件,插到现有链路里。
3. 核心细节解析与实操要点
3.1 文档解析阶段:怎么把页码和结构抽干净
PageIndex 的地基是文档解析。这一步如果没做好,后面的索引全是歪的。我按文档类型分别说一下实操要点。
PDF 解析是坑最多的。很多 PDF 是扫描件,直接抽文本会得到空字符串,必须先做 OCR。即使是文本型 PDF,也可能存在多栏排版、页眉页脚干扰、表格跨页等问题。我常用的组合是:先用解析库抽原始文本和页码,再用规则清洗页眉页脚,最后对表格区域做特殊标记。
页眉页脚的清洗有个简单有效的办法:统计每一页开头和结尾若干行,如果某几行在多页中重复出现,大概率就是页眉页脚,直接剔除。这个规则能干掉大部分干扰,但要注意有些文档的章节标题也会重复出现在页眉,剔除前先判断一下是不是标题。
Word 和 Markdown 没有物理页码,需要构造逻辑页。我的策略是按标题层级切分:一级标题作为大章节,二级标题作为页边界,如果某个二级标题下的内容超过阈值(比如 1500 字),再按段落拆成多页。这样构造出来的“页”虽然不是物理页,但保留了文档的层级结构,检索效果一样好。
# 逻辑页构造的简化示例 def build_logical_pages(markdown_text, max_chars=1500): pages = [] current_page = {"title": "", "content": "", "page_num": 1} for line in markdown_text.split("\n"): if line.startswith("## "): if current_page["content"]: pages.append(current_page) current_page = { "title": line[3:], "content": "", "page_num": len(pages) + 1 } else: current_page["content"] += line + "\n" if len(current_page["content"]) > max_chars: pages.append(current_page) current_page = { "title": current_page["title"] + " (续)", "content": "", "page_num": len(pages) + 1 } if current_page["content"]: pages.append(current_page) return pages这段代码的逻辑很直白:遇到二级标题就开新页,内容超长就续页。实际项目中你还需要处理代码块、表格、图片说明等特殊元素,但核心思路就是这个。
3.2 页面级索引:摘要怎么生成才靠谱
页面级索引的核心是给每一页生成一个可检索的摘要。这个摘要的质量直接决定了页面定位的准确率。
我试过三种方案。第一种是直接取页面前 N 个字符作为摘要,简单粗暴,但效果不稳定——有些页面开头是过渡段落,信息量很低。第二种是用 LLM 生成摘要,效果好但成本高,两千页文档跑一遍要不少 token。第三种是混合方案:先用规则抽取页面内的标题、首段、关键词,拼成一个结构化摘要,只有规则抽取效果差的页面才走 LLM。
实测下来第三种最划算。规则抽取的摘要大概长这样:
页码: 12 章节: 第三章 系统架构 关键词: 微服务, 网关, 负载均衡, 容错 摘要: 本章介绍系统整体架构,包含网关层、服务层、数据层三部分。网关层负责路由和鉴权,服务层按业务域拆分,数据层采用读写分离。这个摘要既有人工可读的概述,又有机器可检索的关键词。页面定位时,用户 query 先跟关键词做匹配,再跟摘要做向量相似度计算,两路结果加权融合,准确率比单一方案高不少。
注意:页面摘要不要写太长,控制在 200 字以内。太长的摘要会稀释关键词权重,反而降低定位精度。
3.3 页内段落索引:切分粒度怎么定
页面锁定之后,页内段落索引的切分粒度就变得很关键。因为范围已经缩小到几页,这时候切得细一点问题不大,反而能提升精度。
我的经验值是:页内按语义段落切分,每个段落控制在 200 到 400 字。太短了上下文不足,太长了精度下降。切分时优先按自然段边界切,如果单个自然段超过 400 字,再按句子边界二次切分。
这里有个细节:段落切分时要保留“段落序号”和“所属页码”两个 metadata。段落序号用于最后的结构组装,所属页码用于检索时的范围过滤。这两个字段看起来不起眼,但少了它们,PageIndex 就退化成普通 RAG 了。
另外,页内切分不要用固定长度硬切。我见过有人为了省事,直接按 256 字符切,结果把一句话切成两半,embedding 出来语义都不完整。宁可段落长短不一,也要保证语义边界完整。
3.4 检索阶段:两阶段召回怎么串起来
检索阶段是 PageIndex 真正发力地方。整个流程分两步:页面定位和页内检索。
页面定位阶段,用户 query 同时走两条路。一条是关键词匹配,用 BM25 或简单的倒排索引,跟页面摘要里的关键词做匹配。另一条是向量相似度,把 query 向量化后跟页面摘要的向量做比对。两条路各取 Top 10 页面,然后做加权融合,取综合得分最高的 3 到 5 页作为候选范围。
页内检索阶段,带着候选页码去向量数据库做过滤检索。这时候的 query 向量跟页内段落的向量做相似度计算,每个页面内取 Top 3 段落。最后把所有候选段落按页码和段落序号排序,组装成最终上下文。
# 两阶段检索的简化逻辑 def pageindex_retrieve(query, page_index, vector_db, top_pages=5, top_chunks=3): # 第一阶段:页面定位 keyword_hits = page_index.keyword_search(query, top_k=10) vector_hits = page_index.vector_search(query, top_k=10) page_scores = merge_scores(keyword_hits, vector_hits) candidate_pages = [p["page_num"] for p in page_scores[:top_pages]] # 第二阶段:页内检索 results = [] for page_num in candidate_pages: chunks = vector_db.search( query, filter={"page_num": page_num}, top_k=top_chunks ) results.extend(chunks) # 按页码和段落序号排序 results.sort(key=lambda x: (x["page_num"], x["chunk_index"])) return results这个流程看起来简单,但每一步都有调优空间。比如页面定位阶段的关键词匹配,分词策略对中文文档影响很大;向量相似度的阈值设多少,直接决定了候选页面的数量。我一般会把候选页面数设得稍微宽一点,宁可多召回几页,也不要漏掉关键页,因为第二阶段还会再筛一遍。
4. 实操过程与核心环节实现
4.1 环境准备与依赖选型
动手之前先把环境理清楚。我这套方案不依赖特定框架,核心组件就三个:文档解析库、向量数据库、一个轻量级的页面索引存储。
文档解析方面,PDF 用 PyMuPDF 或 pdfplumber,Word 用 python-docx,Markdown 直接读文本。向量数据库我选的是本地部署的方案,支持 metadata 过滤就行,Chroma、Qdrant、Milvus 都可以。页面索引存储用 SQLite 就够了,因为页面级数据量不大,两千页文档也就两千条记录,SQLite 完全扛得住。
Embedding 模型的选择要看你的硬件条件。如果有 GPU,可以用 BGE 或 M3E 这类中文效果好的模型;如果只有 CPU,用 text2vec 或者小参数的模型也能跑,就是速度慢一点。我实测下来,页面摘要的 embedding 和页内段落的 embedding 可以用同一个模型,没必要分开。
# 依赖安装 pip install pymupdf pdfplumber python-docx pip install chromadb # 或 qdrant-client pip install sentence-transformers4.2 完整索引构建流程
索引构建分四个阶段,我按顺序说。
第一阶段是文档解析。遍历文档目录,按文件类型调用对应的解析器,输出一个统一的中间格式:每份文档一个列表,列表里每个元素是一页,包含页码、章节标题、原始文本。
第二阶段是页面摘要生成。对每一页,先做规则抽取,拿到标题、首段、关键词。关键词抽取可以用 TF-IDF 或 TextRank,不需要太复杂。如果规则抽取的摘要质量不达标(比如关键词少于三个),再调 LLM 补一个摘要。
第三阶段是页内切分。对每一页的文本,按自然段切分,超长段落二次切分,每个段落记录页码和段落序号。
第四阶段是向量化入库。页面摘要和页内段落分别向量化,页面摘要存入页面索引表,页内段落存入向量数据库,metadata 里带上页码和段落序号。
# 索引构建主流程 def build_pageindex(doc_dir, page_index_db, vector_db): for doc_path in scan_docs(doc_dir): pages = parse_document(doc_path) for page in pages: # 生成页面摘要 summary = generate_summary(page) page_index_db.insert({ "doc_id": doc_path, "page_num": page["page_num"], "title": page["title"], "summary": summary, "keywords": extract_keywords(page["content"]) }) # 页内切分并向量化 chunks = split_page_into_chunks(page["content"]) for idx, chunk in enumerate(chunks): vector_db.add( text=chunk, metadata={ "doc_id": doc_path, "page_num": page["page_num"], "chunk_index": idx } )这个流程跑一遍,两千页文档大概需要十几分钟,主要时间花在 embedding 计算上。如果文档量更大,可以考虑并行化,但要注意向量数据库的写入并发限制。
4.3 检索链路搭建与参数调优
检索链路的搭建比索引构建要简单,但参数调优是个细活。我把关键参数列一下,再说说怎么调。
| 参数 | 含义 | 建议值 | 调优方向 |
|---|---|---|---|
| top_pages | 页面定位召回页数 | 5 | 文档结构复杂时调大 |
| top_chunks | 页内召回段落数 | 3 | 问题宽泛时调大 |
| keyword_weight | 关键词匹配权重 | 0.4 | 专有名词多时调大 |
| vector_weight | 向量匹配权重 | 0.6 | 语义问题多时调大 |
| chunk_size | 页内切分长度 | 300 | 按文档密度调整 |
调参的基本逻辑是:先保证召回率,再优化精度。你可以准备一组测试问题,每个问题标注好正确答案所在的页码,然后跑检索看命中率。如果命中率低,先调大 top_pages;如果命中率高但精度差(召回了无关页面),再调小 top_pages 或调整权重。
我自己的经验是,keyword_weight 和 vector_weight 的比例不用太纠结,0.4 比 0.6 是个比较稳的起点。真正影响大的是 top_pages,这个值设小了会漏页,设大了会引入噪声。我的做法是先用一个较大的值(比如 8)跑一遍,看正确页面排在第几位,然后根据分布来定最终值。
4.4 跟 LLM 的对接:上下文怎么组装
检索出来的段落最终要喂给 LLM,组装方式直接影响回答质量。我的做法是按页码和段落序号排序后,给每个段落加上位置标记,再拼成上下文。
[文档: 产品白皮书, 第 12 页, 第 2 段] 网关层采用统一入口设计,所有外部请求先经过鉴权再路由到内部服务。 [文档: 产品白皮书, 第 12 页, 第 3 段] 鉴权模块支持 JWT 和 OAuth2 两种方式,具体选型取决于客户端类型。 [文档: 产品白皮书, 第 13 页, 第 1 段] 路由规则支持按路径、按请求头、按权重三种策略,配置热更新无需重启。这种带位置标记的上下文有两个好处。一是 LLM 能感知到信息的来源和顺序,回答时更容易做跨段落的推理。二是当用户追问“这个在第几页”时,系统能直接给出页码,体验比传统 RAG 好很多。
提示:位置标记不要写得太啰嗦,控制在 20 字以内。太长的标记会占用宝贵的上下文窗口,得不偿失。
5. 常见问题与排查技巧实录
5.1 页面定位不准怎么办
这是最常见的问题。用户 query 明明指向第 12 页,系统却定位到了第 8 页。排查思路分三步。
先看页面摘要质量。如果摘要里关键词太少或者太泛,定位肯定不准。解决办法是加强关键词抽取,或者对摘要质量差的页面补一个 LLM 摘要。
再看分词策略。中文文档如果分词分得不好,关键词匹配基本失效。我建议用 jieba 或 HanLP 做分词,并且把文档里的专有名词加入自定义词典。这一步看起来麻烦,但对定位准确率的提升非常明显。
最后看权重配置。如果 query 里有很多专有名词,把 keyword_weight 调大;如果是语义型问题,把 vector_weight 调大。这个没有固定公式,得根据你的文档特点和问题分布来试。
5.2 页内检索召回重复内容
有时候同一个段落会被多次召回,原因是页面定位阶段召回了相邻页面,而相邻页面有内容重叠。解决办法是在组装上下文时做去重,按段落文本的哈希值去重,保留页码最小的那个。
另一个原因是页内切分时 overlap 设得太大。如果你在切分时加了重叠窗口,重叠部分会被重复索引。我的建议是页内切分不要加 overlap,因为页面定位已经提供了足够的上下文,页内再重叠就是冗余。
5.3 文档更新后索引怎么增量维护
这是工程化必须面对的问题。文档更新后,全量重建索引成本太高。我的做法是给每份文档维护一个版本号或修改时间戳,更新时只重建变更文档的索引。
具体操作是:页面索引表里加一个 doc_version 字段,向量数据库的 metadata 里也带上 doc_version。文档更新时,先删除该文档的所有旧记录,再重新解析入库。删除操作要同时清理页面索引表和向量数据库,别漏了。
如果文档量特别大,可以考虑按文档分片存储,每个文档一个独立的索引文件,更新时只替换对应文件。这样维护成本更低,但检索时需要跨文件查询,实现复杂度会高一些。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 | 解决办法 |
|---|---|---|---|
| 定位到错误页面 | 摘要质量差 | 检查关键词数量 | 补 LLM 摘要或加强关键词抽取 |
| 召回内容不完整 | 页内切分过粗 | 检查 chunk_size | 调小切分粒度 |
| 召回内容重复 | 相邻页重叠 | 检查去重逻辑 | 按文本哈希去重 |
| 检索速度慢 | 页面索引过大 | 检查索引结构 | 加缓存或分片 |
| 中文匹配差 | 分词不准 | 检查分词词典 | 加自定义词典 |
| 更新后检索旧内容 | 增量维护遗漏 | 检查删除逻辑 | 补全删除操作 |
5.5 几个我踩过的坑
第一个坑是 PDF 解析时忽略了表格。很多技术文档的关键信息在表格里,但普通文本抽取会把表格拍扁成一行,语义全丢了。后来我加了表格检测,把表格区域单独抽出来,转成 Markdown 表格再入库,效果好很多。
第二个坑是页面摘要用了 LLM 但没做缓存。两千页文档跑一遍 LLM 摘要要不少钱,而且每次重建索引都重跑一遍,成本扛不住。后来我加了摘要缓存,按页面内容的哈希值缓存,内容没变就直接复用。
第三个坑是检索时没做超时控制。向量数据库查询偶尔会慢,如果没设超时,整个请求就卡住了。后来我加了超时和降级逻辑,向量检索超时就退化成纯关键词检索,保证服务可用性。
6. 这套方案还能怎么扩展
PageIndex 这套结构索引的思路,其实不止能用在文本 RAG 上。我最近在试的一个方向是把它扩展到多模态场景。比如文档里有图片,解析时记录图片所在的页码和位置,检索时如果命中图片所在页面,可以把图片的描述或 OCR 结果一并带出来。这样用户问“那个架构图里画了什么”,系统能定位到图所在的页,再把图的描述喂给 LLM。
另一个方向是跟知识图谱结合。页面索引提供了物理结构,知识图谱提供语义结构,两者叠加可以做更精准的推理。比如用户问“A 服务和 B 服务之间有什么关系”,页面索引定位到相关页面,知识图谱补充实体关系,LLM 综合两者给出答案。这个方向我还在摸索,目前的效果还不稳定,但思路是通的。
最后一个方向是检索结果的可视化。因为 PageIndex 保留了页码信息,前端可以直接把命中的页面高亮出来,用户点一下就能跳到原文对应位置。这个体验比传统 RAG 只给一段文字要好得多,尤其适合文档审阅和合规检查场景。
我在实际项目里用这套方案跑了大概三个月,最直观的感受是:RAG 的瓶颈往往不在模型,而在检索的粒度。把粒度做对了,用同样的 embedding 模型和 LLM,回答质量能上一个台阶。PageIndex 不是什么黑科技,它就是把文档本来的结构用起来了,但恰恰是这一点,很多 RAG 方案在切分阶段就丢掉了。