企业文档抽取不能只看“已输出字段有多准”,还要检查应该输出的字段是否遗漏、结果能否回到原文,以及每处理一份文档要花多少钱。本文用一个可运行的 Node.js 脚本串起评测集、指标计算和失败样本分组。

为什么只看准确率容易误判

发票、合同、采购单等文档通常是半结构化的:同一个字段可能出现在表格、页眉、印章附近,也可能跨页出现。一个系统完全可以通过“少输出”获得看起来不错的准确率。例如只返回最有把握的发票号码,却漏掉金额和税号,已输出字段的准确率仍可能很高。

因此,至少需要同时观察四组指标:

维度指标回答的问题
字段质量Precision、Recall、F1输出值是否正确,必需字段是否被找全
完整性Omission Rate金标中有多少字段完全没有输出
溯源Evidence Hit Rate正确结果能否在指定页的 OCR 文本中找到证据
成本Cost per DocumentOCR、输入 Token、输出 Token 的单文档成本

对于单值字段,本文把值归一化后完全相等视为正确。错误值既会占用一次预测,又无法命中金标,因此会同时影响 Precision 和 Recall;完全没返回的字段另外计入遗漏率。生产环境还可以为金额、日期、公司名称分别增加类型化比较器,但不要用过度宽松的模糊匹配掩盖真实错误。

评测集要保存答案,也要保存原文证据

一条金标不应只有 total: 12345.67。建议同时记录文档类型、字段名、期望值、所在页码和最小证据片段。页码用于快速定位,证据片段用于验证结果是否真的来自文档,而不是模型根据上下文猜出的值。

预测侧则保留三层数据:OCR 页文本、模型未经 Schema 过滤的 rawFields,以及最终对外输出的 fields。如果只保存最终 JSON,后续很难区分“模型没有抽到”和“模型抽到了,但被类型校验或枚举约束丢弃”。

可选字段需要明确适用条件。例如合同没有续约条款时,不应把 renewal_date 当作遗漏。简单做法是在金标中只列出当前文档应当存在的字段;规模更大时,可以增加 applicablerequired 标记。

证据定位不一定一开始就做到字符坐标。页码加原文引用已经能覆盖基本审计需求。如果 OCR 结果本身提供单词或文本块的边界框,可以进一步保存坐标,但评测集应使用实际 OCR 结构,不能凭空推算位置。

用 Node.js 实现可运行的评测脚本

下面的脚本只使用 Node.js 内置能力,可在 Node.js 18 及以上版本运行。示例内置三份文档,分别制造 OCR 遗漏、模型抽取错误和 Schema 拒绝。将代码保存为 eval.mjs,执行 node eval.mjs 即可。

const dataset = [
  {
    id: 'invoice-001',
    gold: {
      invoice_no: { value: 'INV-2025-001', evidence: { page: 1, text: '发票号码 INV-2025-001' } },
      total: { value: '12,345.67', evidence: { page: 1, text: '价税合计 12,345.67 元' } }
    },
    prediction: {
      ocr: [{ page: 1, text: '发票号码 INV-2025-001 购买方 示例科技有限公司' }],
      rawFields: { invoice_no: 'INV-2025-001' },
      fields: {
        invoice_no: { value: 'INV-2025-001', evidence: { page: 1, quote: '发票号码 INV-2025-001' } }
      },
      schemaErrors: [], inputTokens: 900, outputTokens: 80
    }
  },
  {
    id: 'contract-001',
    gold: {
      party_a: { value: '示例科技有限公司', evidence: { page: 1, text: '甲方:示例科技有限公司' } },
      effective_date: { value: '2025年1月1日', evidence: { page: 2, text: '本合同自2025年1月1日起生效' } }
    },
    prediction: {
      ocr: [
        { page: 1, text: '甲方:示例科技有限公司 乙方:演示贸易有限公司' },
        { page: 2, text: '本合同自2025年1月1日起生效,有效期一年' }
      ],
      rawFields: { party_a: '示例科技有限公司', effective_date: '2025年1月11日' },
      fields: {
        party_a: { value: '示例科技有限公司', evidence: { page: 1, quote: '甲方:示例科技有限公司' } },
        effective_date: { value: '2025年1月11日', evidence: { page: 2, quote: '本合同自2025年1月1日起生效' } }
      },
      schemaErrors: [], inputTokens: 1500, outputTokens: 120
    }
  },
  {
    id: 'invoice-002',
    gold: {
      seller_name: { value: '演示贸易有限公司', evidence: { page: 1, text: '销售方 演示贸易有限公司' } },
      seller_tax_id: { value: '91310000EXAMPLE01', evidence: { page: 1, text: '纳税人识别号 91310000EXAMPLE01' } }
    },
    prediction: {
      ocr: [{ page: 1, text: '销售方 演示贸易有限公司 纳税人识别号 91310000EXAMPLE01' }],
      rawFields: { seller_name: '演示贸易有限公司', seller_tax_id: '91310000EXAMPLE01' },
      fields: {
        seller_name: { value: '演示贸易有限公司', evidence: { page: 1, quote: '销售方 演示贸易有限公司' } }
      },
      schemaErrors: [{ field: 'seller_tax_id', message: '不符合当前 Schema 的长度约束' }],
      inputTokens: 850, outputTokens: 75
    }
  }
];

const norm = value => String(value ?? '')
  .normalize('NFKC')
  .replace(/[\s,,]/g, '')
  .toLowerCase();

const same = (left, right) => norm(left) === norm(right);
const contains = (text, part) => norm(text).includes(norm(part));

