Spring AI + Docling 企业级文档解析完全指南

摘要:本文从入门到精通,详细介绍如何在 Spring Boot 项目中集成 Docling 文档解析服务。包含环境搭建、配置详解、661 行核心代码逐行解析、请求参数详解、返回数据处理、向量存储集成、生产级优化方案和完整测试用例。看完本文即可独立实现企业级文档解析功能。

关键词:Spring AI;Docling;文档解析;RAG;OCR;表格识别;向量存储


📋 文章目录


一、背景介绍

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 - 输出格式:mdjson
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:解析大文件内存溢出怎么办?

  1. 降低并发数
  2. 增加 JVM 堆内存 -Xmx4g
  3. 拆分大文件分批处理

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 最佳实践

推荐

  1. 异步处理大文档
  2. 合理设置并发数
  3. 按元素分块
  4. 添加元数据
  5. 错误重试机制

避免

  1. 同步处理大文档
  2. 并发数过高
  3. 整个文档一个 chunk
  4. 忽略图片数据

参考资料

  1. Docling 官方:https://github.com/DS4SD/docling
  2. 技术报告:https://arxiv.org/abs/2408.09869
  3. Spring AI:https://spring.io/projects/spring-ai

版权声明:本文为 CSDN 博主原创文章,遵循 CC 4.0 BY-SA 版权协议。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