主题
证据化结构抽取流水线
把 PDF 或邮件丢给模型,要求“整理成 JSON”,适合演示,不适合入库。生产抽取必须回答四个问题:值从哪里来、为什么这样归一化、违反了哪些业务规则、谁确认过结果。
本教程以“供应商合同条款台账”为例,构建一条通用流水线。相同设计可用于检测报告、维修记录、投标文件、报销附件和历史表单迁移。
1. 定义真正的交付物
业务需要的不是摘要,而是一条可以检索、比较和追责的数据记录:
ts
type Evidence = {
page: number;
quote: string;
start: number;
end: number;
};
type ExtractedField<T> = {
value: T | null;
status: "accepted" | "needs_review" | "missing";
evidence: Evidence[];
normalizationNote: string;
validationErrors: string[];
};
type ContractRecord = {
documentId: string;
supplierName: ExtractedField<string>;
effectiveDate: ExtractedField<string>;
autoRenewal: ExtractedField<boolean>;
terminationNoticeDays: ExtractedField<number>;
dataRetentionDays: ExtractedField<number>;
governingLaw: ExtractedField<string>;
};字段值只是结果的一部分。evidence 让审核人一键回原文;status 决定是否可自动入库;validationErrors 把程序判断与模型解释分开。
2. 设计一条可定位故障的流水线
text
文件接收
→ 解析与页码保留
→ 文档分类
→ 候选段落召回
→ 字段抽取
→ 引用对齐
→ 确定性校验
→ 跨字段校验
→ 人工复核队列
→ 版本化入库不要一步完成所有事情。分类错误、解析丢表格、候选段落没召回、抽取误判和归一化错误的修复方式完全不同;拆开后才能知道该改解析器、检索还是模型任务。
3. 先保住原文坐标
3.1 统一解析格式
无论来源是 PDF、DOCX、邮件还是扫描件,都先转成带坐标的块:
json
{
"document_id": "contract-acme-2026",
"pages": [
{
"page": 8,
"blocks": [
{
"block_id": "p8-b12",
"kind": "paragraph",
"text": "任何一方可提前三十(30)日书面通知终止本协议。",
"char_start": 8421,
"char_end": 8449
}
]
}
]
}扫描件先 OCR,但同时保存原图坐标和 OCR 置信度。表格要保留行列关系,不能只按视觉顺序拼成一段文本。解析层一旦丢了页码和位置,后面再让模型“补引用”只会得到不可验证的引用。
3.2 给解析做三项体检
每个文档自动记录:页面数量、字符数量、空白页比例。再对表格页、双栏页和扫描页抽样。字符数突然比历史同类文档少 80%,应直接进入解析异常队列,而不是继续调用模型。
4. 先找候选,再抽字段
整份长合同直接进上下文会增加成本和干扰。为每个字段配置召回词、标题和语义查询:
ts
const fieldSpecs = {
terminationNoticeDays: {
keywords: ["终止", "解除", "提前通知", "notice", "termination"],
preferredSections: ["期限与终止", "Term and Termination"],
question: "任意一方无过错终止需要提前多少天通知?"
},
dataRetentionDays: {
keywords: ["保留", "删除", "返还", "retention", "deletion"],
preferredSections: ["数据处理", "保密与安全"],
question: "服务结束后供应商最多保留客户数据多少天?"
}
} as const;第一版可用关键词和标题召回;有真实漏召回样本后,再加入向量检索或重排。对每个字段保存候选块 ID,离线评测“正确证据是否在候选中”。如果没召回,抽取模型再强也没有用。
5. 一次只抽取同类字段
提示任务应明确字段定义、排除项和输出协议,而不是泛泛地说“提取关键信息”:
text
任务:从候选段落中提取“无过错终止提前通知天数”。
定义:任意一方无需证明违约即可终止合同时,必须提前通知的自然日天数。
排除:因违约立即解除、试用期取消、数据删除期限、付款宽限期。
规则:
1. 只能依据给定候选块;没有明确条款时 value=null。
2. 每个值必须返回逐字引用、block_id 和块内字符区间。
3. 中文数字和阿拉伯数字可归一化,但要说明过程。
4. 多个条款冲突时全部返回,不要自行选择。
5. 输出必须符合给定 Schema。把日期、金额、枚举、主体名称分别抽取,通常比一次生成整个合同对象更容易校准。字段之间的关系留给后续规则检查。
6. 服务端重新对齐引用
模型返回的 quote 和坐标都不可信,服务端必须核对:
ts
type Block = { blockId: string; page: number; text: string };
type Claim = { value: unknown; blockId: string; quote: string; start: number; end: number };
export function alignEvidence(block: Block, claim: Claim): Evidence | null {
const exact = block.text.indexOf(claim.quote);
if (exact >= 0) {
return {
page: block.page,
quote: claim.quote,
start: exact,
end: exact + claim.quote.length
};
}
const normalizedBlock = block.text.replace(/\s+/g, "");
const normalizedQuote = claim.quote.replace(/\s+/g, "");
if (!normalizedQuote || !normalizedBlock.includes(normalizedQuote)) return null;
// 空白归一化后只能证明文本存在,无法可靠复原原始坐标,交给人工复核。
return { page: block.page, quote: claim.quote, start: -1, end: -1 };
}生产实现还可加入 Unicode 归一化、OCR 常见混淆和模糊匹配,但模糊命中不应自动通过。引用无法对齐时,字段状态必须是 needs_review。
7. 用代码验证确定性规则
7.1 单字段规则
ts
function validateNoticeDays(value: number | null): string[] {
if (value === null) return ["NOTICE_DAYS_MISSING"];
if (!Number.isInteger(value)) return ["NOTICE_DAYS_NOT_INTEGER"];
if (value < 1 || value > 365) return ["NOTICE_DAYS_OUT_OF_RANGE"];
return [];
}7.2 跨字段规则
ts
function validateRecord(record: ContractRecord): string[] {
const errors: string[] = [];
if (record.autoRenewal.value === true && record.terminationNoticeDays.value === null) {
errors.push("AUTO_RENEWAL_WITHOUT_TERMINATION_NOTICE");
}
if (record.dataRetentionDays.value !== null && record.dataRetentionDays.value > 3650) {
errors.push("DATA_RETENTION_REQUIRES_LEGAL_REVIEW");
}
return errors;
}Schema 只保证“是一个整数”,业务规则才判断这个整数是否合理。供应商名称主数据匹配、日期先后关系、币种与金额组合、条款冲突都应由程序或专门规则完成。
8. 人工队列只展示需要判断的内容
不要让审核人重新阅读整份文档。复核卡片应并排展示:字段定义、模型值、原文高亮、相邻上下文、规则错误和历史版本值。审核动作只有:接受、修改、标记缺失、标记不适用。
每次修改必须保存原因码:
text
WRONG_EVIDENCE 引用段落不支持该值
NORMALIZATION_ERROR 原文正确但值转换错误
MISSED_CLAUSE 候选召回遗漏正确条款
CONFLICT_UNRESOLVED 多条规则冲突
PARSER_ERROR 文本或表格解析错误
SCHEMA_MISMATCH 字段定义不适用当前文档原因码比自由文本反馈更容易转成评测切片和修复任务。
9. 建立三层评测集
9.1 解析层
- 页数和段落是否完整;
- 表格单元格关系是否保留;
- OCR 数字、日期和专有名词错误率;
- 原文坐标能否正确高亮。
9.2 召回层
为每个标注字段保存正确 block_id,计算 Recall@K。召回率低时先修字段词典、分块和检索,不要调抽取提示。
9.3 抽取层
分别计算字段准确率、证据对齐率、缺失判断准确率和需要人工复核比例。对金额、日期、否定条款、表格、双语冲突等切片单独报告。
推荐发布门禁:
text
关键字段证据对齐率 = 100%
关键字段严重错误率 = 0
字段准确率不得低于当前基线
人工复核率变化超过 5 个百分点必须解释
解析失败文档不得静默进入空记录10. 版本、幂等与重跑
原始文件、解析结果、字段 Schema、提示模板和模型都要有版本。建议生成稳定任务键:
text
job_key = sha256(document_hash + parser_version + schema_version + extractor_version)同一任务键重复提交应返回已有结果;只修改一个字段定义时,只重跑受影响字段;旧结果保留但标记为已替代。这样才能解释“为什么同一合同上个月和本月抽取结果不同”。
11. 上线切片
第一阶段只选一种文档、五个字段、一个审核角色:
- 收集 50 份脱敏真实文档;
- 人工标注字段与原文证据;
- 跑通解析、召回、抽取、校验和审核;
- 影子运行两周,不写正式台账;
- 达到门禁后,只让
accepted字段自动入库; - 每周把人工修改按原因码加入回归集。
成熟度不体现在“支持多少文件格式”,而体现在每个入库字段能否回答:来自哪一页哪一段、经过什么校验、由谁在何时确认。