第1810篇:PDF中的表格解析——让RAG系统真正读懂结构化数据
第1810篇:PDF中的表格解析——让RAG系统真正读懂结构化数据
适读人群:正在做企业知识库RAG系统的工程师 | 阅读时长:约20分钟 | 核心价值:解决RAG系统的"表格盲区",让AI能准确回答文档里的结构化数据问题
做了几年RAG系统,我发现有一类问题始终是用户投诉的高频来源:
"我的合同里有一张报价表,问AI某个型号的价格,它要么说找不到,要么给一个完全错误的数字。"
"财报里有个季度数据对比表,问AI上半年收入多少,回答说第一季度是X,第二季度是Y,但这两个数字对不上。"
这类问题的根源几乎总是一样的:RAG系统在处理PDF时,把表格当成普通文本切片了,表格的结构信息完全丢失了。
原本是这样的表格:
| 产品型号 | 单价 | 最小起订量 | 备注 |
|---------|------|----------|---------|
| A-100 | 299 | 100件 | 含安装 |
| A-200 | 599 | 50件 | 含培训 |
| B-300 | 1299 | 20件 | 不含安装 |经过大多数PDF解析库处理之后变成了:
产品型号 单价 最小起订量 备注 A-100 299 100件 含安装 A-200 599 50件 含培训 B-300 1299 20件 不含安装这些文字变成了一段毫无结构的文本,向量化之后存进向量库,再被检索出来,AI看到这一坨字,有时候能猜出点上下文,更多时候直接乱答。
今天这篇,就是专门解决这个问题的。
先搞清楚:PDF里的表格是什么
PDF不是Word,它本质上是一个描述"在哪个坐标画什么东西"的指令集。PDF里的表格,在文件底层可能是这样的:
在(100, 200)到(300, 220)画一条横线
在(100, 200)到(100, 350)画一条竖线
在坐标(105, 205)放置文字"产品型号"
在坐标(205, 205)放置文字"单价"
...PDF本身不知道这些线条和文字组成了一张表格。解析库需要通过分析线条位置来"猜"出表格结构。
这就是为什么大多数PDF解析库对表格的处理都不理想——线条识别本来就是个复杂的几何问题,加上很多PDF里的表格没有显式边框线(用颜色块来区分行),难度更高。
还有一种情况:PDF里的表格是图片形式的(扫描件),完全没有文字信息,必须用OCR才能提取内容。
方案一:用Apache PDFBox做表格提取
PDFBox是Java生态里最成熟的PDF处理库,它可以提取PDF中的文字及其坐标信息,然后我们自己写逻辑来还原表格结构。
这个方案的优点是不依赖外部服务,完全本地运行,适合对数据安全要求高的场景。缺点是表格结构还原的算法相对复杂,对于没有清晰边框线的表格效果有限。
依赖配置:
<dependency>
<groupId>org.apache.pdfbox</groupId>
<artifactId>pdfbox</artifactId>
<version>3.0.1</version>
</dependency>核心实现——基于坐标的表格还原:
@Service
public class PdfTableExtractor {
/**
* 从PDF文件中提取所有表格,返回结构化的表格数据
*/
public List<TableData> extractTables(byte[] pdfBytes) throws IOException {
List<TableData> tables = new ArrayList<>();
try (PDDocument document = PDDocument.load(pdfBytes)) {
for (int pageIndex = 0; pageIndex < document.getNumberOfPages(); pageIndex++) {
PDPage page = document.getPage(pageIndex);
// 提取页面上所有文字及其坐标
TextWithPositionExtractor extractor = new TextWithPositionExtractor();
extractor.processPage(page);
List<TextPosition> textPositions = extractor.getTextPositions();
// 基于Y坐标分组成行,再基于X坐标排列成列
List<List<TextPosition>> rows = groupIntoRows(textPositions);
// 识别表格区域(连续多行且列数一致的区域)
List<TableData> pageTables = detectTables(rows, pageIndex + 1);
tables.addAll(pageTables);
}
}
return tables;
}
private List<List<TextPosition>> groupIntoRows(List<TextPosition> positions) {
// 按Y坐标排序,Y坐标相近(差值小于5pt)的认为在同一行
positions.sort(Comparator.comparingDouble(TextPosition::getY));
List<List<TextPosition>> rows = new ArrayList<>();
List<TextPosition> currentRow = new ArrayList<>();
float lastY = -1;
for (TextPosition position : positions) {
if (lastY < 0 || Math.abs(position.getY() - lastY) < 5.0f) {
currentRow.add(position);
} else {
if (!currentRow.isEmpty()) {
// 行内按X坐标排序
currentRow.sort(Comparator.comparingDouble(TextPosition::getX));
rows.add(new ArrayList<>(currentRow));
}
currentRow.clear();
currentRow.add(position);
}
lastY = position.getY();
}
if (!currentRow.isEmpty()) {
currentRow.sort(Comparator.comparingDouble(TextPosition::getX));
rows.add(currentRow);
}
return rows;
}
private List<TableData> detectTables(List<List<TextPosition>> rows, int pageNumber) {
// 简化版检测:找连续3行以上、每行有3列以上的区域
List<TableData> tables = new ArrayList<>();
int i = 0;
while (i < rows.size()) {
List<List<String>> tableRows = new ArrayList<>();
int columnCount = -1;
while (i < rows.size()) {
List<String> rowCells = extractCells(rows.get(i));
if (columnCount < 0) {
columnCount = rowCells.size();
}
// 列数差异超过1,认为表格结束
if (Math.abs(rowCells.size() - columnCount) > 1) {
break;
}
tableRows.add(rowCells);
i++;
}
// 至少3行、3列才认为是表格
if (tableRows.size() >= 3 && columnCount >= 3) {
TableData table = new TableData();
table.setPageNumber(pageNumber);
table.setHeaders(tableRows.get(0)); // 第一行认为是表头
table.setRows(tableRows.subList(1, tableRows.size()));
tables.add(table);
}
if (tableRows.size() == 0) i++; // 防止无限循环
}
return tables;
}
private List<String> extractCells(List<TextPosition> rowPositions) {
// 基于X坐标间隙来分割单元格
List<String> cells = new ArrayList<>();
StringBuilder currentCell = new StringBuilder();
float lastX = -1;
for (TextPosition pos : rowPositions) {
if (lastX > 0 && pos.getX() - lastX > 30.0f) {
// X坐标间隙超过30pt,认为是新单元格
cells.add(currentCell.toString().trim());
currentCell = new StringBuilder();
}
currentCell.append(pos.getUnicode());
lastX = pos.getX() + pos.getWidth();
}
if (!currentCell.isEmpty()) {
cells.add(currentCell.toString().trim());
}
return cells;
}
}方案二:用视觉大模型直接理解表格
坦白说,上面那个方案写起来挺麻烦的,而且效果上限有限——碰到复杂的合并单元格、斜线表头、或者扫描件,基本上就投降了。
现在有一个更简单粗暴也往往更有效的方案:把PDF页面渲染成图片,直接让多模态大模型来理解。
Claude Vision、GPT-4V、通义千问VL这些模型现在对表格的理解能力都不错。你只需要告诉它"把这张图里的表格转成Markdown格式",它通常能给出相当不错的结果。
@Service
public class VisionTableExtractor {
private final ChatClient visionChatClient;
/**
* 把PDF页面渲染成图片,用视觉模型提取表格
*/
public List<TableData> extractTablesViaVision(byte[] pdfBytes) throws IOException {
List<TableData> allTables = new ArrayList<>();
try (PDDocument document = PDDocument.load(pdfBytes)) {
PDFRenderer renderer = new PDFRenderer(document);
for (int pageIndex = 0; pageIndex < document.getNumberOfPages(); pageIndex++) {
// 渲染为150DPI的图片(够清晰,文件不太大)
BufferedImage pageImage = renderer.renderImageWithDPI(pageIndex, 150);
byte[] imageBytes = toJpegBytes(pageImage);
// 先快速判断这页是否有表格(省成本)
if (!hasTableByQuickCheck(imageBytes)) {
continue;
}
// 用视觉模型提取表格
List<String> tableMarkdowns = extractTableMarkdowns(imageBytes, pageIndex + 1);
for (String markdown : tableMarkdowns) {
TableData table = parseMarkdownTable(markdown, pageIndex + 1);
if (table != null) {
allTables.add(table);
}
}
}
}
return allTables;
}
private boolean hasTableByQuickCheck(byte[] imageBytes) {
// 简单检测:图片里有没有表格(用更便宜的模型做快速判断)
String response = visionChatClient.prompt()
.user(u -> u.text("这张图片里有没有表格?只回答'是'或'否'。")
.media(MimeTypeUtils.IMAGE_JPEG, new ByteArrayResource(imageBytes)))
.call()
.content();
return response.trim().startsWith("是");
}
private List<String> extractTableMarkdowns(byte[] imageBytes, int pageNumber) {
String systemPrompt = """
你是一个专业的文档解析助手。
你的任务是从图片中识别并提取所有表格。
对于每个表格,将其转换为标准的Markdown表格格式。
如果有多个表格,用"---TABLE---"分隔。
保持原始数据的完整性,不要修改或补充任何数据。
如果图片中没有表格,回答"NO_TABLE"。
""";
String response = visionChatClient.prompt()
.system(systemPrompt)
.user(u -> u.text("请提取这张图片中的所有表格,转换为Markdown格式。")
.media(MimeTypeUtils.IMAGE_JPEG, new ByteArrayResource(imageBytes)))
.call()
.content();
if ("NO_TABLE".equals(response.trim())) {
return Collections.emptyList();
}
return Arrays.asList(response.split("---TABLE---"));
}
private TableData parseMarkdownTable(String markdown, int pageNumber) {
String[] lines = markdown.trim().split("\n");
if (lines.length < 3) return null; // 至少要有表头、分隔行、一行数据
// 解析表头(第一行)
List<String> headers = parseMarkdownRow(lines[0]);
// 跳过分隔行(第二行,形如 |---|---|---| )
// 解析数据行
List<List<String>> rows = new ArrayList<>();
for (int i = 2; i < lines.length; i++) {
List<String> row = parseMarkdownRow(lines[i]);
if (!row.isEmpty()) {
rows.add(row);
}
}
TableData table = new TableData();
table.setPageNumber(pageNumber);
table.setHeaders(headers);
table.setRows(rows);
return table;
}
private List<String> parseMarkdownRow(String line) {
// 解析 | col1 | col2 | col3 | 格式的行
return Arrays.stream(line.split("\\|"))
.map(String::trim)
.filter(s -> !s.isEmpty())
.collect(Collectors.toList());
}
}表格数据的向量化策略
提取出表格之后,怎么向量化进RAG系统,是另一个需要认真考虑的问题。
直接把整张表格作为一个文档向量化,有明显的问题:表格太大的话会超出Embedding模型的token限制,而且向量化一整张表,在检索单行数据时精度很差。
我目前用的是行级向量化策略:
@Service
public class TableVectorizationService {
private final EmbeddingModel embeddingModel;
private final VectorStore vectorStore;
/**
* 把表格数据向量化存入RAG系统
* 每行+上下文作为一个向量单位
*/
public void vectorizeTable(String docId, String docTitle,
TableData table, int tableIndex) {
List<Document> documents = new ArrayList<>();
List<String> headers = table.getHeaders();
for (int rowIdx = 0; rowIdx < table.getRows().size(); rowIdx++) {
List<String> rowData = table.getRows().get(rowIdx);
// 把这一行数据转成自然语言描述,增强语义理解
String naturalLanguageDescription = buildNaturalLanguageDescription(
docTitle, headers, rowData, table.getPageNumber(), tableIndex, rowIdx
);
// 同时保留结构化的JSON格式,用于精确查询
Map<String, String> rowMap = new HashMap<>();
for (int colIdx = 0; colIdx < Math.min(headers.size(), rowData.size()); colIdx++) {
rowMap.put(headers.get(colIdx), rowData.get(colIdx));
}
Document doc = Document.builder()
.id(docId + "_table" + tableIndex + "_row" + rowIdx)
.text(naturalLanguageDescription)
.metadata(Map.of(
"doc_id", docId,
"doc_title", docTitle,
"content_type", "table_row",
"table_index", String.valueOf(tableIndex),
"row_index", String.valueOf(rowIdx),
"page_number", String.valueOf(table.getPageNumber()),
"structured_data", toJson(rowMap),
"headers", toJson(headers)
))
.build();
documents.add(doc);
}
// 同时添加一个"表格摘要"文档,用于匹配"这个表格讲什么"类型的问题
Document tableSummary = Document.builder()
.id(docId + "_table" + tableIndex + "_summary")
.text(buildTableSummary(docTitle, headers, table))
.metadata(Map.of(
"doc_id", docId,
"doc_title", docTitle,
"content_type", "table_summary",
"table_index", String.valueOf(tableIndex),
"page_number", String.valueOf(table.getPageNumber()),
"full_table_data", toJson(table)
))
.build();
documents.add(tableSummary);
vectorStore.add(documents);
log.info("表格向量化完成:docId={}, tableIndex={}, 行数={}",
docId, tableIndex, table.getRows().size());
}
private String buildNaturalLanguageDescription(
String docTitle, List<String> headers, List<String> rowData,
int pageNumber, int tableIndex, int rowIdx) {
StringBuilder sb = new StringBuilder();
sb.append("来源:").append(docTitle)
.append("(第").append(pageNumber).append("页,表格").append(tableIndex + 1).append(")\n");
// 把 列名:值 的键值对写成自然语言
for (int i = 0; i < Math.min(headers.size(), rowData.size()); i++) {
String header = headers.get(i);
String value = rowData.get(i);
if (!value.isBlank()) {
sb.append(header).append(":").append(value).append(",");
}
}
// 去掉最后的逗号
if (sb.charAt(sb.length() - 1) == ',') {
sb.deleteCharAt(sb.length() - 1);
}
return sb.toString();
}
private String buildTableSummary(String docTitle,
List<String> headers,
TableData table) {
return String.format(
"文档《%s》第%d页的表格,包含以下字段:%s。共%d行数据。",
docTitle,
table.getPageNumber(),
String.join("、", headers),
table.getRows().size()
);
}
}RAG检索时的增强处理
表格数据被向量化存进去之后,检索时还需要做一点特殊处理:
当检索到的文档是 table_row 类型时,我们应该在把它喂给LLM之前,把整行的结构化数据重建出来,而不是只给一段自然语言描述:
@Service
public class TableAwareRagService {
public String buildContextWithTableSupport(List<Document> retrievedDocs) {
StringBuilder context = new StringBuilder();
for (Document doc : retrievedDocs) {
String contentType = (String) doc.getMetadata().get("content_type");
if ("table_row".equals(contentType)) {
// 表格行:重建结构化表格格式
context.append(buildTableRowContext(doc));
} else if ("table_summary".equals(contentType)) {
// 表格摘要:直接提供摘要
context.append(doc.getText()).append("\n\n");
} else {
// 普通文本段落
context.append(doc.getText()).append("\n\n");
}
}
return context.toString();
}
private String buildTableRowContext(Document doc) {
Map<String, Object> meta = doc.getMetadata();
String docTitle = (String) meta.get("doc_title");
String structuredData = (String) meta.get("structured_data");
String headersJson = (String) meta.get("headers");
int pageNumber = Integer.parseInt((String) meta.get("page_number"));
StringBuilder sb = new StringBuilder();
sb.append("来源:《").append(docTitle).append("》第")
.append(pageNumber).append("页\n");
// 把JSON格式的行数据转成易读的格式
Map<String, String> rowData = fromJson(structuredData);
for (Map.Entry<String, String> entry : rowData.entrySet()) {
sb.append(entry.getKey()).append(": ").append(entry.getValue()).append("\n");
}
sb.append("\n");
return sb.toString();
}
}测试效果对比
把这套方案上线之前,我做了一个简单的对比测试。
用同一份包含20个表格的产品手册,分别用"直接切片"和"表格感知解析"两种方式构建RAG系统,然后问10个关于表格数据的问题。
提升是显著的。从30%的正确率到80%,主要的收益来自两点:第一,表格数据不再被当成乱码文本;第二,行级向量化让检索精度大幅提升。
几个实际使用中的注意事项
1. 视觉模型提取的成本控制
如果文档很多、每页都调用视觉模型,成本会比较高。我的做法是先做一次快速的启发式判断(看PDF元数据里有没有表格结构标记),只有无法用传统方法解析的页面才调视觉模型。
2. 提取精度的验证
无论是PDFBox还是视觉模型,提取结果都不是100%准确的。对于关键业务文档(合同、财报),建议在存入向量库之前加一个人工审核步骤,或者至少记录下每次提取的置信度,方便后续排查问题。
3. 表格的时效性
如果文档会更新,表格数据也会变。需要在文档更新时,把该文档对应的所有向量数据(包括表格行向量)全部删除重建,不能只更新文本部分。
4. 超大表格的处理
有些财报的表格可能有几百行,全部向量化会占用大量向量库空间。可以设定一个阈值,超过100行的表格用摘要+索引方式存储,用时再按需检索。
把这些细节处理到位之后,用户反馈里"AI回答了表格里不存在的数字"这类问题基本上就消失了。
RAG系统的质量上限,很大程度上取决于知识库数据的质量。对表格数据的认真处理,是提升知识库数据质量里最容易拿到的一批收益。
