ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Day99-复杂文档解析:表格/多模态/扫描件的处理方案

Day99-复杂文档解析:表格/多模态/扫描件的处理方案

上篇我们把高级 RAG 的全景图画完了——Query 改写、多路检索、上下文压缩、自纠错。但有个扎心的事实得先说破:这些全建立在"喂进去的文档已经被正确解析成文本"这个前提下。现实里企业文档全是带表格的 PDF、图文混排的产品手册、扫描盖章的合同。解析这关过不去,"垃圾进垃圾出"谁也救不了。这篇就专门拆文档解析这道上游工序。


一、为什么文档解析是 RAG 的"隐形天花板"

新手做 RAG,精力全花在选向量库、调 Prompt 上,结果一跑,答案里表格数据全错位、图片内容完全丢失、扫描件干脆空空如也。问题不在下游,在源头。

我把复杂文档分成三类,难度递增:

类型典型场景核心难点粗暴解析的后果
结构化文本(含表格)财报、产品说明书、API 文档表格跨页、单元格合并、多级标题表格被压成一段乱序文字,数值对不上行
图文混排技术白皮书、PPT 导出的 PDF图注与正文分离、公式、双栏排版切分把图和说明拆开,语义断裂
扫描件/图片盖章合同、历史档案、手写单本质是图片,无文本层直接拿到空字符串,检索全失效

痛点很清楚:文本型 PDF 丢结构,扫描型 PDF 丢内容。下面一段段给可落地的 Java 方案。


二、统一解析入口:先把"文档"抽象成一个接口

不管什么来源,我们最终要的是"一块块带结构的文本"。先定义统一契约,后续不同文件类型各自实现,上层检索逻辑不感知差异。

// 统一的文档解析接口,所有文件类型都实现它 // 依赖:JDK 17+ public interface DocumentParser { ​ // 解析结果:保留"结构块",而不是一坨纯文本 List<DocChunk> parse(InputStream source, ParseContext ctx) throws ParseException; ​ boolean supports(String fileExtension); } ​ // 结构块:区别于普通字符串,带类型和层级,方便后续切分与重排 public record DocChunk( String id, ChunkType type, // TEXT / TABLE / IMAGE_CAPTION / FORMULA int pageNo, String content, // 表格已是 Markdown 表格串,图片是 OCR/多模态描述 Map<String, Object> meta ) {} ​ public enum ChunkType { TEXT, TABLE, IMAGE_CAPTION, FORMULA } ​ public record ParseContext( boolean extractTable, // 是否抽表格 boolean ocrFallback, // 无文本层时是否走 OCR String lang // 语种,影响 OCR 模型选择 ) {}

这个抽象的价值:检索层永远拿List<DocChunk>,后面换 PDFBox、换视觉模型、换 OCR 引擎,都不用动上面。我见过太多项目把 PDFBox 代码直接写死在 Controller 里,半年后想加扫描件支持,得重写一遍。


三、表格提取:让财报里的数字"对得上行"

很多 PDF 表格没有真正的表格线,文字是靠坐标"排"成表格的。直接用PDFTextStripper抽,数字会乱序。正确做法是用区域化抽取 + 列边界推断。tabula-java(基于 PDFBox)是工业级选择。

