第1801篇:多模态文档理解——PDF、图片、表格的统一处理架构
第1801篇:多模态文档理解——PDF、图片、表格的统一处理架构
先说一个让我头疼了三周的问题
去年我们做一个财务分析平台,需求说起来很简单:用户上传PDF财报,系统自动提取关键指标,给出分析建议。
我当时想,这不就是PDF转文本,然后丢给LLM嘛,三天搞定。
然后就踩坑了。
第一个坑:财报里有大量表格,用pdfbox提取出来全是乱码顺序,行列完全对不上号。第二个坑:很多报告是扫描版PDF,压根就是图片,文本提取直接返回空。第三个坑:有些报告里有图表,文字描述说"如图3所示利润增长40%",但图3被截断了,LLM根本不知道在说什么。
折腾了一圈之后,我意识到这根本不是一个"PDF解析"问题,而是一个多模态文档理解问题。PDF、图片、表格,得用一套统一的架构去处理,不能各搞各的。
这篇文章就来拆解这套架构怎么设计,代码也会给到位。
多模态文档的本质问题
先把问题说清楚。
一份企业文档,通常包含以下几类内容:
- 纯文本段落:叙述性内容,天然适合LLM理解
- 结构化表格:行列关系,数字密集,需要保留结构
- 图表(Chart):折线图、柱状图,视觉编码的数据
- 图片/截图:说明性图片、流程图、产品截图
- 混合排版:图文并排,文字绕图
传统的纯文本提取管道,对第一类还好,其他几类处理效果都很差。而多模态LLM(比如GPT-4V、Claude 3)的出现,让"直接看图"成为可能,但你也不能无脑把整个PDF丢过去,token消耗会爆掉,而且效果也不一定好。
所以,好的多模态文档理解架构,核心是分类路由:不同类型的内容,走不同的处理通道,最终汇聚成结构化输出。
整体架构设计
这个架构有几个关键设计点:
- 预处理层做格式归一化,所有输入统一转成中间格式
- 分类路由是核心,准确识别内容类型决定后续质量
- 三条处理通道并行,提高吞吐量
- 统一语义层做上下文关联,让图文之间有引用关系
- 最终汇给LLM做理解,而不是每个通道单独问LLM
预处理层:格式归一化
先搞定输入格式的统一。
/**
* 文档预处理器,负责将各种格式统一转为内部表示
*/
@Component
public class DocumentPreprocessor {
@Autowired
private PdfProcessor pdfProcessor;
@Autowired
private ImageProcessor imageProcessor;
/**
* 文档内容的统一表示
*/
@Data
@Builder
public static class DocumentContent {
private String documentId;
private DocumentType type;
private List<ContentBlock> blocks; // 按顺序排列的内容块
private Map<String, Object> metadata;
}
@Data
@Builder
public static class ContentBlock {
private String blockId;
private BlockType blockType; // TEXT, TABLE, IMAGE, CHART
private String rawText; // 如果有文本
private byte[] imageData; // 如果是图像
private int pageNumber;
private BoundingBox position; // 在页面中的位置
private Map<String, String> attributes;
}
public enum BlockType {
TEXT, TABLE, IMAGE, CHART, MIXED
}
/**
* 主处理入口
*/
public DocumentContent process(MultipartFile file) {
String filename = file.getOriginalFilename();
String ext = FilenameUtils.getExtension(filename).toLowerCase();
return switch (ext) {
case "pdf" -> pdfProcessor.process(file);
case "jpg", "jpeg", "png", "tiff" -> imageProcessor.process(file);
case "docx" -> processDocx(file);
default -> throw new UnsupportedDocumentException("不支持的格式: " + ext);
};
}
}PDF处理是最复杂的,需要区分"原生PDF"和"扫描PDF":
@Component
public class PdfProcessor {
private static final int DPI = 150; // 渲染分辨率,平衡质量和速度
private static final float TEXT_COVERAGE_THRESHOLD = 0.1f; // 判断是否为扫描版的阈值
public DocumentPreprocessor.DocumentContent process(MultipartFile file) {
try (PDDocument doc = PDDocument.load(file.getInputStream())) {
List<ContentBlock> blocks = new ArrayList<>();
for (int pageIdx = 0; pageIdx < doc.getNumberOfPages(); pageIdx++) {
PDPage page = doc.getPage(pageIdx);
// 先检测这页是不是扫描页
boolean isScanned = isScannedPage(doc, pageIdx);
if (isScanned) {
// 扫描页:整页渲染成图片,走图像通道
byte[] pageImage = renderPageToImage(doc, pageIdx);
blocks.add(ContentBlock.builder()
.blockId(UUID.randomUUID().toString())
.blockType(BlockType.IMAGE)
.imageData(pageImage)
.pageNumber(pageIdx + 1)
.attributes(Map.of("source", "scanned_page"))
.build());
} else {
// 原生PDF:精细化提取各类内容
blocks.addAll(extractNativePdfContent(doc, pageIdx));
}
}
return DocumentContent.builder()
.documentId(UUID.randomUUID().toString())
.type(DocumentType.PDF)
.blocks(blocks)
.metadata(extractPdfMetadata(doc))
.build();
}
}
/**
* 通过文本覆盖率判断是否为扫描页
* 思路:如果页面上几乎没有可提取文本,大概率是扫描图片
*/
private boolean isScannedPage(PDDocument doc, int pageIdx) {
try {
PDFTextStripper stripper = new PDFTextStripper();
stripper.setStartPage(pageIdx + 1);
stripper.setEndPage(pageIdx + 1);
String text = stripper.getText(doc);
PDPage page = doc.getPage(pageIdx);
float pageArea = page.getMediaBox().getWidth() * page.getMediaBox().getHeight();
float textDensity = text.trim().length() / pageArea;
return textDensity < TEXT_COVERAGE_THRESHOLD;
} catch (Exception e) {
// 提取失败,保守处理为扫描页
return true;
}
}
/**
* 原生PDF内容提取,按区域分类
*/
private List<ContentBlock> extractNativePdfContent(PDDocument doc, int pageIdx) {
List<ContentBlock> blocks = new ArrayList<>();
// 使用PDFBox的布局分析
PDFLayoutTextStripper layoutStripper = new PDFLayoutTextStripper();
// ... 这里是具体的布局分析逻辑,重点是识别表格区域
// 简化版:先提文本,再识别表格
blocks.addAll(extractTextBlocks(doc, pageIdx));
blocks.addAll(extractTableBlocks(doc, pageIdx));
blocks.addAll(extractImageBlocks(doc, pageIdx));
// 按位置排序,保持阅读顺序
blocks.sort(Comparator.comparingDouble(b ->
b.getPosition().getY() * 1000 + b.getPosition().getX()));
return blocks;
}
}表格处理通道:最难的部分
说实话,表格处理是整个管道里最难的环节,没有之一。
PDF里的表格,底层可能是:
- 有边框线的表格(相对好处理)
- 无边框、只靠空格对齐的表格(噩梦)
- 合并单元格的复杂表格(双重噩梦)
我最终的方案是:用视觉方法检测表格区域,然后用结构化方法提取内容。
@Component
public class TableExtractor {
@Autowired
private VisionModelClient visionClient;
/**
* 两阶段表格提取:
* 阶段1:视觉检测表格区域
* 阶段2:结构化提取单元格内容
*/
public List<TableData> extractTables(byte[] pageImage) {
// 阶段1:让视觉模型标注表格位置
List<TableRegion> regions = detectTableRegions(pageImage);
List<TableData> tables = new ArrayList<>();
for (TableRegion region : regions) {
// 裁剪出表格区域
byte[] tableImage = cropImage(pageImage, region.getBoundingBox());
// 阶段2:让模型解析表格结构
TableData tableData = parseTableStructure(tableImage);
tableData.setSourceRegion(region);
tables.add(tableData);
}
return tables;
}
private List<TableRegion> detectTableRegions(byte[] pageImage) {
String prompt = """
请分析这张页面图片,找出所有表格区域。
对每个表格,返回JSON格式的坐标信息:
{
"tables": [
{
"x": 左边界百分比,
"y": 上边界百分比,
"width": 宽度百分比,
"height": 高度百分比,
"has_header": 是否有表头,
"row_count": 估计行数,
"col_count": 估计列数
}
]
}
只返回JSON,不要其他文字。
""";
String response = visionClient.analyze(pageImage, prompt);
return parseTableRegions(response);
}
private TableData parseTableStructure(byte[] tableImage) {
String prompt = """
请将这个表格转换为结构化数据。
要求:
1. 识别表头行(如果有)
2. 准确提取每个单元格的文字,注意合并单元格
3. 返回JSON格式:
{
"headers": ["列名1", "列名2", ...],
"rows": [
["值1", "值2", ...],
...
],
"merged_cells": [
{"row": 0, "col": 0, "rowspan": 2, "colspan": 1}
]
}
注意数字格式,保留原始格式(如百分比、千分位)。
""";
String response = visionClient.analyze(tableImage, prompt);
return parseTableData(response);
}
/**
* 将表格数据转为Markdown格式,方便LLM理解
*/
public String tableToMarkdown(TableData table) {
StringBuilder sb = new StringBuilder();
if (table.getHeaders() != null && !table.getHeaders().isEmpty()) {
sb.append("| ").append(String.join(" | ", table.getHeaders())).append(" |\n");
sb.append("| ").append(table.getHeaders().stream()
.map(h -> "---").collect(Collectors.joining(" | "))).append(" |\n");
}
for (List<String> row : table.getRows()) {
sb.append("| ").append(String.join(" | ", row)).append(" |\n");
}
return sb.toString();
}
}图像通道:让视觉模型干活
图像类内容(包括扫描页、图表、说明图),走视觉分析通道:
@Component
public class ImageContentAnalyzer {
@Autowired
private VisionModelClient visionClient;
@Autowired
private OcrService ocrService;
/**
* 图像内容分析,根据图像类型选择策略
*/
public ImageAnalysisResult analyze(ContentBlock imageBlock) {
byte[] imageData = imageBlock.getImageData();
// 先做快速分类,判断图像类型
ImageType imageType = classifyImage(imageData);
return switch (imageType) {
case SCANNED_TEXT -> analyzeScannedText(imageData);
case CHART -> analyzeChart(imageData);
case DIAGRAM -> analyzeDiagram(imageData);
case PHOTO -> analyzePhoto(imageData);
};
}
/**
* 扫描文字页:OCR + 结构理解
* 这里我的经验是:先OCR,再让LLM做语义修正,比直接问LLM更准
*/
private ImageAnalysisResult analyzeScannedText(byte[] imageData) {
// 步骤1:OCR粗提取
String ocrText = ocrService.extract(imageData);
// 步骤2:用视觉模型修正OCR错误并提取结构
String prompt = String.format("""
OCR识别结果如下(可能有错误):
%s
请结合图片原文,完成以下任务:
1. 修正OCR识别错误
2. 识别并保留文档结构(标题层级、段落分隔)
3. 如果有表格,用Markdown表格格式输出
4. 返回纯文本,不要说明性文字
""", ocrText);
String correctedText = visionClient.analyze(imageData, prompt);
return ImageAnalysisResult.builder()
.imageType(ImageType.SCANNED_TEXT)
.extractedText(correctedText)
.build();
}
/**
* 图表分析:重点提取数据而不是描述外观
*/
private ImageAnalysisResult analyzeChart(byte[] imageData) {
String prompt = """
请分析这个图表,提取以下信息:
1. 图表类型(折线图/柱状图/饼图等)
2. X轴和Y轴的含义及单位
3. 图例说明
4. 关键数据点(最大值、最小值、趋势)
5. 如果能读出具体数值,请列出数据表格
输出格式:
【图表类型】xxx
【轴说明】xxx
【数据摘要】xxx
【数据表格】(如果可读)
""";
String analysis = visionClient.analyze(imageData, prompt);
return ImageAnalysisResult.builder()
.imageType(ImageType.CHART)
.chartAnalysis(analysis)
.build();
}
/**
* 快速图像分类,用轻量模型判断
*/
private ImageType classifyImage(byte[] imageData) {
String prompt = """
判断这张图片的类型,只回答一个词:
- SCANNED_TEXT(扫描的文字页)
- CHART(数据图表:折线/柱状/饼图等)
- DIAGRAM(流程图/架构图/示意图)
- PHOTO(照片/产品图)
""";
String result = visionClient.analyze(imageData, prompt).trim().toUpperCase();
try {
return ImageType.valueOf(result);
} catch (IllegalArgumentException e) {
return ImageType.PHOTO; // 默认兜底
}
}
}统一语义层:让内容之间有联系
这一层是整个架构的灵魂,也是很多人忽略的地方。
文档里的内容是相互关联的。"如表3所示"这句话,要能找到表3在哪;"下图显示了季度趋势",要能把图和上下文对应起来。
@Component
public class SemanticAssembler {
/**
* 将分散处理的内容块组装成有语义关联的统一上下文
*/
public UnifiedContext assemble(DocumentContent document,
List<TableData> tables,
List<ImageAnalysisResult> imageResults) {
List<ContextChunk> chunks = new ArrayList<>();
Map<String, String> crossReferences = new HashMap<>(); // 交叉引用映射
// 按页码和位置顺序处理每个块
for (ContentBlock block : document.getBlocks()) {
ContextChunk chunk = buildChunk(block, tables, imageResults);
chunks.add(chunk);
// 建立交叉引用:如果文本中提到"表X"或"图X",记录引用关系
if (block.getBlockType() == BlockType.TEXT) {
extractCrossReferences(block.getRawText(), chunks, crossReferences);
}
}
// 注入交叉引用上下文
injectCrossReferences(chunks, crossReferences);
return UnifiedContext.builder()
.documentId(document.getDocumentId())
.chunks(chunks)
.crossReferences(crossReferences)
.build();
}
private ContextChunk buildChunk(ContentBlock block,
List<TableData> tables,
List<ImageAnalysisResult> imageResults) {
return switch (block.getBlockType()) {
case TEXT -> ContextChunk.builder()
.chunkId(block.getBlockId())
.type("text")
.content(block.getRawText())
.pageNumber(block.getPageNumber())
.build();
case TABLE -> {
// 找到对应的表格数据
TableData tableData = findTableForBlock(block, tables);
String tableMarkdown = tableData != null ?
tableExtractor.tableToMarkdown(tableData) : "[表格提取失败]";
yield ContextChunk.builder()
.chunkId(block.getBlockId())
.type("table")
.content(tableMarkdown)
.pageNumber(block.getPageNumber())
.tableSequence(getTableSequence(block)) // 第几张表
.build();
}
case IMAGE -> {
// 找到对应的图像分析结果
ImageAnalysisResult imgResult = findImageResult(block, imageResults);
String imageContent = imgResult != null ?
formatImageContent(imgResult) : "[图像分析失败]";
yield ContextChunk.builder()
.chunkId(block.getBlockId())
.type("image")
.content(imageContent)
.pageNumber(block.getPageNumber())
.imageSequence(getImageSequence(block)) // 第几张图
.build();
}
default -> ContextChunk.builder()
.chunkId(block.getBlockId())
.type("unknown")
.content("[未识别内容]")
.build();
};
}
/**
* 构建最终发给LLM的prompt上下文
* 核心:给每个表格和图片一个编号,让文本中的引用能对上
*/
public String buildLlmContext(UnifiedContext context) {
StringBuilder sb = new StringBuilder();
sb.append("以下是文档内容(按页码顺序排列):\n\n");
int tableCount = 0;
int imageCount = 0;
for (ContextChunk chunk : context.getChunks()) {
sb.append(String.format("[第%d页] ", chunk.getPageNumber()));
switch (chunk.getType()) {
case "text" -> sb.append(chunk.getContent());
case "table" -> {
tableCount++;
sb.append(String.format("\n[表%d]\n%s", tableCount, chunk.getContent()));
}
case "image" -> {
imageCount++;
sb.append(String.format("\n[图%d]\n%s", imageCount, chunk.getContent()));
}
}
sb.append("\n\n");
}
return sb.toString();
}
}踩过的坑,省你时间
坑1:token爆炸问题
如果文档有100页,每页都转成图片分析,token消耗是灾难性的。我的解法是:
- 对于明显是纯文字的页面,不走视觉通道
- 图像压缩到合理分辨率(长边不超过1024像素)
- 表格分析只取结构,不让模型描述每个单元格
// 图片压缩,控制大小
private byte[] compressImage(byte[] imageData, int maxLongEdge) {
BufferedImage img = ImageIO.read(new ByteArrayInputStream(imageData));
int w = img.getWidth(), h = img.getHeight();
if (Math.max(w, h) <= maxLongEdge) return imageData; // 不需要压缩
double scale = (double) maxLongEdge / Math.max(w, h);
int newW = (int)(w * scale);
int newH = (int)(h * scale);
BufferedImage scaled = new BufferedImage(newW, newH, img.getType());
Graphics2D g = scaled.createGraphics();
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION,
RenderingHints.VALUE_INTERPOLATION_BILINEAR);
g.drawImage(img, 0, 0, newW, newH, null);
g.dispose();
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ImageIO.write(scaled, "jpeg", baos);
return baos.toByteArray();
}坑2:扫描PDF方向识别
很多扫描文档是横向扫描的(旋转90度),直接处理会识别乱。要先检测方向再纠正:
// 用Tesseract的OSD检测文字方向
private int detectTextOrientation(byte[] imageData) {
Tesseract tesseract = new Tesseract();
tesseract.setPageSegMode(0); // OSD模式
try {
OSDResult osd = tesseract.doOSD(imageData);
return osd.getOrientationDegree();
} catch (Exception e) {
return 0; // 检测失败默认正向
}
}坑3:表格跨页问题
财报里经常有跨两页的大表格,上面那页是表头,下面那页是数据。我的处理方式是:如果检测到某一页的表格没有表头,且上一页最后有表格,就尝试合并:
private List<TableData> mergePageSpanningTables(List<List<TableData>> pagesTables) {
List<TableData> result = new ArrayList<>();
TableData pendingTable = null;
for (List<TableData> pageTables : pagesTables) {
for (TableData table : pageTables) {
if (pendingTable != null && !table.hasHeaders()) {
// 当前表格没表头,可能是上一个表格的延续
pendingTable.appendRows(table.getRows());
} else {
if (pendingTable != null) result.add(pendingTable);
pendingTable = table;
}
}
}
if (pendingTable != null) result.add(pendingTable);
return result;
}完整流程的性能考量
实际生产里,一份100页的财报,这套流程跑下来大概需要多久?
根据我们的测试数据:
- 预处理 + 页面分类:约5秒
- 表格提取(视觉API调用,并行):约15-30秒(取决于表格数量)
- 扫描页OCR:约10-20秒
- 语义组装:约1-2秒
- 最终LLM分析:约10-20秒
总计大约40-70秒,对于离线分析场景是可接受的。如果要做实时场景,需要把预处理做成异步队列,用户上传后先返回任务ID,处理完后通知。
@Service
public class DocumentAnalysisService {
@Autowired
private TaskQueue taskQueue;
/**
* 异步提交文档分析任务
*/
public String submitAnalysis(MultipartFile file) {
String taskId = UUID.randomUUID().toString();
// 立即返回任务ID,后台处理
taskQueue.submit(taskId, () -> {
try {
DocumentContent content = preprocessor.process(file);
UnifiedContext context = analysisPipeline.run(content);
String result = llmAnalyzer.analyze(context);
taskQueue.complete(taskId, result);
} catch (Exception e) {
taskQueue.fail(taskId, e.getMessage());
}
});
return taskId;
}
/**
* 查询任务状态
*/
public TaskResult queryResult(String taskId) {
return taskQueue.getResult(taskId);
}
}小结
多模态文档理解,不是"用一个好模型就能搞定"的事情。关键是架构:
- 分类路由先行,识别内容类型是一切的基础
- 表格要专门处理,纯文本提取永远不够用
- 扫描页要纠偏,方向、OCR修正不能省
- 语义层很关键,让图表和文字能互相引用
- 性能要异步化,大文档处理就是要排队
这套架构我们跑了半年,效果稳定。当然各公司业务场景不同,具体还需要调整,但核心思路是通用的。
下一篇我会讲视觉语言模型在具体企业场景(发票识别、合同扫描)里的工程实践,比这篇更具体,也更好玩。
