摘要:面向 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 工具能力等),Spring AI 版本、Apache Tika 版本、版面模型能力等请以官方文档为准
文章目录
文档接入与智能解析:基于 Spring AI 1.1.x 的多格式解析、版面理解与结构化抽取 📚 前言:一次让问答准确率"腰斩"的解析事故 一个能共情的"事故量化" 🎯 本文你将学到 🔬 理论深度 🛠️ 工程实践 ⚖️ 方案对比 🚀 系列导航 ⚡ 一、Spring AI DocumentReader 体系全景与局限 1.1 核心抽象:DocumentReader 与 Document 1.2 内置六种 Reader 横向对比 1.3 纯文本抽取的局限:三个必然失真的点 🧩 二、核心机制深挖:解析为什么能工作、为什么失真 2.1 双路径模型:PDF 文本层 vs 图像层 2.2 阅读顺序算法:多栏 / 绕排如何重排 2.3 表格结构识别:单元格 / 合并单元格 → Markdown 2.4 图片抽取与多模态落点 2.5 为什么朴素抽取必然丢结构(信息论视角) 2.6 版面理解(VLM)在解析层的落点 2.7 解析质量评估指标 2.8 标题层级抽取:把"位置"变成可检索的 `title_path` 2.9 PDF 文本编码黑盒:为什么有的 PDF 抽出来是乱码(ToUnicode CMap) 2.10 渲染分辨率与 DPI:为什么 VLM 读 PDF 要先"拍张照" 🏗️ 三、系统架构:解析层在双管道中的位置 3.1 解析层架构图 3.2 为什么解析层必须"离线 + 可失败可重试" 🔬 四、版面理解:为什么纯文本抽取不够 4.1 四种版面陷阱,逐个拆解 4.2 表格丢失踩坑闭环(现象 → 根因 → 复现 → 修复 → 预防) 4.3 六问剖析:版面理解到底解决什么 4.4 解析质量对比数据(示例测算) 🛠️ 五、结构化抽取:表格转 Markdown、标题层级作为元数据 5.1 Maven 依赖与版本校验(专业度 + 风险兜底) 5.2 格式路由:不让 Excel 误入 Tika 5.3 PDF 按页解析 + 元数据注入 5.4 Excel 结构化抽取:合并单元格 → Markdown 表格 5.5 自定义 DocumentReader:把任意来源接入管道 5.6 数据建模:解析元数据字段表 5.7 元数据是检索质量的"隐形杠杆" 5.8 其他格式接入:PPT / HTML / CSV PPT / PPTX:按幻灯片切分 + 保留备注 HTML:先去样板噪声,再抽结构 CSV:扁平二维表直接转 Markdown 5.9 Word 标题层级抽取:重建 `title_path` 🔥 六、OCR 与多模态解析的接入点 6.1 扫描件识别边界:哪些文档必须走 OCR 6.2 多模态文档智能(版面模型 / VLM)的落点 6.2.1 OCR 内部原理:检测→识别两阶段,与 VLM 端到端有何不同 6.3 解析成本与延迟模型(示例测算) 6.4 文档智能模型格局与解析质量度量(2026 更新) 2026 文档解析模型格局 解析质量度量:对齐输出类型选指标 🚫 七、解析失败的兜底与人工复核流 7.1 兜底三板斧:重试 → 降级 → 入队 7.2 熔断:OCR / VLM 服务挂了不能拖垮整条管道 7.3 人工复核流与可观测 ⚖️ 八、横向全景对比:开源 vs 商用、自研 vs 开源 8.1 解析引擎:开源 vs 商用全景 8.2 自研 vs 开源:什么时候才值得自研 8.3 OCR / VLM 选型决策表 📊 九、生产实践:性能 / 成本 / 安全 / 监控 / 扩展性 / 可靠性 / 运维 🧪 十、解析器自动化测试与回归防护(Golden File Testing) 10.1 黄金文件测试:把"正确解析结果"钉死 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 文本,问答准确率直接腰斩。
这不是个例。我看过太多团队,把"数据入口"当成"读文件"来敷衍,却在规模化生产的第一道门槛前崩盘。
一个能共情的"事故量化"
为了让没踩过坑的人也有体感,把上面那次事故换算成可感知的数字(示例测算,非真实数据,仅用于说明量级):
这张表想说明一件事:解析层的"好指标"是假象——你测的是文本 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):
// org.springframework.ai.document.DocumentReaderpublic 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 | |||
JsonReader | ||||
MarkdownDocumentReader | ||||
PagePdfDocumentReader | ||||
ParagraphPdfDocumentReader | ||||
TikaDocumentReader |
关键判断:
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 失败,而是它本来就没有文本,只有像素。于是解析层必须存在两条互斥路径:
┌─────────────────────────────────────┐PDF 输入 →│ 是否含文本层 (有 Tj 算子 / 可抽字符)? │└─────────────────────────────────────┘│是 │否▼ ▼路径 A:文本解析 路径 B:图像解析PagePdfDocumentReader OCR / VLM 识别像素(PDFBox 读内容流) → 文本 + 结构快(0.3~1s/页) 慢(2~8s/页)、计费
判定算法(工程化 isImagePdf):先抽整本文本层,若可见字符数极少(C < threshold,如全本 < 30 个非空字符)且文档确实存在图像对象,判定为图片型 PDF,转路径 B。注意——这里不能用"逐页字符数 + 图像面积占比"的近似(坐标归一化极易误判),直接用 PDFBox 的 PDFTextStripper 抽全文再判定最稳:
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)是一个带坐标的盒子:
Fragment = { text, x0, y0, x1, y1 }// (x0,y0)=左上角, (x1,y1)=右下角,原点在左下(PDF 坐标)
投影剖面算法(Projection Profile)+ 列聚类是工业界的主流做法,分四步:
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 * 平均字符宽,缺口超过它才算分栏。代码骨架:
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,本质是做一次二维网格重建。
算法(基于坐标的网格推断):
输入:表格区域内所有单元格 Fragment(含坐标)1. 按 y0 聚类 → 得到"行"集合 R0..Rn2. 按 x0 聚类 → 得到"列"集合 C0..Cm3. 建立网格 G[row][col],把每个 Fragment 填入对应 (row,col)4. 合并单元格:若某 Fragment 的 (x0,x1) 横跨多个列 → colspan;(y0,y1) 横跨多个行 → rowspan5. 输出 Markdown:首行后插 |---|---|,colspan 用连续 | 占位
Excel 比 PDF 幸福——POI 直接给出合并区域 CellRangeAddress,省去了坐标推断:
// 合并单元格还原:只把值写在左上角,其余格子标记为"已被合并覆盖"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 抽出来:
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:
扫描件/复杂图 → [OCR 出字 + 坐标] → [VLM 看原图做结构重组] → 结构化 Markdown或:扫描件 → [VLM 直接看图出 Markdown](一步到位,成本更高)
为什么 VLM 能补规则的盲区:规则算法对"绕排、跨页表格、图表混排"束手无策,但它们在"人眼看来一目了然"。VLM 用视觉先验理解了版面整体,输出自然连贯。代价是慢(秒级/页)且贵(按图像 token 计费),且要处理限流与降级——所以它必须是"按需升级",而非默认路径。
2.7 解析质量评估指标
解析做没做对,必须可度量。定义五个核心指标(参考业界文档智能评测口径,截至 2026-07-12 的工程判断):
| 结构保真度 | |||
| 表格还原率 | |||
| 阅读顺序正确率 | |||
| 噪声污染率 | |||
| 元数据完整率 |
架构师视角:这五个指标要打点进 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):
原理要点: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 四种版面陷阱,逐个拆解
| 多栏乱序 | |||
| 页眉页脚污染 | |||
| 表格结构丢 | |||
| 图文混排 |
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 份真实混合文档做抽样(示例测算,非真实数据,仅说明量级):
读表提示:版面增强解析把答准率从 41% 拉到 82%,接近翻倍;再加上 OCR/VLM 处理扫描件,到 87%。代价是摄取耗时上升(离线可接受)。这再次印证:检索质量的天花板,在解析入口就已经焊死。
🛠️ 五、结构化抽取:表格转 Markdown、标题层级作为元数据
原理讲完,上生产级代码。这一节给出"按格式路由 + 版面清洗 + 表格转 Markdown + 元数据注入"的完整实现骨架。
5.1 Maven 依赖与版本校验(专业度 + 风险兜底)
<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
@Componentpublic 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); // 专用结构化路径,不走 Tikacase "csv" -> csvReader.read(resource); // 扁平二维表,直接转 Markdowncase "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 元数据,非常适合"检索结果可溯源到页码":
@Configurationpublic class PdfReaderConfig {@Beanpublic 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()); // 文档唯一IDd.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 友好、语义完整):
@Componentpublic 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 即可无缝接入:
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;}@Overridepublic 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"来对待):
source | ||||
docId | ||||
version | ||||
page | ||||
sheet | ||||
title_path | ||||
security | ||||
parseMode | ||||
confidence | ||||
ingestAt |
架构师视角:很多人把 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:
@Componentpublic 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 先清洗再抽:
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 表,注意处理引号转义:
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 的标题等级,自己拼:
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 ... 否则 0if (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):
PagePdfDocumentReader | |||
判断代码(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 置信度低 + 高价值"时升级(呼应 6.3 两级路由)。理解这个原理,你才不会在选型时把 VLM 当万能银弹。
🔍 冷知识(原理向):OCR 识别阶段常把图像 resize 到固定高度(如 32px)再送进网络,所以"字很小但拍得很清楚"和"字很大但模糊"对模型是两种完全不同的分布——这就是为什么扫描件 DPI 低时,再聪明的识别模型也救不回小字(接 2.10 节 DPI 原理)。
6.3 解析成本与延迟模型(示例测算)
OCR/VLM 是离线摄取管道里的主要"花钱点"。把单次摄取的成本与延迟拆开(示例测算,价格以官方为准):
规模测算:假设知识库有 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 / 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 兜底三板斧:重试 → 降级 → 入队
@Component@Slf4jpublic 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 爆满。
// 用 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 | ||||||
| PaddleOCR + LayoutLM | ||||||
| AWS Textract | ||||||
| Azure Document Intelligence | ||||||
| 百度/腾讯/阿里 文档智能 |
选型铁律:金融 / 政企 / 涉密场景,数据不能出域 → 优先自托管开源(PaddleOCR + LayoutLM)或厂商私有化部署,哪怕效果略逊于纯 SaaS。海外业务对延迟/合规不敏感 → 商用 SaaS 省运维。Spring AI 的
DocumentReader抽象让你"今天用 Tika、明天换 Textract"只改一个实现类,不碰主链路。
8.2 自研 vs 开源:什么时候才值得自研
一句话心法:除非你有"开源搞不定的私有格式 + 养得起 ML 团队 + 强合规"三重条件,否则不要自研解析内核——把 Spring AI 的 DocumentReader 当扩展点,在它之上做"路由 + 增强 + 兜底"才是 ROI 最高的做法。自研只该发生在"连 VLM 都理解不了你的特殊版面"这种极端场景。
8.3 OCR / VLM 选型决策表
PagePdfDocumentReader | |||
📊 九、生产实践:性能 / 成本 / 安全 / 监控 / 扩展性 / 可靠性 / 运维
解析层要覆盖生产七维度中的至少五项。本文重点覆盖:性能、成本、安全、监控、可靠性、扩展性、运维(七项全中)。
| 性能 | ||
| 成本 | ||
| 安全 | security | |
| 监控 | ||
| 可靠性 | IngestionGuard | |
| 扩展性 | DocumentReader | |
| 运维 |
监控埋点示例(Prometheus 指标):
// 解析质量闸门打点:表格保留率、失败率、降级率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 要贯穿解析→分块→向量化→检索,这样"某个答案不对"能反查到入口这次摄取:
// 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 配置按价值分级路由):
kb:ingest:ocr:enabled: trueprovider: internal-ocr # 内部 OCR 服务(数据不出域)vlm:enabled: trueprovider: qwen-vl # 多模态 VLM(截至2026-07-12,复核可用性)only-when:confidence-below: 0.7 # OCR 置信度低于 0.7 才升级 VLMdoc-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 做归一化比对,相似度低于阈值就红。
@Testvoid 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 却没人发现":
@TestvoideveryReader_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 计费)、质量可度量(解析质量评测)、权限治理(来源标权) ✅ 本篇定位:离线摄取管道第一道工序,决定下游天花板
数据入口工程的"前后对比"预期
读表提示:这些数字不是承诺,而是"方向正确的改造"理应带来的量级提升。真正重要的是——你必须有解析质量评测去验证它们是否发生(第 11 篇评测体系)。
下一步学习
- [第 3 篇] 分块策略深度实战
——固定 / 递归 / 语义 / 父子分块(解析之后,检索质量第一杠杆) - [第 4 篇] Embedding 选型与向量化工程
——把解析好的 Document 变成向量 - [第 9 篇] 权限与多租户治理
——本文注入的 security元数据如何变成行级过滤 - [第 11 篇] RAG 评测体系
——用 Hit Rate / MRR 度量解析质量对检索的影响 - [第 12 篇] 可观测性与成本治理
——解析失败率、OCR 成本的全链路埋点 - [本文第 十 节] 解析器回归测试
——用黄金文件 + 契约测试把解析质量钉进 CI,防止依赖升级悄悄劣化
解析这道闸门焊死了检索质量的天花板——入口干净,下游才能干净。下一篇我们钻进"分块",看怎么把干净的文档切成检索友好的碎片。 见!🚀
夜雨聆风