
1. 从向量数据库的困境说起PageIndex 到底想解决什么问题做过 RAG 项目的人大概都有过这种体验文档切了几百上千个 chunk灌进向量数据库用户问一个问题检索出来一堆语义相似但逻辑上根本不挨着的片段然后 LLM 拿着这些碎片拼出一个似是而非的答案。表面上看是“检索增强生成”实际上很多时候是“检索干扰生成”。这个问题的根源不在于 embedding 模型不够好也不在于向量数据库性能不行而在于向量相似度本身就不等于逻辑相关性。PageIndex 这个项目我第一次看到标题的时候直觉告诉我它跟“页面索引”有关但结合 RAG、LLM、SDK、向量数据库这些热搜词来看它显然不是传统意义上的数据库索引。它更像是一种面向 RAG 场景的文档结构化索引方案——在向量检索之前先给文档建立一层“页面级”或“段落级”的逻辑索引让检索不再只依赖语义相似度而是结合文档本身的层级结构来定位信息。说白了PageIndex 想做的事情是在 chunk 和向量之间插入一层结构化的中间表示。这层表示可以是页面的标题层级、段落之间的引用关系、表格与正文的关联、甚至是图片与说明文字的绑定。有了这层索引检索的时候就可以先定位到“哪个页面的哪个章节”再在这个范围内做细粒度的向量匹配而不是一上来就在全量 chunk 里做 top-k 相似度搜索。这个思路其实跟传统搜索引擎的倒排索引有异曲同工之妙。倒排索引解决的是“哪个词出现在哪些文档里”PageIndex 解决的是“哪个概念出现在哪个页面的哪个结构位置”。两者都是通过预处理建立额外的索引结构来加速和优化检索过程。区别在于倒排索引面向的是关键词匹配PageIndex 面向的是语义检索与逻辑结构的结合。适合谁来参考这个内容如果你正在做 RAG 项目尤其是遇到以下问题PageIndex 的思路值得你花时间研究检索结果总是差那么一点意思明明文档里有答案但就是检索不到chunk 切分粒度不好把握切大了噪声多切小了上下文丢失向量数据库选型纠结不知道用 FAISS 还是 Milvus 还是 Qdrant想给 RAG 加一层结构化的检索逻辑但不知道从哪下手。这篇文章会从设计思路、核心细节、实操过程到问题排查完整拆解 PageIndex 这套方案的来龙去脉。2. 核心设计与思路拆解为什么要在向量检索之前加一层页面索引2.1 向量检索的先天缺陷与 PageIndex 的补位逻辑向量检索的本质是在高维空间中找最近邻。你把一段文本通过 embedding 模型映射成一个稠密向量然后计算查询向量与文档向量之间的余弦相似度或内积取 top-k 作为检索结果。这个过程有两个隐含假设第一语义相似的文本在向量空间中距离更近第二查询的语义可以用一个向量完整表达。这两个假设在实际场景中经常不成立。第一个假设的问题在于语义相似不等于逻辑相关。比如用户问“PageIndex 支持哪些文档格式”检索出来的可能是“PageIndex 的安装步骤”里提到了一句“支持 PDF 和 Markdown”也可能是一篇对比文章里说“PageIndex 不像其他工具那样只支持纯文本”。这两段文本跟查询的语义相似度都很高但只有前者是真正回答问题的。第二个假设的问题在于复杂查询往往包含多个语义维度。用户问“PageIndex 在处理表格时怎么保证检索准确性”这里面既有“表格处理”的语义又有“检索准确性”的语义单一向量很难同时捕捉这两个维度。PageIndex 的补位逻辑是在向量检索之前先用文档的结构信息缩小检索范围。具体来说它会给每个文档建立一个页面级的索引树树的节点对应文档的章节、段落、表格、图片等结构单元每个节点上挂载该单元的摘要或关键词。检索的时候先在这棵索引树上做一次粗粒度的定位找到与查询最相关的几个节点然后只在这些节点对应的 chunk 集合里做向量检索。这样既保留了向量检索的语义匹配能力又通过结构索引过滤掉了大量逻辑上不相关的候选。这个思路跟数据库里的“分区裁剪”很像。你有一张大表查询的时候如果不加分区条件数据库就要全表扫描如果按时间或地区做了分区查询时先定位到具体分区再在分区内做细粒度过滤效率会高很多。PageIndex 做的就是给文档建立“语义分区”让向量检索不再在全量 chunk 上盲目搜索。2.2 为什么不用纯图数据库或纯关键词索引有人可能会问既然要加结构索引为什么不直接用图数据库把文档的结构关系存起来或者用 Elasticsearch 做关键词索引这个问题我在实际项目里也纠结过后来想明白了图数据库和关键词索引各有各的适用场景但它们都不能替代向量检索的语义匹配能力。图数据库擅长表达实体之间的显式关系比如“章节 A 包含段落 B”“表格 C 引用了章节 D”。但 RAG 场景下的查询往往是自然语言用户不会按照图的结构来提问。你没法要求用户说“请给我章节 3.2 下第二个表格的内容”用户只会说“PageIndex 怎么处理表格”。这时候图数据库的遍历能力就用不上了还是得靠语义匹配来定位。关键词索引比如 BM25擅长精确匹配用户问“PageIndex SDK 怎么安装”它能准确找到包含“PageIndex”“SDK”“安装”这些词的文档。但用户如果问“PageIndex 的客户端集成方式”文档里写的是“PageIndex 的 SDK 接入指南”关键词匹配就抓瞎了。向量检索能解决这个问题因为“客户端集成”和“SDK 接入”在向量空间里距离很近。PageIndex 的设计哲学是融合而不是替代。它不排斥向量数据库也不排斥关键词索引而是在它们之上加一层结构化的路由层。你可以把它理解成一个“检索调度器”先看查询的类型如果是精确匹配类的查询走关键词索引如果是语义理解类的查询走向量检索如果查询涉及文档结构比如“第三章讲了什么”先走页面索引定位到第三章再在第三章范围内做语义检索。这种融合方案比单一检索方式更鲁棒也更贴近真实用户的查询习惯。2.3 页面索引的粒度选择为什么是“页面”而不是“段落”或“文档”PageIndex 这个名字里的“Page”很关键。为什么索引粒度选在页面级别而不是更细的段落级别或更粗的文档级别这里面有实际的工程考量。如果索引粒度太粗比如整个文档一个索引节点那检索的时候还是要在全量 chunk 里做向量搜索起不到缩小范围的作用。如果索引粒度太细比如每个段落一个索引节点那索引树会变得非常庞大检索时的路由开销可能比直接做向量检索还大。而且段落级别的索引很难表达“这个段落属于哪个章节”“这个表格跟哪段文字相关”这类结构信息。页面级别是一个比较平衡的选择。对于大多数文档来说一页的内容量大概在 500 到 1500 字之间正好是一个语义相对完整的单元。一页通常包含一个或几个段落可能还有表格、图片、代码块这些元素在页面上有明确的空间关系容易建立结构索引。检索的时候先定位到相关页面再在页面内的 chunk 集合里做细粒度匹配既控制了候选集大小又保留了足够的上下文信息。当然页面级别的索引也有它的局限。对于没有明确分页的文档比如纯文本或 Markdown需要先做一次虚拟分页按照标题层级或固定长度来切分。对于跨页的表格或长段落需要在索引时做特殊处理避免把一个完整的语义单元拆散。这些细节在实操部分会展开讲。3. 核心细节解析与实操要点PageIndex 的索引构建与检索流程3.1 文档预处理从原始文件到结构化页面PageIndex 的第一步是把各种格式的文档统一转换成结构化的页面表示。这一步的输入可能是 PDF、Word、Markdown、HTML甚至是扫描件。输出是一个页面列表每个页面包含文本内容、标题层级、表格数据、图片位置等元信息。PDF 的处理是最麻烦的。PDF 本质上是一种排版格式不是语义格式。你从 PDF 里提取出来的文本可能丢失了段落结构表格可能变成一堆乱序的文字标题和正文可能混在一起。我试过用 PyMuPDF 和 pdfplumber 做提取PyMuPDF 速度快但表格处理弱pdfplumber 表格处理好但速度慢。实际项目中我一般用 PyMuPDF 做快速提取遇到表格密集的页面再切换到 pdfplumber 做精细解析。Markdown 和 HTML 的处理相对简单因为它们本身就有结构标记。Markdown 的标题层级可以直接映射成索引树的节点HTML 的 DOM 树也可以直接用来构建页面结构。但要注意的是Markdown 里经常有代码块和表格这些内容在索引时需要单独标记因为它们的检索逻辑跟普通文本不一样。代码块更适合用关键词匹配表格更适合用结构化查询。注意文档预处理阶段一定要保留原始页码或章节编号。很多 RAG 项目在 chunk 切分时把页码信息丢了导致检索结果无法回溯到原文位置。PageIndex 的页面索引依赖页码来做路由如果页码丢失整个索引树就建不起来。3.2 页面索引树的构建节点设计与摘要生成页面索引树是 PageIndex 的核心数据结构。树的根节点代表整个文档第一层子节点代表文档的主要章节第二层子节点代表章节下的页面第三层子节点代表页面内的段落或表格。每个节点上挂载三类信息结构信息页码、标题、层级关系、内容摘要用 LLM 生成的简短摘要、关键词列表从内容中提取的关键实体。摘要生成这一步很关键。你不能直接把页面的全文塞到索引节点上那样索引树会变得巨大无比检索时的路由开销受不了。你需要用 LLM 给每个页面生成一个 50 到 100 字的摘要概括这一页讲了什么。这个摘要的质量直接影响检索路由的准确性。我试过用 GPT-3.5 和 GPT-4 做摘要GPT-4 的摘要更准确但成本高GPT-3.5 的摘要有时候会漏掉关键信息。实际项目中我一般用 GPT-3.5 做初筛对摘要质量要求高的页面再用 GPT-4 重新生成。关键词提取可以用传统的 TF-IDF 或 TextRank也可以用 LLM 来做。传统方法速度快但只能提取显式出现的词LLM 能提取隐含的概念。比如一页讲的是“PageIndex 的 SDK 集成”传统方法可能提取出“SDK”“集成”“PageIndex”LLM 还能提取出“客户端”“API”“接入”这些相关概念。我一般两种方法都用取并集然后去重。索引树的构建过程可以用一个简单的递归算法来实现先解析文档的标题层级建立章节树然后对每个章节按页码切分页面最后对每个页面提取段落和表格生成摘要和关键词。这个过程可以并行化因为不同章节之间没有依赖关系。3.3 检索路由如何根据查询选择索引节点检索路由是 PageIndex 区别于普通 RAG 的关键环节。用户输入一个查询后系统不是直接去向量数据库做相似度搜索而是先做一次路由决策这个查询应该走哪条检索路径路由决策的依据是查询的类型。我一般把查询分成三类事实型查询比如“PageIndex 支持哪些文档格式”、结构型查询比如“第三章第二节讲了什么”、对比型查询比如“PageIndex 和 LangChain 的 RAG 有什么区别”。事实型查询适合走“页面索引定位 向量检索”的路径结构型查询适合走“章节树遍历 页面摘要匹配”的路径对比型查询适合走“多文档索引 跨文档向量检索”的路径。路由决策可以用一个轻量级的分类模型来做也可以用 LLM 做 zero-shot 分类。我试过用 LLM 做路由准确率不错但延迟高后来换成一个微调过的小模型比如 DistilBERT延迟降到了 10ms 以内准确率也能接受。如果你的查询量不大直接用 LLM 做路由也没问题毕竟路由错了顶多是检索效果差一点不会导致系统崩溃。路由到具体节点后系统会在该节点对应的 chunk 集合里做向量检索。这里有一个细节不同节点的 chunk 集合可能有重叠。比如一个表格跨了两页它在两页的索引节点里都会出现。检索的时候要去重否则同一个表格会被检索出来两次。去重可以用 chunk 的哈希值或原文位置来做。3.4 与向量数据库的集成选型与配置要点PageIndex 本身不存储向量它依赖外部的向量数据库来做细粒度检索。向量数据库的选型要考虑几个因素数据量、查询延迟、过滤能力、运维成本。数据量在百万级以下FAISS 是最省事的选择。它就是一个库不需要单独部署服务直接嵌到你的应用里就行。但 FAISS 不支持动态增删改索引建好之后要更新就得重建。如果你的文档库经常更新FAISS 就不太合适。数据量在百万到千万级Qdrant 或 Milvus 是更好的选择。Qdrant 的过滤能力很强支持在向量检索的同时做结构化过滤这跟 PageIndex 的“先路由再检索”思路很契合。Milvus 的生态更成熟支持多种索引类型但部署和运维复杂度也更高。数据量在千万级以上或者对延迟有极致要求可以考虑 Milvus 的分布式部署或者云厂商的向量数据库服务。但说实话大多数 RAG 项目的文档量都在十万级以下用 FAISS 或 Qdrant 就够了没必要上分布式。配置向量数据库的时候有几个参数需要特别注意。向量维度要跟 embedding 模型的输出维度一致比如 OpenAI 的 text-embedding-3-small 是 1536 维BGE-M3 是 1024 维。距离度量一般用余弦相似度因为 embedding 向量通常做了归一化。索引类型方面HNSW 的检索速度快但内存占用高IVF 的内存占用低但需要训练实际选型要看你的硬件条件和延迟要求。提示如果你用的是 Qdrant建议开启 payload 索引把页码、章节号、内容类型文本/表格/代码作为 payload 字段存进去。这样检索的时候可以直接用 filter 条件过滤不用在应用层再做一次筛选。4. 实操过程与核心环节实现从零搭建一个 PageIndex 原型4.1 环境准备与依赖安装我假设你用的是 Python 环境版本 3.10 以上。先建一个虚拟环境避免依赖冲突。python -m venv pageindex-env source pageindex-env/bin/activate # Windows 用 pageindex-env\Scripts\activate核心依赖包括文档解析库PyMuPDF、pdfplumber、python-docx、markdown、向量数据库客户端faiss-cpu 或 qdrant-client、embedding 模型sentence-transformers 或 openai、LLM 客户端openai 或 ollama。如果你用 Ollama 做本地 LLM还需要安装 ollama 的 Python 客户端。pip install pymupdf pdfplumber python-docx markdown faiss-cpu sentence-transformers openai ollama如果你打算用 Qdrant把 faiss-cpu 换成 qdrant-client。如果你用 OpenAI 的 embedding把 sentence-transformers 换成 openai 的 embedding 接口。4.2 文档解析与页面切分先写一个文档解析函数把 PDF 或 Markdown 转换成页面列表。这里以 PDF 为例。import fitz # PyMuPDF def parse_pdf(file_path): doc fitz.open(file_path) pages [] for page_num, page in enumerate(doc): text page.get_text(text) # 提取表格 tables page.find_tables() table_data [table.extract() for table in tables] if tables else [] pages.append({ page_num: page_num 1, text: text, tables: table_data, images: page.get_images() }) return pages这个函数返回的 pages 列表就是 PageIndex 的输入。每个页面包含页码、文本内容、表格数据和图片信息。注意页码从 1 开始方便后续回溯。对于 Markdown 文档解析逻辑不太一样。Markdown 没有物理页码需要按标题层级做虚拟分页。import markdown from bs4 import BeautifulSoup def parse_markdown(file_path): with open(file_path, r, encodingutf-8) as f: md_text f.read() html markdown.markdown(md_text) soup BeautifulSoup(html, html.parser) pages [] current_page {page_num: 1, text: , tables: [], images: []} for element in soup.children: if element.name in [h1, h2]: if current_page[text]: pages.append(current_page) current_page {page_num: len(pages) 1, text: element.get_text(), tables: [], images: []} elif element.name table: current_page[tables].append(element.get_text()) else: current_page[text] element.get_text() \n if current_page[text]: pages.append(current_page) return pages这个逻辑是按一级和二级标题做分页每个标题下的内容归到同一页。如果你的文档标题层级更深可以调整判断条件。4.3 页面摘要与关键词生成有了页面列表之后下一步是给每个页面生成摘要和关键词。这里用 LLM 来做。from openai import OpenAI client OpenAI(api_keyyour-api-key) def generate_summary(page_text, max_length100): prompt f请用不超过{max_length}字概括以下内容保留关键信息\n\n{page_text[:2000]} response client.chat.completions.create( modelgpt-3.5-turbo, messages[{role: user, content: prompt}], temperature0.3 ) return response.choices[0].message.content.strip() def extract_keywords(page_text, top_k10): prompt f从以下内容中提取{top_k}个最重要的关键词用逗号分隔\n\n{page_text[:2000]} response client.chat.completions.create( modelgpt-3.5-turbo, messages[{role: user, content: prompt}], temperature0.1 ) keywords response.choices[0].message.content.strip().split() return [kw.strip() for kw in keywords if kw.strip()]如果你用 Ollama 做本地 LLM把 client 换成 ollama 的调用方式就行。摘要生成的质量直接影响后续检索路由的准确性所以这一步不要省。我试过用规则方法做摘要比如取前 100 字效果很差因为文档的开头往往是背景介绍不是核心内容。4.4 索引树构建与向量库写入摘要和关键词生成之后就可以构建索引树了。索引树可以用嵌套字典来表示也可以用专门的树结构库。我这里用嵌套字典简单直观。def build_index_tree(pages): tree {root: {children: [], summary: , keywords: []}} for page in pages: summary generate_summary(page[text]) keywords extract_keywords(page[text]) node { page_num: page[page_num], summary: summary, keywords: keywords, text: page[text], tables: page[tables] } tree[root][children].append(node) return tree这个索引树是扁平的所有页面都是根节点的直接子节点。如果你的文档有明确的章节结构可以按章节再分一层让树更有层次。但扁平结构在检索时更简单路由决策只需要在页面级别做一次匹配。接下来把页面内容切分成 chunk写入向量数据库。chunk 的切分粒度建议在 300 到 500 字之间太短会丢失上下文太长会引入噪声。from sentence_transformers import SentenceTransformer import faiss import numpy as np model SentenceTransformer(BAAI/bge-m3) def chunk_text(text, chunk_size400, overlap50): chunks [] start 0 while start len(text): end start chunk_size chunks.append(text[start:end]) start end - overlap return chunks def build_vector_index(pages): all_chunks [] metadata [] for page in pages: chunks chunk_text(page[text]) for chunk in chunks: all_chunks.append(chunk) metadata.append({page_num: page[page_num], chunk_text: chunk}) embeddings model.encode(all_chunks, normalize_embeddingsTrue) index faiss.IndexFlatIP(embeddings.shape[1]) index.add(embeddings.astype(np.float32)) return index, metadata这里用的是 FAISS 的 IndexFlatIP内积索引因为 embedding 做了归一化内积等价于余弦相似度。如果你的数据量很大可以换成 IndexIVFFlat 来加速检索。4.5 检索流程实现路由、检索与重排检索流程分三步路由决策、向量检索、结果重排。def route_query(query, index_tree): # 用 LLM 做路由决策 page_summaries \n.join([ f第{node[page_num]}页{node[summary]} for node in index_tree[root][children] ]) prompt f根据以下页面摘要判断查询最可能涉及哪几页最多3页只返回页码用逗号分隔\n\n查询{query}\n\n页面摘要\n{page_summaries} response client.chat.completions.create( modelgpt-3.5-turbo, messages[{role: user, content: prompt}], temperature0.1 ) page_nums [int(x.strip()) for x in response.choices[0].message.content.split(,) if x.strip().isdigit()] return page_nums def retrieve(query, index, metadata, index_tree, top_k5): # 路由 target_pages route_query(query, index_tree) # 向量检索 query_embedding model.encode([query], normalize_embeddingsTrue) distances, indices index.search(query_embedding.astype(np.float32), top_k * 3) # 过滤只保留目标页面的结果 results [] for dist, idx in zip(distances[0], indices[0]): if metadata[idx][page_num] in target_pages: results.append({ page_num: metadata[idx][page_num], text: metadata[idx][chunk_text], score: float(dist) }) if len(results) top_k: break return results这个实现里路由决策用 LLM 来做向量检索用 FAISS最后按页码过滤。实际项目中你还可以加一个重排步骤用 cross-encoder 对检索结果做精排进一步提升准确率。4.6 与 LLM 的集成生成最终答案检索到相关 chunk 之后把它们拼成上下文交给 LLM 生成答案。def generate_answer(query, retrieved_chunks): context \n\n.join([ f[第{r[page_num]}页] {r[text]} for r in retrieved_chunks ]) prompt f根据以下上下文回答问题。如果上下文不包含答案请说根据现有信息无法回答。\n\n上下文\n{context}\n\n问题{query} response client.chat.completions.create( modelgpt-4, messages[{role: user, content: prompt}], temperature0.2 ) return response.choices[0].message.content这个 prompt 里加了“如果上下文不包含答案”的约束是为了减少 LLM 的幻觉。实际测试下来这个约束能显著降低编造答案的概率但也会让一些本来能回答的问题变成“无法回答”。你可以根据业务场景调整这个约束的严格程度。5. 常见问题与排查技巧实录5.1 检索结果不准确路由错了还是向量检索错了这是最常见的问题。用户问了一个问题检索出来的内容完全不相关。排查的时候先看路由决策对不对。把路由返回的页码打印出来人工判断一下这些页面是否真的跟查询相关。如果路由就错了那问题出在页面摘要或路由 prompt 上。页面摘要太笼统、路由 prompt 不够明确、LLM 理解偏差都可能导致路由错误。如果路由是对的但向量检索出来的 chunk 不相关那问题可能出在 embedding 模型上。不同的 embedding 模型在不同领域的表现差异很大。通用领域的模型在专业文档上可能表现不佳这时候需要换一个在目标领域微调过的模型或者用领域数据做一次微调。还有一个容易被忽略的问题chunk 切分粒度。如果 chunk 太大一个 chunk 里包含多个主题向量检索时可能匹配到不相关的部分。如果 chunk 太小上下文丢失检索出来的片段可能无法回答完整问题。我一般建议 chunk 大小在 300 到 500 字之间overlap 在 50 到 100 字之间具体数值要根据文档类型和查询特点来调。5.2 索引构建太慢LLM 调用是瓶颈PageIndex 的索引构建需要给每个页面生成摘要和关键词如果文档有几百页LLM 调用次数就是几百次耗时可能达到几十分钟。优化方法有几个并行调用用 asyncio 或线程池同时处理多个页面批量调用把多个页面的摘要生成合并到一个 prompt 里缓存结果同一个页面不要重复生成摘要。我试过用 asyncio 做并行调用把 100 页的索引构建时间从 15 分钟降到了 2 分钟。但要注意 OpenAI 的 rate limit并发数太高会被限流。一般建议并发数控制在 5 到 10 之间。如果你用本地 LLM比如 Ollama瓶颈可能在 GPU 显存上。7B 参数的模型在 8GB 显存的显卡上跑并发数只能开到 2 到 3。这时候可以考虑用量化模型比如 4-bit 量化显存占用能降到 4GB 左右并发数可以翻倍。5.3 表格和图片检索效果差结构化内容的特殊处理表格和图片是 RAG 里的老大难问题。表格的文本提取出来往往是一堆乱序的数字和文字向量检索很难匹配。图片更是完全无法用文本 embedding 来检索。对于表格我的做法是把表格转换成自然语言描述再生成 embedding。比如一个三列的表格可以转换成“表格包含三列名称、描述、示例。第一行是……第二行是……”。这样转换之后表格的语义信息就保留下来了向量检索也能匹配到。对于图片如果图片里有文字可以用 OCR 提取出来跟图片的说明文字一起做 embedding。如果图片是纯图形比如流程图、架构图可以用多模态模型比如 GPT-4V生成图片描述再用描述文本做 embedding。PageIndex 的索引树里可以给图片单独建一个节点挂载图片描述和 OCR 文本。注意表格和图片的检索结果在生成答案时最好把原始表格或图片也返回给用户而不是只返回文本描述。用户看到原始表格能更快理解信息文本描述只是用来做检索匹配的。5.4 多文档场景下的索引管理实际项目中RAG 系统往往要处理多个文档而不是单个文档。多文档场景下PageIndex 的索引树需要做一层扩展根节点下面先按文档分叉每个文档下面再按页面分叉。检索的时候路由决策要先判断查询涉及哪个文档再在文档内部做页面路由。如果查询涉及多个文档比如对比型查询就需要在多个文档的索引树上分别检索然后合并结果。多文档场景下的一个常见问题是文档间的重复内容。比如两个文档都引用了同一段标准条款检索的时候可能同时命中两个文档的相同内容。这时候需要在结果合并阶段做去重可以用文本哈希或语义相似度来判断重复。5.5 常见问题速查表问题现象可能原因排查方法解决方案检索结果完全不相关路由决策错误打印路由返回的页码人工判断优化页面摘要质量调整路由 prompt检索结果部分相关chunk 切分粒度不当检查 chunk 大小和 overlap调整 chunk_size 到 300-500 字表格内容检索不到表格未做结构化处理检查表格是否被正确提取将表格转成自然语言描述再 embedding索引构建太慢LLM 调用串行执行统计 LLM 调用次数和耗时用 asyncio 并行调用加缓存多文档检索结果重复文档间有重复内容检查检索结果中的重复片段用文本哈希或语义相似度去重答案包含幻觉LLM 未加约束检查 prompt 是否有“无法回答”约束在 prompt 中明确要求基于上下文回答5.6 几个踩过的坑和实操心得第一个坑是页码从 0 开始还是从 1 开始。PyMuPDF 的 page_num 是从 0 开始的但用户看到的页码是从 1 开始的。我在索引构建时忘了加 1导致检索结果回溯到原文时总是差一页。这个 bug 找了半天才定位到后来在 parse_pdf 里统一加了 1。第二个坑是中文文档的 chunk 切分。英文文档按空格切分就行中文文档没有空格按字符数切分可能把一个词切成两半。我后来用 jieba 做分词按词边界切分效果好很多。如果你不想引入 jieba也可以按标点符号切分但效果不如按词切分。第三个坑是LLM 摘要的稳定性。同样的页面内容LLM 每次生成的摘要可能不一样导致索引树不稳定。我后来把 temperature 设成 0并且把摘要结果缓存起来同一个页面只生成一次摘要。这样索引树就是确定性的方便调试和复现。第四个坑是向量数据库的维度匹配。我一开始用 BGE-M3 生成 embedding维度是 1024后来换成 OpenAI 的 text-embedding-3-small维度是 1536忘了改 FAISS 索引的维度导致检索时报错。这个错误很明显但如果你同时用多个 embedding 模型就容易搞混。建议在代码里把 embedding 维度和模型名称绑定在一起避免手动配置出错。6. 扩展方向PageIndex 还能怎么玩PageIndex 这套思路不局限于 RAG 场景。我在实际项目中还把它用在了文档问答系统和知识库管理上。文档问答系统里PageIndex 的路由层可以显著减少 LLM 的上下文长度降低推理成本。知识库管理里PageIndex 的索引树可以可视化展示文档结构帮助管理员发现内容缺口和重复。另一个扩展方向是增量索引。现在的实现是全量构建索引文档更新后需要重建整个索引树。如果文档更新频繁全量重建的成本太高。增量索引的思路是只对变化的页面重新生成摘要和 embedding然后更新索引树和向量库。FAISS 不支持增量更新但 Qdrant 和 Milvus 支持所以如果你需要增量索引建议用 Qdrant 或 Milvus。还有一个有意思的方向是多模态 PageIndex。现在的实现主要处理文本和表格图片只是简单提取。如果引入多模态 embedding 模型比如 CLIP可以把图片也映射到向量空间实现文本和图片的统一检索。用户问“PageIndex 的架构图长什么样”系统可以直接检索到架构图并返回。这个方向我还在探索中目前的效果还不够稳定但值得持续关注。最后分享一个小技巧索引树的节点摘要可以用查询日志来优化。你把用户实际问过的问题收集起来看哪些页面的摘要经常被路由到但实际不相关然后针对性优化这些页面的摘要。这个反馈循环能让索引树越来越准比一次性构建的效果好很多。