function evidenceIsValid(prediction, field) {
  if (!field?.evidence) return false;
  const page = prediction.ocr.find(item => item.page === field.evidence.page);
  if (!page) return false;
  return contains(page.text, field.evidence.quote) &&
    contains(field.evidence.quote, field.value);
}

function failureGroup(goldField, name, prediction) {
  const page = prediction.ocr.find(item => item.page === goldField.evidence.page);
  if (!page || !contains(page.text, goldField.evidence.text)) return 'ocr';

  const rejected = prediction.schemaErrors.some(error => error.field === name);
  if (rejected && same(prediction.rawFields[name], goldField.value)) return 'schema';

  return 'model';
}

const stats = {
  gold: 0, predicted: 0, correct: 0, omitted: 0,
  evidenceEligible: 0, evidenceHit: 0,
  failures: [], byField: {}, totalCost: 0
};

const inputPerMillion = Number(process.env.INPUT_PER_MILLION ?? 0);
const outputPerMillion = Number(process.env.OUTPUT_PER_MILLION ?? 0);
const ocrPerPage = Number(process.env.OCR_PER_PAGE ?? 0);

for (const doc of dataset) {
  const { gold, prediction } = doc;
  stats.gold += Object.keys(gold).length;
  stats.predicted += Object.keys(prediction.fields).length;

  const cost = prediction.inputTokens / 1_000_000 * inputPerMillion +
    prediction.outputTokens / 1_000_000 * outputPerMillion +
    prediction.ocr.length * ocrPerPage;
  stats.totalCost += cost;

  for (const [name, expected] of Object.entries(gold)) {
    const actual = prediction.fields[name];
    stats.byField[name] ??= { gold: 0, correct: 0, omitted: 0 };
    stats.byField[name].gold++;

    if (!actual) {
      stats.omitted++;
      stats.byField[name].omitted++;
    }

    if (actual && same(actual.value, expected.value)) {
      stats.correct++;
      stats.byField[name].correct++;
      stats.evidenceEligible++;
      if (evidenceIsValid(prediction, actual)) stats.evidenceHit++;
    } else {
      stats.failures.push({
        documentId: doc.id,
        field: name,
        expected: expected.value,
        actual: actual?.value ?? null,
        group: failureGroup(expected, name, prediction)
      });
    }
  }
}

const precision = stats.predicted ? stats.correct / stats.predicted : 0;
const recall = stats.gold ? stats.correct / stats.gold : 0;
const report = {
  documents: dataset.length,
  precision,
  recall,
  f1: precision + recall ? 2 * precision * recall / (precision + recall) : 0,
  omissionRate: stats.gold ? stats.omitted / stats.gold : 0,
  evidenceHitRate: stats.evidenceEligible
    ? stats.evidenceHit / stats.evidenceEligible : 0,
  averageCostPerDocument: stats.totalCost / dataset.length,
  byField: stats.byField,
  failures: stats.failures
};

console.log(JSON.stringify(report, null, 2));

价格默认设为零,因为不同 OCR 服务、模型和合同价格并不相同。运行时可以显式传入自己的计价参数,例如:

INPUT_PER_MILLION=你的输入价格 \
OUTPUT_PER_MILLION=你的输出价格 \
OCR_PER_PAGE=你的单页价格 \
node eval.mjs

三个参数应使用同一种货币。这里计算的是可直接归集的调用成本,不包含人工复核、存储和重试;如果系统存在重试,应记录每次调用,而不是只记录最终成功请求。

用失败分组定位流水线问题

脚本采用一组可解释的诊断规则:先检查金标证据是否存在于对应 OCR 页;如果不存在,优先归入 ocr。如果 OCR 中有证据,模型原始结果也正确,但最终字段缺失,并且存在该字段的校验错误,则归入 schema。其余错误归入 model

分组常见现象优先检查
OCR金额、日期或印章文字未进入 OCR 文本图像清晰度、旋转、版面区域、OCR 配置
Model原文存在,但字段选错、抄错或混淆提示词、字段定义、上下文裁剪、示例
Schema原始抽取正确,最终结果被过滤类型转换、长度限制、枚举和必填规则

这套规则是诊断启发式,而不是严格的因果证明。例如 OCR 把字符识别错后,模型也可能根据上下文“修复”为正确值;此时仅比较证据字符串会低估 OCR 问题。实际复盘时应打开原始页面、OCR 中间结果、模型原始响应和校验日志,评测脚本负责缩小排查范围,而不是替代人工判断。

报表还应按文档类型、字段名、页数区间和供应商分组。总体 F1 可能稳定,但某个合同日期字段持续下降;按字段查看 goldcorrectomitted,通常比只看总分更容易发现回归。成本也应与质量联合观察:降低上下文长度节省了 Token,却导致跨页字段遗漏,并不一定是有效优化。

总结

企业文档抽取的评测对象不是一个孤立的模型,而是 OCR、上下文组装、模型抽取、Schema 校验和结果交付组成的流水线。本文的核心要点是:

  • 用 Precision、Recall、F1 和遗漏率同时衡量正确性与完整性;
  • 在金标中保存页码和原文证据,验证正确字段是否可以溯源;
  • 保留 OCR、模型原始字段和最终字段,为失败归因提供中间状态;
  • 将失败样本分为 OCR、模型和 Schema 问题,但把分组视为诊断线索;
  • 按真实合同价格计算 OCR 与 Token 成本,并与质量指标一起比较;
  • 进一步按文档类型和字段拆分指标,避免总体平均值掩盖局部回归。

从几十份人工复核过的代表性文档开始,比先追求庞大评测集更实用。只要金标、证据和调用数据能够持续沉淀,这套 Node.js 脚本就可以逐步接入 CI,在提示词、模型、OCR 或 Schema 变更时执行回归评测。