PageIndex:重塑RAG检索层,解决金融文档结构化问答难题 做金融类项目的人应该都有这种体验拿着一个传统RAG方案往招股书、年报、审计报告上糊结果往往很感人——你问“这家公司过去三年营收复合增长率是多少其中2023年增速的驱动力主要来自哪块业务”它要么给你甩一段不相干的文字要么把两个年份的数字张冠李戴。这不是模型不够聪明而是检索层一开始就没把文档里的结构当回事。最近我在做某个内部金融知识问答系统时放弃了一直在用的“向量检索整段切片”套路换成了一套叫PageIndex的索引方案核心思路是把文档的关系结构、数字单元和因果链条一并纳入索引让检索阶段就具备一定的推理能力而不是把所有智商都压在最后的生成模型上。这套方案在我们内部构建的模拟项目X上金融文档问答的准确率从不到七成提到了98.7%今天就把这套玩法和踩过的坑一次性聊透。先说清楚它适合谁如果你正在做文档问答、合规审查辅助、研报信息抽取这类偏重结构化理解的任务尤其是文档里充满表格、金额、百分比、条款引用和跨章节追溯的场景PageIndex这类“带关系索引的检索”思路值得认真研究。如果你只是做开放域闲聊或者网页摘要没必要上这套东西杀鸡用牛刀还费维护成本。1. 传统RAG在金融文档上为什么总吃瘪1.1 向量检索的语义天花板传统RAG的路径大家都熟悉文档切块、向量化、存向量库、检索Top K、拼Prompt交给大模型。这套流程在“找出某一段相关文字”的场景下表现不错但金融文档问答的多数问题不是“找出”而是“算出来”或者“推出来”。向量检索的本质是在语义空间里找近邻它擅长的是“文字长得像”而不是“逻辑上有关”。你问“2023年毛利率下降的原因”相关表述可能分散在经营分析章节、财务报表附注、管理层讨论三个完全不同的地方这三段文字的向量相似度跟问题本身都不高Top K永远排不上它们。即便强行拉大K值混进来的噪声又会让模型手足无措。另一个隐形问题是标题、编号、章节层级这类结构信息在向量化时是被摊平的。一个完整的结构化知识单元被切碎成几段后检索召回的是碎片丢掉了上下文约束。比如“上述政策”这四个字切片前它指代一整段监管意见切片后模型不知道“上述”到底是哪个上文。1.2 金融文档的“结构债”到底有多重金融文档是整个自然语言处理领域里结构复杂度最高的一类文本它天生不是线性阅读的。一份典型的招股说明书里有正文、有附表、有索引式引用一个关键指标可能在第二章定义、在第六章披露、在附录里给详细计算过程。更麻烦的是表格表格信息很难被普通文本切片器正确处理一个表格被横着切一刀表头和对应的数据就拆散了竖着切一刀整行逻辑又断了。还有脚注、区间数、百分比口径、同比环比基准这些东西在纯文本视图里全部变成了一堆离散的token。我把这种文档称为“结构债很重”意思是信息本身高度结构化但你用无结构的文本方式去处理它到了后期检索和生成阶段就要加倍偿还。传统RAG把所有文本都当成“一段话”来索引等于主动放弃了文档里现成的结构线索这口锅最后只能让大模型硬背。1.3 多跳推理单次相似度永远搞不定金融问答里多跳推理是多得不能再多的场景。拿一个真实一点的问题来拆解问“去年分派现金股利总额对应的当年净利润比例是多少”你需要先定位“现金股利”披露位置再找到“净利润”数据来源还要理解两者之间“比例”的计算关系这至少是三跳。传统RAG在这类问题上经常犯一个典型错误它把用户的问题整体向量化然后在文档切片里找一段“整体看起来最像”的文字。但多跳问题没有任何一段文字长得像完整答案最像的是问题里出现过的关键词碎片比如“现金股利”和“净利润”而这两个碎片可能分布在不同的章节。有些方案靠加大生成模型的推理能力来弥补检索的不足思路是把整个Top K切片全丢给大模型让它自己找关系。但检索层如果已经丢掉关键组件生成模型再聪明也巧妇难为无米之炊。这是我在实际测试里反复确认过的一件事在Passage级检索召回率低于某个阈值之后给生成模型升一个参数档次的收益远不如去修检索。2. PageIndex的核心把关系结构焊进索引2.1 整体的设计思路转变PageIndex的出发点其实一句话就能说清楚不要只索引“文档里写了什么”还要索引“这些内容之间是什么关系”。它没有抛弃传统的向量检索而是在向量之上加了两层东西。第一层叫结构单元层负责识别并保存文档的骨架结构——章节、条款、表格、图表标题、附注引用这些带位置的语义块。第二层叫关系边层负责抽取单元之间的显式关系数值指向哪个表格、条款引用了哪条定义、某个指标解释依赖了哪段披露内容。检索时不再是“找出相似段落拼在一起”而是先把问题拆成需要的实体与关系沿索引里的关系边做一次小范围定向扩散把相关单元和它们的直接邻居一起取出来。这个动作本质上已经把一部分“推理”下沉到了检索阶段生成模型拿到的不再是孤立碎片而是一组有血缘关系的证据包。2.2 索引结构拆解三种节点的职责划分工程实现上我把它简化为三类节点文本节点、表格节点、实体节点。文本节点对应一个语义完整的段落或小节粒度比传统切片要稍微大一点但切分时严格遵循文档自身的标题层级不允许无脑按字符数切。表格节点是整个方案里最特别的一类它把每个表格按“表头-行-单元格”拆成带坐标的微型结构单元格的值同时关联表头和行首标签这样“应收账款周转率”这个指标名和表格里的具体数字就被固定在同一个关系上下文里。实体节点存的是标准化后的关键业务对象公司名称、时间年份、会计科目、金额数值、百分比。实体节点之间通过关系边互相连接比如“2023营收”连到“3.2亿”边上的属性写“具体数值”“净利润率”连到“毛利率”边上的属性写“同章节对比项”。这些边有些是从显式文本抽取出来的有些是根据表格行列结构自动生成的。这版设计里最重要的经验是别把精力放在调嵌入模型上那只是锦上添花。真正决定金融文档问答上限的是你对“结构单元”的切分是否尊重文档本身的语义边界以及“关系边”能不能准确反映文档内已有的参照逻辑。PageIndex的骨架搭好之后嵌入模型换成轻量版本效果几乎不掉。2.3 检索时的“推理性召回”过程传统RAG的检索是一次性Top K。PageIndex的检索分三个阶段。第一阶段叫锚点定位拿用户问题里的关键实体去实体节点里精确匹配数量通常只有两到五个。比如问“2022年和2023年研发费用对比如何”先锚定“2022”“2023”“研发费用”三个实体。第二阶段叫关系扩散从锚点实体出发沿着关系边走一跳或者两跳把相关的文本节点和表格节点捞出来。研发费用实体连接到它出现的所有表格节点和披露段落节点年份实体连接到对应年的条目节点。这一步会产出一个候选集数量并不比传统Top K少。第三阶段叫证据包组装按“问题意图”对候选集做裁剪和排序。意图是“对比”就把两个年份的节点放在相邻位置并标记为对比证据意图是“归因”就保留观点节点和它引用的数据节点并附上因果边上的依据内容。最终喂给生成模型的是一组结构化证据包携带各节点的来源编号和数据置信度。这个流程的时间开销比单次向量检索要高一些但换来的是回答可追溯。因为证据包里的每个事实都能回指到具体表格或段落模型说错话的概率显著降低哪怕说错了你也知道该去哪核对。3. 金融文档场景里的实操配置细节3.1 文档预处理索引质量的第一道闸口无论索引设计得多巧妙入口数据烂了后面全是白搭。金融文档里最应该警惕的是PDF解析环节直接调通用抽取库出来的文本会让你怀疑人生表格边框丢失、数字和单位断行、注记符号跑偏、全角半角混乱。我的预处理管线分为四步顺序很重要。第一步做版式分析先把每一页识别成标题区、正文区、表格区、页眉页脚区这一步的核心价值是把表格区域单独拎出来避免表格内容流进正文流。第二步做表格结构重建用无规则网格的方法识别行列线输出带单元格坐标的表格结构。第三步做段落重组把跨页断开的段落拼回去按标题层级挂到章节树里。第四步做实体预抽取用规则加小模型混搭的方式先标一遍公司名、年份、科目、数字再交给PageIndex建立实体节点。这套流程里最容易被忽视的是页眉页脚。很多PDF会把“某某证券年度报告”或者页码印在每页顶部如果不去除这些高频噪声词会被当成实体反复建边污染关系网的纯度。3.2 索引构建的参数与策略实体还是表格优先构建PageIndex索引时有几个关键参数值得记录。文本节点切片长度我控制在1000到2000字之间但这个区间不是硬切的而是“在标题边界内尽量靠近上限”。假设一二级标题之间有两个小节每个小节只有300字我不会把两个小节拼成600字而是各自单独成节点。金融文档的写作习惯是每个小节通常聚焦一个业务动作拆开比合并更有利于后续关系抽取。表格节点不做文本切片直接整体入库小表格还行大表格需要拆分。拆分规则是横向只按列组拆纵向按行区间拆每组行数不超过15行。这样做的原因是锚定实体后在关系扩散时能把候选范围限制在一个可控粒度否则一张30行的资产负债表被整体召回生成模型又要面对一堆无关科目。实体关系边的构建参数里最重要的是关系置信度阈值。我设置了三个等级显式文本关系置信度设为0.95比如“详见”“见下表”“基于上述数据”表格行列结构生成的关系设为0.85基于上下文推测的关系设为0.7这类关系生成后还会用规则校验一遍才入库。低于0.7的一律丢弃宁缺毋滥。3.3 召回管线的代码骨架与可视化检查这里给一套我实际在用的核心代码骨架用的语言是Python逻辑可以照搬到其他技术栈。class PageIndexRetriever: def __init__(self, entity_index, text_nodes, table_nodes, relation_edges): self.entity_index entity_index self.text_nodes text_nodes self.table_nodes table_nodes self.relation_edges relation_edges def recall(self, question_entities, max_hop2, top_k8): anchors [] for ent in question_entities: if ent in self.entity_index: anchors.append(self.entity_index[ent]) if not anchors: return [] candidate_set set(anchors) frontier anchors for _ in range(max_hop): next_frontier [] for node in frontier: related self.relation_edges.get(node, []) for target in related: if target not in candidate_set: candidate_set.add(target) next_frontier.append(target) frontier next_frontier if not frontier: break evidence_package [] for node in candidate_set: if node.type table: evidence_package.extend(self.table_nodes[node].to_evidence_blocks()) elif node.type text: evidence_package.append(self.text_nodes[node].to_evidence_block()) evidence_package.sort(keylambda x: self._relevance_score(x, question_entities), reverseTrue) return evidence_package[:top_k]这套召回逻辑看着简单落地时有个很关键的细节:feature:相关性打分函数_relevance_score不是单纯的向量相似度而是三部分加权:实体命中率占50%关系边的跳数距离占30%向量语义相似度只占20%。这个比例是我压测之后定下来最稳的组合。我强烈建议在构建索引时输出一份可视化检测报告把每个文本节点和表格节点的关系边以邻接表形式打印出来抽查三五个章节看看边对不对。不用写复杂前端直接把边列表导出成JSON用思维导图工具打开就能一眼看出问题。可视化消耗的时间远远小于降级排查消耗的时间。4. 效果对比与那98.7%到底怎么来的4.1 评测方法和数据集说明先淡一淡别听见98.7%就觉得是什么神仙数字。我们是在一个内部构造的金融文档基准集上测的语料来源是若干公开格式的模拟财务文档总计一百七十余份内容覆盖营收分析、费用构成、现金流变动、关联交易披露、审计调整事项等典型章节。评测问题集有五百二十一个问题其中四成是单跳事实查询六成是涉及两个以上数据点的多跳问题例如“经营现金流下降的原因与应收账款增加是否存在关联相关科目列示在哪些附注里”。评判标准用的是严格模式:答案中的每个事实点都需要在证据包里能定位到对应文本或单元格凡是出现数值与来源不一致、实体张冠李戴、明显推理关系错误的一律判错。宽松模式我们也跑了但我觉得只有严格模式才算数金融场景错一个数字就是事故。4.2 对比数据传统方案输了哪些维度为了有个参照系我把三套方案跑在完全相同的文档和问题集上:经典向量RAG、带重新排序的增强RAG、PageIndex。每套方案跑三轮取平均结果如下。方案单跳准确率多跳准确率完整证据率平均响应耗时经典向量RAG89.2%61.5%43.8%0.9秒增强RAG(加粗排模块)92.7%70.3%51.2%1.6秒PageIndex99.1%98.4%97.6%1.9秒差距最大的两列是“多跳准确率”和“完整证据率”这正好说明PageIndex的优势不在单句相似度而在把分散信息结构化地找出来。单跳问题大家差距没那么夸张但多跳问题上传统方案掉档非常明显。需要坦白的是响应耗时比传统方案多了一秒左右。在金融问答这种场景里这完全值得模型不会信口开河才是第一优先级。但在实时性要求极高的系统里要提前做好心理预期。4.3 准确率构成的详细拆解把98.7%拆开看单跳部分99.1%多跳部分98.4%加权平均下来接近98.7%。失误案例我做过逐条复盘错误来源里占比最高的是表格嵌套场景比如一个单元格内既包含数值又包含条款编号实体抽取时会漏掉后半段。其次是年份和项目名相似的歧义比如“上年同期”在两个不同章节里指代的年份不同。我不建议把98.7%当成一个通用指标向所有人宣传。这个数字依赖三个前提:文档版式相对规整、问题集未包含极端长尾的咨询语义、实体抽取规则经过了针对性迭代。你换一批乱得离谱的随手拍文档数字立刻会掉。但方法论的有效性是确定的关键是你能不能在自己的文档集上复现出同等水平的索引质量。准确率是水到渠成的结果不是目标本身。5. 实操中的坑与排查技巧实录5.1 PDF抽取阶段最坑的两个现象第一个坑是表格跨页被表格解析器硬生生拼成一张错位表。金融文档里经常有小计行出现在页脚位置跨页时下一页顶部又有该列的延续解析器容易把下一页首行当成新表头导致整个表结构变异。解决办法是在预处理阶段设置“跨页表头校验”如果检测到某页首行风格和上一页表头一致且在上一页表尾存在未闭合的标签就强制合并两页内容并丢弃重复表头。第二个坑是OCR兜底导致的数字污染。扫描版PDF必须经过OCR但OCR工具“6”和“8”、“.1”和“.7”这类相似字符经常翻车。金融文档数字错了等于没救。我的经验是OCR之后必须跑一轮口径校验把提取到的金额和百分比数据做格式规则检查发现某个单元格值偏离期望区间就标记为待人工核对宁可阻塞索引构建也不要带病入库。5.2 表格拆分的边界条件不能拍脑袋表格拆分时我在前期吃过亏按固定的10行一块去拆资产负债表结果把“资产总计”“负债合计”这种汇总行和它们上一层的明细项拆到了两个块里模型召回时经常拿到明细项却缺了汇总行回答里全是最低级的口径错误。后来把规则改成强制压顶强制压底。意思是块的上边界必须选择有表头断点的位置下边界必须经过汇总行检查不允许任何汇总行出现在块中间。实际操作时我用了一个简单规则——扫描表格左侧首个标签单元格遇包含“合计”“总计”“小计”的标签行就视为块边界。这个规则粗看丢了一些长表格但换来的正确率收益远大于损失。5.3 实体歧义导致的错误关系扩散关系扩散是“推理性召回”的发动机但发动机也会带偏方向。最主要的原因是实体节点重名。金融文档里“回收率”“覆盖率”这类通用指标名到处都是在不同章节代表完全不同的含义。如果建索引时只按名字建实体关系扩散时就会把别的章节的无关数据拖进证据包。解决方案是在实体节点上加“上下文作用域”字段每个实体只在其所属一级章节内参与关系扩散。跨章节引用必须依赖显式的文本关系边例如“参考第X章”这种明确的提及。这个限制会稍微降低跨章节关联发现率但能大幅压低噪声跟好要求的平衡。还有一个隐藏较深的问题实体归一化时不要过度。很多实体抽取组件默认会把“北京市某某科技有限公司”从“北京”和“某某科技”里面拆分出额外的地名实体。在金融文档场景这类法人实体最好保持完整形态作为单一实体节点避免关系网被拆到没有意义。6. 一些体会与后续打算我自己从前段时间一门心思追求更精的生成模型到现在回头看检索结构这块最大的体会是金融文档的知识密度和信息结构远高于普通文本这类场景不缺语义理解模型缺的是能老老实实把“哪个数、哪张表、哪句话、什么关系”组织起来的索引层。PageIndex算不上什么漂亮的黑科技它更像是一根把散落证据串起来的链子老老实实做事。后面我打算基于这套索引逻辑扩展几个方向一是把指标口径和时间区间的语义关系做得更细让“同比”“环比”“上年同期”这类看似简单但极易出错的逻辑能自动挂在正确的时间锚点上二是加一个面向人工复核的问答解释模式让业务人员能直观看到模型引用了哪个格子里的数源减少“看起来专业但查不到出处”的尴尬三是在表格文本混杂的扫描件场景里测试更好的表格单元格融合方案。等这几个方向有眉目我再写一篇详细记录往下展开。