晨光里的AI

固定版本 · 查看当前页面

「解析、切分、检索」:从 PDF 到向量检索的代码拆解

晨旭 · 内容日期 2026-01-10。此页保留当时的文本、性质和段落,不用于判断现在是否仍然有效。

版本 5c781ce93c735c6f87cfc941e102c29055a1f7cf7aff492df6305682b161aa85 · 读取 JSON

p1

在之前的金融研报 RAG 应用:「离线解析」和「在线问答」双链路详解文章中,是从 RAG 系统的上帝视角,梳理了数据从入库到检索的流动逻辑。但当真的有一堆文档需要作为知识库给模型的时候,好像还是无从下手。

p2

这篇文章是接着之前的金融研报项目,深入代码层面,梳理了从保险 PDF 文档到向量检索的代码逻辑。一共会分为四个部分,先从数据的角度梳理整个流程,再分别展开解析、切分、检索这三个模块。

p3

(后面三章涉及到的代码会比较多,如果不太看得懂代码的话,可以看一看代码注释理解一下逻辑。)

p4

从「静态文档」到「向量索引」

p5

一个 PDF 文档「入库」的过程可以拆解为以下四个状态流转:

p6

PARSED(已解析)→ CHUNKED(已切分)→ EMBEDDED(已向量化)→ INDEXED(已索引)

p7

1、解析阶段:不仅仅是提取文字

p8

如果只是简单地提取文本(text = pdf.read()),就会丢失掉最重要的结构信息。标题、正文、页眉、页脚、表格如果全部混成一团字符串,后面的切分就会一团糟。

p9

因此在工业级 RAG 中,解析阶段的输出不是一段文本,而是一个结构化的文档对象树:

p10

- 层级结构:谁是父章节,谁是子章节 - 样式信息:字号、加粗、缩进(这些是判断标题级别的重要依据) - 版面信息:表格的行列关系、图片的上下文位置

p11

在这个阶段,文档仅仅只是被「看懂」了。

p12

2、切分阶段:元数据的诞生之地

p13

拿着上一步得到的结构化对象就可以开始切分了。这个阶段的核心任务有两个:

p14

确定边界:利用解析阶段得到的章节、段落信息,尽量让切分点落在自然的语义边界上(比如句号、段落结束符),而不是生硬地切断一句话。

p15

生成元数据(Metadata):不是切完就扔进数据库,而是在切分的同时,给每个 Chunk 办一张身份证:

p16

- chunk_id:它的唯一标识 - section_path:它属于《保险条款》>第二章>责任免除 - page_num:它在第 15 页 - prev/next_id:它的前文是 Chunk A,后文是 Chunk B

p17

此时得到的是一批「携带丰富身份信息的文本块」。

p18

3、向量化阶段:文本转数学

p19

在这个阶段,系统会遍历上一步生成的所有 Chunk,提取其中的 content 字段,调用 Embedding 模型(如 bge-large-zh),将其转化为一个高维向量。

p20

这里只对文本做向量化,元数据通常不需要向量化,而是直接存储用于过滤。

p21

4、存储与索引:双库联动

p22

存完了是不是就能检索了呢?还不可以。为了兼顾「搜得准」和「看得懂」,通常会把数据存两份(逻辑上是这样,具体按情况而定):

p23

- 向量索引:存放 chunk_id + vector,用来算余弦相似度,快速找到 Top-K 的 chunk_id - 文档存储:存放 chunk_id + text + metadata,用来存原始内容。当向量索引找到 ID 后,去这里把人能看懂的字和元数据也捞出来

p24

再回顾一下这条链路:

p25

1. PDF → 解析成结构化对象 2. 结构化对象 → 切分成带元数据的 Chunk 3. Chunk → 向量化成 Vector 4. Vector → 建索引,发布上线 5. 在线检索 → Query 变向量,去索引里找 Chunk,最后由 LLM 生成答案

p26

搞清楚了整体的流程,接下来深入到每一个环节的代码实现,就会发现:所有复杂的代码,无非是在为这张流程图里的某一个箭头做优化。

p27

解析模块

p28

解析的质量直接决定了 RAG 的上限。

p29

虽然可以直接使用通用的 PyPDF2,但结果可能会不尽人意;也可以使用 Deepdoc(LayoutLMv3 + PaddleOCR),但面对复杂场景仍需定制化改造。

p30

1、攻克表格解析

p31

保险文档中最难处理的是表格。文档中存在三类表格,单一的解决方案无法通吃。这个项目中设计了一套分流策略:

p32

- 有边框表格:Deepdoc 原生处理(准确率 95%) - 无边框 / 半结构化表格:这是重灾区,Deepdoc 识别率仅 62%

p33

针对无边框表格,引入 MinerU 2.5。但因为它推理慢,不能全部都用这个方法,需要在解析前置加一个分类器,只对「对齐」但「无框」的区域调用 MinerU。

