Skip to content

文档解析优化 ​

Java MossKB 的文档解析只在需要时调用远程 OCR。PDF 先按页尝试原生文本层提取,只有无法可靠取到文字的页面才交给 Gitee 的 PaddleOCR-VL-1.5;图片和 DOCX 内嵌图片同样走这条路径。本地不需要部署 OCR 模型权重。

本篇说明解析策略、调用链路、缓存、限制与实测结论。部署和配置见Windows 与 Linux 部署;模型对比数据见文档解析模型实测。

代码位置 ​

环节主要类
检测与路由DocumentParsingService
分段预览服务MossKbDocumentSplitService
HTTP 接口ApiDatasetDocumentController
Gitee 客户端GiteeClient、GiteeDocumentParseRequest、GiteeTaskResponse
结果转 MarkdownGiteeSimpleMarkdownUtils
提示词常量GiteePromptConst
缓存表moss_kb_document_markdown_cache
前端调用SetRules.vue、api/document.ts
解析测试DocumentParsingServiceTest

DocumentParsingService 是这条路径上唯一的生产解析实现。同包下的 MossKbDocumentConvertService 是原 MossKB“逐页转图片再调多模态模型”的旧实现,当前没有任何调用方,不参与解析。

先检测后解析 ​

文件类型不只看扩展名。PDF 通过文件头 %PDF- 识别,扩展名写错也能识别成 PDF;DOCX 通过 PK 文件头加 .docx 后缀识别。路由结果记录为 parse_strategy。

入口方法先做尺寸与空文件校验,再按内容分发:

java
import java.io.ByteArrayInputStream;
import java.nio.charset.StandardCharsets;
import java.util.Locale;

public Parsed parse(byte[] data, String filename) throws Exception {
  if (data == null || data.length == 0) {
    throw new IllegalArgumentException("文件为空");
  }
  if (data.length > 100 * 1024 * 1024) {
    throw new IllegalArgumentException("文件不能超过100MB");
  }
  String name = filename.toLowerCase(Locale.ROOT);
  Parsed result;
  if (new String(data, 0, Math.min(5, data.length), StandardCharsets.US_ASCII).equals("%PDF-")) {
    // 见下一节:PDF 逐页选择原生文本或 OCR
  } else if (data.length > 4 && data[0] == 'P' && data[1] == 'K' && name.endsWith(".docx")) {
    String extracted = docx(data);
    if (extracted.replaceAll("\\s", "").length() < 40) {
      String images = docxImages(data);
      result = new Parsed(images.isBlank() ? "docx-structure" : "docx-ocr", extracted + images, 0);
    } else {
      result = new Parsed("docx-structure", extracted, 0);
    }
  } else if (name.endsWith(".xlsx") || name.endsWith(".xls")) {
    result = new Parsed("spreadsheet-structure", spreadsheet(data), 0);
  } else if (javax.imageio.ImageIO.read(new ByteArrayInputStream(data)) != null) {
    result = new Parsed("image-ocr", ocr(data, filename), 1);
  } else {
    throw new IllegalArgumentException("不支持的文件格式,请转换为PDF、DOCX、TXT或图片后上传");
  }
  if (result.text().isBlank()) {
    throw new IllegalArgumentException("未提取到内容,请检查文档或使用扫描PDF");
  }
  return result;
}

分发顺序体现“先看内容再选路径”:PDF 判定在扩展名之前,图片判定依赖 ImageIO 能否真正解码,而不是后缀名。DOCX 先提取正文,正文去空白后不足 40 字符才回退去解析内嵌图片。

文档特征策略结果标识
可提取有效文字的 PDF 页面按阅读顺序提取文字,保留页码pdf-text
扫描 PDF 页面或无法可靠提取文字的页面调用 PaddleOCR,按原页序合并pdf-ocr
同时含文字页和扫描页的 PDF逐页选择提取或 OCRpdf-mixed
DOCX 正文与表格提取段落、表格结构docx-structure
DOCX 正文极少但含图片提取内嵌图片并 OCRdocx-ocr
DOC提取正文doc-text
XLS、XLSX按工作表和行提取显示值spreadsheet-structure
TXT、Markdown、日志、CSV、JSON、HTML文本解码,HTML 提取可见正文text
PNG、JPEG、TIFF 等可识别图片OCRimage-ocr
ZIP在内存中读取文档并合并,不解压到磁盘zip-documents

