「解析、切分、检索」:从 PDF 到向量检索的代码拆解
接着金融研报 RAG 项目往下走一层:一份保险 PDF 从入库到被检索出来,要经历 PARSED → CHUNKED → EMBEDDED → INDEXED 四个状态,每个环节的代码在优化什么。
TL;DR
上帝视角的 RAG 流程图看懂了,真有一堆文档要做知识库时还是无从下手。 这篇沿着保险 PDF 的四个状态流转往下钻:解析阶段输出的不是一段文本而是结构化文档树; 切分阶段真正的产出是元数据,chunk_id、section_path、page_num 决定了后面能不能溯源和扩展上下文; 检索阶段发现单一向量检索对低频专业词不敏感,于是要上向量 + BM25 混合、按意图动态调权、用 RRF 融合、再用 Cross-Encoder 精排。 所有这些代码优化,本质都是在为具体业务场景买单。
目录
在之前的金融研报 RAG 应用:「离线解析」和「在线问答」双链路详解文章中,是从 RAG 系统的上帝视角,梳理了数据从入库到检索的流动逻辑。但当真的有一堆文档需要作为知识库给模型的时候,好像还是无从下手。
这篇文章是接着之前的金融研报项目,深入代码层面,梳理了从保险 PDF 文档到向量检索的代码逻辑。一共会分为四个部分,先从数据的角度梳理整个流程,再分别展开解析、切分、检索这三个模块。
(后面三章涉及到的代码会比较多,如果不太看得懂代码的话,可以看一看代码注释理解一下逻辑。)
从「静态文档」到「向量索引」
一个 PDF 文档「入库」的过程可以拆解为以下四个状态流转:
PARSED(已解析)→ CHUNKED(已切分)→ EMBEDDED(已向量化)→ INDEXED(已索引)
1、解析阶段:不仅仅是提取文字
如果只是简单地提取文本(text = pdf.read()),就会丢失掉最重要的结构信息。标题、正文、页眉、页脚、表格如果全部混成一团字符串,后面的切分就会一团糟。
因此在工业级 RAG 中,解析阶段的输出不是一段文本,而是一个结构化的文档对象树:
- 层级结构:谁是父章节,谁是子章节
- 样式信息:字号、加粗、缩进(这些是判断标题级别的重要依据)
- 版面信息:表格的行列关系、图片的上下文位置
在这个阶段,文档仅仅只是被「看懂」了。
2、切分阶段:元数据的诞生之地
拿着上一步得到的结构化对象就可以开始切分了。这个阶段的核心任务有两个:
确定边界:利用解析阶段得到的章节、段落信息,尽量让切分点落在自然的语义边界上(比如句号、段落结束符),而不是生硬地切断一句话。
生成元数据(Metadata):不是切完就扔进数据库,而是在切分的同时,给每个 Chunk 办一张身份证:
chunk_id:它的唯一标识section_path:它属于《保险条款》>第二章>责任免除page_num:它在第 15 页prev/next_id:它的前文是 Chunk A,后文是 Chunk B
此时得到的是一批「携带丰富身份信息的文本块」。
3、向量化阶段:文本转数学
在这个阶段,系统会遍历上一步生成的所有 Chunk,提取其中的 content 字段,调用 Embedding 模型(如 bge-large-zh),将其转化为一个高维向量。
这里只对文本做向量化,元数据通常不需要向量化,而是直接存储用于过滤。
4、存储与索引:双库联动
存完了是不是就能检索了呢?还不可以。为了兼顾「搜得准」和「看得懂」,通常会把数据存两份(逻辑上是这样,具体按情况而定):
- 向量索引:存放
chunk_id+vector,用来算余弦相似度,快速找到 Top-K 的chunk_id - 文档存储:存放
chunk_id+text+metadata,用来存原始内容。当向量索引找到 ID 后,去这里把人能看懂的字和元数据也捞出来
再回顾一下这条链路:
- PDF → 解析成结构化对象
- 结构化对象 → 切分成带元数据的 Chunk
- Chunk → 向量化成 Vector
- Vector → 建索引,发布上线
- 在线检索 → Query 变向量,去索引里找 Chunk,最后由 LLM 生成答案
搞清楚了整体的流程,接下来深入到每一个环节的代码实现,就会发现:所有复杂的代码,无非是在为这张流程图里的某一个箭头做优化。
解析模块
解析的质量直接决定了 RAG 的上限。
虽然可以直接使用通用的 PyPDF2,但结果可能会不尽人意;也可以使用 Deepdoc(LayoutLMv3 + PaddleOCR),但面对复杂场景仍需定制化改造。
1、攻克表格解析
保险文档中最难处理的是表格。文档中存在三类表格,单一的解决方案无法通吃。这个项目中设计了一套分流策略:
- 有边框表格:Deepdoc 原生处理(准确率 95%)
- 无边框 / 半结构化表格:这是重灾区,Deepdoc 识别率仅 62%
针对无边框表格,引入 MinerU 2.5。但因为它推理慢,不能全部都用这个方法,需要在解析前置加一个分类器,只对「对齐」但「无框」的区域调用 MinerU。
2、扫描件的去噪与去水印
金融存量文档中有 30% 是扫描件,且常常会带有「仅供内部使用」的红色印章或水印。这些干扰会导致 OCR 将文字识别为乱码。
这个项目的做法是摒弃传统的全局高斯滤波(会模糊文字),采用动态预处理 Pipeline。
这一步的优化让 OCR 在扫描件上的准确率直接提升了 21 个百分点。
切分模块
在之前的那篇文章中提到了 naive_merge(基于 Token 数的简单装箱)。但这种方法在保险条款面前则会失效。比如:
条款原文:「本保险承保……但以下情况除外:(1) xx……(2) yyy……」
固定切分的问题:切分点恰好落在「但以下情况除外」之前。
- Chunk A:「本保险承保……」
- Chunk B:「(1) xx……(2) yyy……」
后果:用户问「xx 保不保?」系统检索到 Chunk B,但缺少了前文的「除外」限定,导致 LLM 回答「保」。
为了解决这个问题,需要全面重构切分策略,从「固定长度切分」进化到「语义感知切分 + 智能 Overlap」。
1、基于文档树的递归切分
保留文档的章节感。切分逻辑不再是线性的,而是树状的:优先按章节切 → 章节过长按小节切 → 还长按段落切。
2、智能 Overlap
传统的 Overlap(如固定 100 tokens)可能会把句子拦腰截断。因此可以设计基于句子边界的 Overlap:它会回溯前一个 Chunk,找到最近的句号、问号或感叹号,确保 Overlap 的部分是一句完整的话。
此外,如果 Chunk 里只存了文本和向量,却缺少元数据,那么这个 chunk 就失去了灵魂。比如无法实现以下功能:
答案溯源
- 用户问:「核辐射在保障范围内吗?」
- 系统回答:「根据第 3 条 保险责任 > 3.2 责任免除 > (2),核辐射不在保障范围。」
检索加权
- 关键条款(责任、免责、费率)的权重 ×1.5
- 识别方法:关键词匹配 + 章节标题判断
上下文扩展
- 如果检索到的 chunk 语义不完整,自动拉取前后 chunk
- 例如:检索到「但以下除外:」,自动拉取前一个 chunk 的「承保范围」
所以不能仅仅是存储文本,需要将 Chunk 定义为一个富信息的对象。可以扩展为四个维度:基础信息、结构信息、类型信息和位置信息。
检索模块
到了检索阶段,发现单一的向量检索在金融领域有致命弱点:对低频专业词汇不敏感。比如:
用户问:「犹豫期退保扣费吗?」
向量模型可能会召回「退保流程」相关的文档,因为它觉得语义很近,但却漏掉了包含「犹豫期」这个精确关键词的条款。
因此需要构建混合检索架构,并引入动态权重。
1、架构升级:向量 + BM25
- 向量检索:使用 bge-large-zh-v1.5,处理语义查询(如「孩子摔伤怎么赔」)
- 关键词检索:使用 BM25,处理精确查询
2、核心算法:基于意图识别的动态权重
这是检索系统的核心部分。不能对所有的用户问题都使用固定的 0.5 * Vector + 0.5 * BM25,需要先判断用户的意图:是查定义(精确)还是问咨询(语义)呢?
3、结果融合:RRF(倒数排名融合)
如果使用加权求和,需要对分数做归一化(Vector 是 0-1,BM25 可能是 0-50),这样很难调平。但如果使用 RRF,它不看分数,只看排名,会更加鲁棒。
4、Cross-Encoder 精排
为什么还要进行一次精排呢?经过混合检索召回的 Top-10 文档,虽然相关,但排序可能不准。
因此额外引入 bge-reranker-large 模型进行精排。但精排速度慢,一般只精排 Top-10,而不是 Top-50:
- 向量检索(Bi-Encoder)是「用户 query」和「知识库文档」独立编码
- Cross-Encoder 是将 query 和文档拼接在一起输入 BERT,能捕捉更细微的交互语义
至此,检索链路的使命完成。最后经过 Deepdoc 的解析、语义化的切分、混合检索的召回,以及最后 Cross-Encoder 的「去粗取精」,最终从成千上万的文档碎片中筛选出了含金量最高的 Top-10。
所有以上的代码优化,就是为了获取这几段优质文本。本质上都是在为目标业务场景买单。在这个金融领域,是对「犹豫期」「免责条款」的精准召回;在其他场景下,也许就是对速度的极致追求。
代码只是工具,业务场景才是归宿。
核心结论
标注「判断」「假设」的是我的看法而非事实;标注「已推翻」的保留在这里,不删除。
工业级 RAG 的解析阶段输出的不是一段文本,而是一棵结构化文档对象树——包含层级、样式与版面信息。如果只做 text = pdf.read(),标题、正文、页眉、表格混成一团,后面的切分必然一团糟。#
判断 · 把握较大
切分阶段真正的产出不是文本块,而是元数据。chunk_id、section_path、page_num、prev/next_id 这张身份证,决定了系统后面能不能做答案溯源、检索加权和上下文扩展。只存文本和向量的 chunk 是没有灵魂的。#
判断 · 把握较大
基于 Token 数的固定切分在保险条款面前会直接失效。切分点落在「但以下情况除外」之前,用户问「xx 保不保」就会检索到缺少除外限定的下半段,模型答「保」。必须换成基于文档树的递归切分加句子边界的智能 Overlap。#
把握较大
单一向量检索在金融领域有个致命弱点:对低频专业词汇不敏感。问「犹豫期退保扣费吗」,向量模型会召回语义相近的「退保流程」,却漏掉真正包含「犹豫期」精确关键词的条款。这是必须上 BM25 的理由。#
把握较大
所有这些解析、切分、检索的代码优化,本质都是在为目标业务场景买单。在金融领域是对「犹豫期」「免责条款」的精准召回,换个场景也许就是对速度的极致追求。代码只是工具,业务场景才是归宿。#
判断 · 把握较大
引用本文
晨旭,《「解析、切分、检索」:从 PDF 到向量检索的代码拆解》,晨光里的AI,2026-01-10
[晨旭:《「解析、切分、检索」:从 PDF 到向量检索的代码拆解》](https://chenxu.xin/writing/pdf-to-vector-retrieval)带进你的 AI 继续追问
这篇文章有一份干净的 Markdown 原文,可以直接交给任何模型读,不用复制粘贴。