第1806篇:文档智能——结构化提取非结构化合同和报告的技术方案
第1806篇:文档智能——结构化提取非结构化合同和报告的技术方案
先说一个无聊但重要的背景
一个朋友管着法务团队,他们每年要审核几千份合同。审合同这件事,核心是:找到特定条款,判断是否有风险。
传统做法:律师逐页看,标注,写备注。一份合同少则半小时,多则几个小时。
AI的做法:自动提取关键条款,结构化输出,律师只看摘要和风险标注。实测下来一份普通合同30秒出结果,律师确认5分钟。
但这中间有个关键问题——合同是"非结构化"的。它没有固定的格式,条款顺序各不相同,表达方式千变万化,同样的"付款条款"可能叫"费用支付"也可能叫"货款结算",甚至可能嵌在总条款的第七点第三小项里。
把这样的非结构化文档,转成结构化数据,就是"文档智能"要解决的核心问题。
这篇文章就来拆解我们做的这套技术方案。
文档智能的核心挑战
这些挑战对应着不同的技术手段:
| 挑战 | 解法 |
|---|---|
| 格式多样 | 预处理层统一转换 |
| 语义复杂 | 领域增强的LLM |
| 信息分散 | 分段提取 + 汇聚 |
| 表达不固定 | 基于语义而非关键词匹配 |
整体技术架构
文档结构识别:先理解文档的骨架
在提取具体字段之前,先要理解文档的层级结构,这是很多方案遗漏的步骤:
@Component
public class DocumentStructureAnalyzer {
@Autowired
private LlmClient llmClient;
/**
* 分析文档的逻辑结构
* 输出:文档骨架(目录树)
*/
public DocumentStructure analyze(String documentText) {
// 第一步:识别标题层级
List<DocumentSection> sections = identifyStructure(documentText);
// 第二步:标注每个section的语义类型
List<DocumentSection> labeledSections = labelSectionTypes(sections);
return DocumentStructure.builder()
.sections(labeledSections)
.documentType(detectDocumentType(documentText))
.totalLength(documentText.length())
.build();
}
/**
* 用正则 + LLM识别标题层级
* 合同里的标题通常有固定格式:
* 一、 或 第一条 或 1. 或 (一)
*/
private List<DocumentSection> identifyStructure(String text) {
List<DocumentSection> sections = new ArrayList<>();
// 常见的标题模式
List<Pattern> titlePatterns = Arrays.asList(
Pattern.compile("^第[一二三四五六七八九十百]+条\\s*[\\u4e00-\\u9fa5]+",
Pattern.MULTILINE),
Pattern.compile("^[一二三四五六七八九十]+、\\s*[\\u4e00-\\u9fa5]+",
Pattern.MULTILINE),
Pattern.compile("^\\d+\\.\\s+[\\u4e00-\\u9fa5a-zA-Z]+",
Pattern.MULTILINE),
Pattern.compile("^([一二三四五六七八九十]+)\\s*[\\u4e00-\\u9fa5]+",
Pattern.MULTILINE)
);
// 标记所有可能的标题位置
TreeMap<Integer, String> titlePositions = new TreeMap<>();
for (Pattern pattern : titlePatterns) {
Matcher matcher = pattern.matcher(text);
while (matcher.find()) {
titlePositions.put(matcher.start(), matcher.group().trim());
}
}
// 按位置构建章节
List<Integer> positions = new ArrayList<>(titlePositions.keySet());
for (int i = 0; i < positions.size(); i++) {
int start = positions.get(i);
int end = (i + 1 < positions.size()) ? positions.get(i + 1) : text.length();
sections.add(DocumentSection.builder()
.title(titlePositions.get(start))
.content(text.substring(start, end))
.startPos(start)
.endPos(end)
.build());
}
return sections;
}
/**
* 用LLM给每个章节打语义标签
* 比如:付款条款、违约条款、保密条款等
*/
private List<DocumentSection> labelSectionTypes(List<DocumentSection> sections) {
if (sections.isEmpty()) return sections;
// 批量打标签(一次调用处理所有章节标题,节省token)
String titlesJson = sections.stream()
.map(s -> String.format("{\"index\": %d, \"title\": \"%s\"}",
sections.indexOf(s), s.getTitle()))
.collect(Collectors.joining(",\n", "[", "]"));
String labelPrompt = String.format("""
以下是一份合同的章节标题列表:
%s
请为每个章节标注语义类型,返回JSON数组:
[
{
"index": 章节索引,
"section_type": "章节类型",
"importance": "HIGH/MEDIUM/LOW"
}
]
常见的合同章节类型包括:
PARTIES(主体信息)、SCOPE(合同范围)、PRICE(价格金额)、
PAYMENT(付款条款)、DELIVERY(交付验收)、INTELLECTUAL_PROPERTY(知识产权)、
CONFIDENTIALITY(保密条款)、LIABILITY(责任免除)、BREACH(违约责任)、
DISPUTE(争议解决)、TERM(合同期限)、TERMINATION(合同终止)、
FORCE_MAJEURE(不可抗力)、GENERAL(一般条款)、APPENDIX(附件)
""", titlesJson);
String response = llmClient.complete(labelPrompt);
Map<Integer, String> labelMap = parseSectionLabels(response);
return sections.stream().map(section -> {
int idx = sections.indexOf(section);
section.setSectionType(labelMap.getOrDefault(idx, "GENERAL"));
return section;
}).collect(Collectors.toList());
}
}分层提取策略
知道了文档骨架,就可以用更精准的策略提取各类字段。
关键思路:不同类型的章节用不同的提取Prompt,不要用一个大Prompt提取所有内容。
@Component
public class ContractFieldExtractor {
@Autowired
private LlmClient llmClient;
/**
* 路由提取器:根据章节类型选择对应的提取策略
*/
public Map<String, Object> extractSection(DocumentSection section) {
return switch (section.getSectionType()) {
case "PARTIES" -> extractParties(section.getContent());
case "PRICE", "PAYMENT" -> extractFinancialTerms(section.getContent());
case "DELIVERY" -> extractDeliveryTerms(section.getContent());
case "BREACH" -> extractBreachTerms(section.getContent());
case "DISPUTE" -> extractDisputeTerms(section.getContent());
case "TERM", "TERMINATION" -> extractTimeTerms(section.getContent());
case "CONFIDENTIALITY" -> extractConfidentialityTerms(section.getContent());
default -> extractGenericSection(section);
};
}
/**
* 主体信息提取
* 合同主体是最基础的信息,必须准确
*/
private Map<String, Object> extractParties(String content) {
String prompt = String.format("""
从以下合同文本中提取合同主体信息。
注意:
1. 甲方通常是委托方/买方,乙方通常是承接方/卖方
2. 主体可能包含多个(甲方乙方丙方)
3. 注意区分"公司名称"和"注册地址"
4. 法定代表人和联系人可能不同
文本:
%s
返回JSON格式:
{
"party_a": {
"name": "全称",
"short_name": "简称(如果有)",
"unified_credit_code": "统一社会信用代码",
"legal_representative": "法定代表人",
"registered_address": "注册地址",
"contact_person": "联系人",
"contact_phone": "联系电话"
},
"party_b": { ...同上 },
"additional_parties": [...] // 如果有丙方等
}
无法确定的字段填null,不要猜测。
""", content);
return parseJsonToMap(llmClient.complete(prompt));
}
/**
* 财务条款提取
* 金额、付款方式、付款节点是高风险字段
*/
private Map<String, Object> extractFinancialTerms(String content) {
String prompt = String.format("""
从以下合同文本中提取财务相关条款。
重点关注:
1. 总价格(含税/不含税,注明)
2. 付款节点和每次付款比例(用时间线描述)
3. 付款条件(验收后多少天内?里程碑付款?)
4. 发票类型要求
5. 延迟付款的违约金率
6. 价格调整条款(是否有价格保护/调整机制)
文本:
%s
返回JSON格式:
{
"contract_value": {
"amount": 数字(元,不含税),
"tax_rate": 税率(如0.06),
"total_with_tax": 含税总价,
"currency": "CNY"
},
"payment_schedule": [
{
"sequence": 1,
"trigger": "触发条件(如:合同签署后5个工作日内)",
"percentage": 0.3,
"amount": 金额,
"description": "首付款"
}
],
"invoice_requirements": "发票类型要求",
"late_payment_penalty": "延迟付款违约金条款描述",
"price_adjustment": "价格调整条款(如无则null)"
}
""", content);
return parseJsonToMap(llmClient.complete(prompt));
}
/**
* 违约责任提取
* 这部分是法律风险最集中的地方
*/
private Map<String, Object> extractBreachTerms(String content) {
String prompt = String.format("""
从以下合同文本中提取违约责任相关条款。
请特别关注:
1. 甲方违约和乙方违约的责任是否对等(不对等是风险点)
2. 违约金的计算方式(固定金额 vs 按日计算 vs 按比例)
3. 赔偿上限条款(很多合同限制了索赔上限)
4. 解除合同的条件和赔偿
5. 单方面解除权(谁有权单方面解除?)
文本:
%s
返回JSON格式:
{
"party_a_breach": {
"scenarios": ["甲方违约场景描述"],
"penalties": ["对应违约责任"]
},
"party_b_breach": {
"scenarios": ["乙方违约场景描述"],
"penalties": ["对应违约责任"]
},
"is_asymmetric": 是否存在明显的责任不对等(true/false),
"liability_cap": "赔偿责任上限(如果有)",
"unilateral_termination_right": "谁有单方面解除权及条件"
}
""", content);
return parseJsonToMap(llmClient.complete(prompt));
}
}跨章节信息聚合
合同里很多信息是分散的,需要聚合到一起。比如付款条款在第四条,但验收标准在第七条——这两个密切相关,要结合起来理解。
@Component
public class CrossSectionAggregator {
@Autowired
private LlmClient llmClient;
/**
* 聚合多个章节的提取结果
* 并检测跨章节的信息一致性
*/
public AggregatedContractData aggregate(
Map<String, Map<String, Object>> sectionResults,
DocumentStructure structure) {
// 整合各章节数据
AggregatedContractData data = new AggregatedContractData();
data.setParties((Map) sectionResults.getOrDefault("PARTIES", new HashMap<>()));
data.setFinancial(mergeFinancialData(sectionResults));
data.setTimeline(buildTimeline(sectionResults));
data.setKeyTerms(extractKeyTerms(sectionResults));
// 关键:一致性检查
List<Inconsistency> inconsistencies = checkConsistency(sectionResults);
data.setInconsistencies(inconsistencies);
// 风险评估
List<RiskItem> risks = assessRisks(data, sectionResults);
data.setRisks(risks);
return data;
}
/**
* 一致性检查:找出文档内部矛盾
* 这个功能在实际使用中非常有价值
*/
private List<Inconsistency> checkConsistency(
Map<String, Map<String, Object>> sectionResults) {
List<Inconsistency> inconsistencies = new ArrayList<>();
// 检查1:付款金额与合同总价一致性
checkPaymentTotalConsistency(sectionResults, inconsistencies);
// 检查2:合同期限与各节点时间的逻辑一致性
checkTimelineConsistency(sectionResults, inconsistencies);
// 检查3:跨章节中对同一事物的描述是否矛盾
checkCrossReferenceConsistency(sectionResults, inconsistencies);
return inconsistencies;
}
private void checkPaymentTotalConsistency(
Map<String, Map<String, Object>> sectionResults,
List<Inconsistency> inconsistencies) {
Map<String, Object> financial = sectionResults.get("PRICE");
if (financial == null) return;
// 获取合同总价
Double contractValue = getNumberValue(financial, "contract_value.amount");
// 获取付款计划中各节点金额之和
List<Map<String, Object>> schedule = (List) financial.get("payment_schedule");
if (schedule == null) return;
double totalScheduled = schedule.stream()
.mapToDouble(item -> getNumberValue(item, "amount", 0.0))
.sum();
if (contractValue != null &&
Math.abs(contractValue - totalScheduled) > contractValue * 0.01) {
inconsistencies.add(Inconsistency.builder()
.type("AMOUNT_MISMATCH")
.description(String.format(
"合同总价(%.2f)与付款计划各节点之和(%.2f)不一致",
contractValue, totalScheduled))
.severity("HIGH")
.build());
}
}
/**
* 构建时间线:把所有时间节点排成序列
* 这是给法务/业务人员看的,非常实用
*/
private List<TimelineEvent> buildTimeline(
Map<String, Map<String, Object>> sectionResults) {
List<TimelineEvent> events = new ArrayList<>();
// 从各章节收集时间节点
// 合同签署、付款节点、交付节点、验收节点、合同到期
Map<String, Object> term = sectionResults.get("TERM");
if (term != null) {
addTermEvents(term, events);
}
Map<String, Object> payment = sectionResults.get("PAYMENT");
if (payment != null) {
addPaymentEvents(payment, events);
}
Map<String, Object> delivery = sectionResults.get("DELIVERY");
if (delivery != null) {
addDeliveryEvents(delivery, events);
}
// 按时间排序
events.sort(Comparator.comparing(TimelineEvent::getSequence));
return events;
}
/**
* 风险评估:综合所有章节的信息,识别合同风险
*/
private List<RiskItem> assessRisks(AggregatedContractData data,
Map<String, Map<String, Object>> sectionResults) {
// 将所有提取结果序列化,一次性发给LLM做风险评估
String contractSummary = serializeContractData(data, sectionResults);
String riskPrompt = String.format("""
以下是一份合同的结构化摘要:
%s
请从法律和商业风险角度进行全面分析,识别潜在风险点。
重点关注:
1. 付款条款对己方是否不利(付款条件苛刻/付款周期过长)
2. 违约责任是否对等
3. 知识产权归属是否明确
4. 保密范围和期限是否合理
5. 单方面解除权和赔偿条款
6. 合同内部的逻辑矛盾
7. 模糊条款(可能产生歧义的表述)
8. 不可抗力范围是否合理
以JSON数组返回,每个风险点包含:
[
{
"risk_id": "唯一标识",
"risk_type": "风险类型",
"risk_level": "HIGH/MEDIUM/LOW",
"location": "涉及的合同章节",
"description": "具体风险描述",
"potential_impact": "可能的影响",
"recommendation": "建议处理方式"
}
]
""", contractSummary);
String response = llmClient.complete(riskPrompt);
return parseRiskItems(response);
}
}年度报告结构化提取
合同以外,另一个常见场景是年度报告、分析报告的结构化提取。这类文档的特点是篇幅长、有大量数字,且同一数字可能在不同位置被引用。
@Component
public class AnnualReportExtractor {
@Autowired
private DocumentPreprocessor preprocessor;
@Autowired
private LlmClient llmClient;
/**
* 年报结构化提取的特殊挑战:
* 1. 财务数据要和财务报表交叉验证
* 2. 文字描述中的数字要和表格数字对上
* 3. 同比/环比数据的计算要验证
*/
public AnnualReportData extract(byte[] reportData) {
// 预处理
DocumentContent content = preprocessor.process(reportData);
// 先提取目录结构,定位关键章节
String toc = extractTableOfContents(content);
Map<String, Integer> sectionPages = parseTocPages(toc);
// 分批处理各章节
AnnualReportData result = new AnnualReportData();
// 核心财务数据
result.setFinancialHighlights(extractFinancialHighlights(content, sectionPages));
// 管理层讨论
result.setMdaSection(extractMDA(content, sectionPages));
// 主要财务指标
result.setKeyMetrics(extractKeyMetrics(content, sectionPages));
// 风险因素
result.setRiskFactors(extractRiskFactors(content, sectionPages));
// 交叉验证:确保各处数字一致
result.setValidationResults(crossValidate(result));
return result;
}
/**
* 财务指标提取
* 核心:准确提取数字,并标注单位和时间维度
*/
private List<FinancialMetric> extractKeyMetrics(DocumentContent content,
Map<String, Integer> sectionPages) {
// 定位财务摘要章节
String financialSection = extractSection(content,
sectionPages.get("financial_highlights"), 3);
String prompt = String.format("""
从以下年报片段中提取关键财务指标。
重要要求:
1. 数字必须准确,保留原始精度
2. 标注单位(元/万元/亿元,人民币/美元)
3. 标注时间维度(2023年 vs 2022年,同比/环比)
4. 如果有比较数据,一并提取
文本片段:
%s
提取以下指标(找不到的填null):
{
"revenue": {
"current_year": {"value": 数值, "unit": "亿元", "year": 2023},
"prior_year": {"value": 数值, "unit": "亿元", "year": 2022},
"yoy_change": 同比变化率(小数)
},
"net_profit": { ...同上 },
"gross_margin": { ...(百分比形式)},
"total_assets": { ...},
"roe": { ...(净资产收益率)},
"eps": { ...(每股收益)},
"operating_cashflow": { ...}
}
""", financialSection);
String response = llmClient.complete(prompt);
return parseFinancialMetrics(response);
}
/**
* 交叉验证:检查报告内部数据一致性
* 这在财务分析中非常重要
*/
private List<ValidationResult> crossValidate(AnnualReportData data) {
List<ValidationResult> results = new ArrayList<>();
// 验证:收入增长率与绝对值是否匹配
FinancialMetric revenue = findMetric(data.getKeyMetrics(), "revenue");
if (revenue != null && revenue.getCurrentYear() != null
&& revenue.getPriorYear() != null && revenue.getYoyChange() != null) {
double computedYoy = (revenue.getCurrentYear().getValue() -
revenue.getPriorYear().getValue()) /
revenue.getPriorYear().getValue();
double reportedYoy = revenue.getYoyChange();
if (Math.abs(computedYoy - reportedYoy) > 0.01) {
results.add(ValidationResult.builder()
.field("revenue.yoy_change")
.status("INCONSISTENT")
.description(String.format(
"同比增长率不一致:报告显示%.1f%%,但根据绝对值计算为%.1f%%",
reportedYoy * 100, computedYoy * 100))
.build());
}
}
return results;
}
}增量更新与版本追踪
合同修改是常有的事,需要支持版本对比:
@Service
public class DocumentVersionTracker {
@Autowired
private ContractFieldExtractor extractor;
@Autowired
private LlmClient llmClient;
/**
* 比较两个版本的合同,找出修改点
* 特别关注:金额、时间、责任条款的变化
*/
public VersionDiff compare(String contractV1, String contractV2) {
// 先分别提取结构化数据
AggregatedContractData dataV1 = extractContract(contractV1);
AggregatedContractData dataV2 = extractContract(contractV2);
// 结构化字段对比
List<FieldChange> fieldChanges = compareStructuredData(dataV1, dataV2);
// 文本层面的对比(用于找到被修改的具体段落)
List<TextChange> textChanges = compareText(contractV1, contractV2);
// 用LLM解读变更的含义和影响
List<ChangeImpact> impacts = analyzeChangeImpacts(fieldChanges, textChanges);
return VersionDiff.builder()
.fieldChanges(fieldChanges)
.textChanges(textChanges)
.changeImpacts(impacts)
.riskLevelChanged(assessRiskChange(dataV1, dataV2))
.build();
}
/**
* 分析变更的业务含义
* 不只说"什么变了",还要说"变化的影响是什么"
*/
private List<ChangeImpact> analyzeChangeImpacts(List<FieldChange> fieldChanges,
List<TextChange> textChanges) {
if (fieldChanges.isEmpty() && textChanges.isEmpty()) {
return Collections.emptyList();
}
String changesDescription = buildChangesDescription(fieldChanges, textChanges);
String prompt = String.format("""
以下是合同两个版本之间的修改内容:
%s
请分析每处修改的业务含义和潜在影响。
对于每个重要修改,说明:
1. 修改了什么(用通俗语言描述)
2. 对己方的影响(有利/不利/中性)
3. 风险变化(风险增加/降低/不变)
4. 是否需要谈判/拒绝
返回JSON数组:
[
{
"change_id": "关联的修改ID",
"business_meaning": "业务含义描述",
"impact_on_party": "对己方影响",
"risk_change": "INCREASED/DECREASED/NEUTRAL",
"action_required": "建议行动(如需要则说明)"
}
]
""", changesDescription);
return parseChangeImpacts(llmClient.complete(prompt));
}
}提取质量的自我评估
这是一个重要但经常被忽略的设计:让系统对自己的提取结果做质量评分:
@Component
public class ExtractionQualityEvaluator {
@Autowired
private LlmClient llmClient;
/**
* 对提取结果进行自我评估
* 关键:不是让LLM说"我做得很好",而是让它主动找出可能的问题
*/
public QualityReport evaluate(String originalText,
AggregatedContractData extractedData) {
String evalPrompt = String.format("""
以下是合同原文(节选)和AI提取的结构化数据:
【原文】:
%s
【提取结果】:
%s
请评估提取结果的质量,重点检查:
1. 是否有关键字段遗漏
2. 提取的数字/日期是否准确
3. 是否存在歧义字段(提取结果可能有多种解读)
4. 哪些字段置信度较低(原文表述模糊)
5. 是否有信息被错误归类到错误字段
返回JSON格式:
{
"overall_quality": "HIGH/MEDIUM/LOW",
"confidence_score": 0-1的分数,
"missing_fields": ["可能遗漏的字段"],
"uncertain_fields": [
{
"field": "字段名",
"extracted_value": "提取值",
"uncertainty_reason": "不确定的原因",
"alternative_interpretation": "可能的另一种理解"
}
],
"potential_errors": [
{
"field": "字段名",
"extracted_value": "提取值",
"concern": "疑似错误的原因"
}
],
"review_recommendations": ["建议人工复核的要点"]
}
""",
originalText.substring(0, Math.min(3000, originalText.length())),
toJson(extractedData));
String response = llmClient.complete(evalPrompt);
return parseQualityReport(response);
}
}踩过的坑与经验
坑1:一次提取全部字段效果差
最开始我们设计了一个超长Prompt,想一次性把合同里所有字段都提取出来。实测发现,Prompt越长,模型对每个字段的关注度越低,准确率反而下降。
后来改成"一章节一Prompt"的策略,准确率提升明显。
坑2:数字单位混乱
合同里可能同时出现"元"和"万元",有时候写的是"壹佰万元整",有时候写的是"¥1,000,000.00"。要在提取Prompt里明确要求统一单位,并在后处理中做单位归一化。
坑3:指代不清楚
合同里大量使用代词,比如"甲方须在前述条件满足后十日内"——"前述条件"是什么?这种情况LLM有时候理解错。解决方案:提供足够的上下文,不能只截取单个章节,要把前后相关段落都带上。
坑4:OCR错误污染提取
扫描版合同经过OCR后,会有字符错误,比如"1"被认成"I","0"被认成"O"。这些错误会导致提取的数字不对。要在提取前先做OCR质量检查,低质量文档要先人工校正再提取。
小结
文档智能提取,核心技术链路是:
- 结构识别先行,知道文档骨架才能精准提取
- 分类路由,不同类型章节用不同Prompt
- 跨章节聚合,分散信息要汇聚
- 一致性验证,数字和逻辑的内部矛盾要找出来
- 质量自评,系统要知道自己哪些地方不确定
这套方案落地后,合同审查效率提升了5-8倍,而且让律师可以把精力集中在真正需要判断的地方。
