做金融类项目的人应该都有这种体验:拿着一个传统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_hop=2, top_k=8): 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(key=lambda x: self._relevance_score(x, question_entities), reverse=True) return evidence_package[:top_k]这套召回逻辑看着简单,落地时有个很关键的细节:feature:相关性打分函数_relevance_score不是单纯的向量相似度,而是三部分加权:实体命中率占50%,关系边的跳数距离占30%,向量语义相似度只占20%。这个比例是我压测之后定下来最稳的组合。
我强烈建议在构建索引时输出一份可视化检测报告,把每个文本节点和表格节点的关系边以邻接表形式打印出来,抽查三五个章节看看边对不对。不用写复杂前端,直接把边列表导出成JSON,用思维导图工具打开就能一眼看出问题。可视化消耗的时间远远小于降级排查消耗的时间。
4. 效果对比与那98.7%到底怎么来的
4.1 评测方法和数据集说明
先淡一淡,别听见98.7%就觉得是什么神仙数字。我们是在一个内部构造的金融文档基准集上测的,语料来源是若干公开格式的模拟财务文档,总计一百七十余份,内容覆盖营收分析、费用构成、现金流变动、关联交易披露、审计调整事项等典型章节。评测问题集有五百二十一个问题,其中四成是单跳事实查询,六成是涉及两个以上数据点的多跳问题,例如“经营现金流下降的原因与应收账款增加是否存在关联,相关科目列示在哪些附注里”。
评判标准用的是严格模式:答案中的每个事实点都需要在证据包里能定位到对应文本或单元格,凡是出现数值与来源不一致、实体张冠李戴、明显推理关系错误的,一律判错。宽松模式我们也跑了,但我觉得只有严格模式才算数,金融场景错一个数字就是事故。
4.2 对比数据:传统方案输了哪些维度
为了有个参照系,我把三套方案跑在完全相同的文档和问题集上:经典向量RAG、带重新排序的增强RAG、PageIndex。每套方案跑三轮取平均,结果如下。
| 方案 | 单跳准确率 | 多跳准确率 | 完整证据率 | 平均响应耗时 |
|---|---|---|---|---|
| 经典向量RAG | 89.2% | 61.5% | 43.8% | 0.9秒 |
| 增强RAG(加粗排模块) | 92.7% | 70.3% | 51.2% | 1.6秒 |
| PageIndex | 99.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算不上什么漂亮的黑科技,它更像是一根把散落证据串起来的链子,老老实实做事。后面我打算基于这套索引逻辑扩展几个方向:一是把指标口径和时间区间的语义关系做得更细,让“同比”“环比”“上年同期”这类看似简单但极易出错的逻辑能自动挂在正确的时间锚点上;二是加一个面向人工复核的问答解释模式,让业务人员能直观看到模型引用了哪个格子里的数源,减少“看起来专业但查不到出处”的尴尬;三是在表格文本混杂的扫描件场景里测试更好的表格单元格融合方案。等这几个方向有眉目,我再写一篇详细记录往下展开。