ARTICLE DETAIL

资讯详情

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

PageIndex实战:用结构树+向量索引提升RAG检索命中率

PageIndex实战:用结构树+向量索引提升RAG检索命中率 1. 为什么我会盯上 PageIndex 这个方向第一次看到 PageIndex 这个词是在翻 RAG 相关的项目时。当时我正在给一个内部知识库做检索优化向量数据库换了两种chunk 策略调了四五轮命中率始终卡在一个尴尬的位置简单问题答得挺好一旦用户问的是“第三章第二节里那张表说了什么”或者“这份合同里违约责任在第几页”系统就开始胡言乱语。后来我意识到问题不在于 embedding 模型不够强而在于我一开始就把文档切成了碎片丢掉了文档本身的结构信息。PageIndex 这个思路本质上就是在解决这件事。它不把 PDF 或长文档当成一坨纯文本去切块而是先还原文档的页面结构和层级关系再在这个结构上做索引和检索。你可以把它理解成传统 RAG 是把一本书撕成纸条塞进抽屉PageIndex 是先给书做好目录和页码再让你按目录去翻。前者靠语义相似度碰运气后者靠结构定位加语义匹配两条腿走路。这篇文章适合谁看如果你正在做 RAG 项目被检索命中率折磨过如果你手上有大量 PDF、扫描件、技术手册、合同、研报这类强结构文档如果你用 LangChain4j、LlamaIndex 或者自己手搓检索链路想找一个不依赖重型向量数据库也能提升效果的方案那 PageIndex 值得你花时间研究。我会从设计思路、核心细节、实操落地到踩坑排查完整讲一遍我是怎么理解和落地这套东西的。2. PageIndex 的整体设计与思路拆解2.1 传统 RAG 的瓶颈到底卡在哪先说清楚问题不然没法理解 PageIndex 的价值。常规 RAG 的流程大概是文档解析成文本按固定长度或语义边界切 chunk每个 chunk 过 embedding 模型变成向量存进向量数据库查询时把问题也向量化做相似度检索取 top-k 塞给 LLM 生成答案。这套流程在博客、FAQ、短文档上表现不错但遇到长结构文档就露馅了。我总结下来有三个硬伤。第一个硬伤是上下文断裂。一个 800 字的 chunk 被切出来它可能正好跨在两个小节之间前半段在讲配置参数后半段在讲注意事项embedding 出来的向量是个“四不像”既不像配置也不像注意事项检索时两边都匹配不上。第二个硬伤是结构信息丢失。文档里的标题层级、页码、表格编号、章节归属这些在切 chunk 的时候基本被扔掉了。用户问“第 5 页的表格”向量检索根本不知道“第 5 页”是什么概念它只认语义相似度。第三个硬伤是检索粒度单一。要么 chunk 太大导致噪声多要么 chunk 太小导致信息不全很难找到一个对所有问题都合适的粒度。这就是很多人说的 RAG 瓶颈——不是模型不行是索引结构太扁平。2.2 PageIndex 的核心思路把文档当树而不是当袋子PageIndex 的做法我理解下来核心就一句话先建结构再建索引。它把一份文档解析成一棵层级树根节点是文档本身子节点是章节章节下面是子章节最底层是具体的段落、表格、图片说明。每个节点都带着自己的元信息页码、标题路径、节点类型、在原文中的位置。这样做的好处很直接。检索的时候系统可以先在树的上层做粗定位比如用户问的是“安全配置”相关的内容那就先锁定“安全配置”这个章节节点再往下钻到具体段落。这比在全量 chunk 里做扁平检索要精准得多因为候选范围被结构约束住了。我打个比方。传统 RAG 像是在一个没有分类的仓库里找东西全靠物品外观相似度PageIndex 像是先给仓库做了货架分区每个区还有子货架找东西时先看分区牌再看货架号最后才看物品本身。效率和信息完整性完全不是一个量级。2.3 和向量数据库的关系不是替代是互补这里要澄清一个常见误解。很多人一听 PageIndex 就以为要抛弃向量数据库其实不是。PageIndex 解决的是“结构索引”这一层向量检索解决的是“语义匹配”这一层两者是互补的。我的实际做法是PageIndex 负责把文档结构树建好每个叶子节点段落级仍然会生成 embedding 存进向量库。查询时先用结构树做候选集裁剪再在裁剪后的候选集里做向量相似度排序。这样既保留了语义检索的灵活性又加上了结构约束的精准性。举个具体场景。用户问“这份技术白皮书里关于容灾的部分RTO 和 RPO 分别是多少”。纯向量检索可能会召回一堆提到 RTO、RPO 的段落包括前言里的概述、附录里的术语表。而 PageIndex 会先定位到“容灾方案”章节再在这个章节范围内找包含 RTO、RPO 的段落噪声直接少一大半。2.4 方案选型时我考虑的几个维度在决定要不要上 PageIndex 之前我对比了几种方案列个表更清楚。方案结构保留检索精度实现复杂度适合场景固定长度切块 向量库无中低博客、FAQ、短文本语义切块 向量库弱中高中一般长文档PageIndex 结构树 向量库强高中高PDF、手册、合同、研报纯关键词 倒排索引中中低术语明确的专业文档我最终选 PageIndex 路线主要是因为我手上的文档 80% 是 PDF 格式的技术手册和行业报告页码和章节结构对用户来说是有意义的检索维度。如果你的文档全是聊天记录或者短笔记那 PageIndex 的收益就没那么明显老老实实用向量库更省事。3. 核心细节解析与实操要点3.1 文档解析结构还原的第一步PageIndex 的地基是文档解析。这一步做不好后面的树就是歪的。我试过几种解析工具各有优劣。对于原生 PDF文字可选中我用得最多的是 PyMuPDF也就是 fitz 库。它能把每一页的文字块、位置、字体大小都提取出来字体大小这个信息特别关键因为标题通常比正文大。你可以根据字号阈值来判断哪些行是标题哪些是正文。import fitz doc fitz.open(manual.pdf) for page_num, page in enumerate(doc): blocks page.get_text(dict)[blocks] for block in blocks: if lines not in block: continue for line in block[lines]: for span in line[spans]: text span[text].strip() size span[size] if not text: continue # 字号大于 14 认为是标题候选 if size 14: print(f[P{page_num1}] 标题候选: {text} (字号 {size}))这段代码的逻辑很直白遍历每一页的文本块拿到每个 span 的字号和文字字号超过阈值的就标记为标题候选。阈值不是固定的不同文档排版不一样我一般会先跑一遍统计字号分布找到正文和标题的分界点。对于扫描件 PDF就得先走 OCR。我常用的是 PaddleOCR中文识别效果比较稳。OCR 出来的结果只有文字和坐标没有字号信息这时候标题判断就得靠其他信号比如行首编号“第一章”“1.1”“一、”、行长度、是否独占一行等。注意OCR 的坐标信息一定要保留因为后面建树时需要知道每个文本块在页面上的位置用来判断它属于哪个章节。丢了坐标结构还原就无从谈起。3.2 结构树构建从扁平文本到层级树拿到带字号和坐标的文本块之后下一步是建树。我的做法是维护一个栈结构遇到标题就根据标题层级压栈或弹栈。具体逻辑是这样的先给每个标题候选分配一个层级。层级怎么定看字号和编号格式。比如“第 3 章”字号 18“3.1 节”字号 16“3.1.1”字号 14那层级就是 1、2、3。然后遍历所有文本块遇到标题就调整栈遇到正文就挂到当前栈顶节点下面。class Node: def __init__(self, title, level, page): self.title title self.level level self.page page self.children [] self.content [] def build_tree(blocks): root Node(ROOT, 0, 0) stack [root] for block in blocks: if block[is_title]: node Node(block[text], block[level], block[page]) while stack and stack[-1].level node.level: stack.pop() stack[-1].children.append(node) stack.append(node) else: stack[-1].content.append(block) return root这个栈的逻辑是新标题的层级如果小于等于栈顶层级就不断弹栈直到找到一个层级比它小的父节点然后挂上去。这样就能保证树的层级关系正确。实操中我踩过一个坑有些 PDF 的标题字号并不统一比如一级标题在某些页是 18在另一些页是 17如果阈值卡得太死就会漏掉一些标题导致树结构断裂。我的解决办法是先用聚类算法把字号分成几档而不是用固定阈值。简单点可以用 KMeans 对字号做 3 到 5 类聚类然后按聚类中心排序分配层级。3.3 节点内容组织段落、表格、图片怎么处理树建好之后每个节点下面挂着的内容还需要进一步组织。段落好办直接按顺序存。表格和图片就麻烦一些。表格的处理我的做法是把表格转成 Markdown 或 HTML 格式存进节点同时保留表格的标题和页码。这样检索到表格节点时LLM 能直接读懂表格内容。如果是复杂表格比如合并单元格很多的转 Markdown 可能会丢结构这时候我会额外存一份表格的文本描述比如“该表包含 5 列分别是参数名、默认值、取值范围、说明、备注”。图片的处理要看需求。如果图片里有文字OCR 提取出来存进节点。如果图片是流程图、架构图纯 OCR 效果不好我会用多模态模型生成一段图片描述把描述文本存进节点。这样检索时至少能通过描述匹配到相关图片。提示表格和图片的页码信息一定要保留。用户问“第 8 页那张图”你得能定位到。我见过太多项目把页码扔了结果用户一问页码就抓瞎。3.4 索引层设计结构索引 向量索引双轨树建好之后索引层我做了两套。一套是结构索引本质上是给每个节点建一个路径字符串比如“第 3 章 3.2 节 3.2.1 小节”然后对这个路径做全文索引。另一套是向量索引对每个叶子节点的内容做 embedding存进向量库。查询时的流程是这样的先用查询词在结构索引里做匹配找到相关章节节点然后把这些节点下的所有叶子节点作为候选集最后在候选集里做向量相似度排序取 top-k 给 LLM。这样做的好处是结构索引负责“缩小范围”向量索引负责“精准排序”。两者结合既避免了全量向量检索的噪声又避免了纯结构检索的僵化。我用的向量库是 Milvus 的单机版轻量够用。如果你不想引入额外组件FAISS 也完全可以就是得自己管理持久化。LangChain4j 里也有内置的 embedding store 抽象切换起来方便。4. 实操过程与核心环节实现4.1 环境准备与依赖安装我先把环境列一下方便你照着搭。Python 3.10 以上主要依赖这几个pip install pymupdf paddleocr paddlepaddle pip install sentence-transformers pip install faiss-cpu pip install langchain langchain-community如果你用 Milvus再加一个pip install pymilvus。embedding 模型我选的是 BGE-M3中文效果好而且支持多语言。如果你追求轻量all-MiniLM-L6-v2 也行就是中文效果会打折扣。4.2 完整解析流程从 PDF 到结构树我把整个流程串一遍。假设你有一份 50 页的技术手册 PDF。第一步用 PyMuPDF 提取每一页的文本块带上字号和坐标。import fitz def extract_blocks(pdf_path): doc fitz.open(pdf_path) all_blocks [] for page_num, page in enumerate(doc): blocks page.get_text(dict)[blocks] for block in blocks: if lines not in block: continue for line in block[lines]: line_text max_size 0 for span in line[spans]: line_text span[text] max_size max(max_size, span[size]) line_text line_text.strip() if not line_text: continue all_blocks.append({ text: line_text, size: max_size, page: page_num 1, bbox: line[bbox] }) return all_blocks第二步判断哪些行是标题。我用字号聚类加编号规则双重判断。import re from sklearn.cluster import KMeans import numpy as np def detect_titles(blocks): sizes np.array([b[size] for b in blocks]).reshape(-1, 1) kmeans KMeans(n_clusters4, random_state0).fit(sizes) centers sorted(kmeans.cluster_centers_.flatten(), reverseTrue) # 最大的两类认为是标题 title_threshold centers[1] title_pattern re.compile(r^(第[一二三四五六七八九十][章节]|\d(\.\d)*[\s、.]|[一二三四五六七八九十]、)) for b in blocks: is_title False if b[size] title_threshold: is_title True if title_pattern.match(b[text]) and len(b[text]) 50: is_title True b[is_title] is_title return blocks这里字号聚类分成 4 类取第二大的聚类中心作为阈值意思是比正文大但又不至于太大的都算标题。编号规则是兜底防止有些标题字号不明显但格式很规范。第三步建树。用前面说的栈逻辑。第四步把树序列化成 JSON 存下来方便后续检索和调试。import json def tree_to_dict(node): return { title: node.title, level: node.level, page: node.page, content: [c[text] for c in node.content], children: [tree_to_dict(c) for c in node.children] } with open(structure.json, w, encodingutf-8) as f: json.dump(tree_to_dict(root), f, ensure_asciiFalse, indent2)4.3 向量索引构建叶子节点 embedding树建好之后遍历所有叶子节点把节点内容拼成一个字符串做 embedding。from sentence_transformers import SentenceTransformer import faiss model SentenceTransformer(BAAI/bge-m3) def collect_leaves(node, path): current_path path node.title if path else node.title if not node.children: text \n.join([c[text] for c in node.content]) if text.strip(): yield { path: current_path, page: node.page, text: text } for child in node.children: yield from collect_leaves(child, current_path) leaves list(collect_leaves(root)) texts [l[text] for l in leaves] embeddings model.encode(texts, normalize_embeddingsTrue) index faiss.IndexFlatIP(embeddings.shape[1]) index.add(embeddings.astype(float32)) faiss.write_index(index, pageindex.faiss)这里用内积索引是因为 embedding 已经归一化了内积等价于余弦相似度。叶子节点的文本我建议加上路径前缀比如“第 3 章 3.2 节 3.2.1 小节\n正文内容”这样 embedding 里也带上了结构信息检索时更准。4.4 查询链路结构裁剪 向量排序查询的时候我分两步走。第一步用查询词在结构树的标题里做匹配找到相关节点。匹配可以用简单的关键词包含也可以用 BM25。def find_relevant_nodes(root, query): relevant [] def dfs(node, path): current_path path node.title if path else node.title if any(kw in node.title for kw in query.split()): relevant.append(node) for child in node.children: dfs(child, current_path) dfs(root) return relevant第二步把这些节点下的叶子节点作为候选集在候选集里做向量检索。def search(query, top_k5): query_vec model.encode([query], normalize_embeddingsTrue).astype(float32) relevant_nodes find_relevant_nodes(root, query) if relevant_nodes: candidate_indices [] for node in relevant_nodes: for i, leaf in enumerate(leaves): if leaf[path].startswith(get_path(node)): candidate_indices.append(i) if candidate_indices: sub_index faiss.IndexFlatIP(embeddings.shape[1]) sub_index.add(embeddings[candidate_indices].astype(float32)) scores, indices sub_index.search(query_vec, min(top_k, len(candidate_indices))) return [leaves[candidate_indices[i]] for i in indices[0]] # 结构匹配不到就回退到全量检索 scores, indices index.search(query_vec, top_k) return [leaves[i] for i in indices[0]]这个链路的关键在于结构匹配命中时候选集可能从几千个叶子节点缩小到几十个向量检索的噪声大幅降低。结构匹配不中时回退到全量检索保证不会因为结构索引漏匹配而完全失效。4.5 和 LLM 对接把检索结果喂给模型检索到 top-k 叶子节点后把它们的内容拼成 context加上页码和路径信息塞进 prompt。def build_prompt(query, results): context for r in results: context f[来源: {r[path]}, 第 {r[page]} 页]\n{r[text]}\n\n prompt f基于以下文档内容回答问题。如果内容中没有相关信息请明确说明。 文档内容 {context} 问题{query} 回答 return prompt页码和路径一定要带上这样 LLM 在回答时可以引用来源用户也能核对。我实测下来带上来源信息的回答用户信任度明显更高。5. 常见问题与排查技巧实录5.1 标题识别不准导致树结构错乱这是最常见的问题。表现是树里出现了层级跳跃比如一级标题下面直接挂了三级标题或者正文被误判成标题。排查方法把结构树打印出来人工看前 20 个节点。如果发现异常先检查字号聚类的结果。我遇到过一次文档里表格内的文字字号和正文一样但被误判成标题原因是表格文字独占一行且长度短触发了编号规则。解决办法是加一个坐标过滤表格区域的文本块不参与标题判断。另一个技巧是标题通常不会以句号结尾也不会太长。我加了两条规则长度超过 60 字的不是标题以句号、问号、感叹号结尾的不是标题。这两条规则过滤掉了大部分误判。5.2 跨页章节的合并问题有些章节标题在上一页末尾内容在下一页开头。如果按页独立处理标题和内容会被拆散。我的做法是建树时不按页分而是把所有页的文本块按顺序拼成一个全局列表页码只作为元信息记录。这样跨页的标题和内容自然就连在一起了。代价是内存占用会高一些但 50 到 200 页的文档完全扛得住。5.3 向量检索召回率低如果发现检索结果不相关先检查 embedding 模型是否适合你的语言和领域。BGE-M3 在中文通用领域表现不错但如果你是法律、医疗等垂直领域可能需要微调或者换领域模型。另一个常见原因是叶子节点文本太短。如果某个叶子节点只有一句话embedding 信息量不足检索效果就差。我的处理是如果叶子节点文本少于 100 字就把它和相邻的兄弟节点合并保证每个检索单元有足够的信息量。5.4 结构匹配和向量检索结果冲突有时候结构匹配找到的节点和向量检索的 top 结果不一致。我的策略是以结构匹配为准因为结构是硬约束向量是软排序。但如果结构匹配的候选集里向量分数普遍很低低于一个阈值那就说明结构匹配可能错了这时候回退到全量检索。这个阈值我一般设 0.5低于 0.5 认为语义相关性不足。具体数值要根据你的 embedding 模型和领域调没有万能值。5.5 常见问题速查表问题现象可能原因排查方法解决思路树层级错乱标题识别不准打印前 20 节点人工检查调整字号阈值加长度和结尾标点过滤跨页内容断裂按页独立处理检查跨页章节全局拼接文本块页码仅作元信息检索结果不相关embedding 模型不匹配看 top-k 分数分布换模型或微调合并短节点结构匹配失效查询词和标题不重合检查查询词加同义词扩展或回退全量检索表格内容丢失解析时未处理表格检查表格页表格转 Markdown 或文本描述存入节点页码引用错误页码元信息丢失检查节点 page 字段解析时保留页码检索结果带上5.6 我踩过的几个坑第一个坑是过度依赖字号。有些设计感强的 PDF标题字号和正文一样大只是加粗了。这时候字号聚类就失效了。后来我加了字体加粗检测PyMuPDF 的 span 里有 flags 字段可以判断是否加粗。第二个坑是忽略页眉页脚。页眉页脚的文字会混进正文干扰标题判断。我的处理是如果某行文字在每一页的相同位置都出现就判定为页眉页脚直接过滤掉。第三个坑是embedding 批量太大导致内存溢出。我一开始把几千个叶子节点一次性 encode结果内存爆了。后来改成 batch 处理每批 64 个稳得很。提示建树之后一定要人工抽检。我一般随机抽 5 个章节对照原文看结构对不对。这一步花 10 分钟能省后面几小时的调试。6. 性能优化与扩展方向6.1 解析速度优化PyMuPDF 本身很快50 页 PDF 大概 2 秒解析完。瓶颈在 OCR 和 embedding。OCR 如果每页都跑50 页可能要几分钟。我的优化是先用 PyMuPDF 判断页面是否有文字层有文字层的直接提取没有的才走 OCR。这样原生 PDF 基本秒级完成。embedding 的优化主要是批处理和缓存。同一个文档重复处理时embedding 结果可以缓存到本地用文档哈希做 key避免重复计算。6.2 检索延迟优化结构匹配是内存操作很快。向量检索如果候选集小FAISS 也是毫秒级。整体查询延迟主要花在 embedding 查询词上一次 encode 大概几十毫秒。如果你用 GPU还能更快。如果候选集很大可以考虑用 IVF 索引替代 Flat 索引用少量精度换速度。不过我的经验是结构裁剪之后候选集通常不大Flat 索引完全够用。6.3 扩展方向多文档和增量更新单文档跑通之后自然会想扩展到多文档。我的做法是给每棵树加一个文档 ID检索时先按文档 ID 过滤再在文档内做结构匹配。这样不同文档的结构树互不干扰。增量更新是个麻烦事。如果文档改了整棵树重建成本高。我的折中方案是按章节做粒度更新。如果只是某个小节内容变了只重建那个子树和对应的向量索引。这需要你在存储时保留章节的边界信息。6.4 和 Agent 结合的可能性PageIndex 的结构树天然适合做 Agent 的工具。你可以把“按章节检索”“按页码检索”“按表格检索”封装成不同的工具让 Agent 根据用户问题自己选择检索方式。比如用户问“第 5 页说了什么”Agent 就调页码检索工具用户问“容灾方案”Agent 就调章节检索工具。这个方向我还在试目前看效果不错尤其是处理复杂查询时比单一检索链路灵活很多。7. 一些个人体会PageIndex 这套东西我最大的感受是RAG 的效果瓶颈很多时候不在模型而在索引结构。大家花大量时间调 embedding 模型、调 chunk size、调 top-k但很少有人回头看看文档本身的结构信息是不是被浪费了。结构索引和向量索引结合之后我手上的技术手册问答命中率从大概 60% 提到了 85% 左右。提升最明显的场景就是带页码、带章节引用的查询这类查询以前基本靠运气现在基本能稳定命中。当然它也不是银弹。如果你的文档本身就没有结构比如聊天记录、零散笔记那 PageIndex 的收益有限。另外建树和调参确实有工作量不是开箱即用的东西。但如果你手上有大量结构化文档又对检索精度有要求这套思路值得投入。最后分享一个小技巧建树之后把结构树可视化出来看一眼。不用多复杂就是缩进打印标题层级。我每次处理新文档都会先看一眼这棵树往往一眼就能发现解析问题。这个习惯帮我省了很多调试时间。
返回列表