// Maven 依赖: // <dependency> // <groupId>technology.tabula</groupId> // <artifactId>tabula</artifactId> // <version>1.0.5</version> // </dependency> ​ public class PdfTableParser implements DocumentParser { ​ private final ObjectExtractor extractor = new ObjectExtractor(); ​ @Override public List<DocChunk> parse(InputStream source, ParseContext ctx) throws ParseException { List<DocChunk> chunks = new ArrayList<>(); try (PDDocument doc = PDDocument.load(source)) { for (int p = 1; p <= doc.getNumberOfPages(); p++) { PageArea page = extractor.extract(doc, p); // 启发式探测表格区域:基于空格列边界识别 List<Table> tables = page.getTables(); for (Table t : tables) { String md = tableToMarkdown(t); // 转成 Markdown 表格 chunks.add(new DocChunk( "p" + p + "-tbl" + t.getIndex(), ChunkType.TABLE, p, md, Map.of("rows", t.getRowCount(), "cols", t.getColCount()) )); } // 表格之外的正文单独成块 String text = page.getTextWithoutTables(); if (!text.isBlank()) { chunks.add(new DocChunk("p" + p + "-txt", ChunkType.TEXT, p, text, Map.of())); } } } catch (IOException e) { throw new ParseException("PDF 解析失败: " + e.getMessage(), e); } return chunks; } ​ // 把 tabula 的 Table 转成 Markdown,保留行列结构,检索时数值不会错位 private String tableToMarkdown(Table t) { StringBuilder sb = new StringBuilder(); List<List<String>> grid = t.getData(); for (int r = 0; r < grid.size(); r++) { sb.append("| ").append(String.join(" | ", grid.get(r))).append(" |\n"); if (r == 0) sb.append("|").append("---|".repeat(grid.get(r).size())).append("\n"); } return sb.toString(); } ​ @Override public boolean supports(String ext) { return "pdf".equalsIgnoreCase(ext); } }

关键点:表格转成Markdown 表格串再入库。这样 Embedding 时"行"和"列"关系保留,用户问"2024 年 Q3 营收多少",向量能命中对应行,而不是在一坨乱序文字里大海捞针。我踩过的坑:曾经直接把表格当纯文本切,结果"营收 1200 万"和"成本 800 万"被切到两个 chunk,大模型算净利润直接瞎编。


四、扫描件与图文混排:OCR 与多模态的分级处理

扫描件(无文本层)和带图文档,光靠 PDFBox 拿不到内容,必须上 OCR 或多模态视觉模型。这里有个成本分级的取舍,别无脑上大模型,那会烧穿预算。

// 解析策略选择器:根据文件特征选最划算的路径 // 低成本:本地 Tesseract OCR(表格少、清晰扫描件) // 高保真:视觉大模型直接读图(图文混排、公式、复杂版面) public class ParseStrategySelector { ​ public ParsePlan select(DetectResult detect) { // 1. 有文本层且表格少 → 纯文本解析,几乎零成本 if (detect.hasTextLayer() && detect.tableRatio() < 0.1) { return ParsePlan.TEXT_ONLY; } // 2. 无文本层(扫描件)但页面规整 → 本地 OCR,单页几分钱 if (!detect.hasTextLayer() && detect.isCleanScan()) { return ParsePlan.LOCAL_OCR; // Tesseract,CPU 即可 } // 3. 图文混排 / 多栏 / 含公式 → 上视觉大模型,保真但贵 return ParsePlan.VISION_LLM; // 如 Qwen-VL / GPT-4V,按图计费 } ​ // 扫描件 OCR 兜底实现(Tesseract 4.x) // 依赖:net.sourceforge.tess4j:tess4j:5.9.0 public String ocrScan(InputStream img, String lang) { Tesseract t = new Tesseract(); t.setDatapath("/opt/tessdata"); // 语言包路径 t.setLanguage(lang); // "chi_sim+eng" try { return t.doOCR(ImageIO.read(img)); } catch (TesseractException | IOException e) { throw new ParseException("OCR 失败", e); } } }

成本取舍的经验值(仅供参考,按时价):本地 OCR 单页几分钱、CPU 可跑;视觉大模型单图几毛到一块,但能直接理解图表含义、把柱状图读成"Q3 营收环比下降 12%"。所以我的判据是——能本地 OCR 解决的绝不调大模型,只有版面复杂、含非文本信息时再上视觉模型。一个 200 页的历史合同扫描库,全用视觉模型跑一遍,账单能吓你一跳。


五、多模态块:把图"翻译"成可检索的描述

