夜雨聆风学习资料网

ARTICLE · 1061312

文档智能:AI自动提取合同报表关键信息

文档智能:AI自动提取合同报表关键信息

2025年11月,法务部发来一个工单——他们每周要手动录入 200+ 份合同的关键字段(合同编号、签约方、金额、生效日期、终止日期、违约金比例),平均每人每天花 3 小时在做这个。财务那边也来凑热闹,说月度报表里的数据经常和合同对不上,需要人工核对。

业务方原话:“能不能搞个系统,扫描合同直接出结构化数据?” 我当时的判断是:OCR + LLM 的方案能搞定 80% 的场景,但合同里的手写签名、盖章遮挡、表格类排版是硬骨头,需要额外处理。

OCR 选型:PaddleOCR vs 阿里云读光

我们试了两个方案。PaddleOCR(PP-OCRv4)免费、自托管、速度很快——CPU 上单页 200ms,GPU 上 50ms。阿里云读光(Text Recognition)按量计费,¥0.001/次,但精度在复杂背景下明显更高。

实际测试下来,PaddleOCR 对打印清晰的合同准确率约 92%,但遇到盖章遮挡、手写补充条款时跌到 78%。阿里云读光整体稳定在 96%,但对竖排文字和表格内的文字识别率不如 PaddleOCR。

最终方案是双引擎——PaddleOCR 做底,阿里云读光做补。先跑 PaddleOCR 得到初步结果,对置信度 < 0.85 的区域用阿里云读光重识别,取两者中置信度高的结果。

