企业文档抽取不能只看“已输出字段有多准”,还要检查应该输出的字段是否遗漏、结果能否回到原文,以及每处理一份文档要花多少钱。本文用一个可运行的 Node.js 脚本串起评测集、指标计算和失败样本分组。
为什么只看准确率容易误判
发票、合同、采购单等文档通常是半结构化的:同一个字段可能出现在表格、页眉、印章附近,也可能跨页出现。一个系统完全可以通过“少输出”获得看起来不错的准确率。例如只返回最有把握的发票号码,却漏掉金额和税号,已输出字段的准确率仍可能很高。
因此,至少需要同时观察四组指标:
| 维度 | 指标 | 回答的问题 |
|---|---|---|
| 字段质量 | Precision、Recall、F1 | 输出值是否正确,必需字段是否被找全 |
| 完整性 | Omission Rate | 金标中有多少字段完全没有输出 |
| 溯源 | Evidence Hit Rate | 正确结果能否在指定页的 OCR 文本中找到证据 |
| 成本 | Cost per Document | OCR、输入 Token、输出 Token 的单文档成本 |
对于单值字段,本文把值归一化后完全相等视为正确。错误值既会占用一次预测,又无法命中金标,因此会同时影响 Precision 和 Recall;完全没返回的字段另外计入遗漏率。生产环境还可以为金额、日期、公司名称分别增加类型化比较器,但不要用过度宽松的模糊匹配掩盖真实错误。
评测集要保存答案,也要保存原文证据
一条金标不应只有 total: 12345.67。建议同时记录文档类型、字段名、期望值、所在页码和最小证据片段。页码用于快速定位,证据片段用于验证结果是否真的来自文档,而不是模型根据上下文猜出的值。
预测侧则保留三层数据:OCR 页文本、模型未经 Schema 过滤的 rawFields,以及最终对外输出的 fields。如果只保存最终 JSON,后续很难区分“模型没有抽到”和“模型抽到了,但被类型校验或枚举约束丢弃”。
可选字段需要明确适用条件。例如合同没有续约条款时,不应把 renewal_date 当作遗漏。简单做法是在金标中只列出当前文档应当存在的字段;规模更大时,可以增加 applicable 和 required 标记。
证据定位不一定一开始就做到字符坐标。页码加原文引用已经能覆盖基本审计需求。如果 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 可能稳定,但某个合同日期字段持续下降;按字段查看 gold、correct 和 omitted,通常比只看总分更容易发现回归。成本也应与质量联合观察:降低上下文长度节省了 Token,却导致跨页字段遗漏,并不一定是有效优化。
总结
企业文档抽取的评测对象不是一个孤立的模型,而是 OCR、上下文组装、模型抽取、Schema 校验和结果交付组成的流水线。本文的核心要点是:
- 用 Precision、Recall、F1 和遗漏率同时衡量正确性与完整性;
- 在金标中保存页码和原文证据,验证正确字段是否可以溯源;
- 保留 OCR、模型原始字段和最终字段,为失败归因提供中间状态;
- 将失败样本分为 OCR、模型和 Schema 问题,但把分组视为诊断线索;
- 按真实合同价格计算 OCR 与 Token 成本,并与质量指标一起比较;
- 进一步按文档类型和字段拆分指标,避免总体平均值掩盖局部回归。
从几十份人工复核过的代表性文档开始,比先追求庞大评测集更实用。只要金标、证据和调用数据能够持续沉淀,这套 Node.js 脚本就可以逐步接入 CI,在提示词、模型、OCR 或 Schema 变更时执行回归评测。
评论