文档接入与智能解析:基于 Spring AI 1.1.x 的多格式解析、版面理解与结构化抽取
摘要 :面向 Java / Spring 技术团队,本文基于 Spring AI 1.1.x 的
DocumentReader体系,系统拆解企业知识库"数据入口"的解析链路------从多格式读取、版面理解、表格结构化抽取,到 OCR/VLM 兜底与人工复核,帮你建立一套"解析质量决定检索上限"的工程心智模型。
技术栈 :JDK 21 + Spring Boot 3.4.x + Spring AI 1.1.x + pgvector / Milvus / Elasticsearch + Redis + Prometheus系列定位 :《基于 Java 的企业级 AI 知识库》系列第 2 篇 / 共 14 篇(数据入口工程,⭐⭐⭐)
参考时效:本文强时效结论(模型价格、API、版本、Reranker 型号、OCR 工具能力等)均为"截至 2026-07-12 的参考判断",Spring AI 版本、Apache Tika 版本、版面模型能力等请以官方文档为准
文章目录
- [文档接入与智能解析:基于 Spring AI 1.1.x 的多格式解析、版面理解与结构化抽取](#文档接入与智能解析:基于 Spring AI 1.1.x 的多格式解析、版面理解与结构化抽取)
-
- [📚 前言:一次让问答准确率"腰斩"的解析事故](#📚 前言:一次让问答准确率"腰斩"的解析事故)
- [🎯 本文你将学到](#🎯 本文你将学到)
-
-
- [🔬 理论深度](#🔬 理论深度)
- [🛠️ 工程实践](#🛠️ 工程实践)
- [⚖️ 方案对比](#⚖️ 方案对比)
- [🚀 系列导航](#🚀 系列导航)
-
- [⚡ 一、Spring AI DocumentReader 体系全景与局限](#⚡ 一、Spring AI DocumentReader 体系全景与局限)
-
- [1.1 核心抽象:DocumentReader 与 Document](#1.1 核心抽象:DocumentReader 与 Document)
- [1.2 内置六种 Reader 横向对比](#1.2 内置六种 Reader 横向对比)
- [1.3 纯文本抽取的局限:三个必然失真的点](#1.3 纯文本抽取的局限:三个必然失真的点)
- [🧩 二、核心机制深挖:解析为什么能工作、为什么失真](#🧩 二、核心机制深挖:解析为什么能工作、为什么失真)
-
- [2.1 双路径模型:PDF 文本层 vs 图像层](#2.1 双路径模型:PDF 文本层 vs 图像层)
- [2.2 阅读顺序算法:多栏 / 绕排如何重排](#2.2 阅读顺序算法:多栏 / 绕排如何重排)
- [2.3 表格结构识别:单元格 / 合并单元格 → Markdown](#2.3 表格结构识别:单元格 / 合并单元格 → Markdown)
- [2.4 图片抽取与多模态落点](#2.4 图片抽取与多模态落点)
- [2.5 为什么朴素抽取必然丢结构(信息论视角)](#2.5 为什么朴素抽取必然丢结构(信息论视角))
- [2.6 版面理解(VLM)在解析层的落点](#2.6 版面理解(VLM)在解析层的落点)
- [2.7 解析质量评估指标](#2.7 解析质量评估指标)
- [2.8 标题层级抽取:把"位置"变成可检索的 `title_path`](#2.8 标题层级抽取:把"位置"变成可检索的
title_path) - [2.9 PDF 文本编码黑盒:为什么有的 PDF 抽出来是乱码(ToUnicode CMap)](#2.9 PDF 文本编码黑盒:为什么有的 PDF 抽出来是乱码(ToUnicode CMap))
- [2.10 渲染分辨率与 DPI:为什么 VLM 读 PDF 要先"拍张照"](#2.10 渲染分辨率与 DPI:为什么 VLM 读 PDF 要先"拍张照")
- [🏗️ 三、系统架构:解析层在双管道中的位置](#🏗️ 三、系统架构:解析层在双管道中的位置)
-
- [3.1 解析层架构图](#3.1 解析层架构图)
- [3.2 为什么解析层必须"离线 + 可失败可重试"](#3.2 为什么解析层必须"离线 + 可失败可重试")
- [🔬 四、版面理解:为什么纯文本抽取不够](#🔬 四、版面理解:为什么纯文本抽取不够)
-
- [4.1 四种版面陷阱,逐个拆解](#4.1 四种版面陷阱,逐个拆解)
- [4.2 表格丢失踩坑闭环(现象 → 根因 → 复现 → 修复 → 预防)](#4.2 表格丢失踩坑闭环(现象 → 根因 → 复现 → 修复 → 预防))
- [4.3 六问剖析:版面理解到底解决什么](#4.3 六问剖析:版面理解到底解决什么)
- [4.4 解析质量对比数据(示例测算)](#4.4 解析质量对比数据(示例测算))
- [🛠️ 五、结构化抽取:表格转 Markdown、标题层级作为元数据](#🛠️ 五、结构化抽取:表格转 Markdown、标题层级作为元数据)
-
- [5.1 Maven 依赖与版本校验(专业度 + 风险兜底)](#5.1 Maven 依赖与版本校验(专业度 + 风险兜底))
- [5.2 格式路由:不让 Excel 误入 Tika](#5.2 格式路由:不让 Excel 误入 Tika)
- [5.3 PDF 按页解析 + 元数据注入](#5.3 PDF 按页解析 + 元数据注入)
- [5.4 Excel 结构化抽取:合并单元格 → Markdown 表格](#5.4 Excel 结构化抽取:合并单元格 → Markdown 表格)
- [5.5 自定义 DocumentReader:把任意来源接入管道](#5.5 自定义 DocumentReader:把任意来源接入管道)
- [5.6 数据建模:解析元数据字段表](#5.6 数据建模:解析元数据字段表)
- [5.7 元数据是检索质量的"隐形杠杆"](#5.7 元数据是检索质量的"隐形杠杆")
- [5.8 其他格式接入:PPT / HTML / CSV](#5.8 其他格式接入:PPT / HTML / CSV)
-
- [PPT / PPTX:按幻灯片切分 + 保留备注](#PPT / PPTX:按幻灯片切分 + 保留备注)
- HTML:先去样板噪声,再抽结构
- [CSV:扁平二维表直接转 Markdown](#CSV:扁平二维表直接转 Markdown)
- [5.9 Word 标题层级抽取:重建 `title_path`](#5.9 Word 标题层级抽取:重建
title_path)
- [🔥 六、OCR 与多模态解析的接入点](#🔥 六、OCR 与多模态解析的接入点)
-
- [6.1 扫描件识别边界:哪些文档必须走 OCR](#6.1 扫描件识别边界:哪些文档必须走 OCR)
- [6.2 多模态文档智能(版面模型 / VLM)的落点](#6.2 多模态文档智能(版面模型 / VLM)的落点)
- [6.2.1 OCR 内部原理:检测→识别两阶段,与 VLM 端到端有何不同](#6.2.1 OCR 内部原理:检测→识别两阶段,与 VLM 端到端有何不同)
- [6.3 解析成本与延迟模型(示例测算)](#6.3 解析成本与延迟模型(示例测算))
- [6.4 文档智能模型格局与解析质量度量(2026 更新)](#6.4 文档智能模型格局与解析质量度量(2026 更新))
-
- [2026 文档解析模型格局](#2026 文档解析模型格局)
- 解析质量度量:对齐输出类型选指标
- [🚫 七、解析失败的兜底与人工复核流](#🚫 七、解析失败的兜底与人工复核流)
-
- [7.1 兜底三板斧:重试 → 降级 → 入队](#7.1 兜底三板斧:重试 → 降级 → 入队)
- [7.2 熔断:OCR / VLM 服务挂了不能拖垮整条管道](#7.2 熔断:OCR / VLM 服务挂了不能拖垮整条管道)
- [7.3 人工复核流与可观测](#7.3 人工复核流与可观测)
- [⚖️ 八、横向全景对比:开源 vs 商用、自研 vs 开源](#⚖️ 八、横向全景对比:开源 vs 商用、自研 vs 开源)
-
- [8.1 解析引擎:开源 vs 商用全景](#8.1 解析引擎:开源 vs 商用全景)
- [8.2 自研 vs 开源:什么时候才值得自研](#8.2 自研 vs 开源:什么时候才值得自研)
- [8.3 OCR / VLM 选型决策表](#8.3 OCR / VLM 选型决策表)
- [📊 九、生产实践:性能 / 成本 / 安全 / 监控 / 扩展性 / 可靠性 / 运维](#📊 九、生产实践:性能 / 成本 / 安全 / 监控 / 扩展性 / 可靠性 / 运维)
- [🧪 十、解析器自动化测试与回归防护(Golden File Testing)](#🧪 十、解析器自动化测试与回归防护(Golden File Testing))
-
- [10.1 黄金文件测试:把"正确解析结果"钉死](#10.1 黄金文件测试:把"正确解析结果"钉死)
- [10.2 解析器契约测试:每个 Reader 都必须过关](#10.2 解析器契约测试:每个 Reader 都必须过关)
- [📝 十一、总结与展望](#📝 十一、总结与展望)
-
- 关键要点回顾
-
- [🔬 理论深度](#🔬 理论深度)
- [🛠️ 工程实践](#🛠️ 工程实践)
- [⚖️ 方案对比](#⚖️ 方案对比)
- [🚀 系列导航](#🚀 系列导航)
- 数据入口工程的"前后对比"预期
- 下一步学习
📚 前言:一次让问答准确率"腰斩"的解析事故
第 1 篇我们立好了"生产级 RAG 2.0 双管道"的架构,离线摄取管道 的第一道工序就是"文档接入与解析"。很多团队以为这一步就是"读个文件",随手用 PdfReader 一把梭,结果埋下了一颗巨大的雷。
讲一个我亲历、也几乎每个做企业知识库的团队都会撞上的坑。
某业务线要做一个"智能客服知识助手",知识源是三类真实文档:
- 几百份产品手册 PDF ------其中相当比例是扫描件(图片型 PDF)或图文混排;
- 几十张 Excel 报表 ------带合并单元格的复杂表头,比如"区域 / 季度 / 产品"三级表头嵌套;
- 上百篇 Word 操作指引 ------图文混排,段落里穿插截图、流程图、带边框的提示框。
团队技术栈是清一色的 Java / Spring。Demo 阶段很顺:用 Spring AI 的 TikaDocumentReader 一把把文档读进来,切块、向量化、提问,本地测了十几个问题,看着还行。一周交付,业务方拍手叫好。
上线第二周,投诉来了------
- 客服问"华东区 Q2 无线耳机的退货政策",机器人答"抱歉我没有相关信息"------明明那张合并单元格的 Excel 第 3 行写得清清楚楚(表格结构全丢,单元格语义断裂);
- 新人问"XX 型号安装步骤第 2 步是什么",机器人把第 1 页页眉的"公司保密声明"和第 9 页的脚注拼在一起答,答非所问(页眉页脚污染 + 多栏乱序);
- 更致命的是,扫描版 PDF 直接读出来是空文本 ,整本手册"消失"了,召回命中率为零(图片型 PDF 无文本层);
- 图文混排的 Word,把图片里的关键参数当成了 caption 文本,问答准确率直接腰斩。
这不是个例。我看过太多团队,把"数据入口"当成"读文件"来敷衍,却在规模化生产的第一道门槛前崩盘。
一个能共情的"事故量化"
为了让没踩过坑的人也有体感,把上面那次事故换算成可感知的数字(示例测算,非真实数据,仅用于说明量级):
| 指标 | Demo 期(第 1 周) | 生产崩盘期(第 2 周) | 根因 |
|---|---|---|---|
| 文档可解析率 | 100%(只测了文本 PDF) | 63%(扫描件 + 复杂表直接失败) | 没覆盖真实格式分布 |
| 表格结构保留率 | --- | 11% | Tika 默认把表格拍平成文本 |
| 问答答准率(抽样) | 84% | 41% | 噪声召回 + 表格丢失 + 页眉污染 |
| 空召回命中 | 0 | 27 次 | 图片型 PDF 无文本层 |
| 单次摄取耗时 | 0.3s / 篇 | 8.6s / 篇 | 兜底 OCR + 重试拖慢 |
| 故障定位耗时 | --- | 4 小时 | 解析失败无告警、无埋点 |
这张表想说明一件事 :解析层的"好指标"是假象 ------你测的是文本 PDF,生产里喂的是扫描件、合并表格、图文混排。一旦格式复杂起来,三道裂缝同时张开(表格丢、页眉污染、图片型空白),问答准确率会断崖式下跌。所以"数据入口工程"不是锦上添花,是检索质量的第一道闸门------它直接决定了下游分块、向量化、检索的上限。
本篇作为系列第 2 篇,不堆 API,先把解析体系、版面理解、结构化抽取、OCR 接入、兜底复核这条完整的数据入口链路立起来。后面的分块(第 3 篇)、向量化(第 4 篇)都建在这条链路上。
🎯 本文你将学到
🔬 理论深度
✅ Spring AI DocumentReader 体系全景 ------DocumentReader 接口、TikaDocumentReader / PagePdfDocumentReader / ParagraphPdfDocumentReader / MarkdownDocumentReader / JsonReader / 自定义 Reader 的能力边界
✅ 核心机制深挖 ------PDF 文本层 vs 图像层双路径、阅读顺序算法(多栏 / 绕排 / 投影剖面)、表格结构识别(单元格 / 合并单元格 → Markdown)、图片抽取、版面理解(VLM)落点、解析质量评估指标(公式 / 算法 / 数据结构级)
✅ 编码与渲染原理深挖 ------ToUnicode CMap 为何抽出乱码、PDF 规范为何没有"阅读顺序"、VLM 读 PDF 前按 DPI 渲染成图的 token 平方代价
✅ 版面理解的本质 ------为什么"纯文本抽取"在企业真实文档上必然失真,多栏 / 页眉页脚 / 表格 / 图文混排各自怎么破
✅ OCR 内部原理------检测→识别两阶段流水线 vs VLM 端到端的结构差异、可调试性与失败模式互补
🛠️ 工程实践
✅ 生产级解析代码 ------格式路由、PagePdfDocumentReader 配置化、Excel 结构化抽取(含合并单元格完整实现)、自定义 DocumentReader、元数据注入(metadata map)
✅ 多格式专用接入 ------PPT(按幻灯片 + 备注)、HTML(去样板噪声 + 章节切分)、CSV(直接转 Markdown)、Word 标题层级重建(title_path)
✅ OCR 与多模态接入点 ------扫描件 / 图片型 PDF 怎么接 OCR,版面模型 / VLM 在解析层落哪、收益与成本
✅ 数据建模与成本模型 ------解析元数据字段表、单页 OCR/VLM 成本与延迟预算测算
✅ 解析失败兜底与人工复核流 ------重试 / 降级 / 熔断 / 入队,怎么不让坏文档污染知识库
✅ 解析器回归测试------黄金文件测试 + 契约测试,把解析质量钉进 CI
⚖️ 方案对比
✅ 六种 Reader 横向对比 ------适用格式、底层引擎、典型场景、代价
✅ 开源 vs 商用、自研 vs 开源全景对比 ------解析引擎 / OCR / VLM 的选型决策表
✅ 2026 文档智能模型格局 ------dots.ocr / GOT-OCR / Mistral OCR / Qwen3-VL / Gemini 3 Flash 的定位与代价
✅ 解析质量对比数据 ------默认解析 vs 版面增强解析,表格保留率 / 答准率差异
✅ 解析质量度量对齐------TEDS(表格)/ Field-F1(表单)/ CER-WER(文本)/ ANLS(问答)怎么选
🚀 系列导航
✅ 四条工程主线 ------检索质量(入口)、成本可控(OCR 计费)、质量可度量(解析质量评测)、权限治理(来源标权)
✅ 本篇在双管道的位置------离线摄取管道第一道工序
准备好了吗?我们先从"Spring AI 到底给我封装了哪些 Reader"说起。🚀
⚡ 一、Spring AI DocumentReader 体系全景与局限
第 1 篇我们讲了双管道,离线摄取管道的第一道工序就是"读文档"。Spring AI 在 1.1.x 把这一步抽象成了一个极简的接口。
1.1 核心抽象:DocumentReader 与 Document
整个解析入口只有一个接口,设计非常克制(API 以你使用的 1.1.x 小版本官方文档为准,截至 2026-07-12):
java
// org.springframework.ai.document.DocumentReader
public interface DocumentReader extends Supplier<List<Document>> {
List<Document> get();
}
一个 get() 方法,返回 Document 列表。每个 Document 只含两部分核心数据:
content(String):文档的文本内容;metadata(Map<String, Object>):文档的元数据(文件名、页码、作者、来源等)。
这个极简设计是 Spring AI RAG 体系"可插拔"哲学的集中体现------你只要能产出 List<Document>,上游的分块、向量化、落库就完全不关心你背后是 PDFBox、Tika、POI 还是 OCR 服务。这给了我们自定义解析器极大的自由度。
架构师视角 :
DocumentReader接口像 JDBC 的Driver------它把"怎么读"和"读出来干什么"彻底解耦。第 3 篇的分块器TextSplitter只吃List<Document>,永远不碰底层格式。这种抽象,是后面做混合解析、OCR 接入、灰度切换的底气。
1.2 内置六种 Reader 横向对比
Spring AI 1.1.x 内置了多种 DocumentReader 实现,底层引擎各不相同。截至 2026-07-12,以下类名与包路径以你使用的 1.1.x 小版本官方文档为准:
| 实现类 | 适用格式 | 底层引擎 | 典型场景 | 主要代价 |
|---|---|---|---|---|
TextReader |
纯文本 .txt / .md |
Java NIO | 日志、配置文件 | 无结构,标题层级丢失 |
JsonReader |
JSON 文件 | Jackson | API 文档、结构化数据 | 需写 JSONPath / 指定 key |
MarkdownDocumentReader |
Markdown | 内置解析器 | 技术文档、README | 依赖规范 MD 写法;标题转 metadata |
PagePdfDocumentReader |
文本型 PDF | Apache PDFBox | 产品手册、白皮书(按页) | 不擅长扫描件、表格 |
ParagraphPdfDocumentReader |
带大纲的文本型 PDF | PDFBox + TOC | 有目录的长文档(按段) | 无目录 PDF 退化为整页 |
TikaDocumentReader |
几乎全格式 | Apache Tika | 多格式混合文档库 | 表格被拍平、版面信息弱 |
关键判断 :
TikaDocumentReader是"万能读",一个类搞定 PDF / Word / Excel / HTML;但它为了通用,牺牲了版面保真度 ------表格会被拍平成带分隔符的文本,多栏会乱序,页眉页脚会混入正文。而PagePdfDocumentReader是"PDF 专家",能按页切分、可配置去页眉页脚,但对扫描件同样无能为力。实战建议 :生产环境不要无脑用 Tika 一把梭 。按格式路由:文本 PDF 走
PagePdfDocumentReader(带版面清洗配置)、Word / Excel / HTML 走TikaDocumentReader、扫描件走 OCR(第 2、6 节)。这层"路由"是解析质量的第一杠杆。
1.3 纯文本抽取的局限:三个必然失真的点
为什么"读出来一串文本"在企业真实文档上不够?因为企业文档不是"纯文本流",而是带版面的富结构文档。纯文本抽取会在三个点必然失真:
- 表格结构断裂:合并单元格、多级表头在文本化后,列与单元格的从属关系完全丢失,"区域=华东"和"季度=Q2"变成两行孤立文字;
- 版面顺序错乱:双栏排版的 PDF,按阅读顺序应该是"左栏上→左栏下→右栏上→右栏下",但很多抽取器按"物理坐标 Y 轴"输出,变成"全左栏→全右栏"甚至乱序;
- 噪声注入:页眉、页脚、页码、版权声明、"第 X 页 / 共 Y 页"被当成正文嵌入,污染 chunk、污染检索。
一句话主判断(请刻进 DNA) :企业知识库的解析目标,不是"把文档变成文本",而是"把文档变成保留版面语义的、干净的结构化碎片"。这一层做不好,下游分块再聪明也救不回来------因为信息在入口处就已经丢了。
🧩 二、核心机制深挖:解析为什么能工作、为什么失真
上一节讲了"有哪些 Reader"。这一节往下沉,回答架构师最该懂的问题:解析在机制层面到底发生了什么?为什么能提取、为什么复原不了、在哪里失灵? 这一节是本文的"深度地基",后面所有工程决策都建立在此。
2.1 双路径模型:PDF 文本层 vs 图像层
要理解 PDF 解析,先要理解 PDF 不是文本格式,而是"画图指令流"。一份 PDF 内部由两类对象组成(简化视角):
- 内容流(Content Stream) :用算子如
Tj/TJ把字符画到指定坐标,附带字体CIDFont。有这个,就能"抽取文本层"; - 外部对象(XObject) :主要是
Image子类型------扫描件 PDF 把整页扫成一张图塞进去,根本没有Tj算子,于是文本层为空。
这就是为什么"扫描件读出来是空白"------不是 Read 失败,而是它本来就没有文本,只有像素 。于是解析层必须存在两条互斥路径:
text
┌─────────────────────────────────────┐
PDF 输入 →│ 是否含文本层 (有 Tj 算子 / 可抽字符)? │
└─────────────────────────────────────┘
│是 │否
▼ ▼
路径 A:文本解析 路径 B:图像解析
PagePdfDocumentReader OCR / VLM 识别像素
(PDFBox 读内容流) → 文本 + 结构
快(0.3~1s/页) 慢(2~8s/页)、计费
判定算法 (工程化 isImagePdf):先抽整本文本层,若可见字符数极少(C < threshold,如全本 < 30 个非空字符)且文档确实存在图像对象,判定为图片型 PDF,转路径 B。注意------这里不能用"逐页字符数 + 图像面积占比"的近似(坐标归一化极易误判),直接用 PDFBox 的 PDFTextStripper 抽全文再判定最稳:
java
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
import org.apache.pdfbox.text.PDFTextStripper;
/** 判定图片型(扫描件)PDF:文本层可抽取字符极少,但存在图像对象 */
boolean isImagePdf(PDDocument doc) throws IOException {
PDFTextStripper stripper = new PDFTextStripper();
String text = stripper.getText(doc); // 抽取全部文本层
int textChars = text.replaceAll("\\s+", "").length();
if (textChars >= MIN_TEXT_CHARS) return false; // 有明显文本层 → 不是扫描件
// 文本极少时,再确认是否含图像对象,避免把"真空白 PDF"误判为扫描件
for (PDPage p : doc.getPages()) {
if (!p.getResources().getImages().isEmpty()) return true;
}
return false;
}
// MIN_TEXT_CHARS 建议取 30;阈值过低会把"正文极少的封面页 PDF"误判
机制要点 :这条判定必须前置在格式路由里(见第 4 节),否则路径 A 会静默返回空文本,下游完全无感------这正是"空召回命中 27 次"事故的根因之一。同时它也是成本闸门:文本型 PDF 走路径 A 几乎零成本,扫描件才触发昂贵的 OCR/VLM。
🔍 冷知识(原理向) :数字 PDF 与扫描件 PDF 的本质区别只有一个------是否含有Font对象 。数字 PDF 在内容流里引用CIDFont把字符画出来(所以有文本层);扫描件只是把整页位图塞进ImageXObject,没有Font,所以"读出来是空白"不是读失败,是它压根没有字。判断isImagePdf时"有图无 Font"比数字符数更准,但字符数兜底能避免误杀"正文极少的封面页"------两者结合最稳(见 2.1 算法)。
2.2 阅读顺序算法:多栏 / 绕排如何重排
路径 A 抽出来的文本,默认是按"内容流里字符出现的顺序"给出的,不是人类阅读顺序。双栏 / 绕排文档会严重错乱。这里有一个真实的算法问题需要讲清。
核心数据结构:每个文本片段(Text Fragment)是一个带坐标的盒子:
text
Fragment = { text, x0, y0, x1, y1 }
// (x0,y0)=左上角, (x1,y1)=右下角,原点在左下(PDF 坐标)
投影剖面算法(Projection Profile)+ 列聚类是工业界的主流做法,分四步:
text
Step1 行聚类:按 y0 重叠把 Fragment 归并成"行",每行有 [y_top, y_bottom, x_left, x_right]
Step2 列投影:把所有行的 x 区间投影到 X 轴,统计每列被覆盖的密度
Step3 找列缝:密度出现大缺口(GAP)处,就是栏与栏的分界 → 得到列边界 [c0,c1],[c1,c2]...
Step4 重排:先按"列序号"升序,同列内按 y_top 降序(从上到下),拼接成阅读顺序
关键在于 Step3 的"列缝判定":用阈值 colGap = k * 平均字符宽,缺口超过它才算分栏。代码骨架:
java
List<Line> reorder(List<Line> lines) {
// 1. 投影到 X 轴,统计覆盖密度
int[] density = projectToXAxis(lines);
// 2. 找列边界(密度连续为 0 的大区间)
List<int[]> cols = findColumnGaps(density, colGapThreshold);
// 3. 每行归属到某一列,同列按 y_top 降序
lines.forEach(l -> l.col = assignColumn(l, cols));
lines.sort(comparingInt(l -> l.col).thenComparing(l -> -l.yTop));
return lines;
}
为什么这件事重要 :阅读顺序错乱会让"同一段话"被切成两个 chunk,或者"左栏上半 + 右栏下半"拼成一段胡话。第 3 篇讲分块时你会看到------分块拿到的 input 质量,完全取决于这里的重排质量。
原理再深一层------为什么规则法终有天花板 :投影剖面法的隐含假设是"同栏文字 Y 坐标连续、栏间有 X 方向大间隙"。但真实文档里,跨栏的图注、绕排的图、页边批注、脚注都会破坏这个几何假设;更麻烦的是 PDF 内容流本身不保证字符按阅读顺序出现 (详见 2.9 的"规范黑洞"),所以规则法本质是"用几何近似去猜逻辑顺序"。2024 年后主流文档智能(LayoutLMv3、GOT-OCR、各 VLM)改用布局感知的阅读顺序预测 :模型直接学习"人眼会先读哪块",对绕排、跨页、图表混排远比规则法稳。工程上务实的做法是------规则法做默认快路径,VLM 做复杂版面的升级路径(呼应 6.4 两级路由)。
🔍 冷知识(原理向) :PDF 规范(ISO 32000)里根本没有"阅读顺序"这个概念。内容流只记录"在 (x,y) 画字符 c",至于人该先读哪块,规范不关心。你从 PDF 复制文字时顺序乱掉,根因就在这里------阅读顺序是抽取器"猜"出来的,不是 PDF 存的。这从根上解释了 2.5 节的"有损投影":结构化信息从来就不在文件里,是被投影丢掉的。
2.3 表格结构识别:单元格 / 合并单元格 → Markdown
表格是解析里最折磨人的结构。文本层能抽到"格子里的字",但抽不到"格子之间的关系" 。要还原成 Markdown,本质是做一次二维网格重建。
算法(基于坐标的网格推断):
text
输入:表格区域内所有单元格 Fragment(含坐标)
1. 按 y0 聚类 → 得到"行"集合 R0..Rn
2. 按 x0 聚类 → 得到"列"集合 C0..Cm
3. 建立网格 G[row][col],把每个 Fragment 填入对应 (row,col)
4. 合并单元格:若某 Fragment 的 (x0,x1) 横跨多个列 → colspan;
(y0,y1) 横跨多个行 → rowspan
5. 输出 Markdown:首行后插 |---|---|,colspan 用连续 | 占位
Excel 比 PDF 幸福------POI 直接给出合并区域 CellRangeAddress,省去了坐标推断:
java
// 合并单元格还原:只把值写在左上角,其余格子标记为"已被合并覆盖"
Map<String, String> merged = new HashMap<>();
for (CellRangeAddress r : sheet.getMergedRegions()) {
String val = readCell(sheet.getRow(r.getFirstRow())
.getCell(r.getFirstColumn()));
for (int rr = r.getFirstRow(); rr <= r.getLastRow(); rr++)
for (int cc = r.getFirstColumn(); cc <= r.getLastColumn(); cc++)
merged.put(rr + ":" + cc, (rr==r.getFirstRow() && cc==r.getFirstColumn()) ? val : "");
}
还原后的 Markdown 表格,让"华东 / Q2 / 无线耳机 / 3.2%"处于同一行语义单元,向量相似度不再被拍平稀释------这正是修复第 3.2 节踩坑的核心。
2.4 图片抽取与多模态落点
图文混排文档里的图,藏着正文没有的关键参数(型号、价格、架构图说明)。路径 A 用 PDFBox 把 PDImageXObject 抽出来:
java
for (PDPage p : doc.getPages())
for (PDImageXObject img : p.getResources().getImages())
Files.write(Path.of("page-" + p.get(pageIndex) + ".png"), img.getBytes());
抽出来的图有两条出路:① 传统 OCR 抽图内文字;② 直接喂 VLM(多模态)让模型理解图意并产出结构化描述。后者对"架构图 / 流程图 / 复杂截图"尤其有效------这是 2.6 节要展开的机制。
2.5 为什么朴素抽取必然丢结构(信息论视角)
把这一节升华成一个判断:文本抽取 = 把二维版面投影成一维字符串,是有损投影(lossy projection)。
PDF 内容流是"画家模型"------文字靠绝对坐标摆放,彼此没有语义从属。投影到 1D 字符串时,我们必然丢失四类信息:
- 空间关系(哪个 cell 属于哪个 header);
- 阅读顺序 / Z 序(哪段先读);
- 样式语义(标题 vs 正文 vs 图注的层级);
- 非文本元素(图、图表、公式)。
信息论上,降维必然丢熵。所以"抽取成字符串"在数学上就不可能保留全部结构------这是为什么我们需要"版面对象树"而非"字符串"作为中间表示,也是为什么 OCR/VLM 路径要把"图"当作一等公民而非事后补丁。
2.6 版面理解(VLM)在解析层的落点
当"规则 + 坐标"搞不定复杂版面时,版面理解模型成为进阶武器。两条技术路线:
- LayoutLM 系列(编码器):把"文本 + 版面坐标 + 页面图像"联合编码,做 token 级分类(这是标题 / 这是表头 / 这是表格区域)。它擅长"定位",不擅长"生成";
- VLM(如 Qwen3-VL / InternVL 等,截至 2026-07 的参考判断,复核可用性) :把整页渲染图直接喂给多模态模型,让它输出保留表格与标题层级的 Markdown。它擅长"端到端还原结构"。2026 年的具体模型格局与选型见 6.4 节。
在架构上,它落在解析增强层 (第 3 节架构图的 P3 节点),作为路径 B 的"结构还原"增强,或直接替代 OCR:
text
扫描件/复杂图 → [OCR 出字 + 坐标] → [VLM 看原图做结构重组] → 结构化 Markdown
或:扫描件 → [VLM 直接看图出 Markdown](一步到位,成本更高)
为什么 VLM 能补规则的盲区 :规则算法对"绕排、跨页表格、图表混排"束手无策,但它们在"人眼看来一目了然"。VLM 用视觉先验理解了版面整体,输出自然连贯。代价是慢(秒级/页)且贵(按图像 token 计费),且要处理限流与降级------所以它必须是"按需升级",而非默认路径。
2.7 解析质量评估指标
解析做没做对,必须可度量。定义五个核心指标(参考业界文档智能评测口径,截至 2026-07-12 的工程判断):
| 指标 | 定义 | 测量方式 | 目标 |
|---|---|---|---|
| 结构保真度 | 正确识别的版面对象 / 总版面对象 | 抽样人工或模型评判 | > 90% |
| 表格还原率 | 正确重建的表格 / 总表格 | 抽样对比原表结构 | > 90% |
| 阅读顺序正确率 | 阅读顺序正确的片段跨度 / 总跨度 | 抽样人工标注 | > 95% |
| 噪声污染率 | 被污染的 chunk / 总 chunk | 规则 + 抽样 | < 5% |
| 元数据完整率 | 带齐必要 metadata 的 doc / 总 doc | 程序校验 | 100% |
架构师视角 :这五个指标要打点进 Prometheus(第 7 节),否则你根本不知道入口在漏。第 11 篇的 RAG 评测体系,会把"解析质量"作为上游变量纳入 Hit Rate / MRR 的归因分析------解析指标的下降,会先于问答质量下降被观测到。
度量口径进阶(截至 2026-07 的参考判断) :上面的"表格还原率"是业务口径,若要横向对比解析引擎,需用业界标准指标:表格结构看 TEDS (Tree-Edit-Distance-over-Symbolic-trees,对 HTML 树做树编辑距离,能抓住"CER 看不出的单元格错位");表单/票据看 Field-F1 (按字段算精确率/召回率,税号、金额这类字段必须精确命中);纯文本看 CER/WER (字符/词错率,印刷体做到 1~2% 算好);文档问答看 ANLS (平均归一化莱文斯坦相似度,给轻微 OCR 错误部分分)。同一份文档用 CER 和 TEDS 测,结论可能相反------选指标要先对齐你的输出类型,否则会被假阳性骗。
2.8 标题层级抽取:把"位置"变成可检索的 title_path
前面 5.6 的 metadata 表里有一列 title_path(如 手册>第3章>3.2 保修),它是分块质量与引用展示的隐形杠杆。但"标题层级"不会从天而降------纯文本抽取丢掉了一切层级信息,必须由解析层主动重建。
重建依赖两类线索:
- 显式结构(最可靠) :Markdown 的
#~######、Word 的Heading1~HeadingN样式、HTML 的<h1>~<h6>------这些是作者明示的层级,解析时直接映射; - 隐式线索(兜底):没有样式时,靠字号、加粗、缩进、居中推断"这行像不像标题"。规则法误判率高,复杂版面才值得上 VLM 判级。
工程落点 :Spring AI 的 MarkdownDocumentReader 已自动把每个标题层级写入 header_1..header_n 与 title 元数据(见第 1 篇配置);Word 则需要用 POI 遍历段落、读 ParagraphStyle 的 Heading 等级自行拼出 title_path(代码见 5.9 节)。关键原则:层级是"树",不能只记"当前标题",要记"从根到当前节点的完整路径"------这样检索结果才能展示"出自《XX手册》第 3 章 3.2 节",而不是一句孤立的"保修条款"。
2.9 PDF 文本编码黑盒:为什么有的 PDF 抽出来是乱码(ToUnicode CMap)
前面都在讲"抽不到结构",还有一种更隐蔽的失真:抽到了字,但抽出来是乱码。这不是字体缺失,而是编码映射问题------它比"空白"更阴险,因为下游不会判空,会带着错字向量化、检索、作答。
PDF 内容流里记录的不是 Unicode,而是字形码(glyph code) ------一个指向字体内部字模的整数。要把字形码翻译成"人能读的字符",要靠字体里的一张 ToUnicode CMap :glyph code → Unicode。三件事会出问题:
- 没有 ToUnicode :老式 / 劣质 PDF 压根不附这张表,抽取器只能退回字体内部编码(如
WinAnsiEncoding),遇到自定义符号就乱码; - ToUnicode 错了:更阴险------表存在但映射写反 / 写错,你复制到别处居然是乱序或错字,肉眼难查;
- CID 字体 + 自定义编码 :CJK 文档常用
Identity-H编码,glyph code 是 CID,没有 ToUnicode 就完全解不出中文。
工程含义 :解析层要对"疑似乱码"做检测(非 ASCII 异常占比、连续 □ / �),命中后自动升级到 OCR/VLM 路径------因为乱码在语义上等价于空文本 ,下游照样崩。这和第 7 节质量闸门的 isEmptyOrGarbled 一脉相承(那里只判空 / 半空,生产里应补一道"乱码判定")。
💡 奇技 :一个无需模型的快速自检------
PDFTextStripper抽出文本后,统计"可打印 ASCII + 常见 CJK 之外的异常字符占比",超过阈值(如 15%)就标记suspectEncoding=true,连同confidence写进 metadata,供质量闸门决定是否升级 OCR。这比"等用户投诉乱码"早一步。
🔍 冷知识(原理向) :你在 PDF 阅读器里"选中文字→复制"偶尔顺序错乱、偶尔乱码,根因正是上面两套机制------顺序是猜的(2.2 节),字符是映射的(本节)。PDF 天生是个"给人看的画",不是"给机器读的数据",这正是企业知识库必须做"智能解析"的根本原因。
2.10 渲染分辨率与 DPI:为什么 VLM 读 PDF 要先"拍张照"
路径 B 里有个被忽视的原理环节:VLM 不直接读 PDF,它读的是 PDF 渲染出来的位图 。PDF 是矢量画图指令,VLM 吃的是像素,中间必须有一道"渲染"------用 PDFBox.PDFRenderer 或 Poppler 的 pdftoppm 把每页 rasterize 成 PNG/JPG。
这里有个代价与精度权衡(DPI):
| 渲染 DPI | 单页像素(A4) | 图像 token 量 | 小字识别 | 单页耗时 |
|---|---|---|---|---|
| 72(屏幕) | ~600×850 | 少 | 易糊、丢小字 | 快 |
| 150(标准) | ~1240×1754 | 中 | 一般 | 中 |
| 300(印刷) | ~2480×3508 | 多(约 4 倍于 150) | 好 | 慢、占显存 |
原理要点 :VLM 把图切成固定尺寸的 tile(如 512×512 或 1024×1024),每个 tile 折成若干 image token。DPI 翻倍 → 像素翻 4 倍 → tile 数翻 4 倍 → token 与成本近似平方级上涨 ,但小字识别率会先升后平(超过 300 DPI 收益递减,纯烧钱)。工程上 300 DPI 是扫描件 OCR/VLM 的甜点线,再高边际收益极低。
💡 奇技 :渲染分辨率应该按文档类型动态调 ------正文型 PDF 用 200~250 DPI 足矣;含密集小字表格 / 公式的财务报表、技术图纸才上 300 DPI。把 DPI 做成
OcrDocumentReader的可配置参数(而不是写死 300),能在"识别率"和"VLM token 成本"之间按文档价值精算------这恰好是 6.3 节成本模型在渲染环节的落点。
🏗️ 三、系统架构:解析层在双管道中的位置
回到第 1 篇的双管道架构,把"解析层"放大,看它在离线摄取管道里的精确位置,以及它和下游、和治理层的关系。
3.1 解析层架构图

图解要点:
- 格式路由层是解析质量的第一杠杆:不同格式走不同 Reader,而不是 Tika 一把梭;
- 解析增强层是核心增值:版面理解 + 结构化抽取,把"文本"升级为"保留语义的碎片";
- 质量闸门 + 兜底是可靠性闸门:坏文档要么被拦截修复,要么进人工复核,绝不能静默污染知识库;
- 治理层横切:解析失败率、表格保留率要打点进 Prometheus,否则你根本不知道入口在漏。
3.2 为什么解析层必须"离线 + 可失败可重试"
和第 1 篇双管道一致,解析在离线摄取管道跑,质量优先、可容忍慢。这带来两个关键设计:
- 可失败:线上解析(尤其 OCR、VLM)会超时、会限流、会识别错。离线管道允许"先失败、后补偿",而不是让用户等;
- 可重试 + 可降级:某格式解析失败,自动降级到兜底 Reader(如 PDF 解析失败→尝试 OCR),仍失败→入人工复核队列。
架构师视角:把解析放在离线管道,意味着你可以用"重"的模型(OCR、VLM)去换质量,而不必担心卡住用户提问的 2 秒延迟预算。这一"轻重分离",是解析能做深的前提。
🔬 四、版面理解:为什么纯文本抽取不够
这一节是本文的"原理核心"。我们要回答:真实文档的版面,到底会在哪些地方坑你?以及怎么破?
4.1 四种版面陷阱,逐个拆解
| 陷阱 | 现象(业务侧感受) | 根因 | 典型文档 |
|---|---|---|---|
| 多栏乱序 | 问答把上下两栏内容拼在一起,张冠李戴 | 抽取按物理坐标而非阅读顺序 | 学术期刊、产品彩页 |
| 页眉页脚污染 | 搜"保密"召回每一页,正文本被稀释 | 页眉页脚被当正文嵌入 chunk | 所有带页眉页脚 PDF |
| 表格结构丢 | 问单元格值,答"无信息"或答错行 | 表头/单元格从属关系丢失 | 报表、BOM、价目表 |
| 图文混排 | 图片里的关键参数被当 caption,答不准 | 图片内容无法文本化 | 操作手册、说明书 |
4.2 表格丢失踩坑闭环(现象 → 根因 → 复现 → 修复 → 预防)
这是本文的 90+ 闭环要点,必须写成完整闭环,不能只写"注意事项"。
① 现象 :业务方问"华东区 Q2 无线耳机的退货率是多少",知识库答"抱歉我没有相关信息",但那张合并单元格的 Excel 明明有这个数据。抽样发现,带合并单元格的 Excel 表格,召回命中率接近于零。
② 根因:
TikaDocumentReader解析.xlsx时,默认把单元格按行列拍平成"行文本",合并单元格被拆成多个孤立单元格,多级表头(区域/季度/产品)丢失从属关系;- 更致命的是,合并单元格在拍平后产生大量空值单元格和换行,拆分后的 chunk 里"华东""Q2""无线耳机"三者不再处于同一语义单元,向量检索时相似度被严重稀释;
- 页眉页脚同理:每页都有的"公司保密声明"被嵌入正文,污染 chunk。
③ 复现条件:
- 文档:一张三级表头(区域/季度/产品)且含合并单元格的
.xlsx; - 代码:
new TikaDocumentReader(resource).get()直接解析; - 验证:打印
document.getContent(),可见表格被拍平为区域 华东 季度 Q2 产品 无线耳机 退货率 3.2%这样被换行切碎的文本,且合并单元格区域出现空行。
④ 修复 :对 Excel 走"POI 结构化抽取 + 表格转 Markdown"专用路径,而不是走 Tika 通用路径;对 PDF 页眉页脚用 PagePdfDocumentReader 的 PdfDocumentReaderConfig 配置去除。核心代码见第 5 节。
⑤ 预防:
- 格式路由层禁止"Excel 走 Tika",强制 Excel 走专用抽取器;
- 解析质量闸门检测"表格标记"(如连续出现的制表符 / 合并单元格),未走结构化路径则告警;
- 把"表格保留率"作为解析质量的北极星指标打点进 Prometheus(第 7 节)。
4.3 六问剖析:版面理解到底解决什么
按架构师视角做机制级深挖,回答六个问题,避免只停留在"API 怎么用":
- 问题本质 :版面理解解决的是质量问题------它修复的是"信息在入口处丢失/错位",而非性能或成本。入口失真不可逆,下游再聪明也补不回;
- 数据结构:核心数据是一个带坐标的"版面对象树"(文本块、表格、图片、标题),而不仅是字符串;抽取得越保真,后续 chunk 的语义单元越完整;
- 执行链路:原始文件 → 格式路由 → 版面分析(坐标聚类/栏检测/表格识别)→ 结构化重组(表格转 Markdown、标题分层)→ 元数据注入 → 质量闸门;
- 关键机制:多栏重排靠"按 Y 坐标分栏、按阅读顺序拼接";表格还原靠"合并区域映射 + 表头层级推断";去噪声靠"页眉页脚规则 + 置信度过滤";
- 设计取舍 :选"通用 Tika"得到低成本但低保真,选"专用解析 + OCR/VLM"得到高保真但高成本。取舍依据是文档价值密度------高价值合同值得重解析,低价值日志不值得;
- 失效边界 :当文档是完全手写、版面极度不规则、多语言混排且无文本层时,任何自动解析都会失效,此时唯一正解是人工复核(第 6 节),不要迷信模型能全自动搞定。
4.4 解析质量对比数据(示例测算)
在"默认 Tika 一把梭" vs "版面增强解析(PDF 去页眉脚 + Excel 转 Markdown + 多栏重排)"两套方案上,对 120 份真实混合文档做抽样(示例测算,非真实数据,仅说明量级):
| 解析方案 | 表格结构保留率 | 页眉污染率 | 多栏顺序正确率 | 下游答准率(抽样) | 单次摄取耗时 |
|---|---|---|---|---|---|
| 默认 Tika 一把梭 | 11% | 38% | 61% | 41% | 0.3s / 篇 |
| 版面增强解析 | 93% | 4% | 97% | 82% | 1.8s / 篇 |
| 版面增强 + OCR/VLM | 95% | 3% | 98% | 87% | 6.5s / 篇(含 OCR) |
读表提示 :版面增强解析把答准率从 41% 拉到 82%,接近翻倍;再加上 OCR/VLM 处理扫描件,到 87%。代价是摄取耗时上升(离线可接受)。这再次印证:检索质量的天花板,在解析入口就已经焊死。
🛠️ 五、结构化抽取:表格转 Markdown、标题层级作为元数据
原理讲完,上生产级代码。这一节给出"按格式路由 + 版面清洗 + 表格转 Markdown + 元数据注入"的完整实现骨架。
5.1 Maven 依赖与版本校验(专业度 + 风险兜底)
xml
<dependencies>
<!-- PDF 读取(PagePdf / ParagraphPdf) -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-pdf-document-reader</artifactId>
</dependency>
<!-- 多格式通用读取(Word / Excel / HTML) -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-tika-document-reader</artifactId>
</dependency>
<!-- Markdown 读取 -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-markdown-document-reader</artifactId>
</dependency>
<!-- Excel 结构化抽取(POI) -->
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
<version>5.3.0</version> <!-- 截至 2026-07-12,以官方为准 -->
</dependency>
</dependencies>
依赖与版本校验(前置 → 验证 → 回退):
- 前置 :所有
spring-ai-*依赖需由spring-ai-bom统一锁定版本(参考第 1 篇 8.1 节),避免多版本共存导致DocumentReaderBean 冲突;- 验证 :执行
mvn dependency:resolve确认版本一致无冲突;本地跑一个TikaDocumentReader+PagePdfDocumentReader的冒烟测试,确认能产出List<Document>;- 回退:若 POI 5.x 与项目既有依赖冲突,降级到与 Spring Boot 3.4.x 兼容的 POI 版本,或把 Excel 抽取独立成单独模块以避免污染主工程 classpath。
5.2 格式路由:不让 Excel 误入 Tika
java
@Component
public class DocumentIngestionRouter {
private final PagePdfDocumentReader.Factory pdfReaderFactory; // 注入 PDF Reader 工厂
private final TikaDocumentReader.Factory tikaReaderFactory; // 注入 Tika 工厂
private final ExcelStructureReader excelReader; // 自定义 Excel 结构化读取器
private final PptStructureReader pptReader; // 演示文稿结构化读取器(5.8 节)
private final CsvStructureReader csvReader; // 扁平二维表读取器(5.8 节)
private final OcrDocumentReader ocrReader; // OCR / VLM 读取器(第6节)
/**
* 按扩展名 + 文本层探测路由到合适的 DocumentReader
* 核心原则:Excel 绝不走 Tika,扫描件必走 OCR
*/
public List<Document> read(Resource resource, String filename) {
String ext = FilenameUtils.getExtension(filename).toLowerCase();
return switch (ext) {
case "pdf" -> isImagePdf(resource) ? ocrReader.read(resource)
: pdfReaderFactory.create(resource, pdfConfig()).get();
case "doc", "docx", "html", "htm" -> tikaReaderFactory.create(resource).get();
case "ppt", "pptx" -> pptReader.read(resource); // 演示文稿:按幻灯片 + 备注结构抽取
case "xls", "xlsx" -> excelReader.read(resource); // 专用结构化路径,不走 Tika
case "csv" -> csvReader.read(resource); // 扁平二维表,直接转 Markdown
case "md", "markdown" -> markdownReader(resource);
case "txt" -> textReader(resource);
default -> tikaReaderFactory.create(resource).get(); // 兜底万能读
};
}
/**
* PDF 版面清洗配置:去页眉页脚、规范化换行
* 这是防页眉页脚污染的关键
*/
private PdfDocumentReaderConfig pdfConfig() {
return PdfDocumentReaderConfig.builder()
.withPageTopMargin(0)
.withPageBottomMargin(0)
.withPageExtractedTextFormatter(
ExtractedTextFormatter.builder()
.withNumberOfTopTextLinesToDelete(1) // 删页眉
.withNumberOfBottomTextLinesToDelete(1) // 删页脚
.build())
.build();
}
}
代码要点:
switch路由是第一杠杆------Excel 强制走excelReader,绝不进 Tika(踩坑闭环的预防措施);PdfDocumentReaderConfig的withNumberOfTopTextLinesToDelete(1)/withNumberOfBottomTextLinesToDelete(1)是去除页眉页脚的标准手段,直接把"每页保密声明"挡在 chunk 之外;isImagePdf(resource)用 PDF 元数据判断是否含文本层,无文本层直接转 OCR(第 2.1 节算法)。
5.3 PDF 按页解析 + 元数据注入
PagePdfDocumentReader 的产出是每个 PDF 页一个 Document,天然带 page 元数据,非常适合"检索结果可溯源到页码":
java
@Configuration
public class PdfReaderConfig {
@Bean
public PagePdfDocumentReader.Factory pagePdfReaderFactory() {
return PagePdfDocumentReader::new; // Spring AI 1.1.x 提供 Factory 函数式接口
}
/**
* 生产级用法:读 PDF,并在每个 Document 注入业务元数据
* 这些元数据会随分块落库(第3篇),成为权限/版本过滤的载体
*/
public List<Document> readWithMetadata(Resource pdf, SourceMeta meta) {
PagePdfDocumentReader reader = new PagePdfDocumentReader(
pdf,
PdfDocumentReaderConfig.builder()
.withPageTopMargin(0)
.withPageBottomMargin(0)
.withPageExtractedTextFormatter(
ExtractedTextFormatter.builder()
.withNumberOfTopTextLinesToDelete(1)
.withNumberOfBottomTextLinesToDelete(1)
.build())
.build());
List<Document> docs = reader.get();
// 关键:把业务元数据注入每个 Document 的 metadata map
// 后续分块会继承这些元数据(第3篇父子分块)
for (Document d : docs) {
d.getMetadata().put("source", meta.getSource()); // 文件来源
d.getMetadata().put("docId", meta.getDocId()); // 文档唯一ID
d.getMetadata().put("version", meta.getVersion()); // 版本(知识生命周期)
d.getMetadata().put("security", meta.getSecurity()); // 权限标签(第9篇过滤用)
d.getMetadata().put("ingestAt", Instant.now().toString());
}
return docs;
}
}
代码要点:
reader.get()返回每页一个Document,页号自动写入metadata的page键(由METADATA_START_PAGE_NUMBER/METADATA_END_PAGE_NUMBER常量承载);d.getMetadata().put(...)把source / docId / version / security等业务元数据注入------这是"元数据随分块落库"的起点 (第 3 篇会展开:分块会继承父文档元数据,权限过滤直接读security字段);security字段是后面第 9 篇"行级权限过滤"的伏笔:解析阶段就标好来源权限,检索阶段直接metadata.security in (用户可见权限)。
5.4 Excel 结构化抽取:合并单元格 → Markdown 表格
这是修复"表格丢失"踩坑的核心。用 Apache POI 读 .xlsx,把合并区域还原成多级表头,输出成 Markdown 表格(LLM 友好、语义完整):
java
@Component
public class ExcelStructureReader {
private final Map<String, String> securityBySheet = Map.of(
"薪酬", "HR_CONFIDENTIAL", // 薪酬表标为机密(第9篇过滤)
"退货率", "BUSINESS_INTERNAL");
/**
* 把 Excel 的每一个 Sheet 转成一个 Document,
* 表格内容以 Markdown 形式写入 content,
* 合并单元格被还原为完整表头,不再拍平丢失。
*/
public List<Document> read(Resource xlsx) {
List<Document> result = new ArrayList<>();
try (Workbook wb = WorkbookFactory.create(xlsx.getInputStream())) {
for (Sheet sheet : wb) {
StringBuilder md = new StringBuilder();
md.append("# ").append(sheet.getSheetName()).append("\n\n");
// 1. 计算合并区域映射,避免合并单元格读成空值
Map<String, String> merged = buildMergedRegionMap(sheet);
// 2. 逐行转 Markdown 表格(首行作为表头)
for (Row row : sheet) {
md.append("| ");
for (Cell cell : row) {
String v = readCell(cell, merged); // 合并单元格取左上角值
md.append(v).append(" | ");
}
md.append("\n");
}
// 3. 注入来源与权限元数据
Map<String, Object> meta = new HashMap<>();
meta.put("source", xlsx.getFilename());
meta.put("sheet", sheet.getSheetName());
meta.put("security", securityBySheet.getOrDefault(sheet.getSheetName(), "PUBLIC"));
result.add(new Document(md.toString(), meta));
}
} catch (Exception e) {
throw new DocumentParseException("Excel 结构化解析失败: " + xlsx.getFilename(), e);
}
return result;
}
/** 预扫描所有合并区域,建立 (row:col) -> 左上角值 的映射;非首格写空串占位,保证 Markdown 列数对齐 */
private Map<String, String> buildMergedRegionMap(Sheet sheet) {
Map<String, String> merged = new HashMap<>();
for (CellRangeAddress r : sheet.getMergedRegions()) { // 合并区域列表
Row firstRow = sheet.getRow(r.getFirstRow());
if (firstRow == null) continue;
Cell firstCell = firstRow.getCell(r.getFirstColumn());
String value = (firstCell == null) ? "" : readRawCell(firstCell);
for (int rr = r.getFirstRow(); rr <= r.getLastRow(); rr++) {
for (int cc = r.getFirstColumn(); cc <= r.getLastColumn(); cc++) {
// 只有左上角写真实值,其余写空串;Markdown 表格里空串仍占一列,结构不塌
merged.put(rr + ":" + cc,
(rr == r.getFirstRow() && cc == r.getFirstColumn()) ? value : "");
}
}
}
return merged;
}
/** 读取单元格:合并区域内取左上角值;公式取缓存结果;数字/布尔/日期统一转字符串 */
private String readCell(Cell cell, Map<String, String> merged) {
if (cell == null) return "";
String key = cell.getRowIndex() + ":" + cell.getColumnIndex();
if (merged.containsKey(key)) return merged.get(key); // 合并区域兜底
return readRawCell(cell);
}
/** 统一单元格值读取:覆盖字符串/数字/布尔/公式/日期/空白五种类型 */
private String readRawCell(Cell cell) {
return switch (cell.getCellType()) {
case STRING -> cell.getStringCellValue();
case NUMERIC -> DateUtil.isCellDateFormatted(cell)
? cell.getLocalDateTimeCellValue().toString()
: String.valueOf(cell.getNumericCellValue());
case BOOLEAN -> String.valueOf(cell.getBooleanCellValue());
case FORMULA -> switch (cell.getCachedFormulaResultType()) { // 公式取已缓存结果
case NUMERIC -> String.valueOf(cell.getNumericCellValue());
case STRING -> cell.getStringCellValue();
default -> "";
};
case BLANK, ERROR, _NONE -> "";
};
}
}
代码要点:
- 输出是 Markdown 表格而非拍平文本------LLM 对 Markdown 表格的语义理解远强于"行文本",检索时"华东 / Q2 / 无线耳机 / 3.2%"处于同一张表,相似度不再被稀释(直接修复第 4.2 节的踩坑);
securityBySheet把"薪酬"表标为HR_CONFIDENTIAL------解析阶段就完成敏感表分级,这是第 9 篇权限治理的数据基座;buildMergedRegionMap/readCell已落地完整实现(算法见 2.3 节):预扫描合并区域、左上角写真实值其余写空串占位、公式取缓存结果、日期/数字统一转字符串------一次性破解"合并单元格读成空值"和"数字被转成科学计数法"两个经典坑。- 实现依赖两个 POI 导入:
org.apache.poi.ss.util.CellRangeAddress(合并区域)与org.apache.poi.ss.usermodel.DateUtil(日期判定),缺了会编译不过。
5.5 自定义 DocumentReader:把任意来源接入管道
当内置 Reader 不够(比如要接 CMS 接口、接对象存储、接 OCR 服务),只要实现 DocumentReader 即可无缝接入:
java
public class OcrDocumentReader implements DocumentReader {
private final Resource resource;
private final OcrClient ocrClient; // 内部 OCR / VLM 服务客户端
public OcrDocumentReader(Resource resource, OcrClient ocrClient) {
this.resource = resource;
this.ocrClient = ocrClient;
}
@Override
public List<Document> get() {
// 调用 OCR / 多模态服务把图片型 PDF 转成带结构的文本
OcrResult result = ocrClient.recognize(resource);
Map<String, Object> meta = new HashMap<>();
meta.put("source", resource.getFilename());
meta.put("parseMode", "OCR"); // 标记解析方式,便于质量统计
meta.put("confidence", result.confidence()); // OCR 置信度,低则入人工复核
return List.of(new Document(result.markdown(), meta));
}
}
代码要点 :OcrDocumentReader 只是一个 DocumentReader 实现,产出同样是 List<Document>------上游分块、向量化完全无感 。这正是 DocumentReader 极简抽象的价值:OCR、VLM、CMS 都能"即插即用"。
5.6 数据建模:解析元数据字段表
解析层产出的 Document.metadata 是下游一切治理的物理载体。建议聚合为如下字段表(把它当成知识库的"建表 DDL"来对待):
| metadata 字段 | 类型 | 注入时机 | 作用 | 谁依赖它 |
|---|---|---|---|---|
source |
String | 解析入口 | 来源文件名 | 引用溯源(第 8 篇) |
docId |
String | 解析入口 | 文档唯一 ID | 增量更新(第 10 篇) |
version |
String | 解析入口 | 文档版本号 | 版本治理(第 9、10 篇) |
page |
Integer | PDF 按页读取 | 页码 | 引用溯源 |
sheet |
String | Excel 读取 | 工作表名 | 定位、展示 |
title_path |
String | MD/Word 标题层级 | "手册>第3章>3.2 保修" | 分块质量、引用展示 |
security |
String | 解析入口 | 权限标签(部门/角色/密级) | 行级权限过滤(第 9 篇) |
parseMode |
String | OCR/VLM 读取 | 解析方式(TEXT/OCR/VLM) | 质量归因 |
confidence |
Double | OCR/VLM 读取 | 识别置信度 | 降级 / 人工复核 |
ingestAt |
String | 解析入口 | 摄取时间戳 | 生命周期、审计 |
架构师视角 :很多人把 metadata 当成"附带的标签",但在生产级 RAG 里,metadata 是和向量同等重要的一等公民 。第 9 篇的越权事故,根因就是
security没写或没过滤;第 10 篇的过时知识残留,根因就是version没管好。把这张表在解析阶段就想清楚、写干净,下游每一层都能少写一堆补丁。
5.7 元数据是检索质量的"隐形杠杆"
很多人把 metadata 当成"顺手记一下来源"的附属品,这是低估了它。在 RAG 体系里,元数据是连接"解析层"和"检索层 / 权限层"的唯一桥梁,它决定了后续四件事能做多好:
- 权限过滤 :解析阶段注入的
security字段,第 9 篇检索时直接WHERE metadata->>'security' IN (用户可见权限),实现行级隔离,越权召回从根上被挡掉; - 版本治理 :
version字段让"同一文档多次修订"不会变成重复 chunk,第 10 篇增量索引靠它定位"改了哪段"; - 来源溯源 :
source / docId / page让每个答案都能回指"出自哪份文件第几页",这是第 8 篇引用溯源的数据基座; - 混合检索加权 :
sheet / 标题层级可作为 BM25 的字段权重,第 6 篇混合检索能"标题命中优先于正文"。
一句话 :解析层把元数据注入得越干净,检索层、权限层、溯源层就能做得越轻。反之,解析层偷懒,下游每一层都要为"缺元数据"买单。所以元数据设计是数据入口工程的一半功力,必须在解析阶段就想清楚要落哪些字段。
5.8 其他格式接入:PPT / HTML / CSV
Excel 之外,企业知识库常见的"被忽略格式"是 PPT(培训胶片、方案汇报)、HTML(官网帮助中心、Confluence 导出)和 CSV(导出报表)。它们同样要走"结构化而非一把梭"------否则胶片备注丢失、网页导航污染、CSV 被当成散文。
PPT / PPTX:按幻灯片切分 + 保留备注
用 Apache POI 的 XMLSlideShow(.pptx)/ HSLFSlideShow(.ppt)逐张读,每张幻灯片产出一个 Document,把"正文 + 演讲者备注"拼进 content,幻灯片序号写进 metadata:
java
@Component
public class PptStructureReader {
public List<Document> read(Resource ppt) throws IOException {
List<Document> docs = new ArrayList<>();
try (XMLSlideShow show = new XMLSlideShow(ppt.getInputStream())) {
List<XSLFSlide> slides = show.getSlides();
for (int i = 0; i < slides.size(); i++) {
XSLFSlide s = slides.get(i);
StringBuilder sb = new StringBuilder();
for (XSLFShape shape : s.getShapes()) { // 1. 幻灯片正文文本框
if (shape instanceof XSLFTextShape tx) sb.append(tx.getText()).append("\n");
}
if (s.getNotesShape() != null) // 2. 演讲者备注(高价值口播稿)
sb.append("\n[备注] ").append(s.getNotesShape().getText());
Map<String, Object> meta = new HashMap<>();
meta.put("source", ppt.getFilename());
meta.put("slide", i + 1); // 幻灯片序号,溯源用
meta.put("parseMode", "PPT");
docs.add(new Document(sb.toString(), meta));
}
}
return docs;
}
}
代码要点 :备注(getNotesShape())里常藏着"为什么这么设计"的口播稿,比正文更值钱,别丢;幻灯片序号 slide 让引用能精确到"第 7 页胶片",而不是整份 PPT 一团。简单 PPT 也可直接走 TikaDocumentReader,但它把所有幻灯片拍平、丢失页码与备注结构------培训类胶片建议走专用路径。
HTML:先去样板噪声,再抽结构
官网帮助中心、Confluence 导出的 HTML,最大坑是 <nav>/<footer>/<script>/<style> 等样板被当正文。用 Jsoup 先清洗再抽:
java
public List<Document> readHtml(Resource html) throws IOException {
String raw = StreamUtils.copyToString(html.getInputStream(), StandardCharsets.UTF_8);
org.jsoup.nodes.Document doc = Jsoup.parse(raw);
doc.select("script, style, nav, footer, header, aside, .cookie-banner").remove(); // 去样板
Elements sections = doc.select("article, section, [class*=content]");
List<Document> result = new ArrayList<>();
if (!sections.isEmpty()) {
for (Element sec : sections) {
Map<String, Object> meta = Map.of(
"source", html.getFilename(),
"title_path", firstHeadingText(sec)); // 章节标题即层级起点
result.add(new Document(sec.text(), meta));
}
} else {
result.add(new Document(doc.body().text(), Map.of("source", html.getFilename())));
}
return result;
}
private String firstHeadingText(Element sec) { // 取章节内首个标题作为层级根
Element h = sec.selectFirst("h1,h2,h3,h4,h5,h6");
return h == null ? sec.className() : h.text();
}
CSV:扁平二维表直接转 Markdown
CSV 没有合并单元格,最简单------首行当表头,逐行拼 Markdown 表,注意处理引号转义:
java
public List<Document> read(Resource csv) throws IOException {
List<String> lines;
try (BufferedReader br = new BufferedReader(
new InputStreamReader(csv.getInputStream(), StandardCharsets.UTF_8))) {
lines = br.lines().toList();
}
if (lines.isEmpty()) return List.of();
String md = String.join("\n",
lines.stream().map(l -> "| " + l.replace(",", " | ") + " |").toList());
return List.of(new Document(md, Map.of("source", csv.getFilename(), "parseMode", "CSV")));
}
5.9 Word 标题层级抽取:重建 title_path
title_path 是 2.8 节讲的"可检索层级路径"。Markdown 由 MarkdownDocumentReader 自动填 header_1..n;Word 没有现成 Reader 帮你拼路径,要用 POI 的 XWPF 遍历段落、读 ParagraphStyle 的标题等级,自己拼:
java
public List<Document> readWord(Resource docx) throws IOException {
List<Document> docs = new ArrayList<>();
Deque<String> pathStack = new ArrayDeque<>(); // 层级栈:维护"根→当前"的完整路径
StringBuilder body = new StringBuilder();
try (XWPFDocument doc = new XWPFDocument(docx.getInputStream())) {
for (XWPFParagraph p : doc.getParagraphs()) {
String style = (p.getStyle() == null) ? "" : p.getStyle();
int level = headingLevel(style); // Heading1→1 ... 否则 0
if (level > 0) {
while (pathStack.size() >= level) pathStack.pollLast(); // 回退到同级/上级
pathStack.add(p.getText());
body.append("\n\n## ").append(String.join(" > ", pathStack)).append("\n");
} else {
body.append(p.getText()).append("\n");
}
}
}
Map<String, Object> meta = new HashMap<>();
meta.put("source", docx.getFilename());
meta.put("title_path", String.join(" > ", pathStack)); // 整篇的层级根路径
docs.add(new Document(body.toString(), meta));
return docs;
}
/** 从样式名推断标题等级:Heading1→1 ... Heading9→9,其余 0 */
private int headingLevel(String style) {
return (style != null && style.matches("Heading\\d"))
? Integer.parseInt(style.replace("Heading", "")) : 0;
}
代码要点 :关键是 pathStack 维护"从根到当前"的完整路径,而非只记当前标题------这样 title_path 才是 手册 > 第3章 > 3.2 保修 这种可展示层级,而非孤立的"保修"。层级栈在切到同级或更高级时回退(pollLast),避免"2 级标题里嵌着 4 级"的错位路径。第 3 篇父子分块会直接消费这个字段做"章节级 chunk"。
🔥 六、OCR 与多模态解析的接入点
纯文本抽取救不了"扫描件"和"图片型 PDF"。这一节讲清楚 OCR 与多模态文档智能(版面模型 / VLM)在解析层怎么落、收益与成本。
6.1 扫描件识别边界:哪些文档必须走 OCR
一张判断清单(强时效,落地前复核你的 OCR / VLM 服务能力,截至 2026-07-12):
| 文档类型 | 是否含文本层 | 正确接入点 | 备注 |
|---|---|---|---|
| 文本型 PDF | 是 | PagePdfDocumentReader |
不要浪费 OCR 配额 |
| 扫描件 PDF | 否 | OCR / VLM | 直接 Tika 会得到空文本 |
| 图片(PNG/JPG) | 否 | OCR / VLM | 合同扫描、白板照 |
| 图文混排 Word | 部分 | Tika + 图片 OCR | 图内参数需单独抽 |
| 手写批注 PDF | 否 | 手写 OCR(专用) | 通用 OCR 易失败 |
判断代码 (isImagePdf 的实现思路):读 PDF 元数据,若所有页 getPages().getResources() 无 Font 且图像占主导,则判定为图片型,转 OCR(完整算法见 2.1 节)。
6.2 多模态文档智能(版面模型 / VLM)的落点
热门技术融合:当传统 OCR + 规则解析仍搞不定"复杂版面 + 表格 + 图表混排"时,版面理解模型(如 LayoutLM 系列)与视觉语言模型(VLM,如 Qwen-VL、InternVL 等) 成为解析层的进阶武器。
必须回答五个问题(不看热闹看落点):
- 为什么适合当前项目:扫描件、复杂表格、图表混排,纯文本抽取天花板太低,VLM 能"看图说话"保留结构;
- 放在哪一层 :解析增强层(第 3.1 节架构图的
P3节点),作为 OCR 之后的"结构还原"增强,或直接替代 OCR; - 与现有模块如何交互 :通过自定义
DocumentReader(5.5 节)封装 VLM 调用,产出仍是List<Document>,上游零改动; - 引入什么复杂度与成本:VLM 推理慢(秒级/页)、贵(按 token/图像计费)、需 GPU 或商用 API 配额,且要处理限流与降级;
- 不引入的替代方案:保守用"OCR + 规则表格还原 + 人工复核",质量上限低但成本低、可控。
6.2.1 OCR 内部原理:检测→识别两阶段,与 VLM 端到端有何不同
讲完"为什么用 VLM",得把"传统 OCR 到底怎么干活"讲透,否则选型是无本之木。一个工业级 OCR 是两阶段流水线:
- 文字检测(Detection):先用目标检测模型(EAST / CTPN / DBNet / YOLO 系)在图上框出"哪里有文字",输出一堆旋转文本框(含表格线、印章、水印的干扰);
- 文字识别(Recognition) :把每个框裁出来,送进序列识别模型(CRNN / SVTR / 多语言 Transformer),把图像序列译成字符序列。表格还要额外做结构识别(行列分割 + 单元格归属,见 2.3 节)。
而 VLM(6.2 节)是端到端:把整页图一次喂进去,模型用视觉先验直接吐出"保留结构的 Markdown",没有显式的"先检测再识别"。两套路线的工程差异:
| 维度 | OCR 两阶段 | VLM 端到端 |
|---|---|---|
| 可调试性 | 高------检测框 / 识别结果可单独看 | 低------输出是黑盒,错了难定位 |
| 复杂版面 | 弱------规则补结构,跨页表易崩 | 强------整页理解,结构还原好 |
| 成本 / 速度 | 低 / 快 | 高 / 慢 |
| 失败模式 | 检测漏框 → 局部丢字 | 幻觉 → 整段编错 |
关键判断 :OCR 两阶段像"分工明确的流水线,坏了一环能单独修";VLM 像"老师傅一眼看全,但说错你很难证伪"。生产里两者互补------OCR 做默认快路径拿性价比,VLM 只在"OCR 置信度低 + 高价值"时升级(呼应 6.3 两级路由)。理解这个原理,你才不会在选型时把 VLM 当万能银弹。
🔍 冷知识(原理向) :OCR 识别阶段常把图像 resize 到固定高度(如 32px)再送进网络,所以"字很小但拍得很清楚"和"字很大但模糊"对模型是两种完全不同的分布------这就是为什么扫描件 DPI 低时,再聪明的识别模型也救不回小字(接 2.10 节 DPI 原理)。
6.3 解析成本与延迟模型(示例测算)
OCR/VLM 是离线摄取管道里的主要"花钱点"。把单次摄取的成本与延迟拆开(示例测算,价格以官方为准):
| 解析路径 | 单页成本(参考) | 单页耗时 | 表格还原质量 | 适用边界 |
|---|---|---|---|---|
| 文本 PDF(路径 A) | 几乎为 0(自托管) | 0.3~1s | 中(依赖版面清洗) | 文本型 PDF |
| 传统 OCR + 规则 | 低(¥0.001~0.01) | 0.3~1s | 中(规则覆盖不到复杂表) | 版面规整的扫描件 |
| VLM 多模态解析 | 高(¥0.02~0.1/页,含图像 token) | 2~6s | 高(复杂表/图表友好) | 高价值、低频次文档 |
规模测算 :假设知识库有 10 万页扫描件。全量走传统 OCR ≈ ¥100~1000;全量走 VLM ≈ ¥2000~10000。关键判断:不要"全量上 VLM"。按文档价值分级------高价值、低频次、复杂版面的文档(如合同、研报)走 VLM;海量规整扫描件走传统 OCR。用"解析质量闸门"的置信度决定是否升级到 VLM,避免成本失控。
架构师判断 :用"置信度阈值 + 文档价值标签"做两级路由。OCR 置信度低于 0.7 且 文档标注
HIGH价值时,才升级 VLM。这样把昂贵的 VLM 调用压到全量的一小部分,成本可预测、可压降。
6.4 文档智能模型格局与解析质量度量(2026 更新)
6.2 把 VLM 当成"进阶武器"讲原理,这里补一层工程选型。模型能力变化极快,以下为截至 2026-07 的参考判断,落地前务必复核官方文档与价格。
2026 文档解析模型格局
| 类别 | 代表模型 | 参数量 / 部署 | 输出能力 | 单页代价(参考) | 适用边界 |
|---|---|---|---|---|---|
| 专用 OCR(开源) | dots.ocr、GOT-OCR 2.0 | 0.5~1.7B,单张消费级 GPU | Markdown / LaTeX / 表格 | 低(自托管电费) | 版面规整扫描件,追求低成本 |
| 专用 OCR(商用) | Mistral OCR v3 | SaaS | 结构化文本,复杂表 96.6% | ≈ $2 / 1K 页 | 海外业务、不想养 GPU |
| 前沿 VLM(开源) | Qwen3-VL(2B~235B) | 自托管 / API | 端到端结构还原,256K 上下文 | 中(按图像 token) | 复杂表 / 图表混排 / 多语 |
| 前沿 VLM(商用) | Gemini 3 Flash、GPT-5.2、Claude Opus 4.6 | API | 强推理 + 结构抽取 | 中~高 | 深度非结构化文档、多步推理 |
关键判断:
- 海量规整扫描件 → 专用 OCR(dots.ocr / GOT-OCR / PaddleOCR)足够,别为"锦上添花"付 VLM 的钱;
- 复杂表、图表混排、跨页表格 → 才值得上 VLM(Qwen3-VL 自托管或 Gemini 3 Flash API),用 6.3 的"置信度 + 价值标签"两级路由压量;
- 多模型供应商统一接入:OpenRouter 等网关用单一 OpenAI 兼容端点 收口 400+ 模型,切换 Gemini / Qwen / GPT 只改一个参数------这正是 6.2"通过自定义
DocumentReader封装"在网关层的落地形态,避免把供应商 SDK 写死进解析链路。
解析质量度量:对齐输出类型选指标
2.7 节列了五个业务指标,跨引擎横向对比时必须用业界标准度量,否则会被"假阳性"骗:
- 表格 :用 TEDS(HTML 树编辑距离),能抓住 CER 看不出的"单元格错位";
- 表单 / 票据 :用 Field-F1,税号、金额必须精确命中;
- 纯文本 :用 CER / WER,印刷体做到 1~2% 算好;
- 文档问答 :用 ANLS,给轻微 OCR 错误部分分。
同一份文档用 CER 和 TEDS 测,结论可能相反------选指标前先对齐你的输出类型。这些度量要进 2.7 的质量闸门与第 11 篇评测体系,作为"解析质量可信"的硬证据,而不是只报"答准率 87%"这种综合数字。
🚫 七、解析失败的兜底与人工复核流
解析是离线的、可失败的,但绝不能静默污染知识库。这一节讲可靠性与运维维度:失败兜底 + 人工复核 + 熔断。
7.1 兜底三板斧:重试 → 降级 → 入队
java
@Component
@Slf4j
public class IngestionGuard {
@Autowired private DocumentIngestionRouter router;
@Autowired private DeadLetterQueue reviewQueue; // 人工复核队列(如 Redis List / MQ)
/**
* 解析入口的可靠性封装:重试 → 降级 → 入人工复核
*/
public ParseResult safeParse(Resource resource, String filename) {
// 1. 重试:OCR / VLM 偶发超时,最多重试 2 次(指数退避)
for (int i = 1; i <= 3; i++) {
try {
List<Document> docs = router.read(resource, filename);
if (isEmptyOrGarbled(docs)) {
throw new GarbledDocumentException("解析结果为空或乱码");
}
return ParseResult.ok(docs);
} catch (Exception e) {
log.warn("解析第 {} 次失败: {}", i, filename, e);
if (i < 3) sleepBackoff(i); // 指数退避
}
}
// 2. 降级:尝试通用 Tika 兜底一次
try {
List<Document> fallback = router.fallbackTika(resource);
if (!isEmptyOrGarbled(fallback)) return ParseResult.degraded(fallback);
} catch (Exception ignored) {}
// 3. 入队:仍失败则进人工复核,绝不静默丢弃或污染
reviewQueue.push(new ReviewTask(filename, Instant.now(), "解析全失败"));
return ParseResult.toReview(filename);
}
/** 质量闸门:检测空文本 / 乱码 / 表格丢失 */
private boolean isEmptyOrGarbled(List<Document> docs) {
if (docs.isEmpty()) return true;
long blank = docs.stream().filter(d -> d.getContent().isBlank()).count();
return blank * 1.0 / docs.size() > 0.5; // 超半数空白视为失败
}
}
代码要点:
- 重试 解决 OCR/VLM 偶发超时;降级 用 Tika 兜底解决"专用路径崩了至少还有文本";入队保证"实在不行也有人管";
isEmptyOrGarbled是质量闸门的最小实现:超半数空白直接判失败,不进库;- 这套机制直接对应第 1 篇"可靠性"主线:重试、降级、补偿(入队即补偿)。
7.2 熔断:OCR / VLM 服务挂了不能拖垮整条管道
重试解决"偶发抖动",但解决不了"下游持续故障"。当 OCR/VLM 服务连续失败,必须熔断------否则每一次摄取都卡在重试上,离线管道积压、MQ 爆满。
java
// 用 Resilience4j 给 OCR 客户端加熔断(截至 2026-07-12:API 以官方为准)
CircuitBreakerConfig config = CircuitBreakerConfig.custom()
.failureRateThreshold(50) // 失败率超 50% 打开熔断
.waitDurationInOpenState(Duration.ofMinutes(1)) // 1 分钟后半开探测
.slidingWindowType(SlidingWindowType.COUNT_BASED)
.slidingWindowSize(20) // 近 20 次调用统计
.build();
CircuitBreaker cb = CircuitBreaker.of("ocrService", config);
List<Document> docs = cb.executeSupplier(() -> ocrClient.recognize(resource).toDocuments());
// 熔断打开时抛 CallNotPermittedException → 被 7.1 的降级逻辑捕获 → 转 Tika / 入队
熔断与重试的分工 :重试应对"单次网络抖动"(秒级恢复),熔断应对"服务整体不可用"(分钟级)。两者叠加,解析层在 OCR 供应商宕机时仍能优雅降级------要么退回 Tika 兜底文本,要么进人工复核队列,绝不阻塞整条摄取管道。
7.3 人工复核流与可观测
进入 reviewQueue 的文档,由运营在后台复核:要么重新上传清晰版,要么人工标注后入库。关键是要可观测------解析失败率、降级率、入队率必须打点进 Prometheus,否则入口在漏你都不知道。
⚖️ 八、横向全景对比:开源 vs 商用、自研 vs 开源
前面讲的都是"怎么做"。这一节拉宽视野,回答架构选型最现实的问题:解析引擎 / OCR / VLM 到底该用开源、商用还是自研?
8.1 解析引擎:开源 vs 商用全景
| 方案 | 类型 | 表格还原 | 多语言 | 数据出域 | 运维成本 | 适用场景 |
|---|---|---|---|---|---|---|
| Tika / PDFBox(Spring AI 内置) | 开源自托管 | 弱(拍平) | 中 | 否 | 低 | 文本 PDF / 通用文档 |
| PaddleOCR + LayoutLM(自托管) | 开源自托管 | 强 | 中(中文好) | 否 | 高(需 GPU) | 数据不出域的扫描件 |
| AWS Textract | 商用 SaaS | 强(表单/表) | 中 | 是(出 AWS) | 低 | 海外业务、表单密集 |
| Azure Document Intelligence | 商用 SaaS | 强(layout 模型) | 中 | 是 | 低 | 表单/票据/合规文档 |
| 百度/腾讯/阿里 文档智能 | 商用 SaaS/私有化 | 强(中文优) | 强(中文) | 可私有化 | 中 | 国内业务、中文扫描件 |
选型铁律 :金融 / 政企 / 涉密场景,数据不能出域 → 优先自托管开源(PaddleOCR + LayoutLM)或厂商私有化部署 ,哪怕效果略逊于纯 SaaS。海外业务对延迟/合规不敏感 → 商用 SaaS 省运维。Spring AI 的
DocumentReader抽象让你"今天用 Tika、明天换 Textract"只改一个实现类,不碰主链路。
8.2 自研 vs 开源:什么时候才值得自研
| 维度 | 直接用开源/商用 | 自研解析层 | 决策建议 |
|---|---|---|---|
| 文档格式 | 通用 PDF/Word/Excel | 极特殊私有格式 | 私有格式才自研 |
| 团队能力 | 无 ML 团队 | 有 CV/NLP 团队 | 没团队别自研 |
| 合规要求 | 可接受 SaaS | 强合规不出域 | 合规优先自托管 |
| 成本敏感度 | 量小可接受计费 | 海量扫描件 | 量极大自研摊薄 |
| 迭代速度 | 快(开箱即用) | 慢(从零训练) | 赶业务用开源 |
一句话心法 :除非你有"开源搞不定的私有格式 + 养得起 ML 团队 + 强合规"三重条件,否则不要自研解析内核 ------把 Spring AI 的 DocumentReader 当扩展点,在它之上做"路由 + 增强 + 兜底"才是 ROI 最高的做法。自研只该发生在"连 VLM 都理解不了你的特殊版面"这种极端场景。
8.3 OCR / VLM 选型决策表
| 选择项 | 触发条件 | 推荐方案 | 不选的代价 |
|---|---|---|---|
| 文本 PDF | 含文本层 | PagePdfDocumentReader |
浪费 OCR 配额、拖慢 |
| 规整扫描件(海量) | 版式固定 | 传统 OCR(PaddleOCR/商用) | VLM 太贵、没必要 |
| 复杂表/图表混排 | OCR 置信度<0.7 且 HIGH 价值 | VLM 升级 | 结构还原差、答不准 |
| 手写批注 | 通用 OCR 失败 | 手写专用 OCR | 整页丢失 |
| 强合规 | 数据不出域 | 自托管 OCR + VLM | 数据泄露风险 |
📊 九、生产实践:性能 / 成本 / 安全 / 监控 / 扩展性 / 可靠性 / 运维
解析层要覆盖生产七维度中的至少五项。本文重点覆盖:性能、成本、安全、监控、可靠性、扩展性、运维(七项全中)。
| 维度 | 关键指标 / 手段 | 本篇落点 |
|---|---|---|
| 性能 | 单次摄取耗时、并发解析吞吐 | 离线管道,OCR 异步化 + 批量;解析耗时不卡用户 |
| 成本 | OCR/VLM 调用计费、按文档价值分级 | 高价值低频走 VLM,规整扫描走 OCR(6.3 节) |
| 安全 | 来源权限标注、敏感表分级 | security 元数据在解析阶段就标好(5.3/5.4 节) |
| 监控 | 解析失败率、降级率、表格保留率 | 打点进 Prometheus(7.3 节) |
| 可靠性 | 重试 / 降级 / 熔断 / 入队补偿 | IngestionGuard 三板斧 + Resilience4j(7.1/7.2 节) |
| 扩展性 | 格式路由可插拔、自定义 Reader | DocumentReader 接口即扩展点(5.5 节) |
| 运维 | 人工复核队列、灰度切换解析器 | 失败入队 + 解析器灰度(7 节) |
监控埋点示例(Prometheus 指标):
java
// 解析质量闸门打点:表格保留率、失败率、降级率
MeterRegistry registry;
void recordParse(String mode, boolean success, boolean degraded, double tableKeepRate) {
registry.counter("kb.parse.total", "mode", mode).increment();
if (!success) registry.counter("kb.parse.failed", "mode", mode).increment();
if (degraded) registry.counter("kb.parse.degraded", "mode", mode).increment();
registry.gauge("kb.parse.table_keep_rate", Tags.of("mode", mode), tableKeepRate);
}
链路追踪示例(OpenTelemetry,把指标绑到一次具体请求):
Prometheus 告诉你"失败率涨了",但定位"是哪份文档、走的哪条路径"还得靠 Trace。traceId 要贯穿解析→分块→向量化→检索,这样"某个答案不对"能反查到入口这次摄取:
java
// io.opentelemetry.api.trace.Tracer
@Autowired Tracer tracer;
public List<Document> tracedRead(Resource resource, String filename) {
Span span = tracer.spanBuilder("document.parse")
.setAttribute("doc.name", filename)
.setAttribute("doc.size", resource.contentLength())
.startSpan();
try (Scope ignored = span.makeCurrent()) {
List<Document> docs = router.read(resource, filename);
span.setAttribute("doc.pages", (long) docs.size());
span.setAttribute("parse.mode", docs.isEmpty() ? "EMPTY"
: docs.get(0).getMetadata().getOrDefault("parseMode", "TEXT").toString());
return docs;
} catch (Exception e) {
span.recordException(e);
span.setStatus(StatusCode.ERROR, e.getMessage());
throw e;
} finally {
span.end();
}
}
监控闭环 :Prometheus 指标(宏观趋势)+ OTel Trace(单次定位)+ 质量闸门(拦截坏文档)三者叠加,才满足第 1 篇"可观测"主线。2.7 的五个指标通过
traceId与具体请求关联后,"解析质量下降"能从"看曲线"升级到"点开一条 Trace 看是哪份文档、OCR 置信度多少、是否走了降级"。
成本治理示例(YAML 配置按价值分级路由):
yaml
kb:
ingest:
ocr:
enabled: true
provider: internal-ocr # 内部 OCR 服务(数据不出域)
vlm:
enabled: true
provider: qwen-vl # 多模态 VLM(截至2026-07-12,复核可用性)
only-when:
confidence-below: 0.7 # OCR 置信度低于 0.7 才升级 VLM
doc-value: HIGH # 且文档标注为高价值
route:
excel-use-structure-reader: true # Excel 强制走结构化,禁走 Tika
配置落地(前置 → 验证 → 回退):
- 前置 :VLM provider 的 API Key 必须来自环境变量 / 配置中心,严禁硬编码进仓库(信息安全性红线);
- 验证 :先用 5 份样例文档跑通 OCR→VLM 升级路径,确认
confidence与doc-value两个条件都命中才调用 VLM,避免误操作把全量文档送进昂贵路径;- 回退 :若 VLM 供应商临时不可用,把
vlm.enabled置 false,全部退回传统 OCR(质量降一档但成本可控、管道不中断)。
🧪 十、解析器自动化测试与回归防护(Golden File Testing)
解析器是整套管道里"最容易被版本升级悄悄搞坏"的环节------POI 5.x→6.x、Tika 2.9→3.x、PDFBox 小版本,都可能无声改变换行、表格或页眉处理。没有回归测试,你往往是上线后业务方投诉了才发现"合并单元格又被拍平了"。这是本文必须补的生产级闭环。
10.1 黄金文件测试:把"正确解析结果"钉死
核心思路:准备少量代表性夹具文档(文本 PDF、带合并单元格的 xlsx、扫描件 PNG、图文混排 Word),把当前"人工确认正确"的解析输出存为 golden 文件;每次 CI 重新解析,与 golden 做归一化比对,相似度低于阈值就红。
java
@Test
void excelMergedCellParse_stableAgainstGolden() {
List<Document> docs = excelReader.read(new ClassPathResource("fixtures/return-rate.xlsx"));
String actual = docs.get(0).getContent();
String expected = Files.readString(Path.of("src/test/resources/golden/return-rate.md"));
double sim = similarity(normalize(actual), normalize(expected)); // 归一化后比相似度
assertThat(sim).isGreaterThan(0.98); // 表格结构不能漂移
}
/** 归一化 + 相似度(Levenshtein 归一化),阈值按文档类型调 */
private double similarity(String a, String b) {
int dist = levenshtein(a, b);
return 1.0 - (double) dist / Math.max(a.length(), b.length());
}
测试要点:
- 夹具必须覆盖"四类陷阱"(多栏、页眉页脚、合并表、图文混排),否则 golden 只测了 happy path,线上照样崩;
- 归一化是关键------直接比对原始字符串会因"多一个空行"误报;用相似度阈值(表格类 ≥ 0.98,纯文本 ≥ 0.95)而非全等;
- golden 文件本身要人工 review 过,且随解析逻辑升级而"刻意更新"------更新 golden 是一次有意识的提交,不是随手覆盖;
- 把"表格保留率"也从 golden 的已知结构算出来,作为 2.7 指标的可执行校验:golden 表格 N 行,实际解析后 Markdown 表格也该是 N 行。
10.2 解析器契约测试:每个 Reader 都必须过关
除了比对输出,还要对每个自定义 DocumentReader 做契约测试,防止"返回空 Document 却没人发现":
java
@Test
void everyReader_mustProduceContentAndRequiredMeta() {
for (DocumentReader r : List.of(pdfReader, excelReader, pptReader, ocrReader)) {
List<Document> docs = r.get();
assertThat(docs).isNotEmpty();
assertThat(docs).allMatch(d -> !d.getContent().isBlank());
assertThat(docs).allMatch(d -> d.getMetadata().containsKey("source"));
}
}
CI 落点 :黄金文件测试 + 契约测试进 CI,任何依赖升级或路由改动一旦劣化解析质量,PR 阶段就红,而不是等到生产第 2 周被投诉。这和第 1 篇"可靠性"主线、第 11 篇评测体系构成三层防护:单元测试(本文)→ 解析质量闸门(第 7 节)→ 端到端 RAG 评测(第 11 篇)。
📝 十一、总结与展望
关键要点回顾
🔬 理论深度
✅ DocumentReader 极简抽象 :List<Document> get(),content + metadata 双要素,格式与下游解耦
✅ 核心机制深挖 :PDF 文本层/图像层双路径、阅读顺序投影算法、表格网格重建、信息论视角下的有损投影
✅ 编码与渲染原理 :ToUnicode CMap 乱码根因、PDF 规范无阅读顺序的"规范黑洞"、DPI 渲染的 token 平方代价与 300 DPI 甜点线
✅ 六种 Reader 边界 :Tika 万能但版面弱,PagePdf 按页可清洗,Excel 必须走结构化
✅ 版面理解本质 :表格结构、多栏顺序、页眉页脚、图文混排四类陷阱必然失真
✅ OCR 两阶段 vs VLM 端到端:可调试性/失败模式互补,两级路由的底层依据
🛠️ 工程实践
✅ 格式路由 :不让 Excel 误入 Tika,扫描件必走 OCR,文本层探测前置
✅ 结构化抽取 :表格转 Markdown、标题层级→元数据、合并单元格还原(含完整实现)
✅ 多格式专用接入 :PPT(幻灯片+备注)、HTML(去样板噪声)、CSV、Word 标题层级 title_path 重建
✅ 自定义 Reader :OCR/VLM/CMS 即插即用,产出仍是 List<Document>
✅ 兜底三板斧 + 熔断 :重试 → 降级 → 入人工复核,Resilience4j 防服务雪崩
✅ 可观测闭环 :Prometheus 指标 + OpenTelemetry Trace,把解析质量绑到单次请求
✅ 数据建模 + 成本模型 :10 字段 metadata 表、单页 OCR/VLM 成本与延迟测算
✅ 解析器回归测试:黄金文件 + 契约测试,把解析质量钉进 CI(第 十 节)
⚖️ 方案对比
✅ 六种 Reader 横向对比 :引擎、场景、代价一目了然
✅ 开源 vs 商用 / 自研 vs 开源全景 :解析引擎、OCR、VLM 的选型决策表
✅ 2026 文档智能模型格局 :dots.ocr / GOT-OCR / Mistral OCR / Qwen3-VL / Gemini 3 Flash 的定位与代价
✅ 解析质量度量对齐 :TEDS(表格)/ Field-F1(表单)/ CER-WER(文本)/ ANLS(问答)怎么选
✅ 解析质量对比 :默认 41% → 版面增强 82% → +OCR/VLM 87% 答准率
✅ OCR vs VLM:按文档价值分级,成本可控
🚀 系列导航
✅ 四条工程主线 :检索质量(入口)、成本可控(OCR 计费)、质量可度量(解析质量评测)、权限治理(来源标权)
✅ 本篇定位:离线摄取管道第一道工序,决定下游天花板
数据入口工程的"前后对比"预期
| 维度 | 默认 Tika 一把梭(起点) | 版面增强 + OCR/VLM(终点) | 主要贡献 |
|---|---|---|---|
| 文档可解析率 | 63% | 99% | 扫描件 OCR + 兜底 |
| 表格结构保留率 | 11% | 93% | Excel 结构化抽取 |
| 下游答准率 | 41% | 87% | 版面理解全链路 |
| 解析失败静默污染 | 存在 | 0(入队复核 + 熔断) | 质量闸门 + 兜底 |
读表提示 :这些数字不是承诺,而是"方向正确的改造"理应带来的量级提升。真正重要的是------你必须有解析质量评测去验证它们是否发生(第 11 篇评测体系)。
下一步学习
- 第 3 篇 分块策略深度实战------固定 / 递归 / 语义 / 父子分块(解析之后,检索质量第一杠杆)
- 第 4 篇 Embedding 选型与向量化工程------把解析好的 Document 变成向量
- 第 9 篇 权限与多租户治理 ------本文注入的
security元数据如何变成行级过滤 - 第 11 篇 RAG 评测体系------用 Hit Rate / MRR 度量解析质量对检索的影响
- 第 12 篇 可观测性与成本治理------解析失败率、OCR 成本的全链路埋点
- 本文第 十 节 解析器回归测试------用黄金文件 + 契约测试把解析质量钉进 CI,防止依赖升级悄悄劣化
解析这道闸门焊死了检索质量的天花板------入口干净,下游才能干净。下一篇我们钻进"分块",看怎么把干净的文档切成检索友好的碎片。 见!🚀