☰
PageIndex:用结构化索引重构RAG检索,解决跨章节与表格难题
2026/10/31 5:49:46 网站建设 项目流程

1. 从“向量检索”到“结构化导航”:PageIndex 要解决的真问题

做过 RAG 的人大概都有过这种体验:知识库文档一多,检索出来的片段就开始“飘”。问一个跨章节才能回答的问题,向量数据库返回的永远是语义最相近的那几段,但真正需要的那条逻辑链,散落在三四个不同的 chunk 里,拼不起来。更麻烦的是,当文档里有大量表格、层级目录、编号条款时,按固定长度切分几乎必然把结构切碎——一个完整的判定条件被拦腰截断,模型拿到半句话就开始编。

PageIndex 这个标题,从字面看就是“页面索引”。但放在 RAG、LLM、向量数据库这组关键词的语境里,它指向的显然不是传统意义上给网页建倒排索引那套东西,而是一种面向文档结构的索引组织方式。我理解它的核心诉求是:在把文档喂给 LLM 之前,先给文档建立一层“可导航的骨架”,让检索不再只依赖向量相似度,而是能沿着文档自身的层级、章节、条目去定位内容。

这件事为什么值得单独拎出来讲?因为当前 RAG 的瓶颈,很大一部分不在模型,而在检索的粒度与结构丢失。向量数据库擅长的是“模糊匹配”,它把文本压成一个稠密向量,语义相近就靠得近。但文档天然是有结构的:标题、子标题、段落、列表、表格、脚注,这些结构本身携带了大量语义信息。一旦切分策略粗暴,这些信息就全丢了。PageIndex 要做的,就是把这层结构重新捡回来,变成检索时可以用的“路标”。

我见过不少团队在 RAG 项目里反复调 chunk size、调 overlap、换 embedding 模型,效果提升却很有限。问题往往不在参数,而在于索引的形态和文档的形态不匹配。PageIndex 这类思路的价值,就是让索引去适配文档,而不是让文档去迁就索引。它适合谁?适合那些知识库文档结构复杂、层级深、表格多、条款密的场景,比如技术手册、法规汇编、产品文档、研究报告。如果你只是拿一堆零散 FAQ 做问答,那传统向量检索可能就够了;但只要文档一“厚”,结构一“深”,PageIndex 这种结构化索引的收益就会立刻显现。

2. PageIndex 的底层逻辑:为什么“先建骨架再检索”比“先切再搜”更靠谱

2.1 向量检索的盲区:它不知道“这段属于哪”

向量检索的本质是相似度计算。你把 query 编码成向量,把文档 chunk 编码成向量,然后算余弦距离。这个过程中,chunk 在文档里的位置、它属于哪个章节、它的上级标题是什么,这些信息完全不参与计算。也就是说,一个 chunk 无论来自文档开头还是结尾,无论它是主条款还是补充说明,在向量空间里都是一视同仁的。

这就导致一个典型问题:检索结果缺乏上下文锚点。模型拿到一个片段,不知道它在原文里的位置,也不知道它和相邻片段的关系。如果这个片段本身语义完整还好,一旦它依赖上下文才能理解,模型就只能靠猜。PageIndex 的思路是在索引阶段就把“位置”和“层级”编码进去,让检索时不仅能匹配内容,还能匹配结构。

2.2 PageIndex 的索引形态:树状结构 + 节点摘要

我理解的 PageIndex 实现,核心是两件事:把文档解析成一棵树,以及给每个节点生成可检索的摘要。

树的结构来自文档自身的层级。比如一份技术手册,根节点是文档标题,一级子节点是各章节,二级子节点是小节,叶子节点是具体段落或表格。解析的时候,需要识别标题层级(H1/H2/H3)、列表缩进、编号规则(1.1、1.1.1)、表格边界。这一步的难点在于文档格式的多样性:PDF 的标题可能靠字号和加粗来区分,Word 靠样式,Markdown 靠井号,HTML 靠标签。PageIndex 需要一套统一的解析层,把这些信号归一化成树节点。

每个节点除了原始内容,还要生成一个摘要。这个摘要不是给用户看的,是给检索用的。它的作用是让节点在向量空间里有一个“代表向量”,同时保留节点的结构路径。检索时,先匹配节点摘要,命中后再决定是返回该节点本身,还是下钻到子节点,还是回溯到父节点取上下文。这就比单纯切 chunk 灵活得多。

2.3 和传统 RAG 切分策略的对比

维度传统固定长度切分PageIndex 结构化索引
切分依据token 数量、字符数文档层级、语义边界
上下文保留靠 overlap 勉强衔接靠树结构天然保留
检索粒度固定,无法动态调整可按节点粒度动态下钻
表格处理容易被截断可作为独立节点完整保留
跨章节问题难以拼出完整逻辑链可沿树路径回溯组合
索引更新全量重切可局部更新子树

