固定版本 · 查看当前页面
「解析、切分、检索」:从 PDF 到向量检索的代码拆解
晨旭 · 内容日期 2026-01-10。此页保留当时的文本、性质和段落,不用于判断现在是否仍然有效。
版本 5c781ce93c735c6f87cfc941e102c29055a1f7cf7aff492df6305682b161aa85 · 读取 JSON
在之前的金融研报 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 后,去这里把人能看懂的字和元数据也捞出来
再回顾一下这条链路:
1. PDF → 解析成结构化对象 2. 结构化对象 → 切分成带元数据的 Chunk 3. Chunk → 向量化成 Vector 4. Vector → 建索引,发布上线 5. 在线检索 → 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(),标题、正文、页眉、表格混成一团,后面的切分必然一团糟。
judgment · 2026-01-10 · 信心 high
切分阶段真正的产出不是文本块,而是元数据。chunk_id、section_path、page_num、prev/next_id 这张身份证,决定了系统后面能不能做答案溯源、检索加权和上下文扩展。只存文本和向量的 chunk 是没有灵魂的。
judgment · 2026-01-10 · 信心 high
基于 Token 数的固定切分在保险条款面前会直接失效。切分点落在「但以下情况除外」之前,用户问「xx 保不保」就会检索到缺少除外限定的下半段,模型答「保」。必须换成基于文档树的递归切分加句子边界的智能 Overlap。
fact · 2026-01-10 · 信心 high
单一向量检索在金融领域有个致命弱点:对低频专业词汇不敏感。问「犹豫期退保扣费吗」,向量模型会召回语义相近的「退保流程」,却漏掉真正包含「犹豫期」精确关键词的条款。这是必须上 BM25 的理由。
fact · 2026-01-10 · 信心 high
所有这些解析、切分、检索的代码优化,本质都是在为目标业务场景买单。在金融领域是对「犹豫期」「免责条款」的精准召回,换个场景也许就是对速度的极致追求。代码只是工具,业务场景才是归宿。
judgment · 2026-01-10 · 信心 high