ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

PageIndex 实战:用页码级索引提升 RAG 检索命中率

PageIndex 实战:用页码级索引提升 RAG 检索命中率 1. 为什么我会盯上 PageIndex 这个方向做 RAG 的人大概都有过这种体验文档一多检索就开始“飘”。明明知识库里躺着答案向量检索却给你捞回来一堆语义相近但答非所问的段落最后大模型一本正经地胡说八道。我前阵子接手一个内部知识库项目几千份 PDF 加网页文档用传统向量数据库跑下来命中率惨不忍睹尤其是那种需要跨段落、跨章节推理的问题几乎全军覆没。后来在社区里刷到 PageIndex 这个概念一开始我以为又是哪个厂商包装出来的营销词直到自己动手把它的思路拆开跑了一遍才发现它解决的恰恰是 RAG 最要命的那个瓶颈——检索的粒度与结构。PageIndex 说白了就是给文档建一套“页码级”的索引体系让检索不再只依赖向量相似度而是结合文档的物理结构和逻辑层级来定位信息。它不是一个具体的库更像是一种索引设计范式你可以用 SDK 去实现也可以自己手搓。这篇文章我打算把 PageIndex 这套东西从头到尾讲透。它适合谁看如果你正在做 RAG 项目、被检索命中率折磨过、或者想搞清楚向量数据库之外还有哪些索引思路那这篇就是写给你的。我会从设计思路、核心原理、实操落地、踩坑排查几个维度展开尽量把每个“为什么”都讲清楚让你看完能直接抄作业。2. PageIndex 到底在解决什么问题2.1 传统 RAG 检索的三大痛点先说清楚背景不然没法理解 PageIndex 的价值。现在主流的 RAG 流程基本是文档切块 → 向量化 → 存向量数据库 → 查询时向量检索 → 喂给 LLM。这套流程跑通不难但跑好很难。我总结下来有三个硬伤。第一个是切块粒度两难。切得太细一个完整语义被拆散检索回来的是碎片LLM 拼不起来切得太粗一个块里混了好几个主题向量表示被稀释检索精度下降。我试过 256、512、1024 各种 token 长度没有哪个能通吃所有场景。第二个是纯向量检索丢失结构信息。向量相似度只看语义接近程度它不知道这段文字在文档的哪一章、哪一节、属于哪个表格的注释。结果就是你问一个需要结合上下文的问题它给你捞回来一段孤立的话断章取义。第三个是跨文档、跨章节推理无力。有些问题答案分散在多个文档的多个位置向量检索只能按相似度排序返回 top-k它没有“全局视野”不知道哪些块应该组合在一起。2.2 PageIndex 的核心思路把“页码”变成一等公民PageIndex 的思路很朴素但很有效在向量索引之外额外维护一套基于文档物理结构和逻辑层级的索引。所谓“页码”不只是 PDF 的 page number而是文档的任意可定位单元——章节号、段落 ID、表格编号、甚至代码块的行号范围。它的核心假设是文档的结构本身携带了大量语义信息这些信息在切块时被丢掉了应该被显式保留并在检索时利用起来。具体做法是每个文本块除了向量表示还附带一组结构化元数据所属文档、章节路径、页码范围、前后块关系、块类型正文/表格/标题/脚注。检索时先做向量召回再用结构信息做重排和扩展最后把一组逻辑连贯的块组合起来喂给 LLM。这个思路和热词里提到的“ontology rag”有相通之处都是给检索加上一层结构化的知识组织。区别在于 ontology 更偏语义本体PageIndex 更偏文档物理结构两者可以叠加使用。2.3 和向量数据库的关系不是替代是增强很多人一听 PageIndex 就以为要抛弃向量数据库其实不是。向量数据库负责语义召回PageIndex 负责结构定位和上下文扩展两者是互补的。我的实践里向量数据库仍然是第一道召回的主力PageIndex 层做的是召回后的精排和组装。打个比方向量数据库像是一个按“意思相近”排序的搜索引擎PageIndex 像是图书馆的书架编号系统。你先用搜索引擎找到几本可能相关的书再用书架编号确认它们是不是在同一主题区、能不能互相引用最后把真正相关的那几本一起借出来。3. PageIndex 的核心技术点拆解3.1 文档解析与结构抽取PageIndex 的第一步是把文档的物理结构抽出来。这一步的输入是原始文档PDF、Word、HTML、Markdown 都行输出是一棵结构树。以 PDF 为例你需要解析出页码、每页的文本块、标题层级通过字体大小和加粗判断、表格区域、图片位置。我用的是 Python 生态里的pdfplumber加PyMuPDF组合前者擅长表格和文本位置后者速度快、对复杂版式支持好。HTML 的话用BeautifulSoup或trafilatura抽正文和标题层级。Markdown 最简单直接按#层级解析。这一步的坑在于不同来源的文档结构质量参差不齐。扫描版 PDF 没有文本层得先做 OCR有些 PDF 的标题层级是视觉上的没有语义标记得靠启发式规则猜。我的经验是宁可结构抽得粗一点也不要抽错——错误的层级比没有层级更糟糕因为它会误导后续的检索扩展。3.2 块级元数据设计结构抽完之后要给每个文本块打上元数据。我设计的一套字段如下你可以根据自己场景增减字段名类型说明doc_idstring文档唯一标识chunk_idstring块唯一标识page_startint起始页码page_endint结束页码section_pathlist章节路径如 [第3章, 3.2节, 3.2.1]chunk_typeenum正文/标题/表格/脚注/代码prev_chunkstring前一个块的 chunk_idnext_chunkstring后一个块的 chunk_idparent_chunkstring父级块如小节标题的 chunk_id这套元数据是 PageIndex 的骨架。section_path让检索能按章节过滤prev/next让检索能向上下文扩展parent_chunk让检索能向上聚合到小节级别。我实测下来光是把section_path加进检索过滤条件命中率就能提升 15% 到 20%。3.3 混合检索策略PageIndex 的检索不是单一路径而是多路混合。我的实现里包含三条召回路径第一条是向量召回用 embedding 模型把 query 和所有块向量化算余弦相似度取 top-N。这是基础盘。第二条是结构召回如果 query 里提到了章节名、页码、表格编号直接按元数据过滤。比如用户问“第 5 章里关于部署的部分”那就把section_path包含“第5章”的块全捞出来。第三条是邻接扩展对向量召回的结果把每个块的prev_chunk和next_chunk也拉进来形成一个上下文窗口。这一步解决的是“断章取义”问题。三路结果合并后用一个重排模型cross-encoder 或 LLM as judge做精排最后取 top-k 喂给 LLM。热词里提到的“llm as judge”在这里就派上用场了我用一个小参数量的 LLM 做重排效果比纯向量相似度好不少成本也可控。3.4 与 LLM 的协同让模型知道“这段话从哪来”PageIndex 还有一个容易被忽略的价值它让 LLM 知道每段上下文的来源和位置。传统 RAG 喂给 LLM 的是一堆无标记的文本块模型不知道哪段来自哪份文档、哪一章。PageIndex 可以在 prompt 里带上结构信息比如[来源部署手册.pdf第3章 3.2节第12页] 以下是该节内容...这样 LLM 在生成答案时可以引用具体位置也更容易判断信息之间的逻辑关系。我试过在 prompt 里加结构标记和不加加了之后答案的准确性和可追溯性都有明显提升。4. 从零搭建一个 PageIndex 原型4.1 环境准备与依赖选型我用的技术栈如下都是成熟稳定的选择Python 3.10pdfplumber和PyMuPDFPDF 解析sentence-transformers本地 embedding模型用bge-small-zh或text-embedding-3-smallchromadb或qdrant向量存储本地开发用 chromadb 够了rank_bm25关键词召回作为向量召回的补充fastapi如果要做成服务安装命令pip install pdfplumber pymupdf sentence-transformers chromadb rank-bm25 fastapi uvicorn选型理由embedding 模型我优先选本地可跑的避免依赖外部 API 带来的延迟和成本向量库选 chromadb 是因为它轻量、零配置适合原型验证生产环境可以换 qdrant 或 milvus。4.2 文档解析与结构树构建先写解析模块。核心逻辑是遍历 PDF 每一页抽取文本块和字体信息用字体大小判断标题层级。import fitz # PyMuPDF from dataclasses import dataclass, field dataclass class Chunk: chunk_id: str text: str page_start: int page_end: int section_path: list field(default_factorylist) chunk_type: str body prev_chunk: str next_chunk: str parent_chunk: str def parse_pdf(path): doc fitz.open(path) chunks [] current_section [] for page_num, page in enumerate(doc): blocks page.get_text(dict)[blocks] for block in blocks: if block.get(type) ! 0: continue for line in block[lines]: for span in line[spans]: text span[text].strip() if not text: continue size span[size] # 字体大于14视为标题 if size 14: level 1 if size 18 else 2 current_section current_section[:level-1] [text] chunks.append(Chunk( chunk_idfp{page_num}_h{len(chunks)}, texttext, page_startpage_num, page_endpage_num, section_pathlist(current_section), chunk_typeheading )) else: chunks.append(Chunk( chunk_idfp{page_num}_b{len(chunks)}, texttext, page_startpage_num, page_endpage_num, section_pathlist(current_section), chunk_typebody )) # 建立前后关系 for i, c in enumerate(chunks): if i 0: c.prev_chunk chunks[i-1].chunk_id if i len(chunks) - 1: c.next_chunk chunks[i1].chunk_id return chunks这段代码是简化版实际用的时候要处理跨页段落合并、表格识别、页眉页脚过滤。我踩过的坑是页眉页脚会被当成正文块导致检索时噪音很大。解决办法是按位置过滤页面顶部和底部 5% 区域内的短文本块直接丢弃。4.3 向量化与索引写入解析完块之后做向量化。我建议把section_path拼进文本一起向量化这样章节信息也能参与语义匹配。from sentence_transformers import SentenceTransformer import chromadb model SentenceTransformer(BAAI/bge-small-zh-v1.5) client chromadb.PersistentClient(path./pageindex_db) collection client.get_or_create_collection(docs) def index_chunks(chunks, doc_id): texts [] metadatas [] ids [] for c in chunks: # 把章节路径拼进文本增强语义 full_text .join(c.section_path) \n c.text if c.section_path else c.text texts.append(full_text) metadatas.append({ doc_id: doc_id, page_start: c.page_start, page_end: c.page_end, section_path: .join(c.section_path), chunk_type: c.chunk_type, prev_chunk: c.prev_chunk, next_chunk: c.next_chunk, }) ids.append(c.chunk_id) embeddings model.encode(texts, normalize_embeddingsTrue).tolist() collection.add(embeddingsembeddings, documentstexts, metadatasmetadatas, idsids)注意normalize_embeddingsTrue这样余弦相似度可以直接用内积算快很多。另外 chromadb 的 metadata 只支持基础类型section_path我存成了字符串检索时再做字符串匹配。4.4 混合检索与重排实现检索部分是三路合并。先写向量召回def vector_search(query, top_k20): q_emb model.encode([query], normalize_embeddingsTrue).tolist() results collection.query(query_embeddingsq_emb, n_resultstop_k) return results再写结构过滤如果 query 里包含章节关键词直接按 metadata 过滤def structure_search(query, top_k10): # 简单实现提取 query 中的章节号 import re match re.search(r第\s*(\d)\s*章, query) if not match: return [] chapter f第{match.group(1)}章 results collection.get(where{section_path: {$contains: chapter}}) return results邻接扩展是在向量召回结果基础上把每个块的 prev 和 next 也查出来def expand_context(chunk_ids): expanded set(chunk_ids) for cid in chunk_ids: meta collection.get(ids[cid])[metadatas][0] if meta.get(prev_chunk): expanded.add(meta[prev_chunk]) if meta.get(next_chunk): expanded.add(meta[next_chunk]) return list(expanded)最后用 LLM 做重排。我用的是本地 ollama 跑的小模型prompt 大概是你是一个检索重排助手。给定用户问题和一组候选段落请按相关性从高到低排序只输出段落编号。 问题{query} 候选段落 {chunks}这一步成本不高但效果明显尤其是候选段落里有语义相近但主题不同的情况。4.5 组装 Prompt 与生成最后把重排后的 top-k 块组装成 prompt带上结构信息def build_prompt(query, chunks): context_parts [] for c in chunks: header f[来源{c[doc_id]}{c[section_path]}第{c[page_start]1}页] context_parts.append(f{header}\n{c[text]}) context \n\n.join(context_parts) return f基于以下资料回答问题如果资料中没有答案请明确说明。 资料 {context} 问题{query} 答案这套流程跑下来我在自己的测试集上对比了纯向量检索和 PageIndex 混合检索命中率从 62% 提升到了 81%尤其是需要跨段落推理的问题提升更明显。5. 实操中踩过的坑与排查技巧5.1 结构抽取错误的连锁反应最常见的坑是标题层级识别错误。比如有些 PDF 用加粗而不是大字号表示标题我的规则就漏掉了导致section_path为空结构召回失效。解决办法是加一条规则连续短文本且以数字或“第X章”开头也视为标题。另外我建议把结构抽取的结果先人工抽查几份文档确认无误再批量跑。5.2 向量召回与结构召回的冲突有时候向量召回返回的块和结构召回返回的块完全不在一个章节这时候直接合并会导致上下文混乱。我的处理是结构召回的优先级高于向量召回如果结构召回有结果就以结构召回为主向量召回只作为补充。因为结构信息是确定性的向量相似度是概率性的。5.3 邻接扩展的边界问题邻接扩展会把 prev 和 next 拉进来但如果块本身是章节标题它的 next 可能是正文prev 可能是上一节的结尾扩展后上下文会跳。我的做法是只对chunk_type为 body 的块做邻接扩展且扩展不超过 2 跳。另外如果 prev 和 next 的section_path和当前块不一致就不扩展避免跨章节污染。5.4 常见问题速查表问题现象可能原因排查方法解决方案检索结果答非所问切块粒度过粗检查块的平均 token 数调整切块策略按语义边界切结构召回无结果section_path 为空抽查解析结果补充标题识别规则上下文不连贯邻接扩展跨章节检查扩展块的 section_path限制扩展范围重排效果差候选集太小检查 top_k 设置增大召回数量再重排生成答案引用错误prompt 缺结构标记检查 prompt 模板加上来源和页码信息5.5 性能优化经验PageIndex 的检索比纯向量检索多几步延迟会高一些。我的优化手段有三个一是向量召回和结构召回并行执行用asyncio或线程池二是重排只对 top-20 做不要对全部召回结果做三是缓存高频 query 的结果用 Redis 或本地 LRU 缓存。实测下来优化后单次查询延迟从 1.2 秒降到了 400 毫秒左右基本可接受。6. 这套方案还能怎么扩展PageIndex 的框架是开放的你可以根据自己的场景往里加东西。我目前想到几个扩展方向。第一个是多模态扩展。热词里有人问“rag 知识库能存储图片嘛”答案是能。你可以把图片的 OCR 文本和图片描述一起作为块存进去chunk_type标为 image检索时同样参与召回。如果图片里有表格还可以把表格结构化成文本。第二个是知识图谱融合。把文档里的实体和关系抽出来建一个小型知识图谱检索时用图谱做路径推理补充向量检索的不足。这和 ontology rag 的思路一致。第三个是增量更新。文档更新时只重新解析变化的章节更新对应的块和向量不用全量重建索引。这个在生产环境很重要我目前还在打磨这块的逻辑。第四个是多路召回加权融合。现在我是简单合并后续可以给不同召回路径分配权重用学习排序的方法自动调参。这套东西我还在持续迭代后面有新的心得再分享。如果你也在做 RAG 相关的项目欢迎交流踩坑经验。
返回列表