1. 从向量数据库的困境说起:PageIndex 到底想解决什么问题
做过 RAG 项目的人大概都有过这种体验:文档切了一堆 chunk,embedding 也算了,向量数据库也搭好了,检索出来的东西看着相似度挺高,但丢给 LLM 之后回答就是不对味。问题出在哪?很多时候不是模型不行,而是检索这一环从根上就偏了。
传统 RAG 的套路是“切块 + 向量化 + 相似度召回”,这套流程默认了一个假设:语义相似的文本块就是相关的。但真实文档里,一个关键结论可能分散在三个段落,一个表格的上下文藏在标题和脚注里,一份合同的核心条款跟它的例外条件隔了十几页。向量相似度只看到了局部语义,看不到文档本身的结构和层级关系。
PageIndex 这个项目,核心思路就是把“页”和“索引”这两个概念重新拉回 RAG 的主链路。它不满足于把文档打碎成无结构的 chunk,而是尝试在检索之前先建立一层带有页面级、章节级结构信息的索引,让检索过程能够感知“这段内容在文档的什么位置、属于哪个层级、跟哪些页面存在引用关系”。说白了,它想让 RAG 从“语义匹配”进化到“结构感知 + 语义匹配”的双通道模式。
这个方向为什么值得关注?因为当前 RAG 的瓶颈已经很明显了。热词里频繁出现的“rag瓶颈”“rag检索增强”“rag知识库能存储图片嘛”这些搜索词,反映的正是大家在实战中遇到的真实困惑:检索不准、多模态内容处理不了、知识库更新成本高。PageIndex 切入的正是“检索不准”这个最痛的环节,而且它的思路不是换一个更强的 embedding 模型,而是从文档组织方式入手,这个角度本身就很有启发性。
适合谁来参考?如果你正在做企业知识库、技术文档问答、法律合同检索、学术论文助手这类对结构敏感的 RAG 应用,PageIndex 的思路值得仔细拆解。如果你只是拿 RAG 做个玩具 demo,那可能感受不深。但只要你处理过超过 50 页的 PDF、带复杂表格的财报、或者有严格章节层级的规范文档,你就会明白“结构感知检索”有多重要。
2. PageIndex 的核心设计思路拆解
2.1 为什么“页”是一个被低估的检索单元
传统 chunk 策略通常是按固定 token 数切分,比如 512 token 一块,重叠 50 token。这种切法的问题在于,它完全无视了文档的物理边界和逻辑边界。一个段落被拦腰截断,一个表格被拆成两半,标题和正文分离,这些都是家常便饭。
PageIndex 把“页”作为一级检索单元,背后有几个很实际的考量。第一,页是文档最自然的物理边界。PDF、Word、PPT 这些格式本身就有明确的页面概念,用户在阅读时也是按页来定位信息的。第二,页的粒度足够大,能保留完整的上下文。一页内容通常包含一个完整的段落群、一个表格、或者一组相关的图表,语义完整性比 512 token 的 chunk 好得多。第三,页的粒度又足够小,不会引入太多噪声。相比把整章整节作为一个检索单元,页的精度更高,召回时更容易定位到具体位置。
当然,单纯按页检索也有问题:一页里可能只有一句话是相关的,其他都是无关内容。所以 PageIndex 的做法是在页的基础上再叠加一层页内索引,把每页的关键信息(标题、摘要、实体、表格结构)提取出来,形成一个轻量级的页级索引。检索时先定位到页,再在页内做细粒度的匹配。
2.2 索引结构的设计:从扁平到层级
PageIndex 的索引不是简单的“文档 ID → 页列表”这种扁平结构,而是带有层级关系的。具体来说,它至少包含三层信息:
- 文档级元数据:文档标题、作者、创建时间、总页数、章节结构树。这一层帮助系统快速判断“这份文档大概讲什么”,在跨文档检索时尤其有用。
- 页级索引:每页的页码、所属章节、页内标题、关键实体、内容摘要、与其他页的引用关系。这一层是检索的主力,决定了召回哪些页。
- 页内块索引:页内的段落、表格、图片说明等细粒度单元,带有位置信息和类型标记。这一层用于在召回页之后做精确的内容提取。
这种层级结构的好处是,检索可以分阶段进行:先用文档级信息做粗筛,再用页级索引做召回,最后用页内块索引做精排。每一层的计算成本不同,可以根据场景灵活调整。比如对延迟敏感的场景,可以只用到页级;对精度要求高的场景,可以下钻到页内块。
2.3 与向量数据库的关系:互补而非替代
很多人看到 PageIndex 的第一反应是:这跟向量数据库什么关系?是不是要替代它?我的理解是,PageIndex 不是向量数据库的替代品,而是它的前置层和补充层。
向量数据库擅长的是“给定一个 query,找出语义最相似的向量”。但它不擅长处理“这个 query 应该在哪份文档的哪一页找”这种结构性问题。PageIndex 恰好补上了这一环:它先通过结构信息缩小检索范围,把候选文档和候选页圈出来,然后再交给向量数据库做语义精排。这样既发挥了向量检索的语义优势,又避免了它在长文档中“大海捞针”式的盲目搜索。
实际部署时,可以把 PageIndex 的页级索引也向量化,存到同一个向量数据库里,但检索时先用结构过滤条件(比如“只搜第 3 章”“只搜包含表格的页”)做预过滤,再做向量相似度计算。这种“结构预过滤 + 语义精排”的组合,在实测中比纯向量检索的准确率有明显提升。
2.4 对多模态内容的处理思路
热词里有人问“rag知识库能存储图片嘛”,这其实是个很典型的痛点。传统 RAG 对图片的处理要么是 OCR 提取文字,要么是直接用多模态模型生成描述,两种方式都有信息损失。PageIndex 的思路是把图片作为页级索引的一个属性来记录,而不是强行把它转成文本。
具体来说,每页的索引里会标记“本页包含 2 张图表、1 个流程图”,并记录这些图片的位置和简要说明。检索时,如果 query 涉及图表内容,系统可以定位到包含相关图表的页,然后把整页内容(包括图片)一起送给多模态 LLM 处理。这样既保留了图片的原始信息,又不需要在索引阶段做复杂的多模态对齐。
3. 核心细节解析与实操要点
3.1 文档解析:PageIndex 的地基
PageIndex 的效果好不好,七成取决于文档解析的质量。如果解析出来的页边界是乱的、表格是散的、标题层级是错的,后面的索引和检索全是空中楼阁。
实操中,文档解析要重点关注几个点。第一是页边界识别。PDF 的页边界通常比较明确,但有些扫描件或者排版复杂的文档会出现跨页表格、跨页段落。这时候需要做跨页合并处理,把被物理分页切断的逻辑单元重新拼起来。第二是标题层级提取。通过字体大小、加粗、编号模式(如“1.1”“第一章”)来识别标题层级,构建章节树。这个步骤的准确率直接影响后续按章节过滤的能力。第三是表格结构化。表格不能只提取文字,还要保留行列关系。常见的做法是把表格转成 Markdown 或 HTML 格式,保留表头和单元格对应关系。
注意:文档解析阶段不要过度清洗。有些团队为了“干净”把页眉页脚、页码、脚注全删了,结果丢失了重要的定位信息。页码恰恰是 PageIndex 定位的关键,脚注里可能藏着关键解释。
3.2 页级索引的构建:提取什么、怎么存
页级索引是 PageIndex 的核心资产。每页需要提取的信息包括:
| 索引字段 | 说明 | 提取方式 |
|---|---|---|
| 页码 | 物理页码和逻辑页码 | 解析器直接获取 |
| 所属章节 | 该页属于哪一章哪一节 | 根据章节树映射 |
| 页内标题 | 该页出现的小标题 | 字体/编号规则识别 |
| 关键实体 | 人名、机构名、术语、数值 | NER 模型 + 规则 |
| 内容摘要 | 该页内容的简要概括 | LLM 生成或抽取式摘要 |
| 内容类型 | 正文/表格/图表/混合 | 版面分析 |
| 引用关系 | 该页引用了哪些页/被哪些页引用 | 交叉引用检测 |
这些字段里,内容摘要和关键实体是检索时最常用的。摘要的生成可以用小模型(如 7B 级别的 LLM)批量处理,成本可控。关键实体则建议用专门的 NER 模型,准确率比通用 LLM 更稳定。
存储方面,页级索引可以放在关系型数据库里(如 PostgreSQL),也可以放在文档数据库里(如 MongoDB)。如果团队已经有向量数据库,也可以把摘要和实体向量化后存进去,但结构字段(页码、章节、类型)建议单独存,方便做过滤查询。
3.3 检索流程:从 query 到页的映射
PageIndex 的检索流程大致分四步:
- Query 解析:分析用户 query 的意图,判断它是在问事实、找定义、对比信息还是做推理。同时提取 query 中的结构线索,比如“第 3 章”“表格里”“关于 XX 的那一页”。
- 文档级粗筛:如果有多个文档,先用文档级元数据(标题、摘要、章节树)做粗筛,排除明显不相关的文档。
- 页级召回:在候选文档内,用页级索引做召回。召回策略可以是关键词匹配 + 向量相似度的混合,也可以加上结构过滤条件。
- 页内精排:对召回的页做细粒度排序,选出最相关的 top-k 页,提取页内具体内容送给 LLM。
这个流程里,Query 解析这一步很关键但容易被忽略。很多 RAG 系统直接把用户 query 扔给向量数据库,不做任何解析,结果就是检索结果飘忽不定。PageIndex 的做法是先理解 query 的结构意图,再决定检索策略,这个思路值得借鉴。
3.4 与 LLM 的配合:什么时候用、怎么用
PageIndex 本身不生成答案,它负责的是“找到正确的页”。找到之后,怎么用这些页内容,取决于具体的 LLM 应用场景。
一种常见做法是页内容 + query 一起送给 LLM,让 LLM 基于页内容生成答案。这时候要注意页内容的长度控制,如果召回 5 页,每页 2000 字,加起来就是 1 万字,对 LLM 的上下文窗口是个考验。建议的做法是:先对页内容做一次压缩(保留关键句和表格),再送给 LLM。
另一种做法是多轮检索。第一轮先召回一批页,让 LLM 判断哪些页真正相关,然后针对相关页做第二轮更细粒度的检索。这种方式适合复杂 query,但延迟会高一些。
实操心得:PageIndex 的页级索引可以缓存 LLM 生成的页摘要,这样每次检索不需要重新生成摘要,响应速度会快很多。摘要的更新频率取决于文档的更新频率,一般文档不变就不需要重新生成。
4. 实操过程与核心环节实现
4.1 环境准备与依赖选型
要跑通一个 PageIndex 的最小可用版本,需要准备以下几类工具:
- 文档解析:PDF 用 PyMuPDF 或 pdfplumber,Word 用 python-docx,PPT 用 python-pptx。如果文档格式混杂,可以考虑用 Apache Tika 做统一解析。
- 版面分析:如果文档排版复杂,需要识别标题、正文、表格、图片区域,可以用 LayoutParser 或 PaddleOCR 的版面分析模块。
- NER 与摘要:NER 可以用 spaCy 或 HanLP(中文场景),摘要可以用本地部署的小模型(如 Qwen2.5-7B)或调用 API。
- 存储:页级索引存 PostgreSQL 或 SQLite,向量存 FAISS 或 Milvus。
- LLM:本地可以用 Ollama 跑 Llama 3 或 Qwen,线上可以用各家 API。
这套组合的优点是全部可本地部署,不依赖外部服务,适合对数据隐私有要求的场景。缺点是初始搭建成本较高,需要一定的工程能力。
4.2 文档解析与页边界处理的具体步骤
以 PDF 为例,解析流程可以这样设计:
import fitz # PyMuPDF def parse_pdf(pdf_path): doc = fitz.open(pdf_path) pages = [] for page_num, page in enumerate(doc): # 提取文本块,保留位置信息 blocks = page.get_text("dict")["blocks"] # 提取表格(需要额外用 pdfplumber 或 camelot) # 提取图片位置 images = page.get_images() pages.append({ "page_num": page_num + 1, "blocks": blocks, "images": images, "text": page.get_text() }) return pages这段代码只是最基础的提取。实际项目中,还需要做几件事:第一,识别页眉页脚并标记但不删除,因为页眉里可能包含章节名,页脚里有页码。第二,检测跨页表格,如果一页的最后一个表格没有闭合,需要跟下一页的第一个表格合并。第三,提取标题层级,通过字体大小和加粗信息判断哪些文本是标题。
4.3 页级索引的生成与存储
页级索引的生成可以批量处理,每页独立生成,互不依赖。具体步骤:
- 对每页的文本做清洗,去掉多余空白和乱码。
- 用 NER 模型提取实体,记录实体类型和位置。
- 用 LLM 生成页摘要,prompt 可以设计为“用 2-3 句话概括本页主要内容,保留关键数据和结论”。
- 根据章节树映射,确定该页所属的章节路径。
- 检测页内是否有表格、图片,记录类型和数量。
- 将以上信息组装成 JSON,存入数据库。
存储表结构可以这样设计:
CREATE TABLE page_index ( id SERIAL PRIMARY KEY, doc_id VARCHAR(64), page_num INT, chapter_path VARCHAR(256), summary TEXT, entities JSONB, content_type VARCHAR(32), has_table BOOLEAN, has_image BOOLEAN, raw_text TEXT );这个表结构支持按文档、按章节、按内容类型做过滤查询,也支持对摘要和实体做全文检索。
4.4 检索接口的实现与参数调优
检索接口的核心逻辑是:接收 query,返回 top-k 页。实现时可以分两步:
def retrieve(query, doc_ids=None, top_k=5): # 第一步:结构过滤 filters = parse_query_structure(query) # 提取章节、类型等条件 candidates = query_page_index(filters, doc_ids) # 第二步:语义排序 query_vec = embed(query) scored = [] for page in candidates: page_vec = embed(page["summary"] + " " + page["raw_text"][:500]) score = cosine_sim(query_vec, page_vec) scored.append((page, score)) scored.sort(key=lambda x: x[1], reverse=True) return [page for page, _ in scored[:top_k]]参数调优方面,top_k 的选择很关键。太小会漏掉相关页,太大会引入噪声。建议从 5 开始试,根据召回率和准确率的平衡来调整。摘要和原文的权重也需要调,如果摘要质量高,可以给摘要更高权重;如果摘要太泛,就多用原文。
4.5 与现有 RAG 框架的集成方式
PageIndex 可以跟 LangChain、LlamaIndex 这些框架集成。集成的思路是把 PageIndex 作为一个 Retriever 组件,替换掉默认的向量检索器。
以 LangChain 为例,可以实现一个自定义 Retriever:
from langchain.schema import BaseRetriever, Document class PageIndexRetriever(BaseRetriever): def _get_relevant_documents(self, query): pages = retrieve(query, top_k=5) return [Document(page_content=p["raw_text"], metadata={"page": p["page_num"]}) for p in pages]这样就能把 PageIndex 的检索能力接入现有的 RAG 链路,上层的 LLM 调用逻辑不需要改动。
5. 常见问题与排查技巧实录
5.1 检索结果不相关:从哪几个方向排查
这是最常见的问题。排查顺序建议是:先看解析质量,再看索引质量,最后看检索策略。
解析质量方面,检查页边界是否正确、标题层级是否准确、表格是否完整。如果解析出来的页是乱的,后面全白搭。索引质量方面,检查摘要是否准确、实体是否漏提、章节映射是否正确。检索策略方面,检查 query 解析是否合理、过滤条件是否过严、相似度阈值是否合适。
一个实用的调试方法是:把检索过程可视化。打印出 query 解析结果、候选页列表、每页的相似度分数,逐条检查为什么某些相关页没有被召回。
5.2 页摘要质量差:原因与改进
页摘要质量差通常有三个原因:LLM 能力不足、prompt 设计不好、页内容本身太杂。
LLM 能力不足的解决办法是换更大的模型,或者用专门微调过的摘要模型。prompt 设计方面,建议明确要求“保留关键数据、结论和实体”,并给出示例。页内容太杂的情况,可以先做页内分段,对每段分别摘要,再合并成页摘要。
避坑技巧:不要用同一个 LLM 既做摘要又做问答。摘要任务和问答任务对模型的要求不同,分开用不同的模型效果更好。摘要可以用小模型批量跑,问答用大模型保证质量。
5.3 多文档场景下的检索混乱
当知识库里有几十上百份文档时,检索容易变得混乱:明明问的是 A 文档的内容,却召回了 B 文档的页。
解决办法是加强文档级粗筛。在页级检索之前,先用文档标题、文档摘要、文档级实体做一次粗筛,把候选文档缩小到 3-5 份,再做页级检索。文档级粗筛可以用 BM25 + 向量混合的方式,准确率比纯向量高。
另外,给每页索引加上文档 ID 和文档标题,检索结果里带上这些信息,方便排查问题。
5.4 性能瓶颈:延迟太高怎么办
PageIndex 的检索链路比纯向量检索长,延迟自然会高一些。优化方向有几个:
- 缓存页摘要和实体:不要每次检索都重新生成,提前算好存起来。
- 向量预计算:页摘要和原文的向量提前算好,检索时直接查。
- 分级检索:先用轻量级索引做粗筛,再用重量级模型做精排。
- 并行处理:多页的相似度计算可以并行,用多线程或向量化计算加速。
实测下来,如果页级索引建得好,检索延迟可以控制在 200-500ms 以内,跟纯向量检索的差距不大。
5.5 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 | 解决建议 |
|---|---|---|---|
| 召回页不相关 | query 解析错误 | 检查结构线索提取 | 调整解析规则 |
| 相关页没召回 | 过滤条件过严 | 检查章节/类型过滤 | 放宽过滤条件 |
| 摘要太泛 | prompt 不具体 | 检查摘要 prompt | 增加关键信息要求 |
| 延迟太高 | 索引未缓存 | 检查缓存命中率 | 预计算并缓存 |
| 多文档混乱 | 文档粗筛缺失 | 检查文档级过滤 | 增加粗筛步骤 |
| 表格内容丢失 | 解析不完整 | 检查表格提取 | 用专门表格解析器 |
6. 一些实战中的个人体会
PageIndex 这个思路,我最初是在处理一批技术规范文档时接触到的。那批文档有 200 多页,章节层级很深,表格特别多。用传统 RAG 做出来的问答系统,回答经常张冠李戴,把第 3 章的内容安到第 5 章头上。后来加了页级索引和章节过滤,准确率提升非常明显。
我的体会是,RAG 的瓶颈往往不在模型,而在检索。大家花很多时间调 prompt、换模型,但检索这一环如果没做好,后面怎么调都是事倍功半。PageIndex 的价值在于它把检索从“纯语义”拉回到了“语义 + 结构”的双通道,这个思路对处理结构化文档特别有效。
另外一点,页级索引的构建成本比想象中低。用本地小模型批量跑摘要和 NER,一台带 24G 显存的机器就能处理几万页文档,成本完全可控。关键是索引结构要设计好,字段要选对,不然后面检索时才发现缺信息就麻烦了。
最后分享一个小技巧:页摘要里一定要保留页码和章节信息。这样检索结果出来之后,你可以直接告诉用户“答案在第 3 章第 12 页”,用户体验会好很多。而且当用户反馈答案不对时,你可以快速定位到具体页去排查,调试效率高很多。