{"schema_version":1,"id":"article:pdf-to-vector-retrieval","kind":"article","slug":"pdf-to-vector-retrieval","title":"「解析、切分、检索」：从 PDF 到向量检索的代码拆解","author":"晨旭","url":"https://chenxu.xin/writing/pdf-to-vector-retrieval","date":"2026-01-10","updated":"2026-01-10","description":"接着金融研报 RAG 项目往下走一层：一份保险 PDF 从入库到被检索出来，要经历 PARSED → CHUNKED → EMBEDDED → INDEXED 四个状态，每个环节的代码在优化什么。","position":null,"group":null,"series":"动手落地AI","tags":["rag","hands-on"],"publication_status":"public","maturity":"evergreen","body_sha256":"e878839b9f5989f625afa362b801cd46075bd54bec84b6abe9062102424b68a8","paragraph_rule":"src/lib/paragraphs.mjs@1","paragraphs":[{"id":"p1","kind":"paragraph","text":"在之前的金融研报 RAG 应用：「离线解析」和「在线问答」双链路详解文章中，是从 RAG 系统的上帝视角，梳理了数据从入库到检索的流动逻辑。但当真的有一堆文档需要作为知识库给模型的时候，好像还是无从下手。","raw":"在之前的[金融研报 RAG 应用：「离线解析」和「在线问答」双链路详解](/writing/financial-rag-dual-pipeline)文章中，是从 RAG 系统的上帝视角，梳理了数据从入库到检索的流动逻辑。但当真的有一堆文档需要作为知识库给模型的时候，好像还是无从下手。"},{"id":"p2","kind":"paragraph","text":"这篇文章是接着之前的金融研报项目，深入代码层面，梳理了从保险 PDF 文档到向量检索的代码逻辑。一共会分为四个部分，先从数据的角度梳理整个流程，再分别展开解析、切分、检索这三个模块。","raw":"这篇文章是接着之前的金融研报项目，深入代码层面，梳理了从保险 PDF 文档到向量检索的代码逻辑。一共会分为四个部分，先从数据的角度梳理整个流程，再分别展开解析、切分、检索这三个模块。"},{"id":"p3","kind":"paragraph","text":"（后面三章涉及到的代码会比较多，如果不太看得懂代码的话，可以看一看代码注释理解一下逻辑。）","raw":"（后面三章涉及到的代码会比较多，如果不太看得懂代码的话，可以看一看代码注释理解一下逻辑。）"},{"id":"p4","kind":"heading","text":"从「静态文档」到「向量索引」","raw":"## 从「静态文档」到「向量索引」"},{"id":"p5","kind":"paragraph","text":"一个 PDF 文档「入库」的过程可以拆解为以下四个状态流转：","raw":"一个 PDF 文档「入库」的过程可以拆解为以下四个状态流转："},{"id":"p6","kind":"quote","text":"PARSED（已解析）→ CHUNKED（已切分）→ EMBEDDED（已向量化）→ INDEXED（已索引）","raw":"> PARSED（已解析）→ CHUNKED（已切分）→ EMBEDDED（已向量化）→ INDEXED（已索引）"},{"id":"p7","kind":"heading","text":"1、解析阶段：不仅仅是提取文字","raw":"### 1、解析阶段：不仅仅是提取文字"},{"id":"p8","kind":"paragraph","text":"如果只是简单地提取文本（text = pdf.read()），就会丢失掉最重要的结构信息。标题、正文、页眉、页脚、表格如果全部混成一团字符串，后面的切分就会一团糟。","raw":"如果只是简单地提取文本（`text = pdf.read()`），就会丢失掉最重要的结构信息。标题、正文、页眉、页脚、表格如果全部混成一团字符串，后面的切分就会一团糟。"},{"id":"p9","kind":"paragraph","text":"因此在工业级 RAG 中，解析阶段的输出不是一段文本，而是一个结构化的文档对象树：","raw":"因此在工业级 RAG 中，解析阶段的输出不是一段文本，而是一个**结构化的文档对象树**："},{"id":"p10","kind":"list","text":"- 层级结构：谁是父章节，谁是子章节\n- 样式信息：字号、加粗、缩进（这些是判断标题级别的重要依据）\n- 版面信息：表格的行列关系、图片的上下文位置","raw":"- **层级结构**：谁是父章节，谁是子章节\n- **样式信息**：字号、加粗、缩进（这些是判断标题级别的重要依据）\n- **版面信息**：表格的行列关系、图片的上下文位置"},{"id":"p11","kind":"paragraph","text":"在这个阶段，文档仅仅只是被「看懂」了。","raw":"在这个阶段，文档仅仅只是被「看懂」了。"},{"id":"p12","kind":"heading","text":"2、切分阶段：元数据的诞生之地","raw":"### 2、切分阶段：元数据的诞生之地"},{"id":"p13","kind":"paragraph","text":"拿着上一步得到的结构化对象就可以开始切分了。这个阶段的核心任务有两个：","raw":"拿着上一步得到的结构化对象就可以开始切分了。这个阶段的核心任务有两个："},{"id":"p14","kind":"paragraph","text":"确定边界：利用解析阶段得到的章节、段落信息，尽量让切分点落在自然的语义边界上（比如句号、段落结束符），而不是生硬地切断一句话。","raw":"**确定边界**：利用解析阶段得到的章节、段落信息，尽量让切分点落在自然的语义边界上（比如句号、段落结束符），而不是生硬地切断一句话。"},{"id":"p15","kind":"paragraph","text":"生成元数据（Metadata）：不是切完就扔进数据库，而是在切分的同时，给每个 Chunk 办一张身份证：","raw":"**生成元数据（Metadata）**：不是切完就扔进数据库，而是在切分的同时，给每个 Chunk 办一张身份证："},{"id":"p16","kind":"list","text":"- chunk_id：它的唯一标识\n- section_path：它属于《保险条款》>第二章>责任免除\n- page_num：它在第 15 页\n- prev/next_id：它的前文是 Chunk A，后文是 Chunk B","raw":"- `chunk_id`：它的唯一标识\n- `section_path`：它属于《保险条款》>第二章>责任免除\n- `page_num`：它在第 15 页\n- `prev/next_id`：它的前文是 Chunk A，后文是 Chunk B"},{"id":"p17","kind":"paragraph","text":"此时得到的是一批「携带丰富身份信息的文本块」。","raw":"此时得到的是一批「携带丰富身份信息的文本块」。"},{"id":"p18","kind":"heading","text":"3、向量化阶段：文本转数学","raw":"### 3、向量化阶段：文本转数学"},{"id":"p19","kind":"paragraph","text":"在这个阶段，系统会遍历上一步生成的所有 Chunk，提取其中的 content 字段，调用 Embedding 模型（如 bge-large-zh），将其转化为一个高维向量。","raw":"在这个阶段，系统会遍历上一步生成的所有 Chunk，提取其中的 `content` 字段，调用 Embedding 模型（如 `bge-large-zh`），将其转化为一个高维向量。"},{"id":"p20","kind":"paragraph","text":"这里只对文本做向量化，元数据通常不需要向量化，而是直接存储用于过滤。","raw":"这里只对文本做向量化，元数据通常不需要向量化，而是直接存储用于过滤。"},{"id":"p21","kind":"heading","text":"4、存储与索引：双库联动","raw":"### 4、存储与索引：双库联动"},{"id":"p22","kind":"paragraph","text":"存完了是不是就能检索了呢？还不可以。为了兼顾「搜得准」和「看得懂」，通常会把数据存两份（逻辑上是这样，具体按情况而定）：","raw":"存完了是不是就能检索了呢？还不可以。为了兼顾「搜得准」和「看得懂」，通常会把数据存两份（逻辑上是这样，具体按情况而定）："},{"id":"p23","kind":"list","text":"- 向量索引：存放 chunk_id + vector，用来算余弦相似度，快速找到 Top-K 的 chunk_id\n- 文档存储：存放 chunk_id + text + metadata，用来存原始内容。当向量索引找到 ID 后，去这里把人能看懂的字和元数据也捞出来","raw":"- **向量索引**：存放 `chunk_id` + `vector`，用来算余弦相似度，快速找到 Top-K 的 `chunk_id`\n- **文档存储**：存放 `chunk_id` + `text` + `metadata`，用来存原始内容。当向量索引找到 ID 后，去这里把人能看懂的字和元数据也捞出来"},{"id":"p24","kind":"paragraph","text":"再回顾一下这条链路：","raw":"再回顾一下这条链路："},{"id":"p25","kind":"list","text":"1. PDF → 解析成结构化对象\n2. 结构化对象 → 切分成带元数据的 Chunk\n3. Chunk → 向量化成 Vector\n4. Vector → 建索引，发布上线\n5. 在线检索 → Query 变向量，去索引里找 Chunk，最后由 LLM 生成答案","raw":"1. PDF → 解析成结构化对象\n2. 结构化对象 → 切分成带元数据的 Chunk\n3. Chunk → 向量化成 Vector\n4. Vector → 建索引，发布上线\n5. 在线检索 → Query 变向量，去索引里找 Chunk，最后由 LLM 生成答案"},{"id":"p26","kind":"paragraph","text":"搞清楚了整体的流程，接下来深入到每一个环节的代码实现，就会发现：所有复杂的代码，无非是在为这张流程图里的某一个箭头做优化。","raw":"搞清楚了整体的流程，接下来深入到每一个环节的代码实现，就会发现：所有复杂的代码，无非是在为这张流程图里的某一个箭头做优化。"},{"id":"p27","kind":"heading","text":"解析模块","raw":"## 解析模块"},{"id":"p28","kind":"paragraph","text":"解析的质量直接决定了 RAG 的上限。","raw":"解析的质量直接决定了 RAG 的上限。"},{"id":"p29","kind":"paragraph","text":"虽然可以直接使用通用的 PyPDF2，但结果可能会不尽人意；也可以使用 Deepdoc（LayoutLMv3 + PaddleOCR），但面对复杂场景仍需定制化改造。","raw":"虽然可以直接使用通用的 PyPDF2，但结果可能会不尽人意；也可以使用 Deepdoc（LayoutLMv3 + PaddleOCR），但面对复杂场景仍需定制化改造。"},{"id":"p30","kind":"heading","text":"1、攻克表格解析","raw":"### 1、攻克表格解析"},{"id":"p31","kind":"paragraph","text":"保险文档中最难处理的是表格。文档中存在三类表格，单一的解决方案无法通吃。这个项目中设计了一套分流策略：","raw":"保险文档中最难处理的是表格。文档中存在三类表格，单一的解决方案无法通吃。这个项目中设计了一套分流策略："},{"id":"p32","kind":"list","text":"- 有边框表格：Deepdoc 原生处理（准确率 95%）\n- 无边框 / 半结构化表格：这是重灾区，Deepdoc 识别率仅 62%","raw":"- **有边框表格**：Deepdoc 原生处理（准确率 95%）\n- **无边框 / 半结构化表格**：这是重灾区，Deepdoc 识别率仅 62%"},{"id":"p33","kind":"paragraph","text":"针对无边框表格，引入 MinerU 2.5。但因为它推理慢，不能全部都用这个方法，需要在解析前置加一个分类器，只对「对齐」但「无框」的区域调用 MinerU。","raw":"针对无边框表格，引入 MinerU 2.5。但因为它推理慢，不能全部都用这个方法，需要在解析前置加一个分类器，只对「对齐」但「无框」的区域调用 MinerU。"},{"id":"p34","kind":"heading","text":"2、扫描件的去噪与去水印","raw":"### 2、扫描件的去噪与去水印"},{"id":"p35","kind":"paragraph","text":"金融存量文档中有 30% 是扫描件，且常常会带有「仅供内部使用」的红色印章或水印。这些干扰会导致 OCR 将文字识别为乱码。","raw":"金融存量文档中有 30% 是扫描件，且常常会带有「仅供内部使用」的红色印章或水印。这些干扰会导致 OCR 将文字识别为乱码。"},{"id":"p36","kind":"paragraph","text":"这个项目的做法是摒弃传统的全局高斯滤波（会模糊文字），采用动态预处理 Pipeline。","raw":"这个项目的做法是摒弃传统的全局高斯滤波（会模糊文字），采用动态预处理 Pipeline。"},{"id":"p37","kind":"paragraph","text":"这一步的优化让 OCR 在扫描件上的准确率直接提升了 21 个百分点。","raw":"这一步的优化让 OCR 在扫描件上的准确率直接提升了 21 个百分点。"},{"id":"p38","kind":"heading","text":"切分模块","raw":"## 切分模块"},{"id":"p39","kind":"paragraph","text":"在之前的那篇文章中提到了 naive_merge（基于 Token 数的简单装箱）。但这种方法在保险条款面前则会失效。比如：","raw":"在之前的那篇文章中提到了 `naive_merge`（基于 Token 数的简单装箱）。但这种方法在保险条款面前则会失效。比如："},{"id":"p40","kind":"quote","text":"条款原文：「本保险承保……但以下情况除外：(1) xx……(2) yyy……」","raw":"> 条款原文：「本保险承保……但以下情况除外：(1) xx……(2) yyy……」"},{"id":"p41","kind":"paragraph","text":"固定切分的问题：切分点恰好落在「但以下情况除外」之前。","raw":"固定切分的问题：切分点恰好落在「但以下情况除外」之前。"},{"id":"p42","kind":"list","text":"- Chunk A：「本保险承保……」\n- Chunk B：「(1) xx……(2) yyy……」","raw":"- Chunk A：「本保险承保……」\n- Chunk B：「(1) xx……(2) yyy……」"},{"id":"p43","kind":"paragraph","text":"后果：用户问「xx 保不保？」系统检索到 Chunk B，但缺少了前文的「除外」限定，导致 LLM 回答「保」。","raw":"后果：用户问「xx 保不保？」系统检索到 Chunk B，但缺少了前文的「除外」限定，导致 LLM 回答「保」。"},{"id":"p44","kind":"paragraph","text":"为了解决这个问题，需要全面重构切分策略，从「固定长度切分」进化到「语义感知切分 + 智能 Overlap」。","raw":"为了解决这个问题，需要全面重构切分策略，从「固定长度切分」进化到「语义感知切分 + 智能 Overlap」。"},{"id":"p45","kind":"heading","text":"1、基于文档树的递归切分","raw":"### 1、基于文档树的递归切分"},{"id":"p46","kind":"paragraph","text":"保留文档的章节感。切分逻辑不再是线性的，而是树状的：优先按章节切 → 章节过长按小节切 → 还长按段落切。","raw":"保留文档的章节感。切分逻辑不再是线性的，而是树状的：优先按章节切 → 章节过长按小节切 → 还长按段落切。"},{"id":"p47","kind":"heading","text":"2、智能 Overlap","raw":"### 2、智能 Overlap"},{"id":"p48","kind":"paragraph","text":"传统的 Overlap（如固定 100 tokens）可能会把句子拦腰截断。因此可以设计基于句子边界的 Overlap：它会回溯前一个 Chunk，找到最近的句号、问号或感叹号，确保 Overlap 的部分是一句完整的话。","raw":"传统的 Overlap（如固定 100 tokens）可能会把句子拦腰截断。因此可以设计基于句子边界的 Overlap：它会回溯前一个 Chunk，找到最近的句号、问号或感叹号，确保 Overlap 的部分是一句完整的话。"},{"id":"p49","kind":"paragraph","text":"此外，如果 Chunk 里只存了文本和向量，却缺少元数据，那么这个 chunk 就失去了灵魂。比如无法实现以下功能：","raw":"此外，如果 Chunk 里只存了文本和向量，却缺少元数据，那么这个 chunk 就失去了灵魂。比如无法实现以下功能："},{"id":"p50","kind":"paragraph","text":"答案溯源","raw":"**答案溯源**"},{"id":"p51","kind":"list","text":"- 用户问：「核辐射在保障范围内吗？」\n- 系统回答：「根据第 3 条 保险责任 > 3.2 责任免除 > (2)，核辐射不在保障范围。」","raw":"- 用户问：「核辐射在保障范围内吗？」\n- 系统回答：「根据第 3 条 保险责任 > 3.2 责任免除 > (2)，核辐射不在保障范围。」"},{"id":"p52","kind":"paragraph","text":"检索加权","raw":"**检索加权**"},{"id":"p53","kind":"list","text":"- 关键条款（责任、免责、费率）的权重 ×1.5\n- 识别方法：关键词匹配 + 章节标题判断","raw":"- 关键条款（责任、免责、费率）的权重 ×1.5\n- 识别方法：关键词匹配 + 章节标题判断"},{"id":"p54","kind":"paragraph","text":"上下文扩展","raw":"**上下文扩展**"},{"id":"p55","kind":"list","text":"- 如果检索到的 chunk 语义不完整，自动拉取前后 chunk\n- 例如：检索到「但以下除外：」，自动拉取前一个 chunk 的「承保范围」","raw":"- 如果检索到的 chunk 语义不完整，自动拉取前后 chunk\n- 例如：检索到「但以下除外：」，自动拉取前一个 chunk 的「承保范围」"},{"id":"p56","kind":"paragraph","text":"所以不能仅仅是存储文本，需要将 Chunk 定义为一个富信息的对象。可以扩展为四个维度：基础信息、结构信息、类型信息和位置信息。","raw":"所以不能仅仅是存储文本，需要将 Chunk 定义为一个富信息的对象。可以扩展为四个维度：基础信息、结构信息、类型信息和位置信息。"},{"id":"p57","kind":"heading","text":"检索模块","raw":"## 检索模块"},{"id":"p58","kind":"paragraph","text":"到了检索阶段，发现单一的向量检索在金融领域有致命弱点：对低频专业词汇不敏感。比如：","raw":"到了检索阶段，发现单一的向量检索在金融领域有致命弱点：**对低频专业词汇不敏感**。比如："},{"id":"p59","kind":"quote","text":"用户问：「犹豫期退保扣费吗？」","raw":"> 用户问：「犹豫期退保扣费吗？」"},{"id":"p60","kind":"paragraph","text":"向量模型可能会召回「退保流程」相关的文档，因为它觉得语义很近，但却漏掉了包含「犹豫期」这个精确关键词的条款。","raw":"向量模型可能会召回「退保流程」相关的文档，因为它觉得语义很近，但却漏掉了包含「犹豫期」这个精确关键词的条款。"},{"id":"p61","kind":"paragraph","text":"因此需要构建混合检索架构，并引入动态权重。","raw":"因此需要构建混合检索架构，并引入动态权重。"},{"id":"p62","kind":"heading","text":"1、架构升级：向量 + BM25","raw":"### 1、架构升级：向量 + BM25"},{"id":"p63","kind":"list","text":"- 向量检索：使用 bge-large-zh-v1.5，处理语义查询（如「孩子摔伤怎么赔」）\n- 关键词检索：使用 BM25，处理精确查询","raw":"- **向量检索**：使用 bge-large-zh-v1.5，处理语义查询（如「孩子摔伤怎么赔」）\n- **关键词检索**：使用 BM25，处理精确查询"},{"id":"p64","kind":"heading","text":"2、核心算法：基于意图识别的动态权重","raw":"### 2、核心算法：基于意图识别的动态权重"},{"id":"p65","kind":"paragraph","text":"这是检索系统的核心部分。不能对所有的用户问题都使用固定的 0.5 * Vector + 0.5 * BM25，需要先判断用户的意图：是查定义（精确）还是问咨询（语义）呢？","raw":"这是检索系统的核心部分。不能对所有的用户问题都使用固定的 `0.5 * Vector + 0.5 * BM25`，需要先判断用户的意图：是查定义（精确）还是问咨询（语义）呢？"},{"id":"p66","kind":"heading","text":"3、结果融合：RRF（倒数排名融合）","raw":"### 3、结果融合：RRF（倒数排名融合）"},{"id":"p67","kind":"paragraph","text":"如果使用加权求和，需要对分数做归一化（Vector 是 0-1，BM25 可能是 0-50），这样很难调平。但如果使用 RRF，它不看分数，只看排名，会更加鲁棒。","raw":"如果使用加权求和，需要对分数做归一化（Vector 是 0-1，BM25 可能是 0-50），这样很难调平。但如果使用 RRF，它不看分数，只看排名，会更加鲁棒。"},{"id":"p68","kind":"heading","text":"4、Cross-Encoder 精排","raw":"### 4、Cross-Encoder 精排"},{"id":"p69","kind":"paragraph","text":"为什么还要进行一次精排呢？经过混合检索召回的 Top-10 文档，虽然相关，但排序可能不准。","raw":"为什么还要进行一次精排呢？经过混合检索召回的 Top-10 文档，虽然相关，但排序可能不准。"},{"id":"p70","kind":"paragraph","text":"因此额外引入 bge-reranker-large 模型进行精排。但精排速度慢，一般只精排 Top-10，而不是 Top-50：","raw":"因此额外引入 bge-reranker-large 模型进行精排。但精排速度慢，一般只精排 Top-10，而不是 Top-50："},{"id":"p71","kind":"list","text":"- 向量检索（Bi-Encoder）是「用户 query」和「知识库文档」独立编码\n- Cross-Encoder 是将 query 和文档拼接在一起输入 BERT，能捕捉更细微的交互语义","raw":"- 向量检索（Bi-Encoder）是「用户 query」和「知识库文档」独立编码\n- Cross-Encoder 是将 query 和文档拼接在一起输入 BERT，能捕捉更细微的交互语义"},{"id":"p72","kind":"paragraph","text":"至此，检索链路的使命完成。最后经过 Deepdoc 的解析、语义化的切分、混合检索的召回，以及最后 Cross-Encoder 的「去粗取精」，最终从成千上万的文档碎片中筛选出了含金量最高的 Top-10。","raw":"至此，检索链路的使命完成。最后经过 Deepdoc 的解析、语义化的切分、混合检索的召回，以及最后 Cross-Encoder 的「去粗取精」，最终从成千上万的文档碎片中筛选出了含金量最高的 Top-10。"},{"id":"p73","kind":"paragraph","text":"所有以上的代码优化，就是为了获取这几段优质文本。本质上都是在为目标业务场景买单。在这个金融领域，是对「犹豫期」「免责条款」的精准召回；在其他场景下，也许就是对速度的极致追求。","raw":"所有以上的代码优化，就是为了获取这几段优质文本。本质上都是在为目标业务场景买单。在这个金融领域，是对「犹豫期」「免责条款」的精准召回；在其他场景下，也许就是对速度的极致追求。"},{"id":"p74","kind":"paragraph","text":"代码只是工具，业务场景才是归宿。","raw":"代码只是工具，业务场景才是归宿。"}],"authored_claims":[{"id":"parsing-outputs-a-tree-not-text","text":"工业级 RAG 的解析阶段输出的不是一段文本，而是一棵结构化文档对象树——包含层级、样式与版面信息。如果只做 text = pdf.read()，标题、正文、页眉、表格混成一团，后面的切分必然一团糟。","stance":"judgment","confidence":"high","supersedes":null,"note":null,"formed_at":"2026-01-10"},{"id":"chunking-is-where-metadata-is-born","text":"切分阶段真正的产出不是文本块，而是元数据。chunk_id、section_path、page_num、prev/next_id 这张身份证，决定了系统后面能不能做答案溯源、检索加权和上下文扩展。只存文本和向量的 chunk 是没有灵魂的。","stance":"judgment","confidence":"high","supersedes":null,"note":null,"formed_at":"2026-01-10"},{"id":"fixed-length-chunking-breaks-clauses","text":"基于 Token 数的固定切分在保险条款面前会直接失效。切分点落在「但以下情况除外」之前，用户问「xx 保不保」就会检索到缺少除外限定的下半段，模型答「保」。必须换成基于文档树的递归切分加句子边界的智能 Overlap。","stance":"fact","confidence":"high","supersedes":null,"note":null,"formed_at":"2026-01-10"},{"id":"vector-search-misses-rare-terms","text":"单一向量检索在金融领域有个致命弱点：对低频专业词汇不敏感。问「犹豫期退保扣费吗」，向量模型会召回语义相近的「退保流程」，却漏掉真正包含「犹豫期」精确关键词的条款。这是必须上 BM25 的理由。","stance":"fact","confidence":"high","supersedes":null,"note":null,"formed_at":"2026-01-10"},{"id":"code-serves-the-business-scenario","text":"所有这些解析、切分、检索的代码优化，本质都是在为目标业务场景买单。在金融领域是对「犹豫期」「免责条款」的精准召回，换个场景也许就是对速度的极致追求。代码只是工具，业务场景才是归宿。","stance":"judgment","confidence":"high","supersedes":null,"note":null,"formed_at":"2026-01-10"}],"claim_ids":["parsing-outputs-a-tree-not-text","chunking-is-where-metadata-is-born","fixed-length-chunking-breaks-clauses","vector-search-misses-rare-terms","code-serves-the-business-scenario"],"revision":"5c781ce93c735c6f87cfc941e102c29055a1f7cf7aff492df6305682b161aa85"}