1. 从PageIndex这个名字说起:它到底想解决什么问题
第一次看到PageIndex这个词,我下意识把它和数据库索引联系到了一起。毕竟做后端的人对index这个词太敏感了,脑子里第一反应就是B+树、倒排索引、哈希索引这些东西。但结合RAG、LLM、向量数据库这几个热搜词一起看,事情就变得有意思了——PageIndex大概率不是传统意义上的数据库索引,而是面向RAG检索增强场景的一种页面级或文档级的结构化索引方案。
我先把结论摆在前面:PageIndex要解决的核心问题,是传统RAG在文档检索环节的粒度失控和上下文断裂。做过RAG项目的人都知道,最让人头疼的不是模型不够强,而是检索回来的内容要么太碎、要么太散、要么答非所问。你问一个跨章节的问题,向量数据库给你返回五个不相关的段落,每个段落单独看都有点关系,拼在一起就是答不出完整答案。PageIndex这类方案的思路,就是在向量检索之前或之上,加一层结构化的页面索引,让检索有章可循,而不是全靠语义相似度碰运气。
这篇文章适合谁看?如果你正在做RAG项目,被检索命中率折磨过,或者你是个刚接触LLM应用开发的工程师,想搞清楚RAG检索这一层到底有多少坑,那这篇内容应该能帮你省下不少试错时间。我会从设计思路、核心细节、实操过程、问题排查几个维度,把PageIndex这类方案拆开讲透,尽量做到你看完就能动手复现。
2. 内容整体设计与思路拆解
2.1 为什么传统RAG的检索层需要PageIndex
传统RAG的典型流程是这样的:文档切块、向量化、存入向量数据库、用户提问时做相似度检索、把Top-K结果塞给LLM生成答案。这个流程看起来没什么问题,但实际跑起来你会发现几个致命伤。
第一个问题是切块粒度。你按512个token切,遇到一个跨页的表格就废了,表格的前半截在一个块里,后半截在另一个块里,检索出来的是残缺信息。你按段落切,遇到长段落又超长,截断之后语义不完整。第二个问题是上下文丢失。一个段落被检索出来,但它所在的章节标题、前后文关系全丢了,LLM拿到这个段落就像拿到一张撕下来的书页,不知道它在整本书里的位置。第三个问题是检索噪声。向量相似度高的段落不一定真的相关,有时候只是用词相似,语义上差得很远。
PageIndex的思路就是在切块和检索之间插入一层页面级或文档级的结构化索引。它不替代向量数据库,而是在向量检索的基础上增加结构约束。你可以把它理解成给每个文本块打上一个位置标签,这个标签记录了它属于哪个文档、哪个章节、哪个页面、在页面中的什么位置。检索的时候,先通过结构索引缩小范围,再在范围内做向量匹配,命中率和相关性都会明显提升。
2.2 PageIndex与向量数据库的关系:互补而非替代
很多人一听到索引就以为要换掉向量数据库,其实不是。PageIndex和向量数据库是互补关系。向量数据库负责语义层面的相似度计算,PageIndex负责结构层面的定位和过滤。两者结合的方式通常有两种。
第一种是前置过滤。用户提问后,先通过PageIndex的结构信息确定候选文档范围,比如只检索某个章节或某几个页面的内容,然后再在这个子集里做向量检索。这种方式适合文档结构清晰、章节划分明确的场景,比如技术手册、法律合同、学术论文。第二种是后置重排。先做全量向量检索拿到Top-K结果,然后用PageIndex的结构信息对结果做重排,把同一章节或同一页面的结果聚在一起,提升上下文的连贯性。这种方式适合文档结构不那么规整的场景,比如会议记录、聊天日志、网页抓取内容。
我个人的经验是,对于结构规整的长文档,前置过滤的效果更好,因为它在检索之前就砍掉了大量无关内容,减少了向量检索的计算量和噪声。对于结构松散的短文档集合,后置重排更实用,因为它不会因为结构索引不准确而漏掉关键内容。
2.3 方案选型背后的考量:为什么不是简单的元数据过滤
你可能会问,这不就是给每个块加个元数据字段然后做过滤吗?有什么新鲜的?确实,基础的元数据过滤谁都会做,但PageIndex要解决的是更细粒度的问题。
普通的元数据过滤只能做到文档级或章节级,比如“只检索文档A”或“只检索第三章”。但PageIndex要做到页面级甚至段落级的位置索引,它需要记录的信息包括:文档ID、章节路径、页码、在页面中的起止位置、与前后块的关系。这些信息不是简单加个字段就能搞定的,它需要在文档解析阶段就做好结构提取,在切块阶段做好位置映射,在检索阶段做好结构感知的重排。
另一个考量是动态性。文档不是一成不变的,今天加一页,明天删一段,PageIndex需要能够增量更新,而不是每次全量重建。这就要求索引结构本身是可扩展的,位置信息是可调整的。我见过一些团队用静态的JSON文件存页面索引,文档一更新就全量重跑,效率极低。更好的做法是把页面索引也做成可增量的结构,比如用轻量级数据库存位置映射,更新时只改动受影响的部分。
3. 核心细节解析与实操要点
3.1 文档解析阶段:如何提取可靠的页面结构
PageIndex的第一步是文档解析。这一步的目标是把原始文档(PDF、Word、HTML等)转换成带结构信息的文本块。很多人这一步做得太粗糙,直接拿个PDF解析库把文字抽出来就完事,结果页码丢了、章节标题丢了、表格结构丢了,后面做PageIndex就无从谈起。
我的做法是分三层解析。第一层是物理层,提取页码、页面尺寸、文字块在页面中的坐标。第二层是逻辑层,识别章节标题、段落边界、列表结构、表格区域。第三层是语义层,判断每个块的类型(正文、标题、表格、图注、脚注)。这三层信息合在一起,才能构成一个完整的页面索引。
具体到工具选型,PDF解析我常用PyMuPDF(也就是fitz)做物理层提取,它能把每个文字块的坐标和页码都拿到。逻辑层用规则加轻量模型结合的方式,比如用正则匹配章节编号,用字体大小和加粗判断标题层级。语义层可以用一个小型的文本分类模型,或者干脆用规则先跑一版,后面再逐步优化。
注意:PDF解析有个大坑,扫描版PDF和文字版PDF的处理方式完全不同。扫描版需要先做OCR,OCR的坐标信息往往不准,这时候页面索引的精度会下降。如果文档以扫描版为主,建议在PageIndex之前先做一轮OCR质量检查,把识别置信度低的页面标记出来,检索时降低这些页面的权重。
3.2 切块策略:页面索引如何影响切块粒度
切块是RAG里最容易被低估的环节。很多人随便设个chunk_size=512就开跑,结果检索效果时好时坏。有了PageIndex之后,切块策略可以做得更精细。
我的建议是采用结构感知的切块方式。具体来说,优先按照文档的自然结构切块,比如一个章节切一块,一个表格切一块,一个列表切一块。如果自然结构块太大,再按照语义边界做二次切分。切分的时候,把PageIndex的位置信息附加到每个块上,包括它属于哪个章节、哪一页、在页面中的位置。
这样做的好处是,检索出来的块自带上下文标签。LLM拿到这个块,不仅知道内容是什么,还知道它在文档中的位置。这对于需要引用来源或需要跨块推理的场景特别有用。比如用户问“第三章提到的那个参数在第四章是怎么应用的”,检索时可以先定位到第三章的相关块,再通过PageIndex找到第四章中引用该参数的位置,把两个块一起送给LLM。
切块粒度上,我一般会设置一个上限和下限。上限控制在1024个token,超过就强制切分。下限控制在128个token,低于这个长度的块合并到相邻块。这个范围是根据实际测试得出的,太小了语义不完整,太大了检索精度下降。
3.3 索引结构设计:位置信息怎么存、怎么查
PageIndex的索引结构设计直接决定了检索效率和可扩展性。我试过几种方案,这里做个对比。
| 方案 | 存储方式 | 查询效率 | 增量更新 | 适用场景 |
|---|---|---|---|---|
| 扁平JSON | 单文件 | 低 | 不支持 | 小型静态文档集 |
| 关系数据库 | MySQL/PostgreSQL | 中 | 支持 | 中等规模文档集 |
| 文档数据库 | MongoDB/Elasticsearch | 高 | 支持 | 大规模文档集 |
| 内存索引 | Redis/自定义结构 | 极高 | 部分支持 | 高频查询场景 |
我目前用得比较多的是Elasticsearch做页面索引,因为它天然支持结构化查询和全文检索的结合,而且增量更新很方便。每个文档块作为一个document存入,字段包括doc_id、chapter_path、page_num、block_type、position_start、position_end、content。检索时先用结构化条件过滤,再用向量字段做相似度匹配。
如果你不想引入额外的组件,用PostgreSQL的JSONB字段也能做,查询效率稍低但够用。关键是索引字段的设计要合理,chapter_path可以用数组或路径字符串存储,page_num用整数,position用范围类型。查询的时候,结构化过滤和向量检索可以分两步走,也可以在一次查询里用混合条件完成。
3.4 检索阶段的PageIndex介入方式
检索阶段是PageIndex发挥价值的地方。我通常会在检索流程里加两个环节。
第一个环节是查询解析。用户的问题往往不包含明确的结构信息,比如用户问“这个项目的预算上限是多少”,他没有说“在第三章第二节”。但PageIndex可以通过查询解析,推断出可能的结构范围。比如如果问题里提到了“预算”,而文档的目录里有一章叫“财务规划”,那就可以优先检索这一章。这个推断可以用规则做,也可以用一个小模型做意图分类。
第二个环节是结果重排。向量检索返回Top-K之后,用PageIndex的结构信息做重排。重排的规则可以包括:同一章节的块优先、相邻页面的块优先、与查询结构范围匹配的块优先。重排的权重需要根据实际效果调,我一般会给结构匹配度一个0.3到0.5的权重,剩下的给向量相似度。
实操心得:重排的时候不要完全依赖结构信息,否则会漏掉跨章节但语义高度相关的内容。我的做法是保留一部分纯向量检索的结果,和结构重排的结果做合并,确保召回率不下降。
4. 实操过程与核心环节实现
4.1 环境准备与依赖安装
动手之前先把环境搭好。我用的技术栈是Python 3.10以上,主要依赖包括PyMuPDF做PDF解析、sentence-transformers做向量化、Elasticsearch做索引和检索、FastAPI做服务接口。如果你不想装Elasticsearch,可以用FAISS加SQLite的组合替代,效果差一些但部署简单。
pip install pymupdf sentence-transformers elasticsearch fastapi uvicornElasticsearch的安装这里不展开,用Docker跑一个单节点就行。关键是装好之后要配置好向量字段的映射,否则后面存向量会报错。
docker run -d --name elasticsearch -p 9200:9200 -e "discovery.type=single-node" elasticsearch:8.11.0向量化模型我选的是BAAI/bge-small-zh-v1.5,中文效果不错,模型体积也小,推理速度快。如果你主要处理英文文档,可以换成all-MiniLM-L6-v2。
4.2 文档解析与页面索引构建
先写文档解析的代码。核心目标是把PDF拆成带位置信息的文本块。
import fitz def parse_pdf(pdf_path): doc = fitz.open(pdf_path) blocks = [] for page_num, page in enumerate(doc): page_dict = page.get_text("dict") for block in page_dict["blocks"]: if block["type"] != 0: continue for line in block["lines"]: for span in line["spans"]: text = span["text"].strip() if not text: continue blocks.append({ "doc_id": pdf_path, "page_num": page_num + 1, "text": text, "bbox": span["bbox"], "font_size": span["size"], "is_bold": "Bold" in span["font"] }) return blocks这段代码把每个文字片段的位置、字号、是否加粗都提取出来了。接下来要根据这些信息推断章节结构。我的做法是:字号明显大于正文且加粗的,判定为标题;标题的层级根据字号大小排序确定。
def infer_structure(blocks): font_sizes = [b["font_size"] for b in blocks if b["text"]] body_size = max(set(font_sizes), key=font_sizes.count) for b in blocks: if b["font_size"] > body_size * 1.2 and b["is_bold"]: b["block_type"] = "heading" else: b["block_type"] = "body" return blocks这只是一个简化版的结构推断,实际项目中还需要处理编号识别、多级标题、表格区域等。但核心思路就是这样:用字体和排版信息反推文档结构。
4.3 切块与向量化
有了结构信息之后,切块就有的放矢了。我按照章节边界切块,同时把位置信息附加到每个块上。
def chunk_by_structure(blocks, max_tokens=1024): chunks = [] current_chunk = [] current_tokens = 0 current_heading = "" for b in blocks: if b["block_type"] == "heading": if current_chunk: chunks.append(build_chunk(current_chunk, current_heading)) current_chunk = [] current_tokens = 0 current_heading = b["text"] token_count = len(b["text"]) if current_tokens + token_count > max_tokens and current_chunk: chunks.append(build_chunk(current_chunk, current_heading)) current_chunk = [] current_tokens = 0 current_chunk.append(b) current_tokens += token_count if current_chunk: chunks.append(build_chunk(current_chunk, current_heading)) return chunks def build_chunk(blocks, heading): text = " ".join(b["text"] for b in blocks) pages = [b["page_num"] for b in blocks] return { "text": text, "heading": heading, "page_start": min(pages), "page_end": max(pages), "doc_id": blocks[0]["doc_id"] }切完块之后做向量化。用sentence-transformers把每个块的文本转成向量。
from sentence_transformers import SentenceTransformer model = SentenceTransformer("BAAI/bge-small-zh-v1.5") def embed_chunks(chunks): texts = [c["text"] for c in chunks] embeddings = model.encode(texts, normalize_embeddings=True) for chunk, emb in zip(chunks, embeddings): chunk["embedding"] = emb.tolist() return chunks4.4 索引写入与检索实现
把切块和向量写入Elasticsearch。索引映射要同时支持结构化字段和向量字段。
from elasticsearch import Elasticsearch es = Elasticsearch("http://localhost:9200") index_mapping = { "mappings": { "properties": { "text": {"type": "text"}, "heading": {"type": "keyword"}, "page_start": {"type": "integer"}, "page_end": {"type": "integer"}, "doc_id": {"type": "keyword"}, "embedding": { "type": "dense_vector", "dims": 512, "index": True, "similarity": "cosine" } } } } es.indices.create(index="page_index", body=index_mapping, ignore=400) for chunk in chunks: es.index(index="page_index", document=chunk)检索的时候,先做结构化过滤,再做向量匹配。Elasticsearch的kNN检索支持预过滤,可以把结构化条件放在filter里。
def search(query, doc_id=None, heading=None, top_k=5): query_vector = model.encode(query, normalize_embeddings=True).tolist() filters = [] if doc_id: filters.append({"term": {"doc_id": doc_id}}) if heading: filters.append({"term": {"heading": heading}}) knn = { "field": "embedding", "query_vector": query_vector, "k": top_k, "num_candidates": top_k * 10 } if filters: knn["filter"] = filters resp = es.search(index="page_index", knn=knn, size=top_k) return [hit["_source"] for hit in resp["hits"]["hits"]]这段代码实现了带结构过滤的向量检索。实际使用的时候,heading这个过滤条件可以通过查询解析自动推断,不需要用户手动指定。
4.5 重排与结果合并
检索返回结果之后,做一轮重排。重排的规则是:如果多个结果来自同一章节或相邻页面,提升它们的排序权重。
def rerank(results, query_heading=None): for r in results: score = r.get("_score", 0) if query_heading and r.get("heading") == query_heading: score *= 1.3 r["final_score"] = score results.sort(key=lambda x: x["final_score"], reverse=True) return results这个重排逻辑很简单,但效果立竿见影。我实测下来,加了结构重排之后,跨章节问题的回答完整度提升了大概20%到30%。
5. 常见问题与排查技巧实录
5.1 检索命中率低:先查切块质量再查向量模型
很多人一发现检索效果不好就换向量模型,其实大部分时候问题出在切块上。我排查这个问题的顺序是这样的:先看切块后的文本是否语义完整,再看页面索引的位置信息是否准确,最后才考虑换模型。
一个快速验证切块质量的方法是:随机抽10个块,人工判断这些块单独拿出来能不能回答一个简单问题。如果大部分块都不能,那说明切块粒度有问题。常见的问题是块太小,一个完整的论述被切成了三四段,每段都不完整。解决办法是增大chunk_size,或者改用语义切块而不是固定长度切块。
另一个常见问题是页面索引的位置信息错位。比如PDF解析时页码从0开始计数,但实际文档页码从1开始,导致检索时页码对不上。这种问题很隐蔽,需要打印出来逐条核对。
5.2 跨章节问题回答不完整:结构重排的权重需要调
跨章节问题是RAG的经典难题。用户问“A和B有什么关系”,A在第三章,B在第五章,向量检索可能只返回了第三章的内容,第五章的内容因为相似度稍低被挤掉了。这时候结构重排就派上用场了。
我的做法是:在检索阶段,如果查询解析发现问题是关系型的(比如包含“关系”“区别”“对比”“联系”这些词),就主动扩大检索范围,把Top-K调大,然后在重排阶段把不同章节但语义相关的结果提上来。重排的权重需要根据实际数据调,我一般从0.3开始试,逐步加到0.5,观察效果变化。
注意:结构重排的权重不是越高越好。权重太高会导致检索结果过于集中在某个章节,漏掉其他章节的相关内容。我建议用A/B测试的方式确定最优权重,不要拍脑袋定。
5.3 增量更新时索引不一致:用版本号做并发控制
文档更新是PageIndex的一个难点。如果文档更新了,页面索引和向量索引都需要同步更新,否则会出现检索到旧内容的情况。我遇到过最坑的问题是:文档更新了一半,索引更新了一半,检索时新旧内容混在一起,LLM生成的答案自相矛盾。
解决办法是引入版本号。每个文档有一个版本号,每次更新时版本号递增。索引里存储版本号,检索时只返回当前版本的内容。更新流程做成事务性的:先更新页面索引,再更新向量索引,最后更新版本号。如果中间失败,回滚到旧版本。
def update_document(doc_id, new_content): old_version = get_current_version(doc_id) new_version = old_version + 1 try: update_page_index(doc_id, new_content, new_version) update_vector_index(doc_id, new_content, new_version) set_current_version(doc_id, new_version) except Exception as e: rollback(doc_id, old_version) raise e这个方案不是完美的,因为Elasticsearch本身不支持跨索引的事务,但通过版本号做逻辑上的隔离,可以避免大部分不一致问题。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 检索结果不相关 | 切块粒度不当 | 抽查切块内容 | 调整chunk_size或改用语义切块 |
| 跨章节问题答不全 | 检索范围太窄 | 检查Top-K和重排权重 | 扩大Top-K,调整结构重排权重 |
| 页码对不上 | 解析时页码计数错误 | 打印页码核对 | 统一页码计数方式 |
| 增量更新后检索到旧内容 | 索引更新不同步 | 检查版本号 | 引入版本号做并发控制 |
| 向量检索报错 | 向量维度不匹配 | 检查模型输出维度 | 统一向量维度 |
| 检索速度慢 | 索引结构不合理 | 分析查询耗时 | 优化索引字段,增加缓存 |
5.5 几个我踩过的坑
第一个坑是PDF解析的坐标系统。PyMuPDF的坐标原点在左上角,但有些PDF的坐标原点在左下角,导致位置信息完全错位。解决办法是统一做一次坐标转换,把所有坐标转到同一个坐标系。
第二个坑是向量模型的归一化。有些模型输出的是未归一化的向量,直接存进Elasticsearch做余弦相似度计算会出问题。一定要在存入之前做归一化,或者在Elasticsearch的映射里指定正确的相似度算法。
第三个坑是Elasticsearch的kNN检索在数据量大时的性能问题。num_candidates设得太小会导致召回率下降,设得太大又会影响查询速度。我的经验是num_candidates设为top_k的10到20倍比较合适,再大就收益递减了。
6. 这套方案还能怎么扩展
PageIndex这套思路不只适用于文本RAG。我最近在尝试把它扩展到多模态场景,比如文档里包含图片和表格,页面索引不仅记录文本位置,还记录图片和表格的位置。检索时如果命中了一个表格,可以把表格的完整结构一起返回,而不是只返回表格里的几个文字片段。
另一个扩展方向是和知识图谱结合。PageIndex提供的是文档内的结构信息,知识图谱提供的是实体之间的关系信息。两者结合,可以实现更精准的检索。比如用户问“张三负责的项目预算是多少”,PageIndex定位到包含“张三”和“预算”的页面,知识图谱补充“张三”和“项目”的关系,检索结果会更准确。
还有一个方向是做查询意图和页面结构的联合建模。现在的查询解析还是基于规则的,如果用一个轻量模型做意图分类,再和页面索引做联合检索,效果应该会更好。这个我还在实验中,有结果了再分享。
最后说一个实际使用中的小技巧:PageIndex的页面结构信息不仅可以用于检索,还可以用于生成引用。LLM生成答案时,可以要求它标注每个结论来自哪个页面的哪个块,这样用户就能快速定位到原文。这个功能在需要高可信度的场景下特别有用,比如法律、医疗、金融领域的文档问答。