1. 为什么我会放弃向量检索,重新思考长文档 RAG 的底层逻辑
先交代一下背景。我最近在做某知识库系统的长文档问答模块,文档动辄几百页,PDF 转文本之后几十万字起步。最初方案和大家一样,切片、向量化、top-k 召回,但实测下来问题非常集中:召回的片段缺乏上下文连贯性,答案经常把不同章节的内容缝合在一起,甚至出现“看似相关但实际答非所问”的幻觉。
我排查了很久,发现根因不在于 embedding 模型选得不好,而在于“向量检索 + 切片”这套组合本身就不适合长文档的精确问答场景。向量检索擅长的是“语义相似”,但长文档问答真正需要的是“位置精确”和“逻辑完整”。一个几十万字的文档,真正的答案往往只在一个几十行的段落里,但它的语义近邻可能分散在十几个不同的章节。
于是我开始尝试一个更朴素的思路:不靠向量检索,而是利用文档自身的结构信息(页码、章节标题、段落层级)建立索引,配合大模型的分步阅读理解能力,把“大海捞针”变成“按图索骥”。我把这套方案命名为 PageIndex,目前在一个模拟的合规审查项目中跑通了,效果比之前的向量方案稳定很多。
这篇内容就围绕这套思路展开,适合正在做 RAG 应用、被长文档召回质量折磨的开发者参考。我会讲清楚为什么索引比向量更可靠、PageIndex 的核心流程怎么设计、具体代码怎么写、以及我踩过的那些坑。
2. PageIndex 整体设计与思路拆解
2.1 核心假设:长文档问答的难点不在语义,而在定位
先做个简单类比。想象你在读一本厚书,你想知道“合同解除的违约责任怎么认定”。你不会翻遍全书找所有“语义相近”的句子,而是先翻目录,找到“违约责任”这一章,再翻到对应页码,精读那几页。向量检索的做法相当于让一个没看过目录的人靠“感觉”全书找相似内容,找到什么算什么,而 PageIndex 的做法是让模型先读目录,再定位页码,再精读。
这个类比背后是长文档问答的一个关键特性:文档本身有强结构,结构里已经包含了绝大多数的定位信息。章节标题、页码、段落编号、图表编号,这些在生成文档时就被写好了,却被传统 RAG 流程当作无意义文本一起切碎、向量化,信息损失非常严重。
PageIndex 的核心假设就是:如果模型能先理解文档结构,再带着结构信息去定位目标内容,那么召回准确率会远高于纯向量召回。因为结构是“精确”的,而向量相似度是“模糊”的。
2.2 与传统向量 RAG 的本质差异
传统 RAG 的处理链路是:
文档 -> 切片 -> 向量化 -> 向量库 -> 相似度检索 -> 拼接上下文 -> 生成答案
PageIndex 的处理链路是:
文档 -> 结构解析(页码/章节/标题) -> 生成结构化索引 -> 模型逐步定位 -> 抽取目标内容 -> 生成答案
两者的核心差异有三点:
| 对比项 | 传统向量 RAG | PageIndex |
|---|---|---|
| 定位依据 | 语义向量相似度 | 文档结构(页码/标题/层级) |
| 上下文来源 | 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_factory=list) 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, start=1): 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( title=stripped, level=1, page_start=page_num, page_end=page_num, content_start=char_start, content_end=char_start + len(stripped) ) root_nodes.append(current_chapter) current_section = None elif is_section_title(stripped): # 启发式判断节标题 current_section = IndexNode( title=stripped, level=2, page_start=page_num, page_end=page_num, content_start=char_start, content_end=char_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_ascii=False, indent=1) prompt = f"""你是文档检索助手。下面是一份文档的结构化索引,每个节点包含标题和页码范围。 请根据用户问题,找出最可能包含答案的章节。只输出 JSON 格式的 {{"index": 章节下标}}。 用户问题:{question} 文档索引: {index_json} 注意: - 如果问题涉及多个章节,选择最核心的一个 - 如果问题太宽泛,选择主要涉及的那个章节 - 不要修改问题,不要直接给出答案 """ resp = client.chat.completions.create( model="gpt-4o-mini", # 按实际使用的模型调整 messages=[{"role": "user", "content": prompt}], temperature=0, 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( model="gpt-4o", # 精读阶段用更强模型 messages=[{"role": "user", "content": prompt}], temperature=0 ) 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 里,强制要求模型在回答时每一条结论都标注“页码 + 原文章节编号”。这个简单的约束会让模型的注意力强制聚焦到原文上,实测可以再降低一两个百分点的幻觉率,而且答案的可追溯性极大提升,用户信任度完全不一样。