p34

2、扫描件的去噪与去水印

p35

金融存量文档中有 30% 是扫描件,且常常会带有「仅供内部使用」的红色印章或水印。这些干扰会导致 OCR 将文字识别为乱码。

p36

这个项目的做法是摒弃传统的全局高斯滤波(会模糊文字),采用动态预处理 Pipeline。

p37

这一步的优化让 OCR 在扫描件上的准确率直接提升了 21 个百分点。

p38

切分模块

p39

在之前的那篇文章中提到了 naive_merge(基于 Token 数的简单装箱)。但这种方法在保险条款面前则会失效。比如:

p40

条款原文:「本保险承保……但以下情况除外:(1) xx……(2) yyy……」

p41

固定切分的问题:切分点恰好落在「但以下情况除外」之前。

p42

- Chunk A:「本保险承保……」 - Chunk B:「(1) xx……(2) yyy……」

p43

后果:用户问「xx 保不保?」系统检索到 Chunk B,但缺少了前文的「除外」限定,导致 LLM 回答「保」。

p44

为了解决这个问题,需要全面重构切分策略,从「固定长度切分」进化到「语义感知切分 + 智能 Overlap」。

p45

1、基于文档树的递归切分

p46

保留文档的章节感。切分逻辑不再是线性的,而是树状的:优先按章节切 → 章节过长按小节切 → 还长按段落切。

p47

2、智能 Overlap

p48

传统的 Overlap(如固定 100 tokens)可能会把句子拦腰截断。因此可以设计基于句子边界的 Overlap:它会回溯前一个 Chunk,找到最近的句号、问号或感叹号,确保 Overlap 的部分是一句完整的话。

p49

此外,如果 Chunk 里只存了文本和向量,却缺少元数据,那么这个 chunk 就失去了灵魂。比如无法实现以下功能:

p50

答案溯源

p51

- 用户问:「核辐射在保障范围内吗?」 - 系统回答:「根据第 3 条 保险责任 > 3.2 责任免除 > (2),核辐射不在保障范围。」

p52

检索加权

p53

- 关键条款(责任、免责、费率)的权重 ×1.5 - 识别方法:关键词匹配 + 章节标题判断

p54

上下文扩展

p55

- 如果检索到的 chunk 语义不完整,自动拉取前后 chunk - 例如:检索到「但以下除外:」,自动拉取前一个 chunk 的「承保范围」

p56

所以不能仅仅是存储文本,需要将 Chunk 定义为一个富信息的对象。可以扩展为四个维度:基础信息、结构信息、类型信息和位置信息。

p57

检索模块

p58

到了检索阶段,发现单一的向量检索在金融领域有致命弱点:对低频专业词汇不敏感。比如:

p59

用户问:「犹豫期退保扣费吗?」

p60

向量模型可能会召回「退保流程」相关的文档,因为它觉得语义很近,但却漏掉了包含「犹豫期」这个精确关键词的条款。

p61

因此需要构建混合检索架构,并引入动态权重。

p62

1、架构升级:向量 + BM25

p63

- 向量检索:使用 bge-large-zh-v1.5,处理语义查询(如「孩子摔伤怎么赔」) - 关键词检索:使用 BM25,处理精确查询

p64

2、核心算法:基于意图识别的动态权重

p65

这是检索系统的核心部分。不能对所有的用户问题都使用固定的 0.5 * Vector + 0.5 * BM25,需要先判断用户的意图:是查定义(精确)还是问咨询(语义)呢?

p66

3、结果融合:RRF(倒数排名融合)

p67

如果使用加权求和,需要对分数做归一化(Vector 是 0-1,BM25 可能是 0-50),这样很难调平。但如果使用 RRF,它不看分数,只看排名,会更加鲁棒。

p68

4、Cross-Encoder 精排

p69

为什么还要进行一次精排呢?经过混合检索召回的 Top-10 文档,虽然相关,但排序可能不准。

p70

因此额外引入 bge-reranker-large 模型进行精排。但精排速度慢,一般只精排 Top-10,而不是 Top-50:

p71

- 向量检索(Bi-Encoder)是「用户 query」和「知识库文档」独立编码 - Cross-Encoder 是将 query 和文档拼接在一起输入 BERT,能捕捉更细微的交互语义

p72

至此,检索链路的使命完成。最后经过 Deepdoc 的解析、语义化的切分、混合检索的召回,以及最后 Cross-Encoder 的「去粗取精」,最终从成千上万的文档碎片中筛选出了含金量最高的 Top-10。

p73

所有以上的代码优化,就是为了获取这几段优质文本。本质上都是在为目标业务场景买单。在这个金融领域,是对「犹豫期」「免责条款」的精准召回;在其他场景下,也许就是对速度的极致追求。

p74

代码只是工具,业务场景才是归宿。

该版本的判断

工业级 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