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', encoding='utf-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_key="your-api-key") def generate_summary(page_text, max_length=100): prompt = f"请用不超过{max_length}字概括以下内容,保留关键信息:\n\n{page_text[:2000]}" response = client.chat.completions.create( model="gpt-3.5-turbo", messages=[{"role": "user", "content": prompt}], temperature=0.3 ) return response.choices[0].message.content.strip() def extract_keywords(page_text, top_k=10): prompt = f"从以下内容中提取{top_k}个最重要的关键词,用逗号分隔:\n\n{page_text[:2000]}" response = client.chat.completions.create( model="gpt-3.5-turbo", messages=[{"role": "user", "content": prompt}], temperature=0.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_size=400, overlap=50): 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_embeddings=True) 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( model="gpt-3.5-turbo", messages=[{"role": "user", "content": prompt}], temperature=0.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_k=5): # 路由 target_pages = route_query(query, index_tree) # 向量检索 query_embedding = model.encode([query], normalize_embeddings=True) 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( model="gpt-4", messages=[{"role": "user", "content": prompt}], temperature=0.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 的架构图长什么样”,系统可以直接检索到架构图并返回。这个方向我还在探索中,目前的效果还不够稳定,但值得持续关注。
最后分享一个小技巧:索引树的节点摘要可以用查询日志来优化。你把用户实际问过的问题收集起来,看哪些页面的摘要经常被路由到但实际不相关,然后针对性优化这些页面的摘要。这个反馈循环能让索引树越来越准,比一次性构建的效果好很多。