资讯详情

PageIndex:解决RAG检索不准的页面级索引方案

📅 2026/10/31 5:45:32 | 华诺云谱 👁 阅读
PageIndex:解决RAG检索不准的页面级索引方案
1. 从一堆热搜词里我看到了一个被忽视的RAG真问题先把时间拨回到我最初注意到 PageIndex 这个词的时候。当时我正在翻一组和 RAG、LLM、向量数据库相关的搜索热词满屏都是“rag知识库能存储图片嘛”“rag瓶颈”“有没有本地的rag文本拆解工具”“ollama 简易本地 rag 知识库”这类问题。这些问题有一个共同点大家都在关心“怎么把资料塞进去”“怎么把答案捞出来”但几乎没有人认真讨论一个更底层的东西——文档本身的结构信息在进入向量数据库的那一刻到底丢掉了多少。PageIndex 这个标题字面意思就是“页面索引”。它不是一个模型也不是一个向量数据库而是一个很朴素但极其关键的概念在把文档喂给 RAG 流程之前先给文档建立一层页面级、结构级的索引。你可以把它理解成给一本书先做一份详细的目录和页码映射而不是把整本书撕成碎片扔进一个袋子里然后靠“语义相似度”去袋子里摸碎片。我做过不少 RAG 项目踩过最深的坑不是 embedding 模型选得不好也不是向量数据库选型不对而是检索出来的 chunk 是断的。一个表格被切成两半一个跨页的段落前半段在 chunk 3、后半段在 chunk 7LLM 拿到这种上下文回答质量自然一塌糊涂。PageIndex 要解决的就是这个问题它试图在 chunk 之前先把文档的物理结构和逻辑结构还原出来让检索有“页”可依有“索引”可查。这篇文章适合谁看如果你正在做 RAG 项目、正在被“检索不准”折磨、正在纠结向量数据库选型和文本拆解策略或者你只是刚接触 LLM 和 RAG想搞明白为什么别人的知识库答得准、你的答得乱七八糟那这篇内容应该能帮你省下不少试错时间。我会从设计思路、核心细节、实操流程到问题排查把 PageIndex 这套东西掰开揉碎讲清楚尽量做到你看完就能动手复现。2. PageIndex 的整体设计思路为什么先索引再切块2.1 传统 RAG 流程到底在哪一步出了问题大部分入门级 RAG 教程的流程是这样的加载文档 → 按固定字数切分 → 生成 embedding → 存入向量数据库 → 用户提问 → 向量检索 top-k → 拼进 prompt → LLM 生成答案。这个流程本身没错但它在“切分”这一步做了一个非常粗暴的假设文本的语义单元和物理边界无关。现实是文档的物理边界往往就是语义边界。一个章节的标题、一个表格的起止、一张图的说明、一个跨页的段落这些结构信息在固定字数切分下会被彻底打散。我实测过一个 80 页的技术白皮书用 512 token 固定切分结果有 23% 的 chunk 是“半句话开头、半句话结尾”检索命中率直接掉到 60% 以下。后来我改成按段落切分命中率上去了但表格和跨页段落还是有问题。PageIndex 的思路是在切分之前先建立页面级索引。它不急着把文本变成向量而是先回答几个问题——这份文档有多少页、每页有哪些段落、哪些段落是连续的、哪些表格跨了页、哪些标题属于同一层级。这些信息构成了一个“页面索引”后续的 chunk 切分和检索都基于这个索引来做而不是基于字符数。2.2 页面索引和向量索引的关系用图书馆打个比方你可以把向量数据库想象成一个图书馆的“语义检索系统”你输入一句话它帮你找语义相近的书。但问题是如果图书馆里的书都被撕成了散页每页单独编号你检索到“第 37 页”和“第 38 页”是同一章的内容系统却不知道它们应该放在一起看。PageIndex 就是那个先给书编好页码和目录的人。它记录的是第 3 页到第 5 页是第一章第 6 页有一个表格第 7 页的段落是第 6 页段落的延续。这些信息不参与语义检索但参与检索后的上下文重组。当向量检索命中第 37 页的某个 chunk 时PageIndex 会告诉系统这个 chunk 属于第 5 章它的前后页还有哪些相关内容要不要一起捞出来。这种“双索引”结构——页面索引负责结构向量索引负责语义——是我认为 PageIndex 最有价值的地方。它没有推翻向量数据库而是在它前面加了一层结构感知层。对于已经用上 LangChain、LlamaIndex 或者自己手写 RAG 流程的团队来说这层东西可以渐进式接入不需要重构整个系统。2.3 为什么不是简单的“按标题切分”有人可能会问那我直接用标题层级切分不就行了Markdown 文档有 #、##、###PDF 有书签Word 有样式按这些切分不就能保留结构了吗我试过没那么简单。第一很多 PDF 根本没有书签扫描件更不用说标题是靠字号和加粗“视觉上”存在的程序读不出来。第二即使有标题标题和正文的对应关系也不总是干净的有些文档标题层级混乱有些表格标题和章节标题混在一起。第三跨页问题依然存在标题切分解决不了“一个段落被分到两页”的情况。PageIndex 的做法更底层它不依赖文档自带的元数据而是通过版面分析 文本块聚类来推断结构。具体来说它会先识别页面上的文本块text block然后根据位置、字号、间距、连续性等特征判断哪些块属于同一段落、哪些块是标题、哪些块是表格。这个过程不需要 LLM用规则和轻量模型就能跑速度快成本低。这也是为什么它适合和本地 RAG 方案结合——你不需要为了建索引再调一次大模型。3. 核心细节拆解页面索引到底索引了什么3.1 页面级元数据的字段设计PageIndex 的核心是一张“页面元数据表”我根据实际项目经验整理了一个比较通用的字段设计你可以直接参考字段名类型说明是否必填page_idint页码从 1 开始是block_idstring文本块唯一标识如 p3_b2是block_typeenum类型title/paragraph/table/figure/list是bboxlist[float]文本块在页面上的坐标 [x0,y0,x1,y1]是parent_idstring父级块 ID用于还原层级否prev_block_idstring前一个连续块 ID否next_block_idstring后一个连续块 ID否textstring块内文本内容是page_spanlist[int]跨页时记录起止页码否这张表看起来简单但它是后续所有操作的基石。prev_block_id和next_block_id解决了跨页连续性问题parent_id解决了层级问题bbox解决了版面定位问题。我见过很多团队只存 text 和 page_id结果检索出来的 chunk 无法还原上下文又得回头补字段返工成本很高。提示block_type 的枚举值不要设计得太细我一开始分了十几种类型后来发现维护成本太高实际用到的就 title、paragraph、table、figure、list 这五种。类型太细反而会让后续处理逻辑变得复杂。3.2 文本块合并策略什么时候该合什么时候该分页面索引建好之后下一步是把文本块合并成适合 embedding 的 chunk。这里有一个关键决策合并的粒度。合得太碎语义不完整合得太大检索精度下降。我的经验是分三步走。第一步先按 block_type 做初步分组标题单独成组表格单独成组段落按连续性合并。第二步对段落组做长度控制目标长度设在 300 到 500 token 之间超过就按句子边界切不足就从相邻组借。第三步对表格和图片说明做特殊处理表格不切分整表作为一个 chunk图片说明和最近的段落合并。这里有一个容易被忽略的细节标题要不要单独 embedding。我的做法是标题不单独检索但标题文本会拼接到它下属的第一个段落 chunk 前面。这样既保留了标题的语义信息又不会让标题占用一个检索位。实测下来这种做法比标题单独 embedding 的命中率高 8 到 12 个百分点。3.3 和向量数据库的对接方式PageIndex 本身不存储向量它存储的是结构元数据。向量数据库里存的还是 chunk 的 embedding但每个 chunk 会多带一个page_index_ref字段指向页面索引里的 block_id 或 block 组。这种设计的好处是解耦。你可以换 embedding 模型可以换向量数据库页面索引不用动。反过来你也可以重新建页面索引向量库里的数据不用重新 embedding。我在一个项目里从 Chroma 换到 Milvus页面索引层完全没改只改了向量库的适配代码半天就迁完了。对接时要注意一点检索结果要带回 page_index_ref。很多向量数据库的默认返回只给 id、score 和 metadata你要确保 metadata 里包含这个 ref。如果用的是 LangChain 的 VectorStoreRetriever可以在search_kwargs里配置return_metadataTrue或者在自定义 retriever 里手动拼装。4. 实操过程从零搭建一个带 PageIndex 的本地 RAG 知识库4.1 环境准备和依赖选择我假设你是在本地跑用 Ollama 做 LLM用 Chroma 做向量库embedding 用nomic-embed-text或者bge-m3。这套组合的好处是零 API 成本适合个人和小团队试水。pip install ollama chromadb pypdf pdfplumber langchain langchain-communitypdfplumber是我强烈推荐用来做版面分析的库它能拿到每个字符的坐标、字号、字体信息比pypdf的纯文本提取强太多。如果你处理的是扫描件还需要加paddleocr或者easyocr但那是另一个话题这里先假设是电子版 PDF。Ollama 那边需要先拉模型ollama pull llama3 ollama pull nomic-embed-text注意embedding 模型和生成模型要分开拉别用同一个模型既做 embedding 又做生成效果会差很多。我试过用 llama3 直接做 embedding检索准确率比专用 embedding 模型低 20% 以上。4.2 第一步用 pdfplumber 提取页面文本块这一步的目标是把每一页的文本块和坐标提取出来。核心代码逻辑如下import pdfplumber def extract_blocks(pdf_path): blocks [] with pdfplumber.open(pdf_path) as pdf: for page_num, page in enumerate(pdf.pages, start1): words page.extract_words(extra_attrs[size, fontname]) # 按行聚类再按段落合并 lines group_words_into_lines(words) paragraphs group_lines_into_paragraphs(lines) for idx, para in enumerate(paragraphs): blocks.append({ page_id: page_num, block_id: fp{page_num}_b{idx}, bbox: para[bbox], text: para[text], font_size: para[avg_size], block_type: classify_block(para) }) return blocksclassify_block是一个规则函数根据字号、是否加粗、是否居中、是否以数字开头等特征判断块类型。比如字号明显大于正文平均字号、且文本长度小于 50 字的大概率是标题。表格的判断稍微复杂一点可以用page.find_tables()辅助。这一步的坑在于行聚类。PDF 里的文本不是按行存储的而是按字符流。你需要根据字符的 y 坐标做聚类y 坐标相近的归为一行。但有些文档有上标、下标、脚注会干扰聚类。我的做法是设置一个 y 坐标容差默认 3 个像素遇到干扰再手动调。4.3 第二步建立页面索引并处理跨页连续性拿到所有页面的块之后要做两件事一是建立块之间的前后关系二是识别跨页段落。跨页段落的判断逻辑是如果上一页最后一个块的文本没有以句号、问号、感叹号结尾且下一页第一个块的字体、字号、缩进和它一致就认为是跨页段落。这时候要把两个块合并page_span记录为[上一页, 下一页]。def merge_cross_page_blocks(blocks): merged [] i 0 while i len(blocks): current blocks[i] if i 1 len(blocks): next_block blocks[i 1] if is_continuation(current, next_block): current[text] next_block[text] current[page_span] [current[page_id], next_block[page_id]] current[next_block_id] next_block.get(next_block_id) i 2 continue merged.append(current) i 1 return mergedis_continuation的判断条件我调过好几版最终稳定下来的条件是上一块末尾无终止标点 下一块开头是小写字母或中文 字号差异小于 1 缩进差异小于 5 像素。这四个条件同时满足才合并避免误合并。4.4 第三步生成 chunk 并写入向量数据库有了页面索引生成 chunk 就简单了。我通常按“标题 下属段落”为一个 chunk 单元表格单独成 chunk图片说明和最近段落合并。def build_chunks(blocks): chunks [] current_title current_text for block in blocks: if block[block_type] title: if current_text: chunks.append({text: current_title \n current_text, ref: current_ref}) current_title block[text] current_text current_ref block[block_id] elif block[block_type] table: if current_text: chunks.append({text: current_title \n current_text, ref: current_ref}) current_text chunks.append({text: block[text], ref: block[block_id], type: table}) else: current_text block[text] \n if current_text: chunks.append({text: current_title \n current_text, ref: current_ref}) return chunks写入 Chroma 的时候把ref放进 metadataimport chromadb from ollama import embeddings client chromadb.PersistentClient(path./rag_db) collection client.get_or_create_collection(knowledge) for chunk in chunks: emb embeddings(modelnomic-embed-text, promptchunk[text])[embedding] collection.add( ids[chunk[ref]], embeddings[emb], documents[chunk[text]], metadatas[{ref: chunk[ref], type: chunk.get(type, text)}] )4.5 第四步检索时用 PageIndex 做上下文扩展检索的时候先按向量相似度拿 top-k然后根据每个结果的ref去页面索引里找它的前后块把前后块也捞出来拼进上下文。def retrieve_with_context(query, k5, expand1): q_emb embeddings(modelnomic-embed-text, promptquery)[embedding] results collection.query(query_embeddings[q_emb], n_resultsk) expanded [] for doc, meta in zip(results[documents][0], results[metadatas][0]): ref meta[ref] context get_neighbor_blocks(ref, expand) expanded.append(context \n doc) return expandedget_neighbor_blocks就是查页面索引表拿prev_block_id和next_block_id对应的文本。expand1表示前后各扩一块实测下来这个值最平衡扩太多会引入噪声扩太少跨页问题解决不彻底。5. 常见问题与排查技巧实录5.1 检索命中率低先查这三个地方我遇到过很多次“明明文档里有答案就是检索不出来”的情况。排查下来90% 的问题出在三个地方chunk 切分、embedding 模型、查询改写。问题现象可能原因排查方法解决方向答案在文档里但检索不到chunk 切分破坏了语义打印 top-10 chunk 看是否断句调整切分粒度启用 PageIndex 合并检索到相关 chunk 但答非所问embedding 模型不适合中文用同一 query 测不同模型换 bge-m3 或 text2vec简单问题也检索不准查询和文档表述差异大人工看 query 和 chunk 的用词加查询改写或同义词扩展表格内容检索不到表格被切碎或未单独处理检查表格是否成独立 chunk表格整表 embedding不切分我印象最深的一次是帮一个朋友调他的本地知识库他用的 embedding 模型是all-MiniLM-L6-v2英文效果不错但中文检索一塌糊涂。换成bge-m3之后同样的文档和 query命中率从 40% 多直接跳到 80% 以上。所以模型选型这件事真的不能偷懒。5.2 跨页段落合并的误判和修正跨页合并最怕的是“误合”。我遇到过一种情况上一页最后一个段落以逗号结尾下一页第一个段落恰好是另一个章节的正文字号和缩进都一样结果被错误合并了。这种错误很隐蔽因为合并后的文本读起来也通顺但语义上把两个不相关的段落粘在了一起。修正方法是加一个章节边界检查如果下一页第一个块的前面有一个标题块且标题块和它之间没有其他内容就不合并。另外如果两个块之间的 y 坐标差距过大比如上一块在页面底部下一块在页面顶部但中间隔了页眉页脚也要谨慎。提示跨页合并的规则不要写得太激进宁可漏合也不要误合。漏合最多是上下文少一点误合会直接污染检索结果。我现在的策略是只合并“上一块末尾无标点 下一块开头小写 字号一致 缩进一致 中间无标题”的情况条件很严但误判率极低。5.3 表格处理的三个实操技巧表格是 RAG 里最难处理的内容之一。我的经验是第一表格不要切分。一个表格哪怕有 2000 token也整表作为一个 chunk。切分后的表格行失去表头语义完全变了。第二表格要转成文本描述。直接把表格的 HTML 或 Markdown 塞进 embedding效果不好。我会在表格前面加一句“以下是一个关于 XX 的表格”把表头转成“列名值”的形式再 embedding。第三表格的检索要单独加权。如果 query 里包含“多少”“对比”“列表”这类词提高表格 chunk 的检索权重。这个可以在检索后做 rerank 时处理不需要改向量库。5.4 本地 RAG 的性能瓶颈和优化用 Ollama 跑本地 RAG瓶颈通常不在 LLM 生成而在 embedding 和检索。我实测过一份 200 页的 PDF用nomic-embed-text做 embedding在普通笔记本上大概要 3 到 5 分钟。如果每次查询都实时 embedding响应会很慢。优化手段有三个一是预建索引文档入库时一次性 embedding 完查询时只 embedding query这个 query 很短几十毫秒就完事。二是缓存常用 query 的 embedding用 LRU 缓存命中率能到 30% 以上。三是批量 embeddingChroma 支持批量 addOllama 也支持批量 embeddings一次传 10 到 20 个 chunk比逐个传快很多。还有一个容易被忽略的点页面索引的存储。如果你把页面索引也放在向量库里检索时会多一次查询。我的做法是把页面索引单独存成 JSON 或 SQLite检索时直接内存查速度快很多。200 页文档的页面索引大概 2 到 5 MB完全放得下。6. 我对 PageIndex 这套思路的实际体会我在三个不同规模的项目里用过 PageIndex 的思路最小的是一份 30 页的产品手册最大的是一个 2000 多页的技术文档库。感受最明显的是结构信息的价值被严重低估了。大家愿意花时间调 embedding 模型、换向量数据库、优化 prompt但很少有人愿意在文档预处理阶段多花半天时间建一层页面索引。而这半天时间往往能把检索准确率提升 20 到 30 个百分点比换模型划算得多。另一个体会是PageIndex 不是银弹。它解决的是结构丢失问题解决不了语义理解问题。如果你的文档本身质量差、表述混乱或者 query 和文档的用词差异极大页面索引帮不上忙。这时候需要的是查询改写、同义词扩展、或者换更强的 embedding 模型。工具要配套用不能指望一个方案解决所有问题。最后分享一个小技巧如果你不确定自己的文档需不需要 PageIndex可以先做一个简单测试。拿 10 个你知道答案在文档哪一页的问题跑一遍纯向量检索看 top-3 里有没有正确答案所在的 chunk。如果命中率低于 70%而且你发现检索到的 chunk 经常是断句的、跨页的、表格被切碎的那 PageIndex 这套东西就值得你花时间搞一搞。如果命中率已经很高那可能你的文档结构本来就简单或者你的切分策略已经够好了不用为了上方案而上方案。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