第2148篇:AI文档智能解析——PDF表格、图表和复杂结构的工程处理
第2148篇:AI文档智能解析——PDF表格、图表和复杂结构的工程处理
适读人群:处理企业文档的AI工程师 | 阅读时长:约18分钟 | 核心价值:掌握复杂PDF文档的智能解析,解决表格错乱、图表内容提取、多栏版面处理等真实工程问题
构建企业知识库时,文档解析是被低估得最厉害的环节。
很多人的第一印象是:PDF不就是文本吗,用pdfbox提取一下就完了。直到遇到真实的企业文档:
- 两栏布局的年报,pdfbox提取出来左右两栏的文字混在一起
- 包含财务数据的表格,数字提取出来顺序完全乱了
- 扫描版的合同,里面全是图片,根本没有文字层
- 包含折线图的分析报告,图表里的关键数字无法提取
这些都是真实项目里遇到的问题。这篇文章讲工程上怎么处理这些复杂情况。
文档解析的挑战分类
/**
* 企业文档解析的典型挑战
*
* ===== 挑战一:多栏布局 =====
*
* 问题:
* 两栏布局的PDF,pdfbox按坐标顺序提取,
* 把左右两栏的文字交错混合
*
* 例:左栏"产品功能包括...",右栏"价格信息..."
* 提取结果:"产品功能包价格信息括..."
*
* 解决:检测文本块坐标,按列分组后重新排序
*
* ===== 挑战二:表格结构 =====
*
* 问题:
* PDF里的表格没有语义结构,只有位置坐标
* pdfbox提取出一堆数字,不知道哪个是行头、哪个是列头
*
* 解决:使用专门的表格检测工具(Camelot、Tabula等)
* 或使用视觉模型检测表格区域
*
* ===== 挑战三:扫描版PDF =====
*
* 问题:
* 扫描的PDF是图片,没有文字层
* pdfbox提取结果为空
*
* 解决:OCR(Tesseract或云OCR服务)
*
* ===== 挑战四:图表内容 =====
*
* 问题:
* 折线图、柱状图、饼图里的关键数据
* 图表本身是矢量图或位图,无法直接提取数字
*
* 解决:用Vision LLM(GPT-4V等)直接理解图表
*
* ===== 挑战五:混合文档 =====
*
* 问题:
* 同一个PDF里:有正常文字、有扫描图片、有表格、有图表
* 需要识别每一页的类型,用不同策略处理
*/智能文档解析管道
/**
* 文档解析服务
*
* 根据文档类型自动选择最合适的解析策略
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class SmartDocumentParser {
private final OcrService ocrService;
private final TableExtractor tableExtractor;
private final ChatLanguageModel visionLlm; // 支持图像输入的LLM
/**
* 解析文档
*
* 自动检测文档类型,选择合适的解析策略
*/
public ParsedDocument parse(byte[] pdfBytes, String fileName) {
log.info("开始解析文档: fileName={}, size={}KB", fileName, pdfBytes.length / 1024);
List<ParsedPage> pages = new ArrayList<>();
try {
PDDocument pdfDoc = PDDocument.load(pdfBytes);
int pageCount = pdfDoc.getNumberOfPages();
for (int pageNum = 0; pageNum < pageCount; pageNum++) {
PDPage page = pdfDoc.getPage(pageNum);
ParsedPage parsedPage = parsePage(pdfDoc, page, pageNum + 1);
pages.add(parsedPage);
}
pdfDoc.close();
} catch (IOException e) {
throw new RuntimeException("PDF解析失败: " + fileName, e);
}
// 合并所有页面的内容
String fullText = pages.stream()
.map(ParsedPage::extractedText)
.collect(Collectors.joining("\n\n---页面分隔---\n\n"));
List<ExtractedTable> tables = pages.stream()
.flatMap(p -> p.tables().stream())
.toList();
log.info("文档解析完成: pages={}, tables={}, textLength={}",
pages.size(), tables.size(), fullText.length());
return new ParsedDocument(fileName, pages, fullText, tables);
}
/**
* 解析单页
*
* 检测页面类型,选择策略
*/
private ParsedPage parsePage(PDDocument doc, PDPage page, int pageNum) {
// 1. 检测页面类型
PageType pageType = detectPageType(doc, page);
log.debug("页面类型检测: page={}, type={}", pageNum, pageType);
return switch (pageType) {
case TEXT_HEAVY -> parseTextPage(doc, page, pageNum);
case SCANNED_IMAGE -> parseScannedPage(doc, page, pageNum);
case TABLE_DOMINANT -> parseTablePage(doc, page, pageNum);
case CHART_DOMINANT -> parseChartPage(doc, page, pageNum);
case MIXED -> parseMixedPage(doc, page, pageNum);
};
}
/**
* 检测页面类型
*/
private PageType detectPageType(PDDocument doc, PDPage page) {
try {
PDFTextStripper stripper = new PDFTextStripper();
stripper.setStartPage(getPageIndex(doc, page));
stripper.setEndPage(getPageIndex(doc, page));
String text = stripper.getText(doc);
// 如果文本很少,可能是扫描页或图表页
if (text.trim().length() < 100) {
return PageType.SCANNED_IMAGE;
}
// 检测是否有大量数字(可能是表格页)
long digitCount = text.chars().filter(Character::isDigit).count();
double digitRatio = (double) digitCount / text.length();
if (digitRatio > 0.15) {
return PageType.TABLE_DOMINANT;
}
return PageType.TEXT_HEAVY;
} catch (Exception e) {
return PageType.TEXT_HEAVY; // 默认按文字页处理
}
}
/**
* 解析普通文字页(处理多栏布局)
*/
private ParsedPage parseTextPage(PDDocument doc, PDPage page, int pageNum) {
try {
// 使用自定义的列感知文本提取器
ColumnAwareTextExtractor extractor = new ColumnAwareTextExtractor();
extractor.processPage(page);
String text = extractor.getOrderedText();
return new ParsedPage(pageNum, text, List.of(), PageType.TEXT_HEAVY);
} catch (Exception e) {
log.warn("文字页解析失败,使用基础提取: page={}", pageNum, e);
return parseWithBasicExtractor(doc, page, pageNum);
}
}
/**
* 解析扫描页(使用OCR)
*/
private ParsedPage parseScannedPage(PDDocument doc, PDPage page, int pageNum) {
try {
// 将页面渲染为图片
PDFRenderer renderer = new PDFRenderer(doc);
BufferedImage image = renderer.renderImageWithDPI(pageNum - 1, 300);
// OCR识别
String ocrText = ocrService.recognize(image);
return new ParsedPage(pageNum, ocrText, List.of(), PageType.SCANNED_IMAGE);
} catch (Exception e) {
log.error("扫描页OCR失败: page={}", pageNum, e);
return new ParsedPage(pageNum, "[扫描页,OCR失败]", List.of(), PageType.SCANNED_IMAGE);
}
}
/**
* 解析表格页
*/
private ParsedPage parseTablePage(PDDocument doc, PDPage page, int pageNum) {
try {
// 提取表格结构
List<ExtractedTable> tables = tableExtractor.extractTables(doc, pageNum);
// 把表格转换为Markdown格式(易于LLM理解)
String tableMarkdown = tables.stream()
.map(this::tableToMarkdown)
.collect(Collectors.joining("\n\n"));
// 同时保留表格外的文字
String surroundingText = parseWithBasicExtractor(doc, page, pageNum).extractedText();
String fullText = surroundingText + "\n\n" + tableMarkdown;
return new ParsedPage(pageNum, fullText, tables, PageType.TABLE_DOMINANT);
} catch (Exception e) {
log.warn("表格页解析失败,降级处理: page={}", pageNum, e);
return parseWithBasicExtractor(doc, page, pageNum);
}
}
/**
* 解析图表页(使用Vision LLM)
*/
private ParsedPage parseChartPage(PDDocument doc, PDPage page, int pageNum) {
try {
// 渲染图表为图片
PDFRenderer renderer = new PDFRenderer(doc);
BufferedImage chartImage = renderer.renderImageWithDPI(pageNum - 1, 150);
// 用Vision LLM描述图表内容
String chartDescription = describeChartWithVisionLlm(chartImage);
return new ParsedPage(pageNum, chartDescription, List.of(), PageType.CHART_DOMINANT);
} catch (Exception e) {
log.warn("图表页解析失败: page={}", pageNum, e);
return new ParsedPage(pageNum, "[图表页,无法提取内容]", List.of(), PageType.CHART_DOMINANT);
}
}
/**
* 用Vision LLM理解图表
*/
private String describeChartWithVisionLlm(BufferedImage chartImage) {
// 将图片转为Base64
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try {
javax.imageio.ImageIO.write(chartImage, "png", baos);
} catch (IOException e) {
throw new RuntimeException("图片转换失败", e);
}
String base64Image = Base64.getEncoder().encodeToString(baos.toByteArray());
// 构建Vision LLM的请求
String prompt = """
请分析这个图表并提取关键信息:
1. 图表类型(折线图/柱状图/饼图等)
2. 图表标题
3. X轴/Y轴的含义
4. 关键数据点(最高值、最低值、趋势)
5. 主要结论
请用结构化的文字描述,以便于后续的文档检索。
""";
// 发送给支持图像输入的LLM(如GPT-4V)
// 简化的接口调用,实际需要根据具体LLM SDK调整
return visionLlm.generateWithImage(prompt, base64Image);
}
private ParsedPage parseMixedPage(PDDocument doc, PDPage page, int pageNum) {
// 混合页面:组合多种策略
ParsedPage textContent = parseTextPage(doc, page, pageNum);
List<ExtractedTable> tables = tableExtractor.extractTables(doc, pageNum);
return new ParsedPage(pageNum, textContent.extractedText(), tables, PageType.MIXED);
}
private ParsedPage parseWithBasicExtractor(PDDocument doc, PDPage page, int pageNum) {
try {
PDFTextStripper stripper = new PDFTextStripper();
stripper.setStartPage(pageNum);
stripper.setEndPage(pageNum);
String text = stripper.getText(doc);
return new ParsedPage(pageNum, text.trim(), List.of(), PageType.TEXT_HEAVY);
} catch (Exception e) {
return new ParsedPage(pageNum, "[文字提取失败]", List.of(), PageType.TEXT_HEAVY);
}
}
/**
* 把表格数据转换为Markdown格式
*/
private String tableToMarkdown(ExtractedTable table) {
if (table.rows().isEmpty()) return "";
StringBuilder sb = new StringBuilder();
// 标题行
List<String> header = table.rows().get(0);
sb.append("| ").append(String.join(" | ", header)).append(" |\n");
sb.append("|").append(" --- |".repeat(header.size())).append("\n");
// 数据行
for (int i = 1; i < table.rows().size(); i++) {
List<String> row = table.rows().get(i);
sb.append("| ").append(String.join(" | ", row)).append(" |\n");
}
return sb.toString();
}
private int getPageIndex(PDDocument doc, PDPage page) {
return doc.getPages().indexOf(page) + 1;
}
public record ParsedDocument(String fileName, List<ParsedPage> pages,
String fullText, List<ExtractedTable> tables) {}
public record ParsedPage(int pageNum, String extractedText,
List<ExtractedTable> tables, PageType type) {}
public record ExtractedTable(int pageNum, List<List<String>> rows, String caption) {}
enum PageType { TEXT_HEAVY, SCANNED_IMAGE, TABLE_DOMINANT, CHART_DOMINANT, MIXED }
}列感知文本提取器
/**
* 列感知文本提取器
*
* 解决多栏布局的文字顺序问题
*
* 核心思路:
* 1. 提取页面上每个文字块的坐标
* 2. 根据X坐标聚类,识别有几列
* 3. 对每列内的文字块按Y坐标排序
* 4. 先输出左列,再输出右列
*/
public class ColumnAwareTextExtractor extends PDFTextStripperByArea {
private final List<TextPosition> allPositions = new ArrayList<>();
private static final double COLUMN_GAP_THRESHOLD = 100.0; // 列间距阈值(点)
public String getOrderedText() {
if (allPositions.isEmpty()) return "";
// 按X坐标识别列组
List<List<TextPosition>> columns = detectColumns(allPositions);
// 按列顺序(左到右)、列内按行顺序(上到下)输出
return columns.stream()
.sorted(Comparator.comparingDouble(col -> col.get(0).getXDirAdj()))
.flatMap(col -> {
// 列内按Y坐标排序,然后合并同行的文字
return groupByRow(col).stream()
.map(row -> row.stream()
.map(TextPosition::getUnicode)
.collect(Collectors.joining()));
})
.collect(Collectors.joining("\n"));
}
private List<List<TextPosition>> detectColumns(List<TextPosition> positions) {
// 简化实现:按X坐标的间隙识别列边界
List<TextPosition> sorted = positions.stream()
.sorted(Comparator.comparingDouble(TextPosition::getXDirAdj))
.toList();
List<List<TextPosition>> columns = new ArrayList<>();
List<TextPosition> currentColumn = new ArrayList<>();
for (int i = 0; i < sorted.size(); i++) {
TextPosition pos = sorted.get(i);
if (currentColumn.isEmpty()) {
currentColumn.add(pos);
} else {
TextPosition prev = sorted.get(i - 1);
double gap = pos.getXDirAdj() - (prev.getXDirAdj() + prev.getWidth());
if (gap > COLUMN_GAP_THRESHOLD) {
columns.add(new ArrayList<>(currentColumn));
currentColumn = new ArrayList<>();
}
currentColumn.add(pos);
}
}
if (!currentColumn.isEmpty()) columns.add(currentColumn);
return columns;
}
private List<List<TextPosition>> groupByRow(List<TextPosition> column) {
// 按Y坐标(行)分组
List<TextPosition> sorted = column.stream()
.sorted(Comparator.comparingDouble(TextPosition::getYDirAdj))
.toList();
List<List<TextPosition>> rows = new ArrayList<>();
List<TextPosition> currentRow = new ArrayList<>();
for (TextPosition pos : sorted) {
if (currentRow.isEmpty() ||
Math.abs(pos.getYDirAdj() - currentRow.get(0).getYDirAdj()) < 5.0) {
currentRow.add(pos);
} else {
rows.add(new ArrayList<>(currentRow));
currentRow = new ArrayList<>();
currentRow.add(pos);
}
}
if (!currentRow.isEmpty()) rows.add(currentRow);
return rows;
}
}实践建议
先做文档类型检测,再选解析策略
不要一套策略处理所有文档。真实的企业文档千差万别:有的全是文字(适合直接用pdfbox),有的是扫描件(必须用OCR),有的是Excel导出的表格密集型报表(需要专门的表格提取)。在解析前花100ms做类型检测,可以让后续的解析质量提升50%以上,同时避免在文字页上跑OCR(浪费资源)。
表格用Markdown格式存储,是向量检索的最佳实践
表格的原始结构(行列矩阵)对向量Embedding不友好——Embedding模型不擅长理解结构化数据。把表格转换为Markdown文本(| 列名 | 数值 |格式),加上表格上下文的描述文字,再向量化。这样的chunk在检索时,语义信息更完整,LLM看到后也能更好地理解表格内容。另外,表格caption(标题)要单独存在metadata里,用于过滤时很有用。
Vision LLM处理图表的成本和质量要均衡考虑
用GPT-4V等Vision LLM解析图表,质量好但成本高(每次约$0.01-0.02)。对于大批量文档处理,成本不可忽视。权衡策略:重要的分析报告和决策文档,用Vision LLM确保质量;常规的操作手册和FAQ,用基础OCR就够了,把Vision LLM用在刀刃上。还有一个实用技巧:如果文档里图表附近有说明文字(大多数专业报告都有),先提取文字,如果文字已经包含了图表的关键数字,就不需要再解析图表本身了。