@Component@RequiredArgsConstructorpublic class DualOcrEngine {    private final PaddleOcrRunner paddleOcr;    private final AliyunOssrClient aliyunOcr;    private static final double CONFIDENCE_THRESHOLD = 0.85;    public OcrResult ocr(File pdfFile) {        // 第一阶段:PaddleOCR 全页识别        PaddleOcrResult paddleResult = paddleOcr.recognize(pdfFile);        // 第二阶段:低置信度区域用阿里云补识        List<TextBlock> refinedBlocks = new ArrayList<>();        for (TextBlock block : paddleResult.getBlocks()) {            if (block.getConfidence() < CONFIDENCE_THRESHOLD) {                // 裁剪该区域,用阿里云读光重识别                BufferedImage crop = extractRegion(pdfFile, block.getPolygon());                AliyunOcrResult aliyunResult = aliyunOcr.recognize(crop);                if (aliyunResult.getConfidence() > block.getConfidence()) {                    refinedBlocks.add(aliyunResult.getBestBlock());                } else {                    refinedBlocks.add(block);                }            } else {                refinedBlocks.add(block);            }        }        return new OcrResult(refinedBlocks, paddleResult.getProcessingTimeMs());    }}

两个引擎的调用是串行的,但因为阿里云只处理低置信度区域(约 15% 的页面),平均延迟增加约 80ms,总 P95 延迟控制在 400ms 以内。

版面分析:区分标题、正文、表格

OCR 出来的文字是散的,需要版面分析还原结构。合同最常见的版式是:标题(合同名称)+ 签约方信息(表格)+ 正文条款 + 签署区。我们用 PaddleOCR 自带的版面分析模型(PP-StructureV2)做区域分类:

@Componentpublic class LayoutAnalyzer {    private final PaddleStructure structure;    public LayoutResult analyze(byte[] image) {        // PP-StructureV2 输出:每个区域的 type + bbox + content        List<StructureBlock> blocks = structure.detect(image);        LayoutResult result = new LayoutResult();        for (StructureBlock block : blocks) {            switch (block.getType()) {                case "title":                    result.addTitle(block.getContent());                    break;                case "table":                    result.addTable(parseTableBlock(block));                    break;                case "text":                    result.addParagraph(block.getContent());                    break;                case "figure":                    // 印章/签名区域,跳过OCR直接标记                    result.markSealRegion(block.getBbox());                    break;            }        }        return result;    }    private ContractTable parseTableBlock(StructureBlock block) {        // 表格解析:识别行列结构,合并合并单元格        Table table = block.getTable();        List<TableRow> rows = new ArrayList<>();        for (int r = 0; r < table.getRowCount(); r++) {            List<String> cells = new ArrayList<>();            for (int c = 0; c < table.getColumnCount(); c++) {                // 处理跨行跨列                String cellContent = table.getCellContent(r, c);                cells.add(cellContent != null ? cellContent : "");            }            rows.add(new TableRow(cells));        }        return new ContractTable(rows, block.getBbox());    }}

表格处理是最难的。合同里的签约方信息、金额明细、违约责任都是表格形式,但合并单元格很常见(比如"甲方"占两行,“乙方"占三行)。PP-StructureV2 能识别合并单元格,但解析后的行列对齐容易出错。我们的做法是:先按表格结构提取,再用人工规则校验——如果某行的列数不等于表头的列数,标记为"结构异常”,走人工复核流程。

LLM 结构化抽取:JSON Schema 约束输出

版面分析完成后,把结构化内容喂给 LLM 做字段提取。这里的关键是用 JSON Schema 约束输出格式,不让模型自由发挥——合同字段很固定(合同编号、甲方、乙方、金额、日期等),输出格式必须稳定才能入库。

我们用的是 qwen-max,因为它对中文合同的理解最好,且在结构化输出方面明显优于 qwen-turbo。Prompt 设计分三部分:角色定义 + 字段定义 + 输出约束。

@Component@RequiredArgsConstructorpublic classContractExtractor{    private final ChatClient chatClient;    public ExtractionResult extract(LayoutResult layout, String contractType) {        String systemPrompt = """            你是一个合同信息提取专家。            请从合同文档中提取以下字段,严格按照 JSON Schema 输出。            字段定义:            - contractNumber: 合同编号,格式通常为"YEAR-XXX-NNNN"或"XXX-YYYY-NNNN"            - partyA: 甲方名称(公司全称)            - partyB: 乙方名称(公司全称)            - contractAmount: 合同金额(数值,单位:元)            - amountCurrency: 金额币种(CNY/USD/EUR)            - signDate: 签署日期(格式 YYYY-MM-DD)            - effectiveDate: 生效日期(格式 YYYY-MM-DD)            - endDate: 终止日期(格式 YYYY-MM-DD,可为null)            - penaltyRatio: 违约金比例(百分比数值,可为null)            - contractType: 合同类型(采购/销售/服务/租赁/劳动)            约束:            1. 日期必须能从原文找到依据,找不到则返回null            2. 金额如果原文有多个(如单价、总价、税费),优先取总价            3. 字段名必须与Schema完全一致,不要增减字段            4. 所有字符串字段不能为null,找不到填"UNKNOWN";日期字段可为null            """;        String userContent = buildExtractionPrompt(layout, contractType);        ChatResponse response = chatClient.prompt()            .system(systemPrompt)            .user(userContent)            .call()            .chatResponse();        return parseExtractionResult(response.getResult().getOutput().getText());    }}

JSON Schema 约束我们用 Spring AI 的 FunctionCalling 模式实现——把字段定义注册为一个 Function,让模型直接输出 Function Call 而非自由文本。这样输出格式 100% 可控,不会出现"模型多了个字段"或"少了个字段"的情况。

@Componentpublic class ContractFieldSchema {    @Tool(description = "提取合同关键字段")    public ContractFields extractFields(            @ToolParam(description = "合同编号"String contractNumber,            @ToolParam(description = "甲方名称"String partyA,            @ToolParam(description = "乙方名称"String partyB,            @ToolParam(description = "合同金额(元)"Double contractAmount,            @ToolParam(description = "币种"String amountCurrency,            @ToolParam(description = "签署日期 YYYY-MM-DD"String signDate,            @ToolParam(description = "生效日期 YYYY-MM-DD"String effectiveDate,            @ToolParam(description = "终止日期 YYYY-MM-DD"String endDate,            @ToolParam(description = "违约金比例(百分比)"Double penaltyRatio,            @ToolParam(description = "合同类型:采购/销售/服务/租赁/劳动"String contractType) {        return new ContractFields(contractNumber, partyA, partyB, contractAmount,            amountCurrency, signDate, effectiveDate, endDate, penaltyRatio, contractType);    }}

字段置信度与人工复核队列

每个字段提取后都要算一个置信度分数。置信度来源有两个:LLM 输出时自带的 probability(qwen-max 在 Function Call 模式下会返回每个参数的置信度),以及规则校验(比如日期格式是否合法、金额是否为正数)。

@Componentpublic class FieldConfidenceCalculator {    public FieldConfidence calculate(String fieldName, String value                                      double llmConfidence, Context context) {        // 规则校验扣分        double penalty = 0.0;        if ("signDate".equals(fieldName) || "effectiveDate".equals(fieldName)) {            if (!value.matches("\\d{4}-\\d{2}-\\d{2}")) {                penalty += 0.3// 日期格式不对            }            if (context.hasSealCoverage(value)) {                penalty += 0.2// 日期被盖章遮挡            }        }        if ("contractAmount".equals(fieldName)) {            try {                double amount = Double.parseDouble(value);                if (amount < 0) penalty += 0.5// 金额为负                if (amount > 1_000_000_000) penalty += 0.1// 金额异常大            } catch (NumberFormatException e) {                penalty += 0.4// 金额格式不对            }        }        double finalConfidence = Math.max(0, llmConfidence - penalty);        return new FieldConfidence(fieldName, value, finalConfidence,             penalty > 0 ? "规则校验扣分" : "LLM直接输出");    }}

置信度 < 0.7 的字段进人工复核队列。我们设了三级处理流程:

置信度 ≥ 0.9:自动入库,不进复核0.7 ≤ 置信度 < 0.9:进复核队列,但不打断流程——法务人员有空时批量处理置信度 < 0.7:强制复核,该合同的所有字段都需要人工确认才能入库

上线后的数据:自动入库率 62%,半自动(部分字段复核)28%,全人工复核 10%。人工复核队列平均积压 15 份合同,法务人员每天花 30 分钟处理即可清空。

表格类文档的专门处理

合同里的表格(金额明细、违约责任表、交付清单)是 OCR + LLM 方案的难点。单纯靠 LLM 从 OCR 文本里还原表格结构,准确率只有 65%。我们加了专门的前处理步骤:

步骤一:表格区域裁剪。 从版面分析结果中识别表格区域,裁剪出表格的 ROI(Region of Interest)。

步骤二:表格结构重建。 用 PaddleOCR 的表格结构识别模型(TableStructurer)重建行列结构,输出 HTML 格式的表格。这一步的准确率约 88%。

步骤三:结构化文本转换。 把 HTML 表格转成 Markdown 表格,便于 LLM 理解。

@Componentpublic class TableStructureReconstructor {    public String reconstructTable(StructureBlock tableBlock) {        Table table = tableBlock.getTable();        StringBuilder md = new StringBuilder();        for (int r = 0; r < table.getRowCount(); r++) {            List<String> rowCells = new ArrayList<>();            for (int c = 0; c < table.getColumnCount(); c++) {                String content = table.getCellContent(r, c);                // 清理换行和多余空白                content = content.replaceAll("\\s+"" ").trim();                rowCells.add(content);            }            md.append(String.join(" | ", rowCells)).append("\n");            // 表头行后加分隔线            if (r == 0) {                md.append("|".repeat(rowCells.size()))                  .replaceAll(".""-").replaceFirst("^-""|");                md.append("\n");            }        }        return md.toString();    }}

表格处理后的 LLM 提取准确率从 65% 提升到 89%,提升幅度明显。

准确率评测集

我们建了一个 500 份合同的评测集,覆盖 5 种合同类型(采购/销售/服务/租赁/劳动),每种 100 份。人工标注了每个字段的标准答案,用于计算精确率、召回率和 F1 分数。

评测结果:

字段
精确率
召回率
F1
contractNumber
98.2%
97.5%
97.8%
partyA
96.8%
95.2%
96.0%
partyB
96.5%
94.8%
95.6%
contractAmount
94.1%
91.3%
92.7%
signDate
93.5%
90.2%
91.8%
effectiveDate
91.2%
88.7%
90.0%
endDate
89.5%
85.3%
87.4%
penaltyRatio
82.1%
78.6%
80.3%
contractType
97.0%
96.2%
96.6%

整体字段级 F1 约 92.5%,合同级全字段匹配率 84%。也就是说,84% 的合同所有字段都能一次性正确提取。这个数据比预期好——最初预估整体准确率在 75% 左右。

penaltyRatio(违约金比例)是最低的分项,原因是合同里这个信息往往藏在长句子里(“如一方违约,应支付合同总额 20% 的违约金”),OCR 识别后文本不够结构化,LLM 不容易准确提取数值。后续计划用正则后处理专门补这个字段。

成本分析

单份合同的 processing cost:

  PaddleOCR(自托管,GPU):¥0(固定成本) 阿里云读光(补识低置信度区域):约 0.15 页 × ¥0.001 = ¥0.00015 qwen-max Function Call:约 2000 input + 300 output tokens = ¥0.024 + ¥0.012 = ¥0.036 总计:约 ¥0.04/份

日均 200 份合同,月成本约 ¥240。如果全部用阿里云读光(不用 PaddleOCR 双引擎),成本约 ¥0.2/份,月成本 ¥1200,高出 5 倍。双引擎方案的成本效益明显更优。

上线注意事项

1. 文件去重。 同一份合同可能以 PDF、图片、扫描件等多种格式提交。我们用文件内容的 SHA-256 做去重,避免重复处理。但要注意:扫描件和图片虽然内容相同,但 OCR 结果可能不同(分辨率、清晰度差异),所以去重只对原始文件生效,处理结果不进缓存。

2. 多页合同。 合同通常 5-20 页,有些长达 50 页。我们的方案是逐页 OCR + 版面分析,然后在 LLM 层做全局提取——把所有页面的版面分析结果拼接成一个 prompt,让模型一次性提取。这样比逐页提取再合并更准确,因为合同编号、金额等信息可能在首页,而签署日期在末页。

3. 手写内容。 合同里的补充协议、手写备注是 OCR 的弱项。PaddleOCR 的手写识别模型(PP-OCRv4 HandWriting)在清晰手写体上准确率约 85%,但潦草手写只有 60%。这部分内容我们直接标记为"需人工复核",不进自动处理流程。

4. 版本迭代。 qwen-max 在 2026 年 3 月升级到 2026.3 版本,结构化输出的稳定性明显提升(JSON Schema 违反率从 3.2% 降到 0.8%),但 prompt 需要微调。建议每次模型升级后跑一遍评测集,记录效果变化。

这个方案的核心思路是"OCR 打底 + 版面分析还原结构 + LLM 结构化提取 + 置信度过滤 + 人工复核兜底"。四个环节缺一不可——OCR 不准,后面全白搭;版面分析不做,表格提取准确率直接腰斩;不设置信度阈值,人工复核队列会爆炸。

你们团队文档自动化的方案是什么?有没有踩过 OCR 或表格识别的坑?

相关学习资料