这张表不是要否定向量检索,而是说明 PageIndex 补的是结构这一维。实际系统里,两者通常是配合使用的:先用结构缩小候选范围,再用向量做精细排序。

2.4 一个容易被忽略的细节:节点摘要怎么生成

节点摘要的生成质量,直接决定检索效果。我的经验是,不要让 LLM 自由发挥写摘要,而是给它一个固定模板:节点标题 + 核心内容一句话 + 关键实体列表。这样生成的摘要既保留了结构信息,又不会引入幻觉。对于叶子节点,摘要可以直接用原文前若干 token 加上标题路径;对于中间节点,摘要需要综合其子节点的内容,这时候可以用 LLM 做一次“归纳”,但必须限制输出长度,避免摘要比原文还长。

还有一个坑:摘要的向量和原文的向量要分开存。检索时先用摘要向量做粗筛,命中后再用原文向量做精排。如果混在一起,摘要的概括性会稀释原文的细节信号,导致检索精度下降。

3. 动手搭一个最小可用的 PageIndex 原型

3.1 环境准备与依赖选择

要跑通一个 PageIndex 原型,不需要太重的技术栈。我的建议是:

  • 文档解析:Python 生态里,unstructured库对多格式支持比较好,PDF、Word、HTML 都能处理,而且会输出带层级信息的元素列表。如果文档主要是 Markdown,直接用markdown-it-py解析成 token 流更轻量。
  • 树结构存储:不需要上图数据库,一个嵌套字典或者anytree库就够了。节点里存node_id、parent_id、title、content、summary、children。
  • 向量化:本地可以用sentence-transformers,模型选bge-small-zh这类中文友好的;如果追求效果,调 API 也行。关键是摘要和原文用同一个模型,保证向量空间一致。
  • 向量存储:原型阶段用faiss的IndexFlatIP就够,几万条数据毫秒级返回。生产环境再考虑 Milvus、Qdrant 这类。

提示:不要一上来就搭全套。先用 10 篇结构清晰的文档跑通“解析→建树→摘要→检索”这条链路,再逐步加复杂度。

3.2 文档解析:把 PDF 变成带层级的节点列表

以 PDF 为例,unstructured的partition_pdf会返回一堆元素,每个元素有category(Title、NarrativeText、ListItem、Table 等)和text。关键是根据category和字号信息推断层级。

from unstructured.partition.pdf import partition_pdf elements = partition_pdf("manual.pdf", infer_table_structure=True) nodes = [] stack = [] # 用于维护层级栈 for el in elements: category = el.category text = el.text.strip() if not text: continue if category == "Title": # 根据字号或位置判断层级,这里简化处理 level = get_title_level(el) node = {"title": text, "content": "", "level": level, "children": []} # 弹栈直到找到合适的父节点 while stack and stack[-1]["level"] >= level: stack.pop() if stack: stack[-1]["children"].append(node) else: nodes.append(node) stack.append(node) else: if stack: stack[-1]["content"] += text + "\n" else: nodes.append({"title": "root", "content": text, "level": 0, "children": []})

这段代码的核心是用栈维护层级关系。遇到标题就根据层级弹栈,遇到正文就挂到当前栈顶节点。实际文档里标题层级判断不会这么干净,可能需要结合字号、加粗、编号正则来综合判断。

3.3 节点摘要生成:模板化比自由发挥更稳

摘要生成我踩过的坑是:让 LLM 自由写,结果它把子节点的内容提前概括了,导致检索时父节点和子节点向量高度相似,区分度下降。后来改成模板:

SUMMARY_TEMPLATE = """标题路径:{path} 核心内容:{content} 关键实体:{entities}""" def generate_summary(node, path=""): full_path = f"{path} > {node['title']}" if path else node["title"] # 叶子节点直接用原文截断 if not node["children"]: content = node["content"][:200] else: # 中间节点用子节点标题拼接 content = ";".join([c["title"] for c in node["children"]]) entities = extract_entities(node["content"]) # 可用简单的关键词抽取 return SUMMARY_TEMPLATE.format(path=full_path, content=content, entities=entities)

这样生成的摘要,路径信息保证了结构可追溯,核心内容保证了语义可匹配,关键实体提升了专有名词的召回。实测下来,比自由摘要的检索准确率高出一截。

3.4 检索流程:先粗筛节点,再决定下钻还是回溯

检索时,query 先编码成向量,和所有节点摘要向量算相似度,取 Top-K。然后对每个命中节点做判断:

  • 如果命中节点是叶子,直接返回其内容。
  • 如果命中节点是中间节点,看它的子节点摘要是否也有高分命中,有就下钻。
  • 如果命中节点内容太短,回溯到父节点取上下文。