策略值由整体结果决定:全部页都有原生文本为 pdf-text,全部页都需要 OCR 为 pdf-ocr,混合为 pdf-mixed。

PDF 文字页判定 ​

PDF 逐页提取时按坐标排序,每页单独判定。判定与回退逻辑如下:

java
import java.io.ByteArrayOutputStream;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.text.PDFTextStripper;
import nexus.io.tio.utils.crypto.Md5Utils;

try (PDDocument pdf = PDDocument.load(data)) {
  if (pdf.isEncrypted()) {
    throw new IllegalArgumentException("请先解密PDF文件");
  }
  PDFTextStripper stripper = new PDFTextStripper();
  stripper.setSortByPosition(true);
  String sourceHash = Md5Utils.md5Hex(data);
  StringBuilder text = new StringBuilder();
  int nativePages = 0;
  for (int i = 1; i <= pdf.getNumberOfPages(); i++) {
    stripper.setStartPage(i);
    stripper.setEndPage(i);
    String page = stripper.getText(pdf);
    if (page.replaceAll("\\s", "").length() >= 40 && !page.contains("\uFFFD")) {
      text.append("\n\n> Page ").append(i).append("\n\n").append(page);
      nativePages++;
    } else {
      try (PDDocument single = new PDDocument(); ByteArrayOutputStream out = new ByteArrayOutputStream()) {
        single.importPage(pdf.getPage(i - 1));
        single.save(out);
        text.append("\n\n> Page ").append(i).append("\n\n")
            .append(ocrPdfPage(sourceHash, i, out.toByteArray()));
      }
    }
  }
  result = new Parsed(
      nativePages == pdf.getNumberOfPages() ? "pdf-text"
          : nativePages == 0 ? "pdf-ocr" : "pdf-mixed",
      text.toString(), pdf.getNumberOfPages());
}

单页判定条件为:

  • 去掉空白字符后长度不少于 40;
  • 页面文本不包含替换字符 U+FFFD。

满足条件即取原生文本,否则把该页单独导出为一份单页 PDF 再送 OCR。逐页处理保证输出顺序与原文档一致,页码写入 > Page N 标记,可供后续分段与引用定位。

判定仍属启发式规则:页眉页脚、水印或少量文字层可能让扫描页误判为文字页;文字层与图片混排、复杂分栏、竖排文档仍应查看分段预览逐段核对。加密 PDF 直接拒绝,要求先解密。

调用链路 ​

recognize 负责单次 OCR。它构造请求、提交异步任务、轮询到终态,最后清洗正文:

java
import java.io.IOException;
import java.util.Arrays;
import java.util.Locale;
import java.util.concurrent.TimeUnit;

protected String recognize(byte[] data, String filename) throws Exception {
  GiteeClient client = new GiteeClient();
  GiteeDocumentParseRequest request = new GiteeDocumentParseRequest();
  request.setModel("PaddleOCR-VL-1.5");
  request.setPrompt(GiteePromptConst.pdf_to_markdown_prompt);
  request.setInclude_image(false);
  request.setInclude_image_base64(false);
  GiteeTaskResponse task = client.parseDocument(data, filename, request);

  long deadline = System.nanoTime() + TimeUnit.MINUTES.toNanos(8);
  while (!Arrays.asList("success", "succeeded", "completed")
      .contains(String.valueOf(task.getStatus()).toLowerCase(Locale.ROOT))) {
    if (task.getStatus() == null || task.getTask_id() == null) {
      throw new IOException("OCR服务未返回有效任务状态");
    }
    if (Arrays.asList("failed", "failure", "cancelled", "canceled")
        .contains(task.getStatus().toLowerCase(Locale.ROOT))) {
      throw new IOException("OCR任务失败: " + task.getTask_id());
    }
    if (System.nanoTime() > deadline) {
      throw new IOException("OCR任务超时: " + task.getTask_id());
    }
    Thread.sleep(2000);
    task = client.getTask(task.getTask_id());
  }

  String markdown = textOnly(GiteeSimpleMarkdownUtils.toMarkdown(task.getOutput(), null));
  if (markdown == null || markdown.isBlank()) {
    throw new IOException("OCR未返回正文");
  }
  return markdown;
}

