文档解析优化
Java MossKB 的文档解析只在需要时调用远程 OCR。PDF 先按页尝试原生文本层提取,只有无法可靠取到文字的页面才交给 Gitee 的 PaddleOCR-VL-1.5;图片和 DOCX 内嵌图片同样走这条路径。本地不需要部署 OCR 模型权重。
本篇说明解析策略、调用链路、缓存、限制与实测结论。部署和配置见Windows 与 Linux 部署;模型对比数据见文档解析模型实测。
代码位置
| 环节 | 主要类 |
|---|---|
| 检测与路由 | DocumentParsingService |
| 分段预览服务 | MossKbDocumentSplitService |
| HTTP 接口 | ApiDatasetDocumentController |
| Gitee 客户端 | GiteeClient、GiteeDocumentParseRequest、GiteeTaskResponse |
| 结果转 Markdown | GiteeSimpleMarkdownUtils |
| 提示词常量 | GiteePromptConst |
| 缓存表 | moss_kb_document_markdown_cache |
| 前端调用 | SetRules.vue、api/document.ts |
| 解析测试 | DocumentParsingServiceTest |
DocumentParsingService 是这条路径上唯一的生产解析实现。同包下的 MossKbDocumentConvertService 是原 MossKB“逐页转图片再调多模态模型”的旧实现,当前没有任何调用方,不参与解析。
先检测后解析
文件类型不只看扩展名。PDF 通过文件头 %PDF- 识别,扩展名写错也能识别成 PDF;DOCX 通过 PK 文件头加 .docx 后缀识别。路由结果记录为 parse_strategy。
入口方法先做尺寸与空文件校验,再按内容分发:
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 | 逐页选择提取或 OCR | pdf-mixed |
| DOCX 正文与表格 | 提取段落、表格结构 | docx-structure |
| DOCX 正文极少但含图片 | 提取内嵌图片并 OCR | docx-ocr |
| DOC | 提取正文 | doc-text |
| XLS、XLSX | 按工作表和行提取显示值 | spreadsheet-structure |
| TXT、Markdown、日志、CSV、JSON、HTML | 文本解码,HTML 提取可见正文 | text |
| PNG、JPEG、TIFF 等可识别图片 | OCR | image-ocr |
| ZIP | 在内存中读取文档并合并,不解压到磁盘 | zip-documents |
策略值由整体结果决定:全部页都有原生文本为 pdf-text,全部页都需要 OCR 为 pdf-ocr,混合为 pdf-mixed。
PDF 文字页判定
PDF 逐页提取时按坐标排序,每页单独判定。判定与回退逻辑如下:
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。它构造请求、提交异步任务、轮询到终态,最后清洗正文:
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 上传文件,并带上密钥与降级开关:
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.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),命中即直接复用,不再调用远程服务:
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 单页缓存的特殊处理:
// 复用上一节已导入的 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.xml | 20 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 与外部实体,避免解析上传文档时读取本地文件:
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,可一次上传多个文件。控制器先落盘上传文件,再交给分段服务:
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);
}
}
分段服务把解析结果切块,并把策略与页数一起返回:
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);
}
响应结构如下:
[
{
"name": "文件名",
"id": "上传文件 id",
"parse_strategy": "pdf-mixed",
"page_count": 35,
"content": [{ "title": "", "content": "分段正文" }]
}
]
前端在 SetRules.vue 中 onMounted 即触发分段预览,请求超时设置为 1 小时:
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 方法,把远程调用替换为可控桩实现:
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 即可验证逐页选择与顺序:
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,且输出顺序保持原页序,三点都在这一次测试中覆盖。
运行测试:
mvn -pl mosskb-business test -Dtest=DocumentParsingServiceTest
覆盖内容包括:扩展名误导的 PDF 仍按页混合解析且顺序正确、DOCX 段落与表格顺序、DOCX 外部实体拒绝、空文件拒绝、GB18030 中文解码、图片走 OCR、工作表保留表名与显示值、ZIP 只在内存读取、重复上传复用分页缓存。
要单独验证远程 OCR 连通性和模型选择,使用付费基准脚本:
.\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 已返回解析策略与页数,但前端尚未展示;高级分段参数当前不生效。
