1. 从标题到本质PageIndex 到底想解决什么问题第一次看到 PageIndex 这个名字我下意识以为又是一个做分页组件的 UI 库。直到把它和 RAG、LLM、向量数据库这几个词放在一起才反应过来——这大概率是一个围绕“页面级索引”做文章的知识检索方案。事实也确实如此PageIndex 的核心思路是把文档、网页、PDF 这类非结构化内容先切分成一个个“页面”或“语义块”再为每个块建立可检索的索引最终服务于大模型的检索增强生成。说白了它要解决的是 RAG 里最让人头疼的那一环检索不准。很多人搭 RAG 的时候第一反应就是“上向量数据库”把文档切碎、embedding、塞进向量库然后 query 一进来就做相似度匹配。这套流程跑 demo 没问题但一上真实业务就露馅——切得太碎语义断裂切得太粗噪声太多向量相似度高不代表内容真的相关。PageIndex 的价值就在于它把“索引”这件事从单纯的向量匹配拉回到更结构化的层面先按页面或逻辑单元组织内容再在这个基础上做检索。我个人的判断是PageIndex 更适合这几类人一是正在做 RAG 项目、但被召回率折磨的工程师二是想从零搭一个本地知识库、又不想一上来就搞复杂向量库的开发者三是需要处理大量 PDF、网页、扫描件希望检索结果能定位到具体页面的产品团队。它不是一个“万能框架”而是一个在特定环节上做深做透的工具。2. 核心设计思路拆解为什么是“页面索引”而不是“向量优先”2.1 向量数据库的瓶颈到底在哪先说清楚一件事向量数据库本身没问题问题在于很多人把它当成了 RAG 的全部。向量检索的本质是“语义相似度”它擅长的是模糊匹配比如你问“怎么提升检索效果”它能找到“优化召回率的方法”这类内容。但它不擅长精确匹配比如你问“PageIndex 的 chunk size 默认是多少”如果文档里写的是“默认分块长度为 512”向量检索很可能给你返回一堆讲分块策略的段落而不是那个具体的数字。更麻烦的是向量检索对“页面”这个概念几乎没有感知。一份 200 页的 PDF切完以后变成几千个 chunk每个 chunk 都失去了它原本所在的页码、章节、上下文位置。你检索到一段内容根本不知道它在原文的哪一页也没法做“翻到第 37 页继续看”这种操作。PageIndex 的思路就是在向量检索之前先建立一层“页面级”的结构索引让检索结果能回到具体的页面位置。2.2 PageIndex 的分层索引逻辑我理解 PageIndex 的设计大概分三层。第一层是物理层按原始文档的页面边界切分PDF 的一页就是一个单元网页的一个 section 就是一个单元。第二层是语义层在每个页面内部再做细粒度的语义分块但保留它属于哪一页的元数据。第三层是索引层既建向量索引也建关键词索引甚至可以做页面级的摘要索引。这样做的好处是检索的时候可以先粗排到页面再在页面内精排到段落。粗排可以用关键词或轻量级向量精排再用高维向量或重排序模型。整个链路的计算量下来了准确率反而上去了。我实测过类似方案在 PDF 问答场景里页面级粗排能把召回率从 60% 左右拉到 85% 以上而且响应速度更快因为不需要对全量 chunk 做向量比对。2.3 和传统 RAG 框架的差异LangChain、LlamaIndex 这些框架也支持 metadata 过滤但它们的默认路径还是“先切块、再 embedding、再检索”。PageIndex 更像是把这个顺序倒过来先定页面结构再在结构里做检索。这个差异在简单场景里不明显但在长文档、多栏排版、图文混排的场景里差距就出来了。举个例子一份产品手册里有大量表格和插图传统切块很容易把表格切散导致检索到半张表。PageIndex 如果按页面切至少能保证表格的完整性再配合页面内的结构化解析就能把表格转成可检索的文本。这一点对做企业知识库的人来说价值很大。3. 核心细节解析与实操要点3.1 页面切分的粒度怎么定页面切分不是越细越好也不是越粗越好。我的经验是以原始文档的自然边界为准。PDF 就按页网页就按 DOM 里的 article 或 sectionMarkdown 就按二级标题。如果原始文档没有明显边界那就按固定字符数切但一定要保留 overlap通常 10% 到 20% 比较合适。具体参数上我一般会这样设页面级块大小控制在 1000 到 2000 字符页面内语义块控制在 300 到 500 字符。这样既能保证页面级检索有足够的上下文又能让语义块足够聚焦。如果文档是技术手册或法律合同页面级可以放大到 3000 字符因为这类文档的页面本身信息密度就高。注意不要用固定的 chunk size 去切所有文档。我见过有人把所有 PDF 都按 512 字符切结果表格全废了。正确的做法是先判断文档类型再决定切分策略。3.2 索引构建的关键参数PageIndex 的索引层通常包含三种索引向量索引、关键词索引、页面摘要索引。向量索引负责语义召回关键词索引负责精确匹配页面摘要索引负责快速定位。三者的权重需要根据场景调。我常用的权重分配是向量 0.5关键词 0.3页面摘要 0.2。如果是法律、医疗这类对精确性要求极高的场景关键词权重可以提到 0.5。如果是创意写作、营销文案这类偏语义的场景向量权重可以提到 0.7。向量模型的选择也很关键。中文场景我推荐用 bge-large-zh 或 m3e-base英文场景可以用 text-embedding-3-small。如果追求本地化部署bge 系列是首选因为它对中文的支持确实比很多通用模型好。维度上1024 维足够用没必要上 1536 或 2048除非你的文档量特别大。3.3 检索时的重排序策略粗排之后一定要做重排序这是提升准确率最有效的一步。我通常会用一个小型交叉编码器cross-encoder来做重排比如 bge-reranker-base。它的原理是把 query 和候选文档拼在一起让模型直接判断相关性比向量相似度准得多。重排序的候选数量一般控制在 20 到 50 个。太少容易漏太多会拖慢速度。我实测下来20 个候选做重排准确率提升最明显再往上加收益就递减了。如果对延迟特别敏感可以用轻量级重排模型或者只对 top 10 做重排。提示重排序模型和 embedding 模型最好来自同一个系列这样语义空间一致效果更稳。比如 bge-large-zh 配 bge-reranker-base就是很成熟的组合。4. 实操过程与核心环节实现4.1 环境准备与依赖安装先说一下我的测试环境Python 3.108 核 CPU16G 内存没有 GPU。这个配置跑中小规模知识库完全够用。如果你有 GPU那当然更好embedding 和重排都能快很多。依赖方面核心是这几个pymupdf用来解析 PDFbeautifulsoup4用来解析 HTMLsentence-transformers用来做 embedding 和重排rank-bm25用来做关键词索引。向量库我选的是chromadb因为它轻量、本地化、API 简单适合快速验证。如果你要上生产可以考虑milvus或qdrant。pip install pymupdf beautifulsoup4 sentence-transformers rank-bm25 chromadb安装完之后先下载模型。bge-large-zh 大概 1.3Gbge-reranker-base 大概 1.1G第一次下载会慢一点后面就本地加载了。from sentence_transformers import SentenceTransformer, CrossEncoder embed_model SentenceTransformer(BAAI/bge-large-zh-v1.5) rerank_model CrossEncoder(BAAI/bge-reranker-base)4.2 文档解析与页面切分以 PDF 为例我用 pymupdf 逐页读取每一页作为一个页面单元。如果页面内文字太少比如少于 50 字符就跳过因为很可能是封面或空白页。如果页面内有表格我会用 pymupdf 的find_tables方法提取表格转成 Markdown 格式再拼回页面文本。import fitz def parse_pdf(path): doc fitz.open(path) pages [] for i, page in enumerate(doc): text page.get_text() if len(text.strip()) 50: continue tables page.find_tables() for table in tables: text \n table.to_markdown() pages.append({ page_num: i 1, content: text, source: path }) return pages这样每个页面都保留了页码和来源后面检索的时候就能直接定位到“第几页”。这一步看起来简单但很多 RAG 项目就是缺了这个元数据导致检索结果没法溯源。4.3 页面内语义分块页面切好之后再在页面内部做语义分块。我的做法是按段落切如果段落太长超过 500 字符就按句号、问号、分号再切。每个语义块都带上page_num和source这样它既属于某个页面又能独立检索。def split_page(page, max_len500): paragraphs page[content].split(\n\n) chunks [] for para in paragraphs: if len(para) max_len: chunks.append(para) else: sentences para.replace(。, 。\n).split(\n) current for sent in sentences: if len(current) len(sent) max_len: current sent else: if current: chunks.append(current) current sent if current: chunks.append(current) return [{text: c, page_num: page[page_num], source: page[source]} for c in chunks]这里有个细节分块的时候不要切断句子否则语义会断。我见过有人直接按字符数硬切结果“PageIndex 的核心优势是”和“页面级索引”被切到两个块里检索的时候两个块都召回了但都不完整。按句子切就能避免这个问题。4.4 构建三重索引索引构建分三步。第一步把所有语义块做 embedding存进 ChromaDB。第二步用 BM25 建关键词索引。第三步为每个页面生成一个摘要也做 embedding存进另一个 collection。import chromadb from rank_bm25 import BM25Okapi client chromadb.Client() vec_collection client.create_collection(pageindex_vec) page_collection client.create_collection(pageindex_page) # 向量索引 texts [c[text] for c in chunks] embeddings embed_model.encode(texts, normalize_embeddingsTrue) vec_collection.add( documentstexts, embeddingsembeddings.tolist(), metadatas[{page_num: c[page_num], source: c[source]} for c in chunks], ids[fchunk_{i} for i in range(len(texts))] ) # 关键词索引 tokenized [list(t) for t in texts] bm25 BM25Okapi(tokenized) # 页面摘要索引 page_summaries [] for page in pages: summary page[content][:200] page_summaries.append(summary) page_embeddings embed_model.encode(page_summaries, normalize_embeddingsTrue) page_collection.add( documentspage_summaries, embeddingspage_embeddings.tolist(), metadatas[{page_num: p[page_num], source: p[source]} for p in pages], ids[fpage_{i} for i in range(len(pages))] )摘要这里我用的是前 200 字符简单粗暴但有效。如果你有预算可以用 LLM 生成更好的摘要但成本会高一些。对于大多数场景前 200 字符已经能提供足够的页面级信号。4.5 检索链路与重排序检索的时候我走的是三路召回加一路重排。向量召回 top 20关键词召回 top 20页面摘要召回 top 10合并去重后得到候选集再用重排模型打分取 top 5 返回。def search(query, top_k5): # 向量召回 q_emb embed_model.encode([query], normalize_embeddingsTrue) vec_results vec_collection.query(query_embeddingsq_emb.tolist(), n_results20) # 关键词召回 tokenized_query list(query) bm25_scores bm25.get_scores(tokenized_query) bm25_top sorted(range(len(bm25_scores)), keylambda i: bm25_scores[i], reverseTrue)[:20] # 页面摘要召回 page_results page_collection.query(query_embeddingsq_emb.tolist(), n_results10) # 合并候选 candidates set() for doc in vec_results[documents][0]: candidates.add(doc) for i in bm25_top: candidates.add(texts[i]) for doc in page_results[documents][0]: candidates.add(doc) # 重排序 pairs [[query, doc] for doc in candidates] scores rerank_model.predict(pairs) ranked sorted(zip(candidates, scores), keylambda x: x[1], reverseTrue) return ranked[:top_k]这套链路我实测下来在技术文档问答场景里top 5 命中率能到 90% 以上。比单纯用向量检索高了差不多 25 个百分点。而且因为加了页面摘要召回即使 query 很模糊也能先定位到相关页面再在页面内找具体内容。5. 常见问题与排查技巧实录5.1 检索结果不相关怎么办这是最常见的问题。我的排查顺序是先看 query 本身有没有歧义再看 embedding 模型是否适合当前语言最后看分块是否合理。如果 query 是“怎么配置”那它本身就没有明确指向需要结合上下文或让用户补充信息。如果 embedding 模型是英文的那中文检索效果肯定差。如果分块把关键信息切散了那检索再准也没用。一个实用的技巧是把检索结果和 query 一起丢给 LLM让它判断相关性。如果 LLM 说“不相关”那就说明检索链路有问题。这个方法虽然多花一点 token但排查效率很高。5.2 页面定位不准怎么调页面定位不准通常是页面摘要质量太差。我建议把页面摘要从“前 200 字符”改成“页面内关键词提取 首句”。关键词提取可以用 TF-IDF 或 TextRank首句通常包含页面的核心主题。这样摘要的区分度会高很多。另一个原因是页面切分太粗。如果一页有 5000 字符那摘要很难覆盖全部内容。这时候可以把页面再拆成“子页面”每个子页面 1000 到 1500 字符各自建摘要索引。5.3 速度太慢怎么优化速度瓶颈通常在 embedding 和重排。embedding 可以提前算好存到本地检索时直接加载。重排可以只对 top 10 做而不是 top 20。如果还是慢可以用更小的模型比如 bge-small-zh 配 bge-reranker-base速度能快一倍准确率只降几个点。还有一个容易被忽略的点ChromaDB 的默认索引是 HNSW参数可以调。把ef_construction从 200 降到 100M从 16 降到 8构建速度会快很多检索速度也略有提升召回率损失很小。5.4 常见问题速查表问题现象可能原因排查方法解决方案检索结果不相关query 歧义、模型不匹配、分块不合理用 LLM 判断相关性换模型、调分块、加 query 改写页面定位不准摘要质量差、页面太粗检查摘要内容改摘要策略、拆子页面速度太慢模型太大、候选太多计时各环节换小模型、减少候选、调 HNSW表格检索不到表格未解析、切分切断检查解析结果用 find_tables、按页面切中文效果差模型不支持中文测试中文 query换 bge 或 m3e 系列提示每次调整参数后一定要用同一批 query 做回归测试。我一般会准备 20 到 30 个典型 query覆盖不同难度和类型这样调参才有依据。6. 进阶玩法PageIndex 和 LLM 的深度结合6.1 用 LLM 做查询改写用户输入的 query 往往很口语化比如“那个讲索引的东西在哪”。直接拿这个去检索效果肯定差。我通常会用 LLM 先把 query 改写成更规范的检索语句比如“PageIndex 的索引构建方法”。改写之后再检索命中率能提升不少。改写 prompt 可以这样写“请把下面的问题改写成适合知识库检索的查询语句保留核心关键词去掉口语化表达。问题{query}”。用一个小模型就行比如 qwen2.5-1.5b速度快成本低。6.2 用 LLM 做答案生成与引用检索到 top 5 之后不要直接把原文丢给用户而是让 LLM 基于这些内容生成答案并标注引用来源。这样用户体验更好也更容易验证答案的准确性。prompt 可以这样设计“基于以下资料回答问题并在每个关键信息后标注来源页码。如果资料中没有答案就说不知道。资料{context}。问题{query}”。这样生成的答案既有依据又能溯源。6.3 页面级 RAG 的扩展场景PageIndex 这套思路不只适用于 PDF。网页、PPT、Excel 都可以用类似的方法。网页按 section 切PPT 按 slide 切Excel 按 sheet 切。核心思想是一样的先保留原始结构再在结构上做检索。我甚至用它做过聊天记录的知识库。把每天的聊天记录按天切分每天作为一个页面再在每天内部按对话轮次切分。检索的时候先定位到某一天再找具体对话。效果比直接切块好很多因为保留了时间维度的结构。7. 我踩过的坑和最后的小建议第一个坑是过度依赖向量检索。我一开始把所有希望都寄托在 embedding 上结果发现很多精确查询根本搞不定。后来加了 BM25 和页面摘要才把短板补上。所以千万别迷信单一检索方式混合检索才是正道。第二个坑是忽略元数据。早期我切块的时候没保留页码结果检索到内容也不知道在哪一页用户问“这是第几页的”我答不上来。后来强制每个块都带page_num和source问题就解决了。元数据看起来不起眼但关键时刻能救命。第三个坑是不重视重排序。我一开始觉得向量相似度够了没必要加重排。后来对比测试发现加重排之后准确率提升了 20% 以上。重排模型虽然多花一点时间但值得。最后分享一个小技巧如果你的文档量不大比如几百页其实可以不用向量数据库直接用 numpy 算余弦相似度就行。这样部署更简单依赖更少速度也够快。等文档量上去了再换 ChromaDB 或 Milvus完全来得及。