放弃向量检索,用PageIndex按图索骥:长文档RAG新思路

发布时间:2026/10/17 5:27:00
放弃向量检索,用PageIndex按图索骥:长文档RAG新思路
1. 为什么我会放弃向量检索重新思考长文档 RAG 的底层逻辑先交代一下背景。我最近在做某知识库系统的长文档问答模块文档动辄几百页PDF 转文本之后几十万字起步。最初方案和大家一样切片、向量化、top-k 召回但实测下来问题非常集中召回的片段缺乏上下文连贯性答案经常把不同章节的内容缝合在一起甚至出现“看似相关但实际答非所问”的幻觉。我排查了很久发现根因不在于 embedding 模型选得不好而在于“向量检索 切片”这套组合本身就不适合长文档的精确问答场景。向量检索擅长的是“语义相似”但长文档问答真正需要的是“位置精确”和“逻辑完整”。一个几十万字的文档真正的答案往往只在一个几十行的段落里但它的语义近邻可能分散在十几个不同的章节。于是我开始尝试一个更朴素的思路不靠向量检索而是利用文档自身的结构信息页码、章节标题、段落层级建立索引配合大模型的分步阅读理解能力把“大海捞针”变成“按图索骥”。我把这套方案命名为 PageIndex目前在一个模拟的合规审查项目中跑通了效果比之前的向量方案稳定很多。这篇内容就围绕这套思路展开适合正在做 RAG 应用、被长文档召回质量折磨的开发者参考。我会讲清楚为什么索引比向量更可靠、PageIndex 的核心流程怎么设计、具体代码怎么写、以及我踩过的那些坑。2. PageIndex 整体设计与思路拆解2.1 核心假设长文档问答的难点不在语义而在定位先做个简单类比。想象你在读一本厚书你想知道“合同解除的违约责任怎么认定”。你不会翻遍全书找所有“语义相近”的句子而是先翻目录找到“违约责任”这一章再翻到对应页码精读那几页。向量检索的做法相当于让一个没看过目录的人靠“感觉”全书找相似内容找到什么算什么而 PageIndex 的做法是让模型先读目录再定位页码再精读。这个类比背后是长文档问答的一个关键特性文档本身有强结构结构里已经包含了绝大多数的定位信息。章节标题、页码、段落编号、图表编号这些在生成文档时就被写好了却被传统 RAG 流程当作无意义文本一起切碎、向量化信息损失非常严重。PageIndex 的核心假设就是如果模型能先理解文档结构再带着结构信息去定位目标内容那么召回准确率会远高于纯向量召回。因为结构是“精确”的而向量相似度是“模糊”的。2.2 与传统向量 RAG 的本质差异传统 RAG 的处理链路是文档 - 切片 - 向量化 - 向量库 - 相似度检索 - 拼接上下文 - 生成答案PageIndex 的处理链路是文档 - 结构解析页码/章节/标题 - 生成结构化索引 - 模型逐步定位 - 抽取目标内容 - 生成答案两者的核心差异有三点对比项传统向量 RAGPageIndex定位依据语义向量相似度文档结构页码/标题/层级上下文来源top-k 切片拼接目标章节精读对长文档的适应性切片越碎越失真结构不丢失天然适配幻觉风险多片段语义缝合较高单章节聚焦较低实现复杂度中等需部署向量库较低只要解析结构 调用模型这里不是说向量检索一无是处它对“开放式问题”“跨文档主题检索”仍然很有效。但如果你面对的是“某个具体条款怎么规定”这种精确查找类问题向量检索其实是杀鸡用牛刀还经常杀不准。2.3 为什么 PageIndex 能在大模型时代成立放在两三年前PageIndex 这个思路很难落地因为当时的模型理解长文本的能力太弱。但现在的大模型已经能在上下文中处理几万甚至几十万 token具备通读长文档的能力。问题变成了如何让模型把注意力集中到正确的位置而不是让它从头到尾盲读。PageIndex 做的就是这件事像给模型配了一个“目录 页码索引”让它先看目录再翻页再精读把大模型的注意力从“全文扫描”降维到“定点精读”。这也符合当前模型能力的发展方向——不依赖外部检索增强而是把文档理解本身做好。3. PageIndex 的核心细节解析3.1 索引结构化不只是目录而是“位置感知”的分层树传统目录只是一个章节列表PageIndex 需要的是更细粒度的位置索引。我的做法是构建一个三层的结构化索引树第一层章Chapter对应文档的一级标题记录页码和标题文本第二层节Section对应二级标题记录页码和标题文本第三层子节/段落Subsection/Paragraph对应三级标题或连续段落块记录页码范围最关键的是要给每个索引节点标注“物理位置”不是简单的章节序号而是精确到页码。因为页码才是模型真正能“翻到”的位置。举个例子某合规文档的结构解析后是这样索引节点标题页码第1章总则1-10第1.1节适用范围2-3第1.2节定义与术语4-6第2章业务流程合规要求11-30第2.1节客户身份识别12-15第2.2节交易监测与报告16-22这个索引表就是 PageIndex 的心脏。它本身只有几百行占用的 token 极少却能把几十万字的文档“压缩”成一张可导航的地图。3.2 两步定位法先定章节再精读段落有了索引树之后回答问题的流程分两步第一步章节定位粗粒度。把用户问题 索引表一起交给模型让模型输出最相关的章节编号。这一步的信息量很小索引表可能只有几百 token模型很容易就能做出判断。第二步段落精读细粒度。根据第一步选出的章节编号从解析好的文档中提取该章节的全文然后再次交给模型让它直接结合原文生成答案。这里有一个重要的设计选择第二步要不要再细分我尝试过在章节定位后再做段落定位但实测效果提升并不明显反而增加了延迟。因为一个章节的文本量通常在几千到几万字之间正好在模型当前上下文窗口的舒适区内直接精读比多级跳转更可靠。只有在章节特别长比如超过上下文窗口时才有必要再拆一次。3.3 为什么不直接切片切片破坏了“位置”信息很多向量 RAG 方案为了适配模型上下文窗口会把长文档切成 500-1000 字的片段。这样做的副作用是位置信息被切碎了。什么叫位置信息被切碎举个例子某文档第 2 章“交易监测”里有一句话“参见 3.2 节的异常报告时限要求”。如果这句话在第 2 章末尾而 3.2 节的内容在第 3 章开头那它们会被切成两个不同的切片。向量检索时由于“3.2 节”和“异常报告时限要求”在语义上关联度不够强模型可能永远无法把这两个信息缝合起来。PageIndex 不切片而是保留完整的章节块。虽然单次生成的 token 消耗会更多但换来的是上下文完整模型不会漏掉交叉引用、前后条款的一致性表述这些关键信息。这也是 PageIndex 在合规、法务这类对精确性要求极高的场景下表现突出的原因。4. 实操过程与核心环节实现4.1 环境准备与依赖安装我用的环境是 Python 3.10主要依赖就三个用于 PDF 解析的 pypdf、用于文档结构解析的 python-docx针对 Word 格式、以及调用大模型 API 的 openai 库。如果你处理的是纯文本或 Markdown连 pypdf 都可以省。pip install pypdf python-docx openai说一下为什么没有引入向量库。PageIndex 的索引表就是普通的结构化数据用 JSON 存就行完全不需要专门的向量数据库。这一点对部署环境不宽裕的场景非常友好省掉了一个 middleware 的运维成本。4.2 核心数据结构与代码实现先定义索引树的数据结构我用的是 dataclass清晰可读也方便后续逻辑处理from dataclasses import dataclass, field from typing import List, Optional dataclass class IndexNode: 索引节点代表文档结构中的一个层级 title: str # 节点标题 level: int # 层级1章2节3子节/段落 page_start: int # 起始页码 page_end: int # 结束页码 content_start: int # 在全文文本中的字符起始位置 content_end: int # 在全文文本中的字符结束位置 children: List[IndexNode] field(default_factorylist) def to_dict(self): return { title: self.title, level: self.level, page_start: self.page_start, page_end: self.page_end, content_start: self.content_start, content_end: self.content_end, children: [child.to_dict() for child in self.children] }然后是从 PDF 中解析结构并构建索引的代码。这里的关键技巧是利用 PDF 中文本的字体大小和位置坐标来推断标题层级。pypdf 可以提取每个文本块的坐标和字体信息通常章标题字号最大且居中节标题次之。from pypdf import PdfReader def build_index_from_pdf(pdf_path: str, font_size_thresholds: dict None) - (str, List[IndexNode]): 从 PDF 构建 PageIndex 索引 返回 (全文文本, 索引树根节点列表) reader PdfReader(pdf_path) full_text [] root_nodes [] current_chapter None current_section None if font_size_thresholds is None: font_size_thresholds {chapter: 16.0, section: 13.0} for page_num, page in enumerate(reader.pages, start1): page_text page.extract_text() if not page_text: continue char_start len(.join(full_text)) full_text.append(page_text \n) # 用 extract_text 加上 layout mode 可以获取字体大小信息 # 这里为了展示核心逻辑简化处理按行判断 lines page_text.split(\n) for line in lines: stripped line.strip() if not stripped: continue # 通过字体大小判断层级这里简化为通过正则和长度做启发式判断 # 实战中应结合 extract_text 的 layout 模式和字体信息 if len(stripped) 50 and not stripped.endswith(。): if is_chapter_title(stripped): # 启发式判断章标题 current_chapter IndexNode( titlestripped, level1, page_startpage_num, page_endpage_num, content_startchar_start, content_endchar_start len(stripped) ) root_nodes.append(current_chapter) current_section None elif is_section_title(stripped): # 启发式判断节标题 current_section IndexNode( titlestripped, level2, page_startpage_num, page_endpage_num, content_startchar_start, content_endchar_start len(stripped) ) if current_chapter is not None: current_chapter.children.append(current_section) # 更新当前章节的结束位置 if current_chapter is not None: current_chapter.page_end page_num current_chapter.content_end char_start len(page_text) return \n.join(full_text), root_nodes上面的代码为了清晰做了简化遗漏了字体大小判断的具体实现。实战中你可以用 pypdf 的 visitor 回调函数读取每个文本块的字体信息或者用 pdfplumber 提取更精确的坐标数据和字号数据。核心原则是一样的利用排版特征字号、加粗、位置代替人工标注自动生成索引。4.3 两步定位的模型调用实现索引建好后接下来是模型调用层面的实现。第一步的章节定位直接把用户问题和索引表拼进 prompt让模型输出章节编号。import json from openai import OpenAI client OpenAI() def locate_chapter(question: str, index_data: List[dict]) - int: 第一步章节定位返回索引数组下标 index_json json.dumps(index_data, ensure_asciiFalse, indent1) prompt f你是文档检索助手。下面是一份文档的结构化索引每个节点包含标题和页码范围。 请根据用户问题找出最可能包含答案的章节。只输出 JSON 格式的 {{index: 章节下标}}。 用户问题{question} 文档索引 {index_json} 注意 - 如果问题涉及多个章节选择最核心的一个 - 如果问题太宽泛选择主要涉及的那个章节 - 不要修改问题不要直接给出答案 resp client.chat.completions.create( modelgpt-4o-mini, # 按实际使用的模型调整 messages[{role: user, content: prompt}], temperature0, response_format{type: json_object} ) result json.loads(resp.choices[0].message.content) return int(result[index])第二步的段落精读根据第一步返回的下标找到索引节点再从全文文本中截取对应位置的内容拼进 prompt 让模型最终回答。def answer_with_chapter(question: str, full_text: str, node: IndexNode) - str: 第二步章节精读直接基于目标章节内容回答 # 找到章节在全文中的起止位置 chapter_text full_text[node.content_start:node.content_end] prompt f你是文档内容解读专家。下面是文档中某个章节的原文请基于原文回答用户问题。 要求 1. 只依据给定章节内容回答不要使用外部知识 2. 如果章节内容不足以回答问题明确回答当前章节未找到足够信息 3. 回答时引用原文关键表述标注出处页码 章节原文 {chapter_text} 用户问题{question} resp client.chat.completions.create( modelgpt-4o, # 精读阶段用更强模型 messages[{role: user, content: prompt}], temperature0 ) return resp.choices[0].message.content注意到这里我做了不对称的模型选择章节定位用的是轻量模型精读回答用的是更强模型。原因是定位任务逻辑简单但需要准确轻量模型配 JSON 结构化输出完全够用而精读任务涉及复杂的语义理解和推理需要更强的模型来保证质量。这个设计也能控制成本毕竟精读阶段消耗的 token 远大于定位阶段。4.4 完整流程串联把两个步骤串联起来就是一个完整的 PageIndex 问答流程def pageindex_qa(question: str, pdf_path: str): # 1. 构建索引可缓存不需要每次重建 full_text, root_nodes build_index_from_pdf(pdf_path) # 2. 索引序列化为 JSON index_data [node.to_dict() for node in root_nodes] # 3. 第一步章节定位 node_index locate_chapter(question, index_data) target_node root_nodes[node_index] # 4. 第二步章节精读 answer answer_with_chapter(question, full_text, target_node) return answer有一点需要特别提醒索引构建是离线操作尽量缓存。一份几百页的文档索引构建只需要跑一次生成一个 JSON 文件之后所有问答都复用同一份索引。我在实际系统里会把索引序列化后存成文件只有文档更新时才重新构建这样在线问答的延迟就只有两次模型调用。5. 常见问题与排查技巧实录5.1 索引构建不准标题识别失败的兜底策略最大的坑出现在“标题识别”。PDF 的排版千奇百怪有的文档标题没有加粗有的章标题和正文同字号有的目录和正文混在一起。我最初用字大小阈值判断标题经常把正文第一行误判成章标题。解决思路是多重启发式叠加。除了字号还要结合位置特征章标题通常在页面顶部 1/3 区域且居中短文本特征标题很少超过 40 个字且不以句号结尾连续空白特征标题前后通常有较大空白间隔如果多重特征仍然无法可靠识别最后的兜底方案是让模型介入索引构建。把整页文本交给模型让它输出“这一页包含哪些标题、标题的层级是什么”。虽然慢但准确率显著提高。我一般只在自动识别失败率超过 10% 时才启用这个方案。5.2 章节定位不准问题指涉多个章节怎么办章节定位是 PageIndex 最容易出错的一环。典型场景是问题涉及两个章节的内容比如“客户身份识别和交易监测分别有什么要求”。我的定位模块只输出一个章节导致另一半答案丢失。解决方法是仿照“子代理”思路做一次预分流先让模型判断问题是否涉及多个章节若涉及则拆分成多个子问题每个子问题单独定位、单独精读最后汇总。算子拆分可以由模型完成也可以由简单的规则完成比如检测到“和”“以及”“分别”等连接词时触发拆分。拆分后每个子问题走一遍 PageIndex 流程最后把各部分的答案拼起来。实测这个策略能把多章节问题的准确率从 60% 提到 90% 以上。5.3 上下文超长章节文本超过模型窗口怎么办部分章节非常长比如一个“案例分析与处罚决定”章节可能多达几万字。直接全文塞进上下文轻则超限报错重则模型注意力被稀释回答质量下降。我试过两个方案各有适用场景方案一锚点定位后再精读。从章节内提取子标题三级标题或加粗段落构建章节内部的小索引再做一次两步定位。适合子结构完整的文档。方案二滑动窗口 关键句提取。把章节文本切成多个窗口先用一次模型调用提取每个窗口的候选关键句再拼接候选句进入最终回答。适合子结构不明显的连续文本。实际使用中大部分章节长度在 3-8 千字普通模型足以处理。只有极端场景才需要这两种方案所以我没有默认集成而是做成可选开关。5.4 模型幻觉答案引用了章节以外的内容PageIndex 虽然大幅降低了幻觉概率但没有完全消除。我在测试中发现一种典型幻觉模型在精读阶段引用了章节原文中的话但把页码标注错了。比如原文在第 12 页模型回答“见第 14 页”。排查后发现原因是页码信息没有传进精读 prompt。我的实现里章节原文是从全文文本中截取的截取时携带了内容位置但拼进 prompt 时没有把页码讲清楚。修复方法是在提取章节文本时同时记录每一段对应的页码映射精读 prompt 中明确标注“以下内容摘自第 X-Y 页”。这里有个值得分享的细节页码标注不要靠模型自己推断要在数据层做好映射。PDF 转文本时用 pypdf 的 visitor 回调可以在提取每个文本块时记录对应的页码然后在章节提取时建立“字符位置 - 页码”的映射表回答中的页码引用就能做到完全准确。6. PageIndex 的边界与扩展可能性6.1 这套方法不适合什么场景先说不足免得大家盲目照搬。PageIndex 的有效性高度依赖文档结构质量。无结构文档比如扫描版 PDF、纯图片、无序笔记没有目录和标题层级索引就无从谈起开放式探索问题比如“总结一下这份文档的核心观点”这类问题不指向特定章节两步定位法没有用武之地极其频繁的文档更新如果文档每天更新几十次索引重建的成本可能超过收益遇到这三类场景我仍然会退回传统向量 RAG或者采用“向量检索 PageIndex 定位”的混合方案向量负责粗召回PageIndex 负责精确定位和上下文补全。6.2 可以扩展的方向PageIndex 的底层思想是“让模型先看地图再看风景”。这个思路还有几个值得探索的扩展。扩展一跨文档问答。把多份文档的索引合并成“文档总索引”先定位文档再定位章节。合规章案、法律文书对比这类场景很合适。扩展二表格与图表索引。长文档里大量信息藏在不规则表格里文本索引定位不到表格单元格。可以单独为表格构建位置索引问答时优先定位相关表格。扩展三全文位置编码。目前 PageIndex 用的是语义层次的“章节定位”更进一步可以做到字符级的“位置编码”——让模型直接预测答案在全文中的字符偏移量。这个方向对模型能力要求更高但一旦成立精确度会远超现在所有 RAG 方案。6.3 与知识图谱的融合思路PageIndex 和知识图谱并不冲突。索引树本身就可以视为文档的“浅层图谱”——章节是节点章节间的引用关系是边。我目前在做的一个方向是把索引树导入图数据库在定位章节的同时顺带检索“与该章节有引用关系的其他章节”让上下文范围自动扩展到交叉引用的位置。比如定位到第 2.1 节如果第 2.1 节提到“参见 3.2 节”PageIndex 扩展模式会自动把 3.2 节的内容一起拉进精读 prompt。这个功能对法务合规场景特别有用因为条款之间互相引用极其普遍。7. 写在最后的实操心得PageIndex 不是什么高深理论它只是把人类阅读长文档的朴素方式搬到了大模型应用里。我实际跑下来最大的感受是很多时候我们过度依赖模型和算法的“智能”而忽略了数据本身的规律。长文档的规律就在它的结构里页码、章节、标题从来没有消失只是传统的 RAG 流程把它们过滤掉了。如果你正准备做长文档问答我建议从最朴素的版本开始解析 PDF 标题构建索引两步定位直接精读。先跑通再优化。不要一上来就上向量库也不要迷信“更大的模型一定更好”先把文档的固有结构利用起来效果往往超出预期。最后分享一个小技巧。在你的精读 prompt 里强制要求模型在回答时每一条结论都标注“页码 原文章节编号”。这个简单的约束会让模型的注意力强制聚焦到原文上实测可以再降低一两个百分点的幻觉率而且答案的可追溯性极大提升用户信任度完全不一样。