客户端把请求提交到异步文档解析接口,以 multipart 上传文件,并带上密钥与降级开关:

java
import okhttp3.MultipartBody;
import okhttp3.Request;
import okhttp3.RequestBody;

private GiteeTaskResponse parseDocument(RequestBody fileBody, String filename,
    GiteeDocumentParseRequest requestEntity) {
  String url = baseUrl + "/v1/async/documents/parse";
  MultipartBody.Builder bodyBuilder = new MultipartBody.Builder().setType(MultipartBody.FORM)
      .addFormDataPart("file", filename, fileBody)
      .addFormDataPart("model", requestEntity.getModel());
  if (requestEntity.getInclude_image() != null) {
    bodyBuilder.addFormDataPart("include_image", requestEntity.getInclude_image().toString());
  }
  if (requestEntity.getInclude_image_base64() != null) {
    bodyBuilder.addFormDataPart("include_image_base64", requestEntity.getInclude_image_base64().toString());
  }
  if (requestEntity.getPrompt() != null && !requestEntity.getPrompt().trim().isEmpty()) {
    bodyBuilder.addFormDataPart("prompt", requestEntity.getPrompt());
  }
  Request request = new Request.Builder().url(url)
      .addHeader("Authorization", "Bearer " + apiKey)
      .addHeader("X-Failover-Enabled", "true")
      .post(bodyBuilder.build()).build();
  return execute(request);
}

请求体字段与 GiteeDocumentParseRequest 的字段一一对应,只提交非空项。X-Failover-Enabled 保留为 true,与服务端默认行为一致。

任务输出同时兼容 pages 与 segments 两种结构:有 pages 时按页输出并写入 > Page N,没有时退化为按 segments 顺序拼接。单页文本会删除 <|ref|>...<|/det|> 布局坐标块、用整页图片地址替换 image 标记,并对只有 ... 占位的页面做空内容处理。

最后一步去掉与检索无关的图片引用,避免把 OCR 服务的临时签名地址写入知识片段:

java
// 只使用 java.lang.String,无需额外 import
private String textOnly(String markdown) {
  if (markdown == null) {
    return "";
  }
  return markdown.replaceAll("(?is)<img\\b[^>]*>", "")
      .replaceAll("(?is)<div[^>]*>\\s*</div>", "")
      .replaceAll("!\\[[^\\]]*\\]\\([^)]*\\)", "");
}

提示词常量为 <image>\n<|grounding|>Convert the document to markdown.。生产解析只关心文字,因此显式关闭图片和 base64 回传。模型可用性以 Gitee 当前模型列表为准。

缓存 ​

OCR 结果按内容哈希写入 moss_kb_document_markdown_cache(id, content),命中即直接复用,不再调用远程服务:

java
import nexus.io.db.activerecord.Db;

private String ocrWithCache(byte[] data, String filename, String hash) throws Exception {
  String cached = readOcrCache(hash);
  if (cached != null && !cached.isBlank()) {
    return textOnly(cached);
  }
  String markdown = recognize(data, filename);
  writeOcrCache(hash, markdown);
  return markdown;
}

protected String readOcrCache(String hash) {
  return Db.queryStr("select content from moss_kb_document_markdown_cache where id=?", hash);
}

protected void writeOcrCache(String hash, String markdown) {
  Db.update("insert into moss_kb_document_markdown_cache(id,content) values(?,?) "
      + "on conflict(id) do update set content=excluded.content", hash, markdown);
}

写入使用 on conflict do update,重复提交同一页不会报主键冲突。