def retrieve(query, top_k=5): q_vec = embed(query) scores = faiss_index.search(q_vec, top_k) results = [] for node_id, score in zip(scores.indices[0], scores.distances[0]): node = node_map[node_id] if node["children"]: # 检查子节点是否有更高分 child_scores = [faiss_index.search(embed(c["summary"]), 1)[0] for c in node["children"]] if max(child_scores) > score: continue # 让子节点去命中 results.append({"node": node, "score": score}) return results

这个逻辑的好处是检索粒度是动态的。简单问题命中叶子就结束,复杂问题可能命中中间节点然后展开子树,跨章节问题可以沿路径把多个节点拼起来。

4. 实测中暴露的三个硬骨头

4.1 表格解析:合并单元格是噩梦

技术文档里的表格往往有合并单元格、多级表头。unstructured的infer_table_structure=True能输出 HTML 表格,但合并单元格的rowspan/colspan处理经常出错。我的做法是:表格不强行拆成文本,而是作为独立节点存 HTML,检索命中后直接把 HTML 喂给 LLM,让它自己理解结构。如果表格特别大,再按行拆分成子节点,但保留表头作为每个子节点的前缀。

注意:表格节点在生成摘要时,不要用 LLM 去概括表格内容,容易丢数字。直接用“表头 + 行数 + 列名”作为摘要,比如“表 3-1 参数对照表,共 12 行 5 列,列名:型号、电压、电流、功率、备注”。

4.2 标题层级识别:PDF 里没有“H1”这个概念

PDF 是排版格式,不是语义格式。一个 18 号加粗的字可能是章标题,也可能是封面上的装饰字。我试过纯靠字号判断,准确率大概七成。后来加了两条规则:编号正则(如^第[一二三四五六七八九十]+章、^\d+\.\d+)和位置特征(标题通常独占一行,前后有较大间距)。两条规则叠加后,准确率能到九成以上。剩下的靠人工抽检修正。

4.3 节点摘要的向量漂移

当文档层级很深时,父节点的摘要会越来越抽象,向量逐渐偏离具体内容。我遇到过一个问题:问一个很具体的参数,检索却命中了顶层章节,因为顶层摘要里提到了这个参数所在的领域。解决办法是给摘要向量加一个层级衰减因子,层级越深权重越高,或者干脆在检索时优先匹配叶子节点,中间节点只作为回溯路径。

5. 把 PageIndex 接进现有 RAG 流水线的几种姿势

5.1 作为独立检索器,和向量检索做融合

最稳妥的接法是双路召回:一路走传统向量检索,一路走 PageIndex 结构化检索,然后用 RRF(Reciprocal Rank Fusion)合并结果。这样既保留了向量检索的模糊匹配能力,又补上了结构导航。实测在跨章节问题上,融合后的召回率比单路提升明显。

5.2 作为预处理层,给 chunk 打上结构标签

如果不想大改现有流水线,可以把 PageIndex 当成一个元数据生成器。每个 chunk 在切分时,记录它来自哪个节点、路径是什么、同级节点有哪些。检索时把这些元数据作为过滤条件或加权项。改动小,收益直接。

5.3 作为 Agent 的导航工具

在 RAG 智能体场景里,PageIndex 可以暴露成几个工具:list_sections、get_node_content、search_within_node。Agent 先看目录,再决定去哪个章节找,找不到再全局搜。这种“先导航再检索”的模式,比一次性向量检索更接近人类查资料的习惯,也更容易解释检索路径。

6. 几个我踩过之后才明白的经验

第一,不要追求全自动。文档解析和层级识别,做到 90% 自动就够了,剩下 10% 留人工校验入口。我见过团队非要做到 100% 自动,结果规则越堆越多,维护成本爆炸,效果还不如人工抽检。

第二,摘要长度要控制。节点摘要超过 200 字,向量就开始“泛化”,区分度下降。我的经验值是 80 到 150 字,路径信息占三分之一,核心内容占三分之二。

第三,树不要建太深。超过四层的树,检索时回溯路径太长,LLM 拼上下文也容易超 token。遇到特别深的文档,我会在第三层做一次“折叠”,把更深的层级压成列表挂在第三层节点下。

第四,更新策略要提前想。文档改了一个小节,理想情况是只更新那一个子树。但如果摘要依赖子节点,改子节点就要重算父节点摘要。我的做法是摘要异步更新,检索时先用旧摘要,后台慢慢刷新,避免更新阻塞查询。

PageIndex 这个方向,本质上是在回答一个问题:当文档本身有结构时,我们为什么要把它打碎了再检索?把结构保留下来,让检索沿着结构走,很多 RAG 的“老大难”问题会自然消解。它不复杂,但需要耐心调解析、调摘要、调检索策略。如果你正在被跨章节问答和表格检索折磨,不妨试试这条路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询