资讯动态

PageIndex:用页码级结构索引提升RAG检索精度

发布时间:2026/10/31 5:45:39 来源:尧图企业网站定制
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 或 1024overlap 设个 50 就完事了但实际上切分策略直接决定了召回的上限。我踩过的坑是这样的一份产品需求文档里面有个表格描述了不同角色的权限矩阵。按固定长度切分后表格被拦腰截断前半段在 chunk A后半段在 chunk B。用户问“管理员有哪些权限”检索只召回了 chunk ALLM 回答时缺了后半段的信息结果就是漏答。这不是 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_chars1500): 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_pages5, top_chunks3): # 第一阶段页面定位 keyword_hits page_index.keyword_search(query, top_k10) vector_hits page_index.vector_search(query, top_k10) 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_ktop_chunks ) results.extend(chunks) # 按页码和段落序号排序 results.sort(keylambda x: (x[page_num], x[chunk_index])) return results这个流程看起来简单但每一步都有调优空间。比如页面定位阶段的关键词匹配分词策略对中文文档影响很大向量相似度的阈值设多少直接决定了候选页面的数量。我一般会把候选页面数设得稍微宽一点宁可多召回几页也不要漏掉关键页因为第二阶段还会再筛一遍。4. 实操过程与核心环节实现4.1 环境准备与依赖选型动手之前先把环境理清楚。我这套方案不依赖特定框架核心组件就三个文档解析库、向量数据库、一个轻量级的页面索引存储。文档解析方面PDF 用 PyMuPDF 或 pdfplumberWord 用 python-docxMarkdown 直接读文本。向量数据库我选的是本地部署的方案支持 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( textchunk, 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 方案在切分阶段就丢掉了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