场景缓存键
图片、DOCX 内嵌图片md5(文件内容):paddle15
PDF 扫描页md5(原PDF内容):page:<页码>:paddle15

两种缓存键的构造体现了 PDF 单页缓存的特殊处理:

java
// 复用上一节已导入的 Md5Utils
protected String ocr(byte[] data, String filename) throws Exception {
  return ocrWithCache(data, filename, Md5Utils.md5Hex(data) + ":paddle15");
}

protected String ocrPdfPage(String sourceHash, int page, byte[] pageData) throws Exception {
  // Serializing a PDF page can generate a fresh internal document ID. Cache by
  // original file content and page number instead of the transient PDF bytes.
  return ocrWithCache(pageData, "page.pdf", sourceHash + ":page:" + page + ":paddle15");
}

PDF 单页缓存刻意不使用序列化后的单页字节哈希。把某一页导入新的 PDDocument 再保存,会产生新的内部文档 ID,同一页的字节并不稳定;而扫描页按单页提交正是这种形式。改用“原文件内容哈希 + 页码”后,同一份 PDF 改文件名重新上传也能复用全部页面缓存。后缀用于区分不同 OCR 模型的缓存,更换模型后旧缓存不会误命中。

测试 repeatedPdfUploadReusesPageCacheDespitePdfSerializationIds 覆盖了这一行为:同一份两页 PDF 以不同文件名解析两次,OCR 调用次数保持为 2(每页一次),第二次全部命中缓存。

容量限制与错误 ​

限制项上限
单个上传文件100 MB
ZIP 成员数量50 个,不支持嵌套 ZIP
ZIP 解压后总量100 MB
DOCX word/document.xml20 MB
DOCX 内嵌图片50 张,单张 20 MB
表格100,000 行、500 列,正文 20 MB
单次 OCR 任务8 分钟轮询超时

拒绝和失败的提示直接返回给调用方:

  • 文件为空、文件不能超过100MB;
  • 请先解密PDF文件;
  • 不支持的文件格式,请转换为PDF、DOCX、TXT或图片后上传;
  • 未提取到内容,请检查文档或使用扫描PDF、DOCX缺少正文;
  • DOCX内容过大、DOCX内嵌图片过多或过大、表格内容过大,请拆分后上传、ZIP最多包含50个文件,不支持嵌套ZIP、ZIP解压后的内容不能超过100MB;
  • OCR服务未返回有效任务状态、OCR任务失败: <task_id>、OCR任务超时: <task_id>、OCR未返回正文。

DOCX 解析额外做了 XML 安全加固,禁用 DOCTYPE 与外部实体,避免解析上传文档时读取本地文件:

java
import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilderFactory;

DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setNamespaceAware(true);
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");

ZIP 只在内存中逐个成员读取并递归调用同一个 parse,不落盘解压,也不接受嵌套 ZIP 与 __MACOSX/、.DS_Store 之类的干扰项。

调大 OCR 超时前先确认单页耗时:整份文档按页串行 OCR,超时按整份文档累加,宽表或高分辨率扫描页会显著拉长总时长。

接入分段预览 ​

接口为 POST /api/dataset/document/split,multipart 接收 file,可一次上传多个文件。控制器先落盘上传文件,再交给分段服务:

java
import java.util.ArrayList;
import java.util.List;
import nexus.io.annotation.RequestPath;
import nexus.io.jfinal.aop.Aop;
import nexus.io.mosskb.service.SystemFileService;
import nexus.io.mosskb.service.kb.MossKbDocumentSplitService;
import nexus.io.model.result.ResultVo;
import nexus.io.model.upload.UploadFile;
import nexus.io.model.upload.UploadResult;
import nexus.io.tio.http.common.HttpRequest;

@RequestPath("/api/dataset/document")
public class ApiDatasetDocumentController {

