第2109篇:多模态文档处理——PDF、图片和表格的智能解析实践
2026/4/30大约 13 分钟
第2109篇:多模态文档处理——PDF、图片和表格的智能解析实践
适读人群:需要处理复杂文档的AI工程师 | 阅读时长:约20分钟 | 核心价值:掌握PDF解析、图片OCR、表格提取的工程方案,以及如何用多模态LLM填补传统解析的盲区
"帮我分析一下这份PDF合同"——这是一个看起来简单,做起来一点都不简单的需求。
普通的PDF可以直接提取文本。但真实世界的文档充满了各种让人头疼的情况:扫描版PDF(图片组成的文字)、包含复杂表格的PDF(提取出来的文字乱序)、带图表的报告(关键数据在图里)、混合中英文的合同(排版规则和文字提取对抗)。
我在做一个法律文档分析系统时,前三个月用传统的PDF文本提取方案,准确率一直上不去。后来加入了多模态处理链路,效果才显著改善。这篇文章把这段经历整理成可落地的工程方案。
文档处理的挑战分类
/**
* 文档处理的四类挑战
*
* 类型1:可直接提取的文字PDF
* - 普通合同、报告、技术文档
* - 工具:Apache PDFBox、iText
* - 挑战:多列布局导致文字顺序混乱
*
* 类型2:扫描版PDF(图片PDF)
* - 扫描的纸质文件、古老合同、手写表单
* - 工具:Tesseract OCR、PaddleOCR、云端OCR API
* - 挑战:识别准确率、倾斜/扭曲校正
*
* 类型3:包含结构化表格的文档
* - 财务报表、数据统计报告、合同附件
* - 工具:Camelot、Tabula、多模态LLM
* - 挑战:合并单元格、跨页表格、无边框表格
*
* 类型4:包含图表/示意图的文档
* - 技术方案、数据可视化报告、说明书
* - 工具:多模态LLM(GPT-4V、Claude等)
* - 挑战:图表中的关键数据需要视觉理解
*
* 真实文档通常是以上类型的混合
* 需要一个多层处理流水线
*/整体处理架构
/**
* 多模态文档处理流水线
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class DocumentProcessingPipeline {
private final PdfTextExtractor pdfTextExtractor;
private final OcrService ocrService;
private final TableExtractor tableExtractor;
private final MultimodalLlmAnalyzer multimodalAnalyzer;
private final DocumentChunker chunker;
/**
* 处理一个PDF文档
* 返回结构化的文档内容,可以直接用于RAG入库
*/
public ProcessedDocument process(byte[] pdfBytes, ProcessingConfig config) {
String documentId = UUID.randomUUID().toString();
log.info("开始处理文档: documentId={}, size={}KB", documentId, pdfBytes.length / 1024);
List<ProcessedPage> processedPages = new ArrayList<>();
// 先尝试文字提取
PdfTextExtractor.ExtractionResult textResult = pdfTextExtractor.extract(pdfBytes);
for (int pageNum = 0; pageNum < textResult.getPageCount(); pageNum++) {
ProcessedPage page = processPage(
pdfBytes, pageNum, textResult.getPageText(pageNum), config
);
processedPages.add(page);
}
// 合并所有页面内容并分块
String fullText = processedPages.stream()
.map(ProcessedPage::getText)
.collect(Collectors.joining("\n\n"));
List<DocumentChunk> chunks = chunker.chunk(fullText, documentId);
return ProcessedDocument.builder()
.documentId(documentId)
.pages(processedPages)
.chunks(chunks)
.totalPages(textResult.getPageCount())
.processingStats(buildStats(processedPages))
.build();
}
private ProcessedPage processPage(byte[] pdfBytes, int pageNum,
String extractedText, ProcessingConfig config) {
ProcessedPage.ProcessedPageBuilder builder = ProcessedPage.builder()
.pageNumber(pageNum + 1);
// 判断这页的文字质量
PageTextQuality quality = assessTextQuality(extractedText);
String finalText;
switch (quality) {
case GOOD -> {
// 文字质量好,直接使用
finalText = extractedText;
builder.extractionMethod("text_extraction");
}
case POOR_OR_EMPTY -> {
// 文字很少或没有,说明可能是扫描页,走OCR
if (config.isEnableOcr()) {
byte[] pageImage = pdfTextExtractor.renderPageToImage(pdfBytes, pageNum);
finalText = ocrService.recognize(pageImage);
builder.extractionMethod("ocr");
} else {
finalText = extractedText;
builder.extractionMethod("text_extraction_degraded");
}
}
case HAS_TABLES -> {
// 包含表格,需要专门的表格提取
finalText = extractedText;
if (config.isEnableTableExtraction()) {
List<TableExtractor.ExtractedTable> tables =
tableExtractor.extractFromPage(pdfBytes, pageNum);
builder.tables(tables);
// 把表格转换成markdown格式并追加到文字后面
String tablesText = tables.stream()
.map(t -> "\n\n" + t.toMarkdown())
.collect(Collectors.joining());
finalText = extractedText + tablesText;
}
builder.extractionMethod("text_and_table_extraction");
}
case HAS_COMPLEX_LAYOUT -> {
// 复杂布局(多列等),可能需要多模态LLM重新理解
if (config.isEnableMultimodalFallback()) {
byte[] pageImage = pdfTextExtractor.renderPageToImage(pdfBytes, pageNum);
finalText = multimodalAnalyzer.analyzePageImage(pageImage,
"请提取这页文档中的所有文字内容,保持原有的逻辑顺序");
builder.extractionMethod("multimodal_analysis");
} else {
finalText = extractedText;
builder.extractionMethod("text_extraction_complex");
}
}
default -> {
finalText = extractedText;
builder.extractionMethod("text_extraction");
}
}
return builder.text(finalText).textQuality(quality).build();
}
private PageTextQuality assessTextQuality(String text) {
if (text == null || text.trim().length() < 50) {
return PageTextQuality.POOR_OR_EMPTY;
}
// 乱码检测:如果非ASCII/中文比例过高,可能是解析失败
long garbledChars = text.chars()
.filter(c -> c > 0x9FFF && c < 0xF900) // 非常规Unicode范围
.count();
if (garbledChars > text.length() * 0.1) {
return PageTextQuality.POOR_OR_EMPTY;
}
// 简单的表格检测:是否有大量制表符或对齐空格
long tabCount = text.chars().filter(c -> c == '\t').count();
long pipeCount = text.chars().filter(c -> c == '|').count();
if (tabCount > 20 || pipeCount > 10) {
return PageTextQuality.HAS_TABLES;
}
return PageTextQuality.GOOD;
}
private ProcessingStats buildStats(List<ProcessedPage> pages) {
Map<String, Long> methodCounts = pages.stream()
.collect(Collectors.groupingBy(
ProcessedPage::getExtractionMethod,
Collectors.counting()
));
return new ProcessingStats(pages.size(), methodCounts);
}
public enum PageTextQuality { GOOD, POOR_OR_EMPTY, HAS_TABLES, HAS_COMPLEX_LAYOUT }
}PDF文字提取层
/**
* PDF文字提取器
*
* 使用PDFBox提取可直接获取的文字
* 处理多列布局、特殊字符等问题
*/
@Service
@Slf4j
public class PdfTextExtractor {
/**
* 提取整个PDF的文字
*/
public ExtractionResult extract(byte[] pdfBytes) {
try (PDDocument document = PDDocument.load(pdfBytes)) {
List<String> pageTexts = new ArrayList<>();
for (int i = 0; i < document.getNumberOfPages(); i++) {
String pageText = extractPageText(document, i);
pageTexts.add(pageText);
}
return new ExtractionResult(pageTexts);
} catch (Exception e) {
log.error("PDF文字提取失败: {}", e.getMessage());
throw new RuntimeException("PDF解析失败", e);
}
}
private String extractPageText(PDDocument document, int pageIndex) {
try {
// 使用PDFTextStripperByArea可以处理多列布局
// 但更简单的做法是先用标准提取,质量差了再用多模态
PDFTextStripper stripper = new PDFTextStripper();
stripper.setStartPage(pageIndex + 1);
stripper.setEndPage(pageIndex + 1);
// 添加排序,尝试按阅读顺序提取
stripper.setSortByPosition(true);
String text = stripper.getText(document);
// 清理多余的空白
return cleanText(text);
} catch (Exception e) {
log.warn("页面{}文字提取失败: {}", pageIndex + 1, e.getMessage());
return "";
}
}
/**
* 将PDF页面渲染为图片(用于OCR或多模态分析)
*/
public byte[] renderPageToImage(byte[] pdfBytes, int pageIndex) {
try (PDDocument document = PDDocument.load(pdfBytes)) {
PDFRenderer renderer = new PDFRenderer(document);
// 300 DPI对OCR效果最好(越高越清晰,但文件也越大)
java.awt.image.BufferedImage image = renderer.renderImageWithDPI(
pageIndex, 200, org.apache.pdfbox.rendering.ImageType.RGB);
java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream();
javax.imageio.ImageIO.write(image, "PNG", baos);
return baos.toByteArray();
} catch (Exception e) {
throw new RuntimeException("PDF页面渲染失败: page=" + (pageIndex + 1), e);
}
}
private String cleanText(String text) {
if (text == null) return "";
return text
// 多个连续空格替换为单个空格
.replaceAll("[ \t]+", " ")
// 多个连续换行替换为两个换行
.replaceAll("\n{3,}", "\n\n")
// 删除行末空格
.replaceAll("[ \t]+\n", "\n")
.trim();
}
public record ExtractionResult(List<String> pageTexts) {
public int getPageCount() { return pageTexts.size(); }
public String getPageText(int index) {
return index < pageTexts.size() ? pageTexts.get(index) : "";
}
}
}OCR集成层
/**
* OCR服务
*
* 支持本地Tesseract和云端OCR API
* 根据质量要求和成本考量选择使用哪个
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class OcrService {
@Value("${ocr.engine:tesseract}") // tesseract 或 cloud
private String ocrEngine;
// Tesseract本地引擎(适合英文、简单中文)
// 使用Tess4J封装库
// 云端OCR(百度/腾讯/阿里,效果更好,有费用)
private final CloudOcrClient cloudOcrClient;
/**
* 识别图片中的文字
*/
public String recognize(byte[] imageBytes) {
return switch (ocrEngine) {
case "tesseract" -> recognizeWithTesseract(imageBytes);
case "cloud" -> recognizeWithCloud(imageBytes);
default -> throw new IllegalStateException("Unknown OCR engine: " + ocrEngine);
};
}
private String recognizeWithTesseract(byte[] imageBytes) {
try {
// 使用Tess4J
net.sourceforge.tess4j.Tesseract tesseract = new net.sourceforge.tess4j.Tesseract();
// 设置tessdata路径(语言包所在目录)
tesseract.setDatapath("/usr/share/tessdata");
// 中英文混合识别
tesseract.setLanguage("chi_sim+eng");
// OSD(方向和脚本检测)提高准确率
tesseract.setPageSegMode(1);
// 从byte[]创建BufferedImage
java.io.ByteArrayInputStream bis = new java.io.ByteArrayInputStream(imageBytes);
java.awt.image.BufferedImage image = javax.imageio.ImageIO.read(bis);
String result = tesseract.doOCR(image);
// Tesseract对中文识别率有限,需要后处理清理
return postProcessOcrText(result);
} catch (Exception e) {
log.error("Tesseract OCR失败: {}", e.getMessage());
return "";
}
}
private String recognizeWithCloud(byte[] imageBytes) {
try {
// 调用云端OCR API(这里用通用封装)
String base64Image = java.util.Base64.getEncoder().encodeToString(imageBytes);
CloudOcrResponse response = cloudOcrClient.recognize(
CloudOcrRequest.builder()
.imageBase64(base64Image)
.language("auto")
.detectDirection(true) // 自动纠正方向
.build()
);
// 按y坐标排序,重新组合成有序文本
return response.getWords().stream()
.sorted(Comparator.comparingInt(w -> w.getBoundingBox().getTop()))
.map(CloudOcrResponse.Word::getText)
.collect(Collectors.joining("\n"));
} catch (Exception e) {
log.error("云端OCR失败,降级到Tesseract: {}", e.getMessage());
return recognizeWithTesseract(imageBytes);
}
}
private String postProcessOcrText(String raw) {
return raw
// 删除OCR产生的噪声字符
.replaceAll("[|\\[\\]{}]", "")
// 修复常见的OCR错误(O和0,l和1等)
// 注意:中文场景下谨慎处理,容易误替换
.replaceAll("\n{3,}", "\n\n")
.trim();
}
}表格提取层
/**
* 表格提取器
*
* 从PDF页面中提取结构化表格数据
*
* 注意:这是文档处理中最难的部分之一
* 特别是:合并单元格、无边框表格、跨页表格
* 在复杂表格场景下,多模态LLM往往比规则方法更可靠
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class TableExtractor {
private final MultimodalLlmAnalyzer multimodalAnalyzer;
/**
* 从PDF页面提取表格
*/
public List<ExtractedTable> extractFromPage(byte[] pdfBytes, int pageIndex) {
List<ExtractedTable> tables = new ArrayList<>();
// 首先尝试基于规则的表格提取(快速、无成本)
List<ExtractedTable> ruleBasedTables = extractWithRules(pdfBytes, pageIndex);
if (!ruleBasedTables.isEmpty()) {
tables.addAll(ruleBasedTables);
} else {
// 规则方法失败时,用多模态LLM分析页面图片
// 这个方法更准确但有API调用成本
tables.addAll(extractWithMultimodal(pdfBytes, pageIndex));
}
return tables;
}
private List<ExtractedTable> extractWithRules(byte[] pdfBytes, int pageIndex) {
// 使用PDFBox的表格检测逻辑
// 通过分析文字的X/Y坐标来推断表格结构
try (PDDocument document = PDDocument.load(pdfBytes)) {
// 使用自定义的TextPositionExtractionStrategy
// 按行和列对文字进行分组
TableDetectionStrategy strategy = new TableDetectionStrategy();
PDFTextStripper stripper = new CustomTableStripper(strategy);
stripper.setStartPage(pageIndex + 1);
stripper.setEndPage(pageIndex + 1);
stripper.getText(document);
return strategy.buildTables();
} catch (Exception e) {
log.debug("基于规则的表格提取失败(可能没有表格): page={}", pageIndex + 1);
return List.of();
}
}
private List<ExtractedTable> extractWithMultimodal(byte[] pdfBytes, int pageIndex) {
try {
byte[] pageImage = new PdfTextExtractor().renderPageToImage(pdfBytes, pageIndex);
String prompt = """
请分析这个页面,找出其中的表格。
对每个表格,返回以下JSON格式:
{
"tables": [
{
"title": "表格标题(如果有)",
"headers": ["列名1", "列名2", ...],
"rows": [
["数据1", "数据2", ...],
...
],
"notes": "表格下方的备注(如果有)"
}
]
}
如果页面没有表格,返回 {"tables": []}
只返回JSON。
""";
String response = multimodalAnalyzer.analyzeWithImage(pageImage, prompt);
return parseTablesFromJson(response);
} catch (Exception e) {
log.warn("多模态表格提取失败: page={}, error={}", pageIndex + 1, e.getMessage());
return List.of();
}
}
private List<ExtractedTable> parseTablesFromJson(String json) {
try {
String cleanJson = extractJsonFromResponse(json);
ObjectMapper mapper = new ObjectMapper();
JsonNode root = mapper.readTree(cleanJson);
List<ExtractedTable> tables = new ArrayList<>();
for (JsonNode tableNode : root.path("tables")) {
String title = tableNode.path("title").asText("");
List<String> headers = new ArrayList<>();
for (JsonNode h : tableNode.path("headers")) {
headers.add(h.asText());
}
List<List<String>> rows = new ArrayList<>();
for (JsonNode rowNode : tableNode.path("rows")) {
List<String> row = new ArrayList<>();
for (JsonNode cell : rowNode) {
row.add(cell.asText());
}
rows.add(row);
}
tables.add(new ExtractedTable(title, headers, rows,
tableNode.path("notes").asText("")));
}
return tables;
} catch (Exception e) {
log.warn("表格JSON解析失败: {}", e.getMessage());
return List.of();
}
}
private String extractJsonFromResponse(String s) {
int start = s.indexOf('{');
int end = s.lastIndexOf('}');
return (start >= 0 && end > start) ? s.substring(start, end + 1) : s;
}
@Data
@AllArgsConstructor
public static class ExtractedTable {
private String title;
private List<String> headers;
private List<List<String>> rows;
private String notes;
/**
* 转换为Markdown格式,可以直接放入文档文本
*/
public String toMarkdown() {
StringBuilder sb = new StringBuilder();
if (title != null && !title.isEmpty()) {
sb.append("**").append(title).append("**\n\n");
}
// 表头
sb.append("| ").append(String.join(" | ", headers)).append(" |\n");
// 分隔线
sb.append("| ").append(headers.stream().map(h -> "---").collect(Collectors.joining(" | "))).append(" |\n");
// 数据行
for (List<String> row : rows) {
sb.append("| ").append(String.join(" | ", row)).append(" |\n");
}
if (notes != null && !notes.isEmpty()) {
sb.append("\n注:").append(notes);
}
return sb.toString();
}
}
// 占位类,实际实现需要PDFBox的文字位置分析
private static class TableDetectionStrategy {
public List<ExtractedTable> buildTables() { return List.of(); }
}
private static class CustomTableStripper extends PDFTextStripper {
CustomTableStripper(TableDetectionStrategy strategy) throws Exception { super(); }
}
}多模态LLM分析层
/**
* 多模态LLM分析器
*
* 处理纯文字提取无法解决的场景:
* - 扫描PDF的复杂布局理解
* - 图表、流程图的内容解读
* - 手写内容识别
* - 复杂表格的精确提取
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class MultimodalLlmAnalyzer {
// 需要支持图片输入的模型(GPT-4V、Claude Vision等)
private final dev.langchain4j.model.chat.ChatLanguageModel visionModel;
/**
* 分析页面图片,提取文字内容
*/
public String analyzePageImage(byte[] imageBytes, String task) {
try {
String base64Image = java.util.Base64.getEncoder().encodeToString(imageBytes);
// 构建包含图片的消息
// LangChain4j的多模态消息格式
dev.langchain4j.data.message.UserMessage message =
dev.langchain4j.data.message.UserMessage.from(
dev.langchain4j.data.message.ImageContent.from(
base64Image, "image/png"),
dev.langchain4j.data.message.TextContent.from(task)
);
dev.langchain4j.model.output.Response<dev.langchain4j.data.message.AiMessage> response =
visionModel.generate(List.of(message));
return response.content().text();
} catch (Exception e) {
log.error("多模态分析失败: {}", e.getMessage());
return "";
}
}
/**
* 分析文档中的图表(折线图、柱状图、饼图等)
*/
public ChartAnalysisResult analyzeChart(byte[] chartImageBytes) {
String prompt = """
请分析这个图表,提取以下信息:
1. 图表类型(折线图/柱状图/饼图/散点图/其他)
2. 图表标题
3. 坐标轴说明(X轴/Y轴的含义和单位)
4. 主要数据点或数据规律(用文字描述)
5. 关键结论(图表要说明什么)
返回JSON:
{
"chartType": "图表类型",
"title": "图表标题",
"xAxis": "X轴说明",
"yAxis": "Y轴说明",
"keyInsights": ["关键洞察1", "关键洞察2"],
"textDescription": "一段完整的自然语言描述,总结图表内容"
}
只返回JSON。
""";
String response = analyzeWithImage(chartImageBytes, prompt);
try {
String json = extractJson(response);
ObjectMapper mapper = new ObjectMapper();
JsonNode root = mapper.readTree(json);
List<String> insights = new ArrayList<>();
for (JsonNode i : root.path("keyInsights")) {
insights.add(i.asText());
}
return new ChartAnalysisResult(
root.path("chartType").asText(),
root.path("title").asText(),
root.path("textDescription").asText(),
insights
);
} catch (Exception e) {
log.warn("图表分析JSON解析失败: {}", e.getMessage());
return new ChartAnalysisResult("unknown", "", response, List.of());
}
}
/**
* 通用的图片+文字提问接口
*/
public String analyzeWithImage(byte[] imageBytes, String question) {
return analyzePageImage(imageBytes, question);
}
private String extractJson(String s) {
int start = s.indexOf('{');
int end = s.lastIndexOf('}');
return (start >= 0 && end > start) ? s.substring(start, end + 1) : s;
}
public record ChartAnalysisResult(
String chartType, String title,
String textDescription, List<String> keyInsights
) {
/**
* 转成可以放入RAG的纯文字描述
*/
public String toRagText() {
StringBuilder sb = new StringBuilder();
if (title != null && !title.isEmpty()) {
sb.append("图表标题:").append(title).append("\n");
}
sb.append("图表类型:").append(chartType).append("\n");
sb.append("内容说明:").append(textDescription).append("\n");
if (!keyInsights.isEmpty()) {
sb.append("关键结论:\n");
keyInsights.forEach(i -> sb.append("- ").append(i).append("\n"));
}
return sb.toString();
}
}
}文档分块策略
/**
* 多模态文档的智能分块
*
* 普通文本用固定大小分块就行
* 但多模态文档需要考虑:
* - 表格不能被切断(一个表格应该作为一个chunk)
* - 图表描述和上下文应该在一起
* - 跨页的段落要拼接
*/
@Service
public class DocumentChunker {
private static final int MAX_CHUNK_SIZE = 1500; // tokens估算
private static final int MIN_CHUNK_SIZE = 200;
private static final int CHUNK_OVERLAP = 100;
/**
* 将文档内容分块
*
* 特殊处理:表格保持完整,不跨块切割
*/
public List<DocumentChunk> chunk(String fullText, String documentId) {
List<DocumentChunk> chunks = new ArrayList<>();
// 用特殊标记识别表格区域(Markdown表格)
String[] segments = splitByTableBoundaries(fullText);
StringBuilder currentChunk = new StringBuilder();
int chunkIndex = 0;
for (String segment : segments) {
boolean isTable = segment.contains("| --- |");
if (isTable) {
// 表格作为完整的chunk
if (currentChunk.length() > MIN_CHUNK_SIZE) {
// 先把当前积累的文字存为chunk
chunks.add(createChunk(documentId, chunkIndex++,
currentChunk.toString(), false));
currentChunk = new StringBuilder();
}
// 表格单独成chunk
chunks.add(createChunk(documentId, chunkIndex++, segment, true));
} else {
// 普通文字,按大小分块
String[] sentences = splitIntoSentences(segment);
for (String sentence : sentences) {
if (currentChunk.length() + sentence.length() > MAX_CHUNK_SIZE * 3) {
// 估算超过最大大小,存档当前chunk
chunks.add(createChunk(documentId, chunkIndex++,
currentChunk.toString(), false));
// 保留重叠部分(取最后CHUNK_OVERLAP个字符)
String overlap = currentChunk.length() > CHUNK_OVERLAP
? currentChunk.substring(currentChunk.length() - CHUNK_OVERLAP)
: currentChunk.toString();
currentChunk = new StringBuilder(overlap);
}
currentChunk.append(sentence);
}
}
}
// 处理最后剩余的内容
if (currentChunk.length() >= MIN_CHUNK_SIZE) {
chunks.add(createChunk(documentId, chunkIndex, currentChunk.toString(), false));
}
return chunks;
}
private DocumentChunk createChunk(String documentId, int index,
String content, boolean isTable) {
return DocumentChunk.builder()
.chunkId(documentId + "_chunk_" + index)
.documentId(documentId)
.chunkIndex(index)
.content(content.trim())
.containsTable(isTable)
.approximateTokens(estimateTokens(content))
.build();
}
private String[] splitByTableBoundaries(String text) {
// 以Markdown表格为边界分割文本
return text.split("(?=\\| )|(?<=\\n\\n)(?!\\|)");
}
private String[] splitIntoSentences(String text) {
// 按句号、换行符分句
return text.split("(?<=[。!?\\.!?])|(?<=\n)");
}
private int estimateTokens(String text) {
// 粗略估算:英文约4字符/token,中文约1.5字符/token
long chineseCount = text.chars()
.filter(c -> c >= 0x4E00 && c <= 0x9FFF)
.count();
long otherCount = text.length() - chineseCount;
return (int)(chineseCount / 1.5 + otherCount / 4);
}
@Data
@Builder
public static class DocumentChunk {
private String chunkId;
private String documentId;
private int chunkIndex;
private String content;
private boolean containsTable;
private int approximateTokens;
}
}实践建议
先搞清楚文档质量,再选方案
在动手写代码之前,先随机抽样100份真实文档,手工分析各种类型的比例。如果80%都是文字PDF,投入大量精力做OCR就是浪费。如果有大量表格,那表格提取才是关键路径。文档质量分析是这个系统最值得花时间的前置工作。
多模态LLM不是银弹
多模态LLM(GPT-4V等)确实很强大,但有两个制约:成本高(处理一页大约是纯文字接口的5-10倍)和速度慢(图片传输+处理时间更长)。我的建议是:把多模态LLM作为"兜底"方案,只对规则方法处理结果不佳的页面使用它。90%的页面用快速的规则方法就够了,剩下10%再动用多模态。
表格是最难的部分,要单独对待
表格提取的质量直接影响财务分析、合同对比等高价值场景。在这块上不要省力,值得专门建立一套测试集(人工标注正确的表格结构),持续评估提取准确率。我见过太多项目因为表格提取不准,导致整个文档分析系统不可用。
