ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

PageIndex 深度解析:页面级索引如何提升 RAG 检索准确率

PageIndex 深度解析:页面级索引如何提升 RAG 检索准确率

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 或 section,Markdown 就按二级标题。如果原始文档没有明显边界,那就按固定字符数切,但一定要保留 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.10,8 核 CPU,16G 内存,没有 GPU。这个配置跑中小规模知识库完全够用。如果你有 GPU,那当然更好,embedding 和重排都能快很多。

依赖方面,核心是这几个:pymupdf用来解析 PDF,beautifulsoup4用来解析 HTML,sentence-transformers用来做 embedding 和重排,rank-bm25用来做关键词索引。向量库我选的是chromadb,因为它轻量、本地化、API 简单,适合快速验证。如果你要上生产,可以考虑milvus或qdrant。

pip install pymupdf beautifulsoup4 sentence-transformers rank-bm25 chromadb

安装完之后,先下载模型。bge-large-zh 大概 1.3G,bge-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_len=500): 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_embeddings=True) vec_collection.add( documents=texts, embeddings=embeddings.tolist(), metadatas=[{"page_num": c["page_num"], "source": c["source"]} for c in chunks], ids=[f"chunk_{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_embeddings=True) page_collection.add( documents=page_summaries, embeddings=page_embeddings.tolist(), metadatas=[{"page_num": p["page_num"], "source": p["source"]} for p in pages], ids=[f"page_{i}" for i in range(len(pages))] )

摘要这里我用的是前 200 字符,简单粗暴但有效。如果你有预算,可以用 LLM 生成更好的摘要,但成本会高一些。对于大多数场景,前 200 字符已经能提供足够的页面级信号。

4.5 检索链路与重排序

检索的时候,我走的是三路召回加一路重排。向量召回 top 20,关键词召回 top 20,页面摘要召回 top 10,合并去重后得到候选集,再用重排模型打分,取 top 5 返回。

def search(query, top_k=5): # 向量召回 q_emb = embed_model.encode([query], normalize_embeddings=True) vec_results = vec_collection.query(query_embeddings=q_emb.tolist(), n_results=20) # 关键词召回 tokenized_query = list(query) bm25_scores = bm25.get_scores(tokenized_query) bm25_top = sorted(range(len(bm25_scores)), key=lambda i: bm25_scores[i], reverse=True)[:20] # 页面摘要召回 page_results = page_collection.query(query_embeddings=q_emb.tolist(), n_results=10) # 合并候选 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), key=lambda x: x[1], reverse=True) 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 降到 100,M从 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,完全来得及。

返回列表