  public ResultVo split(HttpRequest request) throws Exception {
    Object[] files = request.getParams().get("file");
    if (files == null || files.length == 0) {
      return ResultVo.fail("请求体中未找到文件");
    }
    List<Object> results = new ArrayList<>();
    for (Object item : files) {
      if (!(item instanceof UploadFile)) {
        return ResultVo.fail("文件格式不正确");
      }
      UploadFile file = (UploadFile) item;
      // 先落盘拿到文件 id,再解析正文
      UploadResult uploaded = Aop.get(SystemFileService.class).upload(file, "default", "default");
      ResultVo result = Aop.get(MossKbDocumentSplitService.class).split(file.getData(), uploaded);
      results.addAll((List<?>) result.getData());
    }
    return ResultVo.ok(results);
  }
}

分段服务把解析结果切块,并把策略与页数一起返回:

java
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutionException;
import com.jfinal.kit.Kv;
import dev.langchain4j.data.document.Document;
import dev.langchain4j.data.document.DocumentSplitter;
import dev.langchain4j.data.document.splitter.DocumentSplitters;
import dev.langchain4j.data.segment.TextSegment;
import nexus.io.model.result.ResultVo;
import nexus.io.model.upload.UploadResult;
import nexus.io.openai.token.OpenAiTokenizer;

public ResultVo split(byte[] data, UploadResult vo)
    throws IOException, InterruptedException, ExecutionException {
  String filename = vo.getName();
  DocumentParsingService.Parsed parsed;
  try {
    parsed = new DocumentParsingService().parse(data, filename);
  } catch (Exception e) {
    throw new IOException(e.getMessage(), e);
  }
  String markdown = parsed.text();
  List<TextSegment> segments = split(markdown);
  Kv fileSplitResult = Kv.by("name", filename).set("id", vo.getId());
  List<Kv> contents = new ArrayList<>();
  for (TextSegment textSegment : segments) {
    contents.add(Kv.by("title", "").set("content", textSegment.text()));
  }
  fileSplitResult.set("content", contents)
      .set("parse_strategy", parsed.strategy())
      .set("page_count", parsed.pages());
  return ResultVo.ok(List.of(fileSplitResult));
}

public List<TextSegment> split(String markdown) {
  Document document = new Document(markdown);
  // 2000 token 分块,400 token 重叠
  DocumentSplitter splitter = DocumentSplitters.recursive(2000, 400, new OpenAiTokenizer());
  return splitter.split(document);
}

响应结构如下:

json
[
  {
    "name": "文件名",
    "id": "上传文件 id",
    "parse_strategy": "pdf-mixed",
    "page_count": 35,
    "content": [{ "title": "", "content": "分段正文" }]
  }
]

前端在 SetRules.vue 中 onMounted 即触发分段预览,请求超时设置为 1 小时:

ts
import { Result } from '@/request/Result'
import { post } from '@/request/index'

const prefix = '/dataset'

const postSplitDocument: (data: any) => Promise<Result<any>> = (data) => {
  return post(`${prefix}/document/split`, data, undefined, undefined, 1000 * 60 * 60)
}

提交确认后的分段时,应保留解析阶段返回的文件 id;后续保存流程见多文档解析,检索与多轮问答见多轮检索。

当前前端有两处需要留意:

  • parse_strategy 和 page_count 已随响应返回,但界面没有展示。用户无法从界面区分某份文档走的是原生文本还是 OCR,遇到扫描页漏字段落时不易判断原因。排查时可查看接口原始响应,或直接在 moss_kb_document_markdown_cache 中按哈希查询已缓存的 Markdown。
  • 分段预览的高级设置(patterns、limit、with_filter)由前端表单提交,Java 端点只读取 file,其余参数被忽略,实际始终使用固定切块参数。切换“智能分段/高级分段”不会改变后端切分结果。

实测结论 ​

模型对比的结论见文档解析模型实测。与 PaddleOCR-VL 直接相关的要点:

  • 在 35 页扫描为主的 PDF 上,抽样正文无归一化文字差异,整份文件约 95 秒完成,接口接入比逐页图片 OCR 更直接。
  • 该次测试中第 35 页仅返回空内容,遗漏末页印发信息;超宽表格(72 列)没有可靠还原。
  • 该基准走的是整份 PDF 异步接口,生产解析是逐页提交。因此基准的耗时、页级成功率不能直接套用到生产链路,宽表和空页问题在生产中按页出现,影响面更小但同样存在。

