先说个直白的结论:把一份五十页的合同PDF丢给ChatGPT让它“帮我看看”,和真正把合同审阅这件事落地到业务系统,中间隔着一整条工程链路。大模型擅长的是“像人一样说话”,但合同审阅要的是“像审计一样逐条核对”。我最近做了一个合同审阅小工具,把RAG检索增强生成和Structured Extraction结构化抽取拼在一起,用来处理入库合同的风险条款识别与关键信息归档,实测从“聊聊天”升级到“能干活”,中间的差距全在怎么搭这条链路。这篇文章就把我完整走通的技术方案和踩坑过程拆开讲。
1. 合同盲区到底盲在哪里,聊天式大模型为什么救不了
1.1 一份常规合同的“隐藏信息量”远比想象中大
合同这个载体很特殊。它表面上是自然语言文本,但实际上是强结构化的法律文件。一份中等规模的服务合同,通常包含当事人信息、标的描述、交付节点、付款条件、违约责任、保密义务、争议解决、不可抗力、通知送达等十几个模块。
更麻烦的是,这些模块在文档里出现的位置不固定,表达方式千差万别。有的合同把付款条款藏在“商务条款”下面,有的把违约责任散落在“甲方的权利和义务”“乙方的权利和义务”两节里,还有的合同把管辖法院约定放在最后一页的“其他约定”里。传统的关键词扫描只能命中明确包含“违约金”“管辖”字眼的句子,一旦换个说法,比如“守约方可要求违约方承担由此造成的全部损失”,关键词就失效了。合同盲区首先就体现在这里:你靠人眼审阅一次可能漏,靠关键词规则更会漏,而大模型虽然能理解语义,但它默认只会“回答你的问题”,不会主动把所有关键信息翻出来排列好。
1.2 通用问答与合同审阅之间的“最后一公里”
聊天式大模型做不到合同审阅这件事,根子不在模型能力,而在交互范式。
通用问答是“你问我答”:你需要先知道一份合同里可能有什么坑,才能发出正确的问题。但合同审阅的核心需求恰恰是“我不知道我不知道什么”。一份合同有没有隐藏的自动续约条款,有没有排他性合作约定,有没有超过行业惯例的赔偿上限,这些是你需要系统主动告诉你的,不是你问出来的。
另一个问题是不确定性。你对着一个合同PDF问“有哪些风险”,模型会给你一段流畅的总结,但你没法判断它总结得对不对、全不全,也没法追溯它依据的是合同第几条第几款。这就是所谓的“最后一公里”—— 模型给出了答案,但答案没有锚点在原始文档上,无法复核,也就不具备业务可用的可信度。Structured Extraction要解决的正是这件事:把模型的输出从“自由发挥的口语段落”变成“严格符合预定义Schema的结构化字段”,每个字段还能带上来源页码和原文引用。
1.3 我最初用纯提示词方案踩的坑
刚开始做的时候,我偷懒,想直接用单次提示词完成“阅读合同+提取信息+风险提示”全流程。写了一段很长很完整的系统提示词,把要提取的字段都列进去,然后把合同全文塞进去让模型输出JSON。
第一次测试就翻车了。
一份二十页的合同转成文本大概一万五千字。塞进上下文后,模型确实能生成一段看起来像模像样的JSON,但仔细核对发现:付款条款的截止日期提取错了,把“合同签订之日起七个工作日内”理解成了具体日期;保密期限漏了,因为原文写的是“从本协议终止之日起三年”,模型只抽取了“三年”却没关联“终止之日”这个起算点。更要命的是,模型在输出里“脑补”了一个违约责任条款,原文根本没有,它依据的是“常见的合同一般都有这条”。
这件事给了我一个教训:纯靠提示词做大上下文信息抽取,幻觉和遗漏很难控制。输出格式越自由,模型的自由发挥空间就越大。必须用两件事来约束它—— 检索筛选相关片段,结构化输出固定Schema。这也是后面整个方案的核心动机。
2. 为什么是RAG + Structured Extraction,而不是微调或单靠长上下文
2.1 合同场景对能力的真实需求拆解
把合同审阅这件事拆开看,其实需要两种不同的能力。
第一是“找”的能力。给定一份合同,你要先把和某个风险点相关的句子、条款找出来。这个动作本质是召回,要求不漏、不过度。尝试用规则做不行,表达太灵活;用模型做全文理解也不行,上下文一大,注意力被稀释,召回率反而下降。
第二是“填”的能力。找到相关片段之后,要对齐到业务定义的字段上,比如“违约金比例”“付款节点”“管辖法院”。这个动作本质是分类加抽取,要求对齐准确、格式统一。
RAG负责第一件事,它把“找”变成了向量检索加排序,可解释、可追溯,而且只把相关片段喂给模型,让模型不用在一堆无关内容里大海捞针。Structured Extraction负责第二件事,它用固定的输出Schema约束模型的解码空间,减少自由发挥。两者拼起来,正好覆盖合同审阅的两半需求。
2.2 三套技术路线的取舍对比
我认真对比过三条路线:微调、超长上下文直接抽取、RAG加结构化抽取。可以看这个表:
| 方案 | 优势 | 劣势 | 是否适合合同审阅 |
|---|---|---|---|
| 领域微调 | 深度适配专有格式 | 需要大量标注数据;模型更新后要重新调;成本高 | 适合做底层模型,不适合快速落地 |
| 超长上下文直接抽取 | 实现简单,一次调用 | 长文本注意力稀释导致遗漏;幻觉风险高;输出不稳定;无法按片段追溯 | 不适合 |
| RAG + 结构化抽取 | 召回可控、输出可控、支持溯源 | 工程链路稍长,需要处理切片和组装 | 最适合快速落地 |
超长上下文方案我实际测过。上下文达到两万字以上后,模型对中段内容的注意力显著下降,尤其是合同中那些不起眼但关键的“其他约定”条款,经常被漏掉。这类条款恰恰是合同盲区的高发地带,所以这条路我直接否掉了。
微调我也评估过。如果能拿到几千份标注好的合同,微调一个领域模型肯定是最好的长远方案。但现实是,合同版式五花八门,字段标准每家公司不一样,标注成本极高。相比之下,RAG加结构化抽取不需要训练样本,只需要设计好Schema和提示词,就能在几周内达到业务可用的准确率,性价比明显更高。
2.3 拆解最终方案:检索保证广度,抽取保证精度
最终确定的流程是这样的:
- 合同PDF解析成结构化文本,按条款粒度切片,存入向量库,每条切片带元数据(合同编号、条款编号、页码)。
- 针对每个目标字段,用检索器从向量库里召回最相关的Top-K片段。
- 把召回结果按预设模板组装成上下文,调用带有结构化输出约束的模型接口,强制输出符合Schema的JSON。
- 对输出做后校验:缺失字段标为“未识别”,可疑值打上标记,不允许模型乱填。
这个方案的精髓在于分工。向量检索负责“广度”——无论条款藏在合同哪个角落,只要语义相关就能召回;结构化抽取负责“精度”——模型只能从召回的片段里提取数据,不能凭空生成。两者互相制约,幻觉问题就从机制上被压制了。
3. 从PDF到向量库:合同文本预处理的工程细节
3.1 解析层选型与表格、扫描件处理
合同文本预处理是整个链路里最不起眼却最容易埋雷的部分。解析做不好,后续检索和抽取的准确率天花板直接锁死。
文本型PDF处理相对简单。我用的是常见的PDF解析库,优先保留页面布局中的阅读顺序,而不是PDF内部对象顺序。这一步很关键:有些PDF生成工具会把页眉页脚和正文混在一起,直接提文本会发现条款前后颠倒、句子被拦腰截断。
表格是合同里的重灾区。付款计划、价目表、交付里程碑十有八九是表格形式,普通文本解析出来全乱成一团。我的处理办法是:表格区域用专门的表格解析逻辑还原成行列结构,再按“表头:字段名, 单元格:值”的形式转成文本,保证后续抽取模型能看懂。
扫描件合同,也就是纯图片PDF,必须走OCR。建议把扫描页单独抽出来识别,识别完之后再和文本页合并排序。OCR的准确性直接影响抽取结果,如果扫描质量差,宁可先做图像增强,也别硬识别。
3.2 切分策略:先按条款结构切,再补向量检索
纯按固定token长度切片做RAG,在合同场景里效果很差。合同条款经常是一整条包含多个子款的长句,固定切片会把一条完整约定从中间切断,检索时要么漏掉后半段,要么把两件事揉在一起。
我的切分思路是结构优先:
- 先按“第X条”或“第X.X条”这类条款标题切分出条目块。
- 条目块如果超过模型上下文限制,再按自然段边界二次切分。
- 对于没有条款编号的段落型合同,退回到按“换行加缩进”或“序号模式”识别边界。
切完之后还有一个关键步骤:相邻条款合并。合同里经常出现“第7条违约金”只有一句总述,具体计算方式写在“第8条赔偿”里。检索的时候,只召回第7条会丢失关键信息。所以我会对每个切片做“带上下文窗口”的组装—— 每条切片默认携带前后各一个相邻切片作为辅助上下文,这个技巧用起来检索命中率提升非常明显。
3.3 元数据设计:让检索结果自带“位置坐标”
切片存进向量库不只是存文本和向量,元数据设计直接决定下游能否追溯。
我用的元数据字段包括:
- 合同编号:多份合同混库时的硬过滤条件。
- 条款编号:用于定位是第几条,也用于去重。
- 页码:最终展示给业务方时,可以跳转原文。
- 切片类型:条款型还是段落型,影响抽取提示词的组装方式。
- 模糊哈希值:同名合同重复入库时快速检测。
这套元数据最大的价值在于:RAG召回结果不再是“一段孤零零的文字”,而是自带GPS坐标的证据片段。下游展示渲染时,可以直接标注“风险点来自第X页第X条”,业务人员点开就能核验原文,这在合同审阅场景里是刚需。
4. 结构化抽取Schema的设计,是方案质量的命门
4.1 先定业务目标,再定义输出字段
很多人喜欢先设计JSON结构再想业务,这是本末倒置。结构化抽取的第一步不是“模型输出什么”,而是“业务方到底关心什么”。
我做的合同审阅工具,业务方最关心的问题其实是这几个:
- 这份合同的当事人是谁,金额多少,账期多长?
- 有没有自动续约、排他合作、竞业限制这类容易踩坑的条款?
- 违约金比例是否超过常规范围?
- 争议解决是仲裁还是诉讼,管辖地在哪里?
- 保密义务持续多久,有没有例外情况?
围绕这些问题,我定义了五个抽取模块:当事人与金额、付款与交付、违约与解除、保密与知识产权、争议解决。每个模块下再细分字段。这个顶层设计决定了后面所有工作,字段定义得越贴近业务,输出的结果就越有用。
4.2 从模型能力反推Schema的复杂度
Schema不是越细越好,它受模型能力约束。
字段太粗,比如“付款条款”只让模型填一段原文,那等于没抽取;字段太细,比如针对每一句都让模型判断是“义务型”“权利型”“禁止型”,模型又会在小分类上频繁出错,准确率断崖下跌。
我的经验是:字段粒度控制在“业务人员拿到后不需要再理解模型输出,直接能用作判断依据”的程度。以违约金为例,不要只抽一个“违约金比例”,而要拆成:
"违约金_Rate": 具体数值或“无约定”, "违约金_计算基数": 合同总价/未履行部分金额/其他, "违约金_起算条件": 触发违约的情形描述, "违约金_上限": 是否有上限及数值每一个字段都要业务方认可,且模型基于召回片段能够准确判断,两者缺一不可。宁可字段少而准,不要多而废。
4.3 一个可落地的合同抽取Schema示例
下面是我实际在用的精简版Schema,JSON结构可以直接套用:
{ "contract_meta": { "contract_title": "合同名称", "party_a": "甲方全称", "party_b": "乙方全称", "sign_date": "签署日期或未约定", "effective_date": "生效日期或未约定" }, "payment_terms": { "total_amount": "合同总金额,含币种", "payment_installments": [ {"amount": "金额或比例", "due_date_or_condition": "付款节点描述"} ], "acceptance_condition": "验收条件或未约定" }, "liability": { "liquidated_damages": "违约金描述或未约定", "damages_cap": "赔偿责任上限或未约定", "termination_rights": "单方解除权触发条件或未约定" }, "ip_and_confidentiality": { "confidentiality_term": "保密期限及起算点", "ip_ownership": "知识产权归属描述", "non_solicit_clause": "是否存在竞业限制或挖角限制" }, "dispute_resolution": { "method": "诉讼/仲裁/未约定", "arbitration_commission": "仲裁机构,如适用", "jurisdiction_court": "管辖法院,如适用", "governing_law": "适用法律" } }注意每个字段默认值统一用“未约定”,这是策略性的:模型判断不了时填“未约定”,比填一个“看起来合理”的猜测强得多。宁要明确的未知,不要虚假的确定。
4.4 用函数调用机制约束输出格式
Schema定义好了,怎么强制模型按这个格式输出是关键。我用的方案是兼容OpenAI格式的Function Calling机制,把Schema的每一个顶层模块定义成一个函数参数,让模型在调用函数时顺便完成抽取。
这样做有几个好处。第一,输出天然是JSON,不用再做格式解析;第二,函数的参数描述本身就在引导模型“这里需要填什么”;第三,可以强制部分字段为必选,比如违约金模块一定要输出,哪怕内容是“未约定”,也不能缺这个键。
实际组装的时候,我会把函数定义里每个字段的描述写得非常明确,例如违约金计算基数,描述会写成“从合同原文中识别违约金计算依据,如合同总金额、未履行部分金额、实际损失等,原文未明确时填未约定”。描述越具体,模型输出的对齐率越高。
5. 让RAG和抽取协同工作:完整链路的串联与容错
5.1 两阶段流程设计
整个系统跑起来是两阶段。
阶段一是检索召回。每个目标字段预生成一个检索问题模板,比如针对“违约金”模块,检索问题就是“违约金比例和计算方式”,针对“保密期限”,检索问题就是“保密义务的期限和起算时间”。这个模板问题拿去向量库做相似度检索,得到Top-K片段,K一般取5到8。
阶段二是结构化抽取。把召回的片段按“原文引用”的形式拼进上下文,同时把函数定义传给模型接口。模型只能基于这些原文片段作答,提示词里显式写一句:“以下内容是该合同的相关条款片段,抽取时必须严格依据片段原文,不得补充任何片段中不存在的信息。”
两阶段分离最大的好处是:可以单独调优。召回率低了,就调切片方式;抽取准确率低了,就调提示词和Schema。两件事的干扰因素被切开,排查问题效率高很多。
5.2 检索增强如何喂给抽取模型
喂给抽取模型的上下文组装方式直接影响结果质量。
我最开始直接把所有召回片段拼接成一大坨丢进去,效果不理想。因为模型无法区分“哪段文字对应哪个字段需求”。后来改成模板化组装:
[字段目标说明] 需要抽取的信息:违约金约定、违约金计算基数、违约金上限 [合同原文片段] <fragment source="第7条" page="5"> (这里是召回的原文) </fragment> <fragment source="第8条" page="5"> (这里是召回的原文) </fragment> [抽取要求] 依据以上片段回答问题,信息不足时填“未约定”这个模板的要点在于给每条片段加了来源标签,让模型知道每段话的出处和页码范围。有了来源锚点,输出字段里就能顺便带回引用信息,这是实现可追溯的关键一步。
5.3 敏感条款遗漏的兜底机制
即使RAG召回设计得再完善,也总有漏网之鱼。合同里那些藏在犄角旮旯的敏感条款,比如“送达地址条款”“代词定义条款”里夹带的排他性约定,向量检索可能召不到。
我的做法是加了一层“全局扫描兜底”。对整份合同文本跑一个轻量级的规则加正则扫描,专门命中高风险关键词组合,比如“独家”“唯一”“排他”“自动续展”“视为已接受”这些短语。一旦命中,就把命中的段落直接注入抽取上下文,不经过向量检索。
这个兜底机制本质是利用关键词规则的确定性弥补向量检索的不确定性。合同盲区往往不是向量检索本身不行,而是相似度排序把隐藏条款排在后面了。规则扫描直接把“高价值但低相似度”的内容拉出来,双通道保障召回覆盖率。
6. 实测效果与调优实录:从50%准确率到95%的关键调整
6.1 评测集的构建方式
做信息抽取,没有评测集就谈不上优化空间。我的做法是手工标注了一批历史合同,数量不多,但覆盖了常见场景。
每份合同挑出十个关键字段,人工标注标准答案,然后让系统跑一遍,比对输出结果。准确率的计算方式很简单:字段完全正确的除以总字段数。这里的“正确”定义为内容准确、格式符合要求、且能在原文中找到依据,三个条件缺一不算。
刚开始跑,总准确率只有五成左右,问题集中在三块:
- 日期提取错误率高,模型经常把“合同签订之日”这种相对时间理解成绝对日期。
- 违约金计算基数经常抽错,把“合同总价”抽成“已付金额”。
- 保密期限起算点普遍漏掉,模型只写了“三年”,没写“从本协议终止之日起”。
6.2 最常见的三类误抽和根因
先说日期提取。根因是原文里的时间表达是相对性的,而模型倾向于“换算”成具体日期。比如“签订之日起七个工作日内”,模型直接写了个七天后的大概日期,既不准确还无法核实。
我的修正方案是:Schema里明确要求“保留原文表达方式,禁止换算成绝对日期”。系统提示语里写死“凡是原文使用相对时间表达的,必须原样输出相对描述”。这一条改完,日期类字段准确率直接从五成拉到九成。
违约金计算基数的误抽,根因是召回片段不足。模型只看到了条款本身的句子,没看到下文对“合同金额”的定义。修正方式是扩充检索上下文窗口,把违约条款所在的小节整体作为辅助上下文喂入,而不是只依赖单条切片。
保密期限起算点被漏,根因是Schema里没有单独字段引导模型去找起算条件。我把字段从“保密期限”改成“保密期限及起算条件”,提示词里举例“保密期限通常自合同签订之日或披露之日起算,也可能自协议终止之日起算,请识别原文使用的起算点”。字段一改,模型才意识到要找这个信息。
6.3 提示词与校验规则最终形态
经过三轮调优,最终系统里有一套稳定的提示词结构:
你是合同审阅助手。你的任务是从给定的合同条款片段中提取结构化信息。 要求: 1. 所有输出必须严格依据片段原文,禁止补充片段中不存在的任何内容。 2. 原文使用相对时间时,原样输出相对描述,禁止换算为绝对日期。 3. 未在片段中体现的信息,统一填入“未约定”,禁止猜测。 4. 必须验证你的输出是否有原文依据,没有依据则填“未约定”。后校验规则也加上了一层:模型输出里如果出现数值字段但格式不对,比如比例字段填了“违约金为总价的5%”而不是“5%”,会用规则清洗一次;字段值为“未约定”时,再触发一次原文复核,把包含字段关键词的前后文再检索一遍,确认是否真的没有该信息。
这一整套调完,十字段评测集上的整体准确率稳定在九成半左右。剩下的五个点误差,基本集中在手写扫描合同和高度口语化的补充协议上,属于需要专有OCR优化或人工复核的范畴,可以在界面里把“低置信度”标记出来让人再瞄一眼,不影响整体可用性。
7. 我最后想分享的几点经验
这套方案最让我意外的不是抽取准确率提升,而是“检索与抽取分离”带来的可维护性。业务方不关心RAG用了什么向量库、Function Calling机制是什么,他们只关心“模型说的每条结论能不能翻到原文核实”。让我多提醒一句:在一开始就实施证据溯源,把“页码加条款编号”嵌入每个抽取结果,这个设计让我赢得了信任,也躲开了不少返工。
另外一个小技巧:不要只用模型自带的置信度判断,要自己沉淀一套基于规则的对账机制。比如合同总金额,请把中文大写金额、阿拉伯数字金额、表格里的金额三个来源对一遍,不一致就弹出告警。这些规则很笨,但非常管用。
如果你也在做合同相关的智能化改造,我的建议是先别急着上微调,把RAG加Structured Extraction这套链路跑通、把字段评估集建好,你获得的收益和确定性会比想象中大很多。后续如果要把同一套方案迁移到招投标文件审阅、尽调材料提取这些场景,Schema换掉、切片策略微调一下,核心链路不用改,这就是这套架构最大的杠杆。