晨光里的AI

动手落地AI

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

接着金融研报 RAG 项目往下走一层:一份保险 PDF 从入库到被检索出来,要经历 PARSED → CHUNKED → EMBEDDED → INDEXED 四个状态,每个环节的代码在优化什么。

Written by 晨旭发布于 约 8 分钟同步发布

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 后,去这里把人能看懂的字和元数据也捞出来

再回顾一下这条链路:

  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(),标题、正文、页眉、表格混成一团,后面的切分必然一团糟。#

    判断 · 把握较大

  • 切分阶段真正的产出不是文本块,而是元数据。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 原文,可以直接交给任何模型读,不用复制粘贴。