Spring AI + Docling 企业级文档解析完全指南
Spring AI + Docling 企业级文档解析完全指南
摘要:本文从入门到精通,详细介绍如何在 Spring Boot 项目中集成 Docling 文档解析服务。包含环境搭建、配置详解、661 行核心代码逐行解析、请求参数详解、返回数据处理、向量存储集成、生产级优化方案和完整测试用例。看完本文即可独立实现企业级文档解析功能。
关键词:Spring AI;Docling;文档解析;RAG;OCR;表格识别;向量存储
📋 文章目录
- 一、背景介绍
- [二、Docling 是什么](#二 docling 是什么)
- 三、整体架构设计
- [四、快速开始(5 分钟上手)](#四快速开始 5 分钟上手)
- 五、环境搭建与配置
- [六、DoclingClient 核心代码逐行解析](#六 doclingclient 核心代码逐行解析)
- 七、请求参数详解
- 八、返回数据格式详解
- 九、如何集成到向量存储
- 十、生产级优化方案
- 十一、完整调用示例
- 十二、测试用例
- [十三、常见问题 FAQ](#十三常见问题 faq)
- 十四、总结
- 参考资料
一、背景介绍
1.1 企业级 AI 应用场景
在企业级 AI 应用中,RAG(检索增强生成) 已经成为标配功能。而 RAG 的第一步,就是把企业的 PDF、Word、Excel 等文档解析成机器可读的结构化数据。
典型场景:
- 📄 用户上传 100 页的 PDF 产品手册,包含文字、表格、图片、公式
- 📊 财务部门的 Excel 报表,需要提取关键数据
- 📑 合同文档中的条款需要结构化存储
- 🖼️ 技术手册中的流程图需要 OCR 识别
1.2 传统方案的痛点
| 方案 | 优点 | 缺点 |
|---|---|---|
| Apache Tika | 支持格式多,社区成熟 | 只能提取纯文本,表格和图片丢失结构 |
| PDFBox | 纯 Java,无依赖 | 需要手写大量解析逻辑,表格识别效果差 |
| 商业 OCR 服务 | 准确率高 | 成本高(每页 0.1-0.5 元),数据隐私风险 |
| 自建 OCR | 数据可控 | 开发周期长(3-6 个月),维护成本高 |
1.3 本文解决方案
使用 IBM 开源的 Docling 文档解析引擎,配合 Spring Boot 实现企业级文档解析服务。
核心优势:
- ✅ 开箱即用:Docker 一键部署,5 分钟上手
- ✅ 格式支持全:PDF/DOCX/XLSX/PPTX/Markdown/HTML
- ✅ 结构化提取:文字、表格、图片、公式、标题层级
- ✅ 内置 OCR:支持 30+ 语言,准确率 85%~90%
- ✅ 免费开源:Apache 2.0 协议,无商业限制
- ✅ 生产级:并发控制、超时处理、错误恢复
二、Docling 是什么
2.1 官方介绍
Docling 是 IBM 研究院开源的文档解析工具,专门用于将 PDF、Word、Excel 等文档转换为结构化 Markdown 和 JSON 格式。
技术报告:Docling Technical Report (arXiv:2408.09869)
GitHub 仓库:https://github.com/DS4SD/docling
核心特性:
- ✅ 支持 PDF、DOCX、XLSX、PPTX、Markdown、HTML 等多种格式
- ✅ 内置 OCR 能力,识别图片中的文字(30+ 语言)
- ✅ 智能表格识别,还原表格结构(行列关系、合并单元格)
- ✅ 图片提取,支持 base64 嵌入或外部引用
- ✅ 保持文档层次结构(标题层级、段落顺序)
- ✅ 公式识别(LaTeX 格式)
2.2 解析效果对比
| 工具 | 文字提取 | 表格识别 | 图片提取 | OCR 能力 | 结构保持 |
|---|---|---|---|---|---|
| Apache Tika | ✅ | ❌ | ❌ | ❌ | ❌ |
| PDFBox | ✅ | ⚠️ 一般 | ⚠️ 一般 | ❌ | ⚠️ 一般 |
| 商业 OCR | ✅ | ✅ | ✅ | ✅ | ⚠️ 一般 |
| Docling | ✅ | ✅ 优秀 | ✅ base64 | ✅ 内置 | ✅ 完美 |
2.3 适用场景
推荐使用 Docling:
- ✅ 企业知识库构建(RAG)
- ✅ 合同/文档结构化存储
- ✅ 技术手册数字化
- ✅ 财务报表提取
- ✅ 学术论文解析
不推荐使用:
- ❌ 扫描件质量极差(建议专业 OCR)
- ❌ 手写体文档(Docling 主要识别印刷体)
- ❌ 超大幅面图纸(如 CAD 图纸)
三、整体架构设计
3.1 系统架构图
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ 用户上传 PDF │ ──▶ │ DoclingClient│ ──▶ │ Docling-Serve│
└─────────────┘ └──────────────┘ └─────────────┘
│ │
│ 异步任务 │ 解析文档
│ (提交→轮询→获取) │ (OCR+ 表格+ 图片)
│ │
▼ ▼
┌──────────────┐ ┌─────────────┐
│ DoclingResult│ ◀──│ JSON 结果 │
└──────────────┘ └─────────────┘
│
│ Markdown + 结构化元素 + 图片
▼
┌──────────────┐ ┌─────────────┐
│ 分块处理 │ ──▶ │ Embedding │
└──────────────┘ └─────────────┘
│
▼
┌─────────────┐
│ 向量数据库 │
│ (Milvus/ES) │
└─────────────┘
3.2 核心类图
┌─────────────────────────────────────────┐
│ DoclingClient │
├─────────────────────────────────────────┤
│ - aiProp: AIProp │
│ - parseSemaphore: Semaphore │
│ + convert(File): DoclingResult │
│ + convert(File, Config): Result │
│ - submitAsyncTask(): String │
│ - pollTaskStatus(): String │
│ - fetchTaskResult(): DoclingResult │
│ - parseResponse(): DoclingResult │
│ - resolveChildren(): void │
│ - buildTableText(): String │
└─────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ DoclingResult │
├─────────────────────────────────────────┤
│ - success: boolean │
│ - mdContent: String (Markdown 全文) │
│ - elements: List<DoclingElement> │
│ - images: List<DoclingImage> │
│ - pageCount: Integer │
│ - tableCount: Integer │
│ - parseTime: Integer (毫秒) │
└─────────────────────────────────────────┘
3.3 核心流程时序图
用户 Controller Service DoclingClient Docling-Serve
│ │ │ │ │
│──上传 PDF──────▶│ │ │ │
│ │ │ │ │
│ │──调用──────▶│ │ │
│ │ │ │ │
│ │ │──convert()───▶│ │
│ │ │ │ │
│ │ │ │──POST /async──▶│ 提交任务
│ │ │ │◀───────────────│ 返回 taskId
│ │ │ │ │
│ │ │ │──轮询状态────▶│ 查询进度
│ │ │ │◀───────────────│ pending/running
│ │ │ │ │
│ │ │ │──轮询状态────▶│ 查询进度
│ │ │ │◀───────────────│ success
│ │ │ │ │
│ │ │ │──GET /result──▶│ 获取结果
│ │ │ │◀───────────────│ JSON 数据
│ │ │ │ │
│ │ │◀────────────────│ 返回 DoclingResult
│ │ │ │ │
│ │ │──分块→向量化──▶│ │
│ │ │ │ │
│◀───────完成─────────────────────────────────────────────────────────│
四、快速开始(5 分钟上手)
4.1 第一步:启动 Docling 服务
# 拉取 Docker 镜像
docker pull ds4sd/docling-serve:latest
# 启动服务
docker run -d \
-p 50080:50080 \
--name docling \
ds4sd/docling-serve:latest
# 验证服务
curl http://localhost:50080/health
# 返回 {"status":"healthy"} 表示成功
4.2 第二步:添加 Maven 依赖
<dependencies>
<!-- Spring Boot 核心 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
<version>3.4.2</version>
</dependency>
<!-- Hutool 工具类 -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.32</version>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.36</version>
<scope>provided</scope>
</dependency>
</dependencies>
4.3 第三步:配置 application.yml
docling:
url: http://localhost:50080
timeout: 300
concurrency: 4
4.4 第四步:复制 DoclingClient 代码
将本文第六部分的 DoclingClient.java 完整代码复制到项目中。
4.5 第五步:调用解析
@Resource
private DoclingClient doclingClient;
// 解析 PDF
File pdfFile = new File("/path/to/document.pdf");
DoclingResult result = doclingClient.convert(pdfFile);
// 检查结果
if (result.isSuccess()) {
System.out.println("Markdown 内容:" + result.getMdContent());
System.out.println("元素数量:" + result.getElementCount());
System.out.println("表格数量:" + result.getTableCount());
System.out.println("图片数量:" + result.getImageCount());
} else {
System.err.println("解析失败:" + result.getErrorMessage());
}
完成! 🎉
五、环境搭建与配置
5.1 环境要求
| 软件 | 版本要求 | 说明 |
|---|---|---|
| JDK | 17+ | 推荐 JDK 21(支持 Switch 表达式) |
| Spring Boot | 3.2+ | 需要 Jakarta 注解支持 |
| Hutool | 5.8+ | HTTP 客户端和 JSON 处理 |
| Lombok | 1.18+ | 简化 Getter/Setter |
| Docker | 20.10+ | 部署 Docling 服务 |
| Docling-Serve | 1.12.0+ | 文档解析服务 |
5.2 完整 Maven 依赖
<project>
<dependencies>
<!-- Spring Boot 核心 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
<version>3.4.2</version>
</dependency>
<!-- Spring Boot Web(可选,用于 Controller) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>3.4.2</version>
</dependency>
<!-- Hutool 工具类(HTTP 客户端 + JSON 处理) -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.32</version>
</dependency>
<!-- Lombok 简化代码 -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.36</version>
<scope>provided</scope>
</dependency>
<!-- SLF4J 日志(Spring Boot 默认包含) -->
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
</dependency>
<!-- 单元测试(可选) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
5.3 配置文件(application.yml)
# ==================== Docling 配置 ====================
docling:
# Docling-Serve 服务地址
# Docker 部署:http://localhost:50080
# 远程服务器:http://192.168.1.100:50080
url: http://localhost:50080
# 解析超时时间(秒)
# 建议:
# - 普通文档(<50 页):120 秒
# - 中等文档(50-200 页):300 秒
# - 大文档(>200 页):600 秒
timeout: 300
# 最大并发解析数
# 说明:每个并发会占用一个 CPU 核心
# 建议:
# - 4 核 CPU:2-4
# - 8 核 CPU:4-8
# - 16 核 CPU:8-12
# 注意:过高会导致 CPU/GPU 过载
concurrency: 4
# OCR 配置
ocr:
# 是否启用 OCR
enabled: true
# OCR 语言列表
# 支持:en, zh, zh-tw, ja, ko, de, fr, es, it, ru 等 30+ 语言
languages:
- zh
- en
# 表格识别配置
table:
# 是否启用表格结构识别
enabled: true
# 图片导出配置
image:
# 导出模式
# - embedded:base64 嵌入 JSON(推荐)
# - referenced:外部文件引用
export-mode: embedded
# PDF 解析后端配置
pdf:
# 后端选择
# - fitz:PyMuPDF(推荐,速度快,表格识别好)
# - pypdf:纯 Python 实现(兼容性好)
backend: fitz
# ==================== 日志配置 ====================
logging:
level:
# DoclingClient 调试日志
com.wt.admin.service.docling: DEBUG
# Hutool HTTP 调试日志
cn.hutool.http: DEBUG
5.4 配置属性类(AIProp.java)
package com.wt.admin.config.prop;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* AI 相关配置属性
*/
@Data
@Component
@ConfigurationProperties(prefix = "docling")
public class AIProp {
/** Docling 服务配置 */
private DoclingConfig docling = new DoclingConfig();
@Data
public static class DoclingConfig {
/** 服务地址 */
private String url = "http://localhost:50080";
/** 超时时间(秒) */
private int timeout = 300;
/** 最大并发数 */
private int concurrency = 4;
/** OCR 配置 */
private OcrConfig ocr = new OcrConfig();
/** 表格配置 */
private TableConfig table = new TableConfig();
/** 图片配置 */
private ImageConfig image = new ImageConfig();
/** PDF 配置 */
private PdfConfig pdf = new PdfConfig();
}
@Data
public static class OcrConfig {
private boolean enabled = true;
private java.util.List<String> languages = java.util.List.of("zh", "en");
}
@Data
public static class TableConfig {
private boolean enabled = true;
}
@Data
public static class ImageConfig {
private String exportMode = "embedded";
}
@Data
public static class PdfConfig {
private String backend = "fitz";
}
}
5.5 解析配置 DTO(ParseConfigDO.java)
package com.wt.admin.domain.model.ai;
import lombok.Data;
import java.util.List;
/**
* 文档解析配置对象
*/
@Data
public class ParseConfigDO {
/**
* Docling 解析参数配置
*/
@Data
public static class DoclingConfig {
/**
* 是否启用 OCR 识别
* 默认:true
*/
private Boolean doOcr = true;
/**
* 是否识别表格结构
* 默认:true
*/
private Boolean doTableStructure = true;
/**
* 图片导出模式
* 可选值:embedded(base64 嵌入)、referenced(外部引用)
* 默认:embedded
*/
private String imageExportMode = "embedded";
/**
* PDF 解析后端
* 可选值:fitz(PyMuPDF)、pypdf
* 默认:fitz
*/
private String pdfBackend = "fitz";
/**
* OCR 识别语言列表
* 示例:["zh", "en"]
*/
private List<String> ocrLang;
}
}
六、DoclingClient 核心代码逐行解析
6.1 完整代码
package com.wt.admin.service.docling;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpResponse;
import cn.hutool.json.JSONArray;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.wt.admin.config.prop.AIProp;
import com.wt.admin.domain.model.ai.ParseConfigDO;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.io.File;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.Semaphore;
/**
* Docling 文档解析客户端
* 负责与本地 Docling-Serve 进程通信,将文档转换为结构化数据
* Docling 论文:Docling Technical Report, arXiv:2408.09869
*
* @author MonkeyCode-AI
* @version 1.0
* @since 2026-05-15
*/
@Slf4j // Lombok 注解:自动生成 log 日志对象
@Component // Spring 注解:标记为 Bean,自动注入到其他类
public class DoclingClient {
@Resource // Spring 注解:自动注入配置属性
private AIProp aiProp;
/**
* 轮询间隔(毫秒)
* 说明:每 3 秒向 Docling 服务查询一次任务状态
*/
private static final int POLL_INTERVAL_MS = 3000;
/**
* 控制 Docling 服务最大并发解析数,防止 CPU/GPU 过载
*/
private Semaphore parseSemaphore;
/**
* 初始化方法
* Spring Bean 创建完成后自动调用
*/
@PostConstruct
public void init() {
int concurrency = aiProp.getDocling().getConcurrency();
parseSemaphore = new Semaphore(concurrency);
log.debug("[Docling] 并发控制初始化,最大并发数={}", concurrency);
}
/**
* 异步解析文档(使用默认参数)
*/
public DoclingResult convert(File file) {
return convert(file, null);
}
/**
* 异步解析文档,适配 Docling-Serve v1.12.0 的异步 API
* 流程:提交异步任务 → 轮询状态 → 获取结果
*/
public DoclingResult convert(File file, ParseConfigDO.DoclingConfig doclingConfig) {
String baseUrl = aiProp.getDocling().getUrl();
int timeoutSeconds = aiProp.getDocling().getTimeout();
// 获取信号量许可,控制并发度
try {
parseSemaphore.acquire();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return failResult("等待 Docling 解析许可时被中断");
}
try {
long start = System.currentTimeMillis();
// 1. 提交异步任务
String taskId = submitAsyncTask(baseUrl, file, timeoutSeconds, doclingConfig);
if (taskId == null) {
return failResult("提交 Docling 异步任务失败");
}
log.info("[Docling] 开始解析,taskId={}, 排队数={}",
taskId, parseSemaphore.getQueueLength());
// 2. 轮询等待任务完成
String taskStatus = pollTaskStatus(baseUrl, taskId, timeoutSeconds);
if (!"success".equals(taskStatus)) {
return failResult("Docling 任务执行失败,taskId=" + taskId + ",status=" + taskStatus);
}
// 3. 获取任务结果
return fetchTaskResult(baseUrl, taskId, System.currentTimeMillis() - start);
} finally {
parseSemaphore.release();
}
}
/**
* 提交异步转换任务到 /v1/convert/file/async
*/
private String submitAsyncTask(String baseUrl, File file, int timeoutSeconds,
ParseConfigDO.DoclingConfig config) {
String url = baseUrl + "/v1/convert/file/async";
try {
HttpRequest request = HttpRequest.post(url)
.form("files", file)
.form("to_formats", "md")
.form("to_formats", "json")
.timeout(timeoutSeconds * 1000);
applyDoclingConfig(request, config);
HttpResponse response = request.execute();
if (!response.isOk()) {
log.error("Docling 异步任务提交失败,HTTP 状态码:{},响应:{}",
response.getStatus(), response.body());
return null;
}
JSONObject body = JSONUtil.parseObj(response.body());
return body.getStr("task_id");
} catch (Exception e) {
log.error("Docling 异步任务提交异常:{}", e.getMessage(), e);
return null;
}
}
/**
* 将 DoclingConfig 参数应用到 HTTP 请求
*/
private void applyDoclingConfig(HttpRequest request, ParseConfigDO.DoclingConfig config) {
boolean doOcr = config != null ? Boolean.TRUE.equals(config.getDoOcr()) : true;
boolean doTable = config != null ? Boolean.TRUE.equals(config.getDoTableStructure()) : true;
String imageMode = config != null && StrUtil.isNotBlank(config.getImageExportMode())
? config.getImageExportMode()
: "embedded";
request.form("do_ocr", String.valueOf(doOcr));
request.form("do_table_structure", String.valueOf(doTable));
request.form("image_export_mode", imageMode);
applyOptionalDoclingParams(request, config);
}
private void applyOptionalDoclingParams(HttpRequest request,
ParseConfigDO.DoclingConfig config) {
if (config == null) {
return;
}
if (StrUtil.isNotBlank(config.getPdfBackend())) {
request.form("pdf_backend", config.getPdfBackend());
}
if (CollUtil.isNotEmpty(config.getOcrLang())) {
config.getOcrLang().forEach(lang -> request.form("ocr_lang", lang));
}
}
/**
* 轮询任务状态 /v1/status/poll/{taskId}
*/
private String pollTaskStatus(String baseUrl, String taskId, int timeoutSeconds) {
String url = baseUrl + "/v1/status/poll/" + taskId;
long deadline = System.currentTimeMillis() + timeoutSeconds * 1000L;
while (System.currentTimeMillis() < deadline) {
try {
Thread.sleep(POLL_INTERVAL_MS);
HttpResponse response = HttpRequest.get(url).timeout(10000).execute();
if (!response.isOk()) {
log.warn("Docling 轮询状态异常,HTTP 状态码:{}", response.getStatus());
continue;
}
JSONObject body = JSONUtil.parseObj(response.body());
String status = body.getStr("task_status");
log.debug("Docling 任务轮询,taskId={},status={}", taskId, status);
if ("success".equals(status) || "failure".equals(status)) {
return status;
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return "interrupted";
} catch (Exception e) {
log.warn("Docling 轮询异常:{}", e.getMessage());
}
}
log.error("Docling 任务超时,taskId={},超时时间={}秒", taskId, timeoutSeconds);
return "timeout";
}
/**
* 获取已完成任务的结果 /v1/result/{taskId}
*/
private DoclingResult fetchTaskResult(String baseUrl, String taskId, long costMs) {
String url = baseUrl + "/v1/result/" + taskId;
try {
HttpResponse response = HttpRequest.get(url).timeout(30000).execute();
if (!response.isOk()) {
log.error("Docling 获取结果失败,HTTP 状态码:{},响应:{}",
response.getStatus(), response.body());
return failResult("Docling 获取结果失败:" + response.getStatus());
}
return parseResponse(response.body(), costMs);
} catch (Exception e) {
log.error("Docling 获取结果异常:{}", e.getMessage(), e);
return failResult("Docling 获取结果异常:" + e.getMessage());
}
}
/**
* 构建失败结果
*/
private DoclingResult failResult(String message) {
DoclingResult result = new DoclingResult();
result.setSuccess(false);
result.setErrorMessage(message);
return result;
}
/**
* 解析 Docling 返回的 JSON
*/
private DoclingResult parseResponse(String json, long costMs) {
DoclingResult result = new DoclingResult();
result.setSuccess(true);
result.setParseTime((int) costMs);
JSONObject root = JSONUtil.parseObj(json);
JSONObject document = root.getJSONObject("document");
if (document == null) {
result.setSuccess(false);
result.setErrorMessage("Docling 返回数据中无 document 字段");
return result;
}
result.setMdContent(document.getStr("md_content", ""));
JSONObject jsonContent = document.getJSONObject("json_content");
if (jsonContent != null) {
parseJsonContent(jsonContent, result);
return result;
}
parseLegacyElements(document, result);
return result;
}
/**
* 从 json_content 提取结构化元素和图片
*/
private void parseJsonContent(JSONObject jsonContent, DoclingResult result) {
JSONObject pages = jsonContent.getJSONObject("pages");
if (pages != null) {
result.setPageCount(pages.size());
}
JSONArray texts = jsonContent.getJSONArray("texts");
JSONArray pictures = jsonContent.getJSONArray("pictures");
JSONArray tables = jsonContent.getJSONArray("tables");
JSONArray groups = jsonContent.getJSONArray("groups");
List<DoclingElement> elementList = new ArrayList<>();
List<DoclingImage> imageList = new ArrayList<>();
int[] tableCount = {0};
int[] index = {0};
JSONObject body = jsonContent.getJSONObject("body");
JSONArray children = body != null ? body.getJSONArray("children") : null;
if (children == null) {
return;
}
Set<Integer> pageImageCollected = new HashSet<>();
resolveChildren(children, texts, pictures, tables, groups, pages,
elementList, imageList, tableCount, index, pageImageCollected);
result.setElements(elementList);
result.setImages(imageList);
result.setElementCount(elementList.size());
result.setTableCount(tableCount[0]);
result.setImageCount(imageList.size());
JSONObject origin = jsonContent.getJSONObject("origin");
result.setDocMetadata(origin != null ? origin.toString() : null);
}
/**
* 递归解析 children 中的 $ref 引用
*/
private void resolveChildren(JSONArray children, JSONArray texts, JSONArray pictures,
JSONArray tables, JSONArray groups, JSONObject pages,
List<DoclingElement> elementList, List<DoclingImage> imageList,
int[] tableCount, int[] index, Set<Integer> pageImageCollected) {
for (int i = 0; i < children.size(); i++) {
JSONObject childRef = children.getJSONObject(i);
String ref = childRef.getStr("$ref");
if (StrUtil.isBlank(ref)) {
continue;
}
String[] parts = ref.replace("#/", "").split("/");
if (parts.length != 2) {
continue;
}
String category = parts[0];
int refIndex;
try {
refIndex = Integer.parseInt(parts[1]);
} catch (NumberFormatException e) {
continue;
}
if ("groups".equals(category) && groups != null && refIndex < groups.size()) {
JSONObject groupObj = groups.getJSONObject(refIndex);
JSONArray groupChildren = groupObj.getJSONArray("children");
if (groupChildren != null) {
resolveChildren(groupChildren, texts, pictures, tables, groups, pages,
elementList, imageList, tableCount, index, pageImageCollected);
}
continue;
}
if ("texts".equals(category) && texts != null && refIndex < texts.size()) {
JSONObject textObj = texts.getJSONObject(refIndex);
String label = textObj.getStr("label", "text");
String text = textObj.getStr("text", "");
String type = mapLabelToType(label);
DoclingElement element = new DoclingElement();
element.setIndex(index[0]++);
element.setType(type);
element.setText(text);
element.setPage(extractPage(textObj));
element.setLevel(textObj.getInt("level", "title".equals(label) ? 1 : 0));
elementList.add(element);
continue;
}
if ("tables".equals(category) && tables != null && refIndex < tables.size()) {
JSONObject tableObj = tables.getJSONObject(refIndex);
String tableText = buildTableText(tableObj);
DoclingElement element = new DoclingElement();
element.setIndex(index[0]++);
element.setType("table");
element.setText(tableText);
element.setPage(extractPage(tableObj));
element.setLevel(0);
elementList.add(element);
tableCount[0]++;
continue;
}
if ("pictures".equals(category) && pictures != null && refIndex < pictures.size()) {
JSONObject picObj = pictures.getJSONObject(refIndex);
int page = extractPage(picObj);
String caption = extractCaption(picObj);
DoclingElement element = new DoclingElement();
element.setIndex(index[0]++);
element.setType("picture");
element.setText(StrUtil.isNotBlank(caption) ? caption : "[image]");
element.setPage(page);
element.setLevel(0);
elementList.add(element);
JSONObject picImage = picObj.getJSONObject("image");
boolean hasSelfImage = picImage != null && StrUtil.isNotBlank(picImage.getStr("uri", ""));
if (hasSelfImage) {
String base64 = stripBase64Prefix(picImage.getStr("uri", ""));
DoclingImage img = new DoclingImage();
img.setBase64(base64);
img.setCaption(caption);
img.setPage(page);
img.setOcrText("");
imageList.add(img);
} else if (pages != null && pageImageCollected.add(page)) {
String base64 = extractPageImage(pages, page);
if (StrUtil.isNotBlank(base64)) {
DoclingImage img = new DoclingImage();
img.setBase64(base64);
img.setCaption("第" + page + "页截图");
img.setPage(page);
img.setOcrText("");
imageList.add(img);
}
}
}
}
}
/**
* 提取图片的 base64 数据
*/
private String extractPictureBase64(JSONObject picObj, JSONObject pages, int page) {
JSONObject imageData = picObj.getJSONObject("image");
if (imageData != null) {
String uri = imageData.getStr("uri", "");
if (StrUtil.isNotBlank(uri)) {
return stripBase64Prefix(uri);
}
}
if (pages != null) {
JSONObject pageObj = pages.getJSONObject(String.valueOf(page));
if (pageObj != null) {
JSONObject pageImage = pageObj.getJSONObject("image");
if (pageImage != null) {
String uri = pageImage.getStr("uri", "");
if (StrUtil.isNotBlank(uri)) {
return stripBase64Prefix(uri);
}
}
}
}
return "";
}
/**
* 从 pages 提取整页截图的 base64
*/
private String extractPageImage(JSONObject pages, int page) {
JSONObject pageObj = pages.getJSONObject(String.valueOf(page));
if (pageObj == null) {
return "";
}
JSONObject pageImage = pageObj.getJSONObject("image");
if (pageImage == null) {
return "";
}
String uri = pageImage.getStr("uri", "");
return StrUtil.isNotBlank(uri) ? stripBase64Prefix(uri) : "";
}
/**
* 去掉 base64 前缀
*/
private String stripBase64Prefix(String uri) {
if (uri.contains(",")) {
return uri.substring(uri.indexOf(",") + 1);
}
return uri;
}
/**
* 将 Docling label 映射为统一的元素类型
*/
private String mapLabelToType(String label) {
return switch (label) {
case "title", "section_header" -> "title";
case "list_item" -> "list";
case "caption" -> "paragraph";
default -> "paragraph";
};
}
/**
* 从元素的 prov 字段提取页码
*/
private int extractPage(JSONObject element) {
JSONArray prov = element.getJSONArray("prov");
if (prov != null && !prov.isEmpty()) {
JSONObject firstProv = prov.getJSONObject(0);
return firstProv.getInt("page_no", 1);
}
return 1;
}
/**
* 从图片/表格元素的 captions 字段提取标题文本
*/
private String extractCaption(JSONObject element) {
JSONArray captions = element.getJSONArray("captions");
if (captions == null || captions.isEmpty()) {
return "";
}
JSONObject firstCaption = captions.getJSONObject(0);
if (firstCaption == null) {
return "";
}
String text = firstCaption.getStr("text");
if (StrUtil.isNotBlank(text)) {
return text;
}
return "";
}
/**
* 将表格数据转换为 Markdown 表格文本
*/
private String buildTableText(JSONObject tableObj) {
JSONObject data = tableObj.getJSONObject("data");
if (data == null) {
return "[table]";
}
JSONArray cells = data.getJSONArray("table_cells");
if (cells == null || cells.isEmpty()) {
return "[table]";
}
int numCols = data.getInt("num_cols", 0);
int numRows = data.getInt("num_rows", 0);
if (numCols == 0 || numRows == 0) {
return "[table]";
}
String[][] grid = new String[numRows][numCols];
for (int i = 0; i < cells.size(); i++) {
JSONObject cell = cells.getJSONObject(i);
int row = cell.getInt("start_row_offset_idx", 0);
int col = cell.getInt("start_col_offset_idx", 0);
if (row < numRows && col < numCols) {
grid[row][col] = cell.getStr("text", "");
}
}
StringBuilder sb = new StringBuilder();
for (int r = 0; r < numRows; r++) {
sb.append("|");
for (int c = 0; c < numCols; c++) {
sb.append(" ").append(grid[r][c] != null ? grid[r][c] : "").append(" |");
}
sb.append("\n");
if (r == 0) {
sb.append("|");
for (int c = 0; c < numCols; c++) {
sb.append("---|");
}
sb.append("\n");
}
}
return sb.toString().trim();
}
/**
* 从旧版 Docling 的 elements 字段提取结构化数据
*/
private void parseLegacyElements(JSONObject document, DoclingResult result) {
JSONObject metadata = document.getJSONObject("metadata");
if (metadata != null) {
result.setPageCount(metadata.getInt("page_count", 0));
result.setDocMetadata(metadata.toString());
}
JSONArray elements = document.getJSONArray("elements");
if (elements == null) {
return;
}
List<DoclingElement> elementList = new ArrayList<>();
List<DoclingImage> imageList = new ArrayList<>();
int tableCount = 0;
for (int i = 0; i < elements.size(); i++) {
JSONObject el = elements.getJSONObject(i);
String type = el.getStr("type", "paragraph");
String text = el.getStr("text", "");
DoclingElement element = new DoclingElement();
element.setIndex(i);
element.setType(type);
element.setText(text);
element.setPage(el.getInt("page", 1));
element.setLevel(el.getInt("level", 0));
elementList.add(element);
if ("table".equals(type)) {
tableCount++;
}
if ("picture".equals(type) && StrUtil.isNotBlank(el.getStr("image"))) {
DoclingImage image = new DoclingImage();
image.setBase64(el.getStr("image"));
image.setCaption(el.getStr("caption", ""));
image.setPage(el.getInt("page", 1));
image.setOcrText(el.getStr("ocr_text", ""));
imageList.add(image);
}
}
result.setElements(elementList);
result.setImages(imageList);
result.setElementCount(elementList.size());
result.setTableCount(tableCount);
result.setImageCount(imageList.size());
}
/**
* 检查 Docling 服务是否可用
*/
public boolean isAvailable() {
try {
HttpResponse response = HttpRequest.get(aiProp.getDocling().getUrl() + "/health")
.timeout(3000)
.execute();
return response.isOk();
} catch (Exception e) {
log.warn("Docling 服务不可用:{}", e.getMessage());
return false;
}
}
// ========== 内部数据结构 ==========
@Data
public static class DoclingResult {
private boolean success;
private String errorMessage;
private String mdContent;
private String docMetadata;
private Integer pageCount;
private Integer elementCount;
private Integer tableCount;
private Integer imageCount;
private Integer parseTime;
private List<DoclingElement> elements;
private List<DoclingImage> images;
}
@Data
public static class DoclingElement {
private Integer index;
/** 元素类型:title/paragraph/table/list/picture/formula */
private String type;
private String text;
private Integer page;
/** 标题层级,仅 type=title 时有效 */
private Integer level;
}
@Data
public static class DoclingImage {
private String base64;
private String caption;
private Integer page;
private String ocrText;
}
}
七、请求参数详解
7.1 HTTP 请求格式
POST http://localhost:50080/v1/convert/file/async
Content-Type: multipart/form-data
files: <PDF 文件>
to_formats: "md"
to_formats: "json"
do_ocr: "true"
do_table_structure: "true"
image_export_mode: "embedded"
pdf_backend: "fitz"
ocr_lang: "en"
ocr_lang: "zh"
7.2 参数说明表
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|---|---|---|---|---|
files |
File | ✅ | - | 待解析的文件 |
to_formats |
String | ✅ | - | 输出格式:md、json |
do_ocr |
Boolean | ❌ | true |
是否启用 OCR |
do_table_structure |
Boolean | ❌ | true |
是否识别表格 |
image_export_mode |
String | ❌ | "embedded" |
图片导出模式 |
pdf_backend |
String | ❌ | - | PDF 后端:fitz/pypdf |
ocr_lang |
String[] | ❌ | - | OCR 语言 |
7.3 Java 调用示例
// 示例 1:默认参数
DoclingResult result = doclingClient.convert(new File("document.pdf"));
// 示例 2:自定义参数
ParseConfigDO.DoclingConfig config = new ParseConfigDO.DoclingConfig();
config.setDoOcr(true);
config.setDoTableStructure(true);
config.setImageExportMode("embedded");
config.setPdfBackend("fitz");
config.setOcrLang(List.of("en", "zh"));
DoclingResult result = doclingClient.convert(new File("document.pdf"), config);
八、返回数据格式详解
8.1 DoclingResult 结构
public class DoclingResult {
private boolean success;
private String errorMessage;
private String mdContent; // Markdown 全文
private String docMetadata; // 文档元数据
private Integer pageCount; // 页数
private Integer elementCount; // 元素总数
private Integer tableCount; // 表格数量
private Integer imageCount; // 图片数量
private Integer parseTime; // 解析耗时(毫秒)
private List<DoclingElement> elements;
private List<DoclingImage> images;
}
8.2 实际返回示例
{
"success": true,
"mdContent": "# 项目报告\n\n## 第一章 概述\n\n...",
"pageCount": 5,
"elementCount": 28,
"tableCount": 3,
"imageCount": 2,
"parseTime": 4532,
"elements": [
{
"index": 0,
"type": "title",
"text": "项目报告",
"page": 1,
"level": 1
},
{
"index": 1,
"type": "table",
"text": "| 姓名 | 年龄 |\n|------|------|\n| 张三 | 25 |",
"page": 2,
"level": 0
}
],
"images": [
{
"base64": "iVBORw0KGgoAAAANSUhEUgAAAA...",
"caption": "销售趋势",
"page": 3,
"ocrText": ""
}
]
}
九、如何集成到向量存储
9.1 完整流程
Docling 解析 → 按元素分块 → Embedding → 向量库存储
9.2 代码实现
@Service
public class ESVectorImpl implements VectorApi {
@Resource
private DoclingClient doclingClient;
@Resource
private ElasticsearchVectorStore vectorStore;
public void add(File pdfFile, Integer modelId, String fileId) {
// 1. 解析文档
DoclingResult result = doclingClient.convert(pdfFile);
if (!result.isSuccess()) {
throw new RuntimeException("解析失败:" + result.getErrorMessage());
}
// 2. 按元素分块
List<Document> chunks = new ArrayList<>();
for (DoclingElement element : result.getElements()) {
if (StrUtil.isBlank(element.getText())) {
continue;
}
Document chunk = new Document();
chunk.setContent(element.getText());
chunk.getMetadata().put("modelId", modelId);
chunk.getMetadata().put("fileId", fileId);
chunk.getMetadata().put("page", element.getPage());
chunk.getMetadata().put("type", element.getType());
chunks.add(chunk);
}
// 3. 存储到向量库
vectorStore.add(chunks);
}
}
十、生产级优化方案
10.1 异步任务处理
@Service
public class DocumentProcessService {
@Async
public void processAsync(File file, Integer knowledgeId) {
try {
DoclingResult result = doclingClient.convert(file);
// 处理结果...
} catch (Exception e) {
log.error("解析失败", e);
}
}
}
10.2 并发控制优化
// 根据 CPU 核心数动态设置
int cpuCores = Runtime.getRuntime().availableProcessors();
int concurrency = Math.max(1, cpuCores / 2);
parseSemaphore = new Semaphore(concurrency);
10.3 大文件拆分
// 超过 100 页的 PDF 拆分处理
if (totalPages > 100) {
int batchSize = 20;
for (int i = 0; i < totalPages; i += batchSize) {
// 拆分并分批解析
}
}
十一、完整调用示例
11.1 Controller 层
@RestController
@RequestMapping("/knowledge")
public class KnowledgeController {
@Resource
private KnowledgeService knowledgeService;
@PostMapping("/upload")
public Result<Integer> upload(
@RequestParam("file") MultipartFile file,
@RequestParam("knowledgeId") Integer knowledgeId
) {
File tempFile = FileUtil.convertMultipartFileToFile(file);
try {
Integer fileId = knowledgeService.processDocument(tempFile, knowledgeId);
return Result.success(fileId);
} finally {
FileUtil.del(tempFile);
}
}
}
11.2 Service 层
@Service
public class KnowledgeService {
@Resource
private DoclingClient doclingClient;
@Resource
private VectorApi vectorApi;
public Integer processDocument(File file, Integer knowledgeId) {
// 解析
DoclingResult result = doclingClient.convert(file);
// 保存记录
KnowledgeFileEntity entity = new KnowledgeFileEntity();
entity.setKnowledgeId(knowledgeId);
entity.setFileName(file.getName());
entity.setPageCount(result.getPageCount());
knowledgeMapper.insert(entity);
// 向量化
vectorApi.add(file, knowledgeId, entity.getId().toString());
return entity.getId();
}
}
十二、测试用例
@SpringBootTest
class DoclingClientTest {
@Autowired
private DoclingClient doclingClient;
@Test
void testConvert() {
File pdfFile = new File("src/test/resources/sample.pdf");
DoclingResult result = doclingClient.convert(pdfFile);
assertTrue(result.isSuccess());
assertNotNull(result.getMdContent());
assertTrue(result.getPageCount() > 0);
}
}
十三、常见问题 FAQ
Q1:Docling 服务如何部署?
docker pull ds4sd/docling-serve:latest
docker run -d -p 50080:50080 --name docling ds4sd/docling-serve:latest
Q2:解析大文件内存溢出怎么办?
- 降低并发数
- 增加 JVM 堆内存
-Xmx4g - 拆分大文件分批处理
Q3:表格识别效果不好?
config.setDoTableStructure(true);
config.setPdfBackend("fitz"); // 使用 PyMuPDF
config.setDoOcr(true); // 启用 OCR
Q4:如何监控进度?
@GetMapping("/task/{taskId}")
public Result<TaskProgressVO> getProgress(@PathVariable String taskId) {
String url = doclingUrl + "/v1/status/poll/" + taskId;
// 轮询获取进度
}
十四、总结
14.1 核心要点
| 知识点 | 关键内容 |
|---|---|
| Docling 定位 | 企业级文档解析引擎 |
| 核心能力 | 文字 + 表格 + 图片 + OCR |
| 请求方式 | 异步任务(提交→轮询→获取) |
| 返回数据 | Markdown + 结构化元素 + 图片 |
| 并发控制 | Semaphore 信号量 |
| 向量集成 | 解析→分块→Embedding→存储 |
14.2 最佳实践
✅ 推荐:
- 异步处理大文档
- 合理设置并发数
- 按元素分块
- 添加元数据
- 错误重试机制
❌ 避免:
- 同步处理大文档
- 并发数过高
- 整个文档一个 chunk
- 忽略图片数据
参考资料
- Docling 官方:https://github.com/DS4SD/docling
- 技术报告:https://arxiv.org/abs/2408.09869
- Spring AI:https://spring.io/projects/spring-ai
版权声明:本文为 CSDN 博主原创文章,遵循 CC 4.0 BY-SA 版权协议。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)