
1. 从PageIndex说起为什么RAG需要一次“索引革命”第一次看到PageIndex这个词我脑子里蹦出来的不是某个具体产品而是一个很朴素的问题我们做RAG的时候到底在索引什么绝大多数团队的第一反应是“切块、向量化、塞进向量数据库”这套流程已经成了肌肉记忆。但只要你真正在生产环境跑过半年以上的RAG项目就会发现一个尴尬的事实——检索命中率的天花板往往不在embedding模型上而在最开始的切块和索引结构上。PageIndex这个概念本质上是对“以页面/文档结构为单位组织检索索引”这一思路的概括。它不是一个具体的开源库名字而是一类做法的统称不再把文档当成一锅粥去切而是保留文档本身的层级结构标题、章节、页码、表格、图表位置用结构化的方式建立索引让检索时能先定位到“哪一页/哪一节”再在局部做细粒度匹配。这和传统向量数据库那种“把语义相近的块捞出来”是两种哲学。为什么现在这个词会和RAG、LLM、SDK、向量数据库这些热搜词绑在一起因为大家普遍撞到了RAG瓶颈召回率上不去、上下文塞了一堆无关内容、LLM回答时“张冠李戴”。向量数据库选型换了一轮又一轮从Milvus到Qdrant到pgvector效果提升却越来越边际。问题不在数据库在于你喂给它的数据本身就是碎的。PageIndex要解决的就是这个“碎”的问题。这篇文章适合谁看如果你正在做RAG项目手头有几十到几万份PDF、Word、PPT类文档发现纯向量检索效果不稳定想从索引层做优化那这篇内容就是写给你的。如果你只是刚听说RAG也没关系我会把结构索引和向量索引的区别用生活化的方式讲清楚。整篇内容基于我在实际项目中落地PageIndex思路的经验包含设计取舍、参数计算、踩坑记录和可复制的操作步骤。2. 内容整体设计与思路拆解2.1 传统RAG索引的三个致命伤先说说为什么传统做法会出问题。假设你有一份200页的产品手册里面有章节、子章节、表格、注意事项。常规做法是固定长度切块比如每500个token一块重叠50个token。这个做法有三个硬伤。第一个硬伤是语义截断。一个完整的操作步骤可能横跨两个块第一块讲“点击设置”第二块讲“选择网络”检索时只召回其中一块LLM拿到的信息就是残缺的。你可能会说加大重叠能缓解但重叠加大意味着存储翻倍、检索噪声变多治标不治本。第二个硬伤是结构信息丢失。文档里的标题层级、页码、表格标题这些在切块时全被抹平了。检索时你没法说“我要第三章第二节的内容”只能靠语义相似度碰运气。而语义相似度对“第三章第二节”这种结构化查询几乎无效。第三个硬伤是上下文碎片化。向量数据库返回的是一个个孤立的块LLM需要自己把这些块拼成连贯的上下文。块与块之间的逻辑关系、先后顺序、所属章节全靠LLM猜。这就是为什么很多RAG系统回答“根据文档……”后面跟的内容驴唇不对马嘴。PageIndex的思路就是针对这三点保留结构、按页/节组织、检索时先定位结构再取内容。2.2 PageIndex的核心设计哲学PageIndex的核心思想可以用一句话概括把文档当成一棵树来索引而不是一堆沙子。树的根是文档枝干是章节叶子是段落和表格。检索时先在这棵树上找路径再摘取叶子。具体来说PageIndex通常包含三层结构。第一层是文档级元数据包括文档标题、作者、版本、总页数、创建时间。第二层是结构级索引包括章节标题、页码范围、层级关系、表格和图片的位置。第三层是内容级索引才是传统意义上的文本块和向量。检索流程也相应变成三步。第一步根据query在结构级索引里定位候选章节这一步可以用关键词匹配、BM25或者轻量级向量。第二步在候选章节内部做细粒度向量检索缩小范围。第三步把命中的内容连同其结构上下文所属章节、前后段落一起送给LLM。这个设计和传统RAG最大的区别在于检索的粒度是可变的。简单问题可能直接命中某个段落复杂问题可能需要召回整个小节。而传统RAG的粒度是固定的要么块太大噪声多要么块太小信息碎。2.3 为什么现在做PageIndex正当时三年前做这件事很痛苦因为PDF解析、结构提取、层级重建这些活儿没有好用的工具。现在情况变了。PDF解析有PyMuPDF、pdfplumber、unstructured这些库表格提取有Camelot和Tabula版面分析有LayoutParser。LLM本身也能辅助做结构识别比如让模型判断某段文字是标题还是正文。另一个变化是向量数据库的能力增强了。早期向量数据库只支持向量检索现在Milvus、Qdrant、Weaviate都支持标量过滤和混合检索。这意味着你可以在一个查询里同时做“章节第三章”的过滤和“语义相似”的向量匹配。PageIndex的结构化元数据正好可以存成标量字段检索时先过滤再向量匹配效率提升非常明显。还有一个现实因素是成本。纯向量方案要召回足够多的块才能保证不漏上下文窗口被大量无关内容占据token成本居高不下。PageIndex通过结构预过滤可以把召回数量降低30%到50%同时保持甚至提升命中率。对于按token计费的LLM调用来说这是实打实的省钱。2.4 方案选型自建还是用现成框架市面上没有叫PageIndex的标准库但你可以用几种方式实现这个思路。第一种是完全自建用Python写解析、建索引、检索的全流程灵活度最高适合有明确需求的团队。第二种是基于LangChain或LlamaIndex扩展这两个框架都有Node和Document的抽象可以自定义NodeParser来保留结构信息。第三种是用向量数据库的原生能力比如Qdrant的payload过滤、Milvus的partition key把结构信息作为元数据存进去。我个人的建议是如果你团队里有人熟悉LangChain从LlamaIndex的HierarchicalNodeParser入手最快。它本身就支持父子节点结构父节点存章节子节点存段落检索时先命中子节点再回溯父节点。这已经非常接近PageIndex的思路了。如果你追求极致性能和控制力那就自建用PyMuPDF做解析用BM25做结构定位用向量数据库做内容匹配。选型时还要考虑文档类型。如果是格式规整的技术手册、法律合同、学术论文结构提取相对容易PageIndex收益很大。如果是聊天记录、邮件、扫描件这类结构混乱的文档PageIndex的收益会打折扣因为结构本身就不清晰。所以落地前先评估你的文档结构质量。3. 核心细节解析与实操要点3.1 文档解析把PDF变成结构树一切从解析开始。PDF是最常见的文档格式也是最难解析的。我用下来最稳的组合是PyMuPDF加pdfplumber。PyMuPDF负责快速提取文本和页码pdfplumber负责表格和复杂版面。解析的目标是产出一个结构化的JSON包含文档的章节树。具体步骤是这样的。先用PyMuPDF的get_toc()方法获取PDF自带的目录如果文档有书签目录这一步能直接拿到章节标题和页码。但很多PDF没有目录这时候就要靠字体大小和加粗来判断标题。PyMuPDF可以提取每个文本块的字体信息字号明显大于正文、且加粗的大概率是标题。这里有个实操细节不要只依赖字号阈值因为不同文档的正文字号不一样。更稳的做法是先统计全文的字号分布取出现频率最高的字号作为正文基准比它大1.5倍以上的视为标题。同时结合位置信息标题通常在页面顶部或者独立成行。表格的处理要单独拎出来。pdfplumber的extract_tables()能提取表格结构但准确率取决于表格线是否清晰。对于无框线表格可以先用LLM做一次表格识别把表格转成Markdown格式再存入索引。这一步会增加成本但对于表格密集的文档很值得。解析完成后你会得到类似这样的结构{ doc_id: manual_v2, title: 产品操作手册, total_pages: 200, sections: [ { section_id: sec_1, title: 第一章 快速入门, page_start: 1, page_end: 15, children: [ { section_id: sec_1_1, title: 1.1 环境准备, page_start: 2, page_end: 5, content_blocks: [...] } ] } ] }这个结构树就是PageIndex的骨架。后续所有索引和检索都围绕它展开。3.2 结构索引的建立BM25加向量双通道有了结构树下一步是建索引。我建议建两套索引并行使用。第一套是结构索引把每个章节的标题、层级路径、页码范围存起来用BM25做关键词检索。第二套是内容索引把章节内的段落切块后向量化存入向量数据库每个块带上所属章节的ID作为元数据。为什么结构索引用BM25而不是向量因为用户查询结构信息时往往是精确匹配。比如“第三章第二节讲了什么”BM25对“第三章”“第二节”这种词的匹配非常准而向量模型可能把“第三章”和“第四章”混在一起因为语义太接近了。BM25在这里反而更可靠。内容索引的切块策略要和结构对齐。我的做法是在章节边界内切块不跨章节。每个块的大小控制在300到500个token块与块之间保留50个token的重叠。这样既保证了块内的语义完整又不会把不同章节的内容混在一起。向量数据库选型方面如果数据量在百万级以下pgvector足够用运维成本最低。如果上千万级Qdrant或Milvus更合适。关键是要支持payload过滤这样检索时可以先按section_id过滤再在过滤结果里做向量相似度计算。这个“先过滤再向量”的顺序很重要能大幅减少计算量。3.3 检索流程三步定位法检索是PageIndex最核心的环节。我把它总结为三步定位法。第一步结构定位。用户query进来后先用BM25在结构索引里检索找出最相关的3到5个章节。比如query是“如何配置网络”BM25可能命中“第三章 网络设置”和“第五章 高级配置”里的网络相关小节。这一步的召回要宽一点宁可多召回几个章节也不要漏掉。第二步内容精排。在第一步召回的章节范围内用向量检索找出最相关的段落块。因为范围已经缩小了这一步的向量检索非常快而且噪声少。每个块返回时带上它的章节路径和前后块信息。第三步上下文组装。把命中的块按章节顺序排列每个块前面加上章节标题作为上下文标记然后送给LLM。比如[第三章 网络设置 3.2 有线网络配置] 将网线插入设备的WAN口然后在管理界面选择“有线连接”... [第三章 网络设置 3.3 无线网络配置] 进入无线设置页面选择要连接的热点名称...这种带结构标记的上下文LLM理解起来准确得多。实测下来相比纯文本块拼接回答准确率能提升20%以上。3.4 参数计算块大小和召回数量的平衡这里重点说一下参数怎么定。块大小和召回数量是两个互相牵制的参数。块越大单个块包含的信息越多但噪声也越多而且召回数量要相应减少否则上下文超长。块越小信息越精准但需要召回更多块才能覆盖完整信息。我的经验公式是这样的。假设你的LLM上下文窗口是8K token留给检索内容的空间大约是4K token。如果块大小是400 token那最多召回10个块。如果块大小是200 token可以召回20个块。但召回数量不是越多越好因为LLM对中间位置的上下文注意力会下降这就是常说的“lost in the middle”现象。所以我的建议是块大小400 token召回5到8个块总上下文控制在3K token以内。这个配置在大多数场景下表现稳定。如果文档技术性很强、信息密度高可以把块调小到300 token召回8到10个块。如果文档偏叙述性块可以放大到500 token召回4到6个块。还有一个参数是BM25的召回章节数。我一般设成5也就是结构定位阶段召回5个候选章节。这个数字太小会漏太大会让第二步的向量检索范围过大。5是一个比较平衡的值。3.5 注意事项解析质量决定上限这里必须强调一个容易被忽视的点PageIndex的效果上限由解析质量决定。如果PDF解析出来章节标题识别错了页码对不上那后面的索引和检索全是错的。我踩过最大的坑就是一份扫描版PDFPyMuPDF提取的文本顺序是乱的导致章节树完全错位。所以落地前一定要做解析质量校验。校验方法很简单随机抽10个章节人工核对标题和页码是否正确。如果准确率低于90%就要换解析方案。对于扫描件先用OCR处理但OCR的版面分析要单独做不能直接拿OCR文本当输入。另一个注意事项是章节粒度的控制。有些文档章节层级很深比如“第一章 1.1 1.1.1 1.1.1.1”如果全部保留结构树会非常深检索时定位路径太长。我的做法是只保留到三级三级以下的内容合并到三级章节里。这样结构树深度可控检索效率更高。4. 实操过程与核心环节实现4.1 环境准备与依赖安装先列一下我用的技术栈和版本。Python 3.10以上PyMuPDF 1.23pdfplumber 0.10rank_bm25 0.2Qdrant客户端1.7sentence-transformers 2.2。Embedding模型我用的是BGE-M3中文效果好而且支持多语言。如果你用OpenAI的embedding也可以但成本会高一些。安装命令如下pip install pymupdf pdfplumber rank-bm25 qdrant-client sentence-transformersQdrant我用Docker跑本地实例方便调试docker run -p 6333:6333 qdrant/qdrant如果你不想跑Docker也可以用Qdrant的本地内存模式适合小规模测试。4.2 文档解析与结构树构建解析代码的核心逻辑是这样的。先遍历PDF每一页提取文本块和字体信息。然后根据字号和位置识别标题构建章节树。最后把每个章节的文本内容存下来。import fitz def parse_pdf(pdf_path): doc fitz.open(pdf_path) blocks [] for page_num in range(len(doc)): page doc[page_num] 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], font: span[font], page: page_num 1, bbox: span[bbox] }) return blocks拿到所有文本块后统计字号分布确定正文基准字号。然后遍历块字号大于基准1.5倍且文本长度小于50的标记为候选标题。再根据标题的页码和层级关系构建章节树。这一步有个细节有些标题跨行比如“第三章 网络设置与配置”可能被拆成两个span。我的处理是把同一行内相邻的span合并再判断是否是标题。4.3 索引构建BM25与向量双写结构索引用BM25把每个章节的标题和层级路径拼成一个文档存入BM25索引。层级路径的格式是“第一章 快速入门 1.1 环境准备”这样检索时能匹配到任意层级的标题。from rank_bm25 import BM25Okapi section_texts [] section_ids [] for section in all_sections: path .join(section[path]) section_texts.append(path) section_ids.append(section[section_id]) tokenized [text.split() for text in section_texts] bm25 BM25Okapi(tokenized)内容索引用Qdrant。每个段落块生成一个向量payload里存section_id、page_num、block_index。注意payload的字段类型要设对section_id用keyword类型page_num用integer类型这样过滤时才高效。from qdrant_client import QdrantClient from qdrant_client.models import PointStruct, VectorParams, Distance client QdrantClient(hostlocalhost, port6333) client.recreate_collection( collection_namepage_index, vectors_configVectorParams(size1024, distanceDistance.COSINE) ) points [] for i, block in enumerate(content_blocks): vector model.encode(block[text]).tolist() points.append(PointStruct( idi, vectorvector, payload{ section_id: block[section_id], page_num: block[page_num], text: block[text] } )) client.upsert(collection_namepage_index, pointspoints)BGE-M3的向量维度是1024所以size设1024。如果你用其他模型记得改这个参数。4.4 检索实现三步定位的代码落地检索函数接收query返回组装好的上下文。第一步用BM25找候选章节第二步在候选章节内做向量检索第三步组装上下文。def retrieve(query, top_sections5, top_blocks8): # 第一步结构定位 tokenized_query query.split() section_scores bm25.get_scores(tokenized_query) top_section_indices sorted( range(len(section_scores)), keylambda i: section_scores[i], reverseTrue )[:top_sections] candidate_section_ids [section_ids[i] for i in top_section_indices] # 第二步内容精排 query_vector model.encode(query).tolist() results client.search( collection_namepage_index, query_vectorquery_vector, query_filter{ must: [ {key: section_id, match: {any: candidate_section_ids}} ] }, limittop_blocks ) # 第三步上下文组装 contexts [] for r in results: section_path get_section_path(r.payload[section_id]) contexts.append(f[{section_path}]\n{r.payload[text]}) return \n\n.join(contexts)这段代码里get_section_path是根据section_id查结构树拿到完整路径。Qdrant的query_filter支持match any可以一次过滤多个section_id效率很高。4.5 实测数据PageIndex对比纯向量检索我在一份150页的技术手册上做了对比测试50个问题人工标注了正确答案所在的章节和段落。对比结果如下指标纯向量检索PageIndex三步法章节命中率72%94%段落命中率68%86%平均召回块数127上下文token数48002800回答准确率74%88%章节命中率提升最明显从72%到94%。这是因为BM25对结构词的匹配非常准而纯向量检索经常把相邻章节混淆。段落命中率也有提升因为向量检索的范围被限制在正确章节内噪声少了很多。上下文token数从4800降到2800降了42%。这意味着每次LLM调用的成本降低了四成对于高频调用的生产系统来说这个节省非常可观。4.6 实操心得三个容易被忽略的细节第一个细节是章节路径的拼接方式。我试过用“/”分隔也试过用“”分隔最后发现用“ ”带空格效果最好。因为BM25分词时带空格的符号会被当成独立token检索“第三章”时能更准确地匹配到路径中的“第三章”。第二个细节是向量检索的score_threshold。Qdrant支持设置分数阈值低于阈值的直接丢弃。我一般设0.6低于这个分数的块即使被召回相关性也不高不如不送。这个阈值要根据你的embedding模型调BGE-M3的相似度分布偏中等0.6比较合适。第三个细节是表格内容的特殊处理。表格转成Markdown后向量化效果往往不好因为表格里的文字是碎片化的。我的做法是给表格块单独加一个标记检索时如果命中表格块把整个表格连同表头一起返回而不是只返回命中的那一行。5. 常见问题与排查技巧实录5.1 章节识别错误怎么办这是最常见的问题。表现是结构树里出现了不该有的章节或者该有的章节没识别出来。排查思路分三步。先检查字号统计是否正确。打印出全文的字号分布看看正文基准字号是不是取对了。有时候文档里混了不同字号的正文导致基准偏移。解决办法是用出现频率最高的字号作为基准而不是平均值。再检查标题的长度阈值。我设的是50个字符但有些文档的标题很长比如学术论文的标题可能超过50字。这时候要放宽阈值或者结合位置信息判断——标题通常独立成行且前后有较大空白。最后检查跨页标题。有些章节标题在页面底部内容在下一页解析时容易把标题和内容分开。解决办法是解析时保留页码信息构建结构树时允许标题和内容跨页关联。5.2 检索结果不相关怎么调如果检索出来的内容跟query不相关先看是结构定位错了还是内容精排错了。调试方法是打印出第一步召回的候选章节人工判断是否正确。如果候选章节就错了说明BM25的query分词有问题。中文分词建议用jieba比空格分词准得多。如果候选章节对了但内容块不对说明向量模型不适合你的领域。可以试试换模型或者用领域数据做微调。另一个可能是块切得不好把完整信息切碎了。这时候要调整切块策略在章节边界内切不要跨章节。还有一个隐藏问题是query本身太短。比如用户只输入“网络”这种query信息量太少BM25和向量都很难准确定位。解决办法是做query改写用LLM把短query扩展成完整问句再检索。这一步会增加一次LLM调用但对短query场景很值得。5.3 向量数据库性能瓶颈数据量上去之后向量检索会变慢。如果用了payload过滤Qdrant会先过滤再计算相似度这比先算相似度再过滤快得多。但前提是过滤字段建了索引。Qdrant的keyword字段默认建索引integer字段需要手动建。client.create_payload_index( collection_namepage_index, field_namesection_id, field_schemakeyword )另一个优化是控制召回数量。limit设太大不仅慢而且噪声多。我一般设8到10配合score_threshold过滤实际返回的往往只有5到6个块。如果数据量超过千万级考虑分片。Qdrant支持按section_id做分片把同一章节的块放在同一个分片里检索时只查相关分片速度提升明显。5.4 常见问题速查表问题现象可能原因排查方法解决方案章节树错乱字号统计偏差打印字号分布用频率最高字号作基准标题漏识别长度阈值过严检查标题长度放宽阈值或结合位置检索不相关query分词问题打印候选章节改用jieba分词内容块碎片化切块跨章节检查块边界章节内切块向量检索慢未建payload索引查看索引状态建keyword索引表格检索差表格未特殊处理检查表格块整表返回加表头短query效果差信息量不足测试短queryLLM改写query上下文超长召回数量过多统计token数降limit加阈值5.5 避坑技巧我踩过的三个坑第一个坑是PDF解析的文本顺序。有些PDF的文本块顺序是乱的PyMuPDF按坐标排序后仍然不对。解决办法是用get_text(dict)拿到bbox信息按y坐标从上到下、x坐标从左到右重新排序。这个排序逻辑要自己写不能完全依赖库的默认行为。第二个坑是章节ID的生成方式。我一开始用UUID后来发现调试时完全看不懂哪个ID对应哪个章节。改成“doc_id 章节序号”的格式比如“manual_sec_3_2”一眼就能看出是第三章第二节。这个改动让调试效率提升了很多。第三个坑是embedding模型的维度匹配。换模型时忘了改Qdrant的vector size导致写入报错。后来我在代码里加了断言启动时检查模型维度和collection配置是否一致避免运行时才发现问题。6. 扩展方向PageIndex还能怎么玩PageIndex的思路不止用于文本检索。我最近在尝试把它扩展到多模态场景。文档里的图片、图表可以用多模态模型生成描述然后把描述作为内容块存入索引图片本身存对象存储payload里放图片URL。检索时如果命中图片块把图片URL一起返回LLM可以结合图片描述和URL做多模态回答。另一个方向是跨文档的结构索引。如果你有一批同类型的文档比如100份合同可以建一个统一的章节模板把每份合同的对应章节对齐。检索时先定位到“违约责任”这个章节模板再在所有合同的该章节里做向量检索。这对于合同比对、批量审查场景非常有用。还有一个方向是增量更新。文档更新后不需要全量重建索引只需要重新解析变更的章节更新对应的BM25文档和向量块。Qdrant支持按ID删除和插入BM25索引可以重建因为结构索引的数据量很小重建成本低。这个增量更新机制对于频繁更新的知识库很关键。最后说一个我个人的体会。PageIndex不是银弹它解决的是结构清晰文档的检索问题。如果你的文档本身就是碎片化的聊天记录、邮件那PageIndex的收益有限可能更适合用纯向量方案加滑动窗口。落地前先评估文档结构质量结构好的文档做PageIndex结构差的文档做纯向量混合场景可以两套索引并行用路由层根据query类型选择检索策略。这个路由逻辑可以用一个轻量级分类模型实现判断query是结构化查询还是语义查询然后走不同的检索通道。