图文混排文档里,图本身没有文本。我们不直接把图片塞进向量库(除非用多模态 Embedding),更务实的做法是:用视觉模型生成一段结构化描述作为该块的content,让它参与检索。

// 用视觉大模型为图片块生成可检索描述(保留图注上下文) // 依赖:Spring AI 1.0+ (ChatClient + 多模态消息) public class ImageChunker { ​ private final ChatClient visionClient; // 注入支持视觉的模型 ​ public DocChunk describeImage(BufferedImage img, String nearbyCaption) { String b64 = encodeBase64(img); String prompt = """ 请描述这张图的关键信息,若含图表请转述核心数据趋势, 若含流程图请说明步骤。图注上下文:%s。 输出不超过 120 字,不要编造图中没有的数字。 """.formatted(nearbyCaption); ​ String desc = visionClient.prompt() .user(u -> u.text(prompt) .media(MimeType.valueOf("image/png"), new ByteArrayResource(Base64.getDecoder().decode(b64)))) .call().content(); ​ return new DocChunk( "img-" + UUID.randomUUID(), ChunkType.IMAGE_CAPTION, -1, desc, Map.of("source", "vision-llm") ); } }

注意 prompt 里那句"不要编造图中没有的数字"——这是真实教训。视觉模型幻觉起来比文本模型还离谱,让它转述数据必须加约束,并且和 Day 98 讲的自纠错链路打通:检索到图片描述块后,生成答案时让它"引用图中原话",再让RetrievalGrader校验。


六、整体流水线:解析 → 结构块 → 切分 → 入库

把上面几块串起来,一条干净的解析流水线长这样:

原始文档 (PDF/扫描件/图片) │ ▼ [探测器] 有没有文本层?表格占比?是否扫描件? │ ├─有文本层──────► [PdfTableParser] 抽文本+表格 → Markdown 块 │ ├─扫描件────────► [ParseStrategySelector] │ ├─清晰规整 → 本地 OCR(便宜) │ └─复杂版面 → 视觉大模型(贵但准) │ └─图文混排──────► [ImageChunker] 图→视觉描述块 │ ▼ [List<DocChunk>] 结构块(TEXT/TABLE/IMAGE_CAPTION) │ ▼ [语义切分] 表格整块不切断,正文按 512 token 滑窗 │ ▼ [Embedding + 入库] → 进入 Day98 的高级 RAG 检索链路

这条线的核心纪律:表格整块不切断,图片必须转成文字描述再入库。守这两条,下游检索质量的提升比换一个更贵的向量库明显得多。


七、实战建议

  1. 先搞清楚你的文档长什么样,再选技术。别一上来就接视觉大模型。我建议先用探测脚本统计一批真实文档:纯文本占比多少、含表格多少页、扫描件多少。80% 的"复杂文档"其实只是普通 PDF 带几张表,PDFBox + tabula 就够了,根本用不着 OCR。

  2. OCR/视觉模型的输出一定要留原图可追溯。把"图片块 → 原图路径"的映射存下来。哪天用户质疑答案,你能一键调出原图人工复核,而不是对着一段 AI 生成的描述干瞪眼。这也是 Day 96 讲的可靠性工程在解析侧的延伸。

  3. 解析质量必须进回归集。把 Day 95 的离线回归集用起来:挑 20 份典型文档,人工标注"这段表格应该抽到什么",每次改解析逻辑就跑一遍,看表格抽取准确率有没有掉。解析器是最容易"改一处崩一片"的模块,没有量化守住,迟早翻车。


八、下篇预告

RAG 的精度上限,往往在文档被塞进向量库之前的那个 parse 函数里就定了——下游再花哨,也救不回一个把好表格拆成乱码的解析器。

下篇预告:Day 100,我们聊 RAG 的"记忆与状态"——多轮对话里的会话记忆、长期用户画像怎么存、如何让同一个知识库对不同用户给出不同口径的回答。这关系到 RAG 从"能答"到"懂你"的最后一跃。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进