表格、印章覆盖区域和末页印发信息建议人工抽查。判断解析质量不能以返回字符数或 HTML 长度为准,扫描件尤其要对照原图核验。

验证 ​

DocumentParsingServiceTest 覆盖解析路由与缓存,全部离线运行、不消耗 OCR 额度。测试通过继承解析服务并覆写 OCR 方法,把远程调用替换为可控桩实现:

java
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.font.PDType1Font;

public class DocumentParsingServiceTest {
  /** 覆写 OCR 相关方法,测试期间不发起远程调用 */
  private static class Parser extends DocumentParsingService {
    int ocrCalls;
    @Override
    protected String ocr(byte[] data, String filename) {
      ocrCalls++;
      return "Scanned content " + ocrCalls;
    }
    @Override
    protected String ocrPdfPage(String sourceHash, int page, byte[] data) {
      return ocr(data, "page.pdf");
    }
  }
}

用构造出的单页文字 + 单页空白 PDF 即可验证逐页选择与顺序:

java
import java.io.ByteArrayOutputStream;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;

@Test
public void pdfUsesTextAndOcrPerPageInOriginalOrder() throws Exception {
  try (PDDocument pdf = new PDDocument(); ByteArrayOutputStream out = new ByteArrayOutputStream()) {
    PDPage first = new PDPage();
    pdf.addPage(first);
    try (PDPageContentStream canvas = new PDPageContentStream(pdf, first)) {
      canvas.beginText();
      canvas.setFont(PDType1Font.HELVETICA, 12);
      canvas.newLineAtOffset(40, 700);
      canvas.showText("Native administrative review document with sufficient readable text for extraction.");
      canvas.endText();
    }
    pdf.addPage(new PDPage());
    pdf.save(out);

    Parser parser = new Parser();
    DocumentParsingService.Parsed result = parser.parse(out.toByteArray(), "misleading.txt");
    assertEquals("pdf-mixed", result.strategy());
    assertEquals(2, result.pages());
    assertEquals(1, parser.ocrCalls);
    assertTrue(result.text().indexOf("Native administrative") < result.text().indexOf("Scanned content"));
  }
}

文件名故意写成 misleading.txt,断言却是 pdf-mixed:扩展名不参与 PDF 判定,只对空白页调用了一次 OCR,且输出顺序保持原页序,三点都在这一次测试中覆盖。

运行测试:

powershell
mvn -pl mosskb-business test -Dtest=DocumentParsingServiceTest

覆盖内容包括:扩展名误导的 PDF 仍按页混合解析且顺序正确、DOCX 段落与表格顺序、DOCX 外部实体拒绝、空文件拒绝、GB18030 中文解码、图片走 OCR、工作表保留表名与显示值、ZIP 只在内存读取、重复上传复用分页缓存。

要单独验证远程 OCR 连通性和模型选择,使用付费基准脚本:

powershell
.\mosskb-business\src\test\scripts\run-ocr-benchmark.ps1 -Pdf '原始PDF的完整路径' -Models 'PaddleOCR-VL-1.5' -OutputDirectory 'target/ocr-benchmark/new-run'

脚本默认跳过付费网络调用,同一输出目录会复用已有任务与成功页面,PDF 哈希不同则拒绝复用。密钥放在 mosskb-business/secrets.txt,不提交到仓库。

小结 ​

  • 解析以内容检测为前提,原生文本可用时不调用 OCR,OCR 只处理真正需要的页面。
  • OCR 固定使用 Gitee 文档解析接口,按内容哈希缓存,重复上传不再计费。
  • 单页缓存按“原文件哈希 + 页码”设计,避免 PDF 序列化 ID 变化导致缓存失效。
  • 已知短板是超宽表格、印章遮挡和末页漏识别,需人工抽查。
  • API 已返回解析策略与页数,但前端尚未展示;高级分段参数当前不生效。

MIT Licensed