“别只会闲聊,让大模型是时候干点正事了。”这句话,是我最近在某个跨平台系统项目里,给某大型企业的法务团队做合同知识库原型时,团队负责人反复挂在嘴边的口头语。当时项目组面对的是一摊让人头疼的扫描版合同扫描文件:主合同、补充协议、附件条款、机打发票信息,散落在不同文件夹里,格式千奇百怪,条款编号混乱。光靠人工去翻,几百份合同翻下来,眼睛都能看出重影。更麻烦的是,很多风险藏在细节里——比如违约金的百分数写法不统一,比如主合同和补充协议的管辖法院约定互相“打架”,比如付款条件和发票开具时间对不上。这些肉眼很容易看漏、看错的地方,我把它叫做“合同盲区”。
我当时的判断是:别指望大模型像人一样“读懂”合同,也别指望它靠纯粹的记忆去回答。真正可落地的路子,是把 RAG(检索增强生成)和 Structured Extraction(结构化信息抽取)组合起来,前者负责“从大海里捞针”,后者负责“把针摆到格子里”。这套组合拳打完,标题里说的“终结合同盲区”,实际上就是两件事:第一,把散落在长文档里的关键信息全部找出来;第二,把这些信息按字段、按约束、按规则校验过一遍,输出成机器可读、人可复核的结构化结果。整篇我会把我在这个原型项目里踩过的坑、改过的方案、实测后觉得值得复用的细节,全部拆开讲透。
1. 内容整体设计与思路拆解
1.1 先搞清楚“合同盲区”到底是什么
在做技术方案之前,必须先定义清楚问题。不同人口中的“合同盲区”不是同一样东西。我在这个项目里,把盲区分成了三类,每一类对应不同的技术难点。
第一类是“被埋没的关键字段”。比如违约责任条款里写的是“每日万分之五”还是“每日百分之五”,续签条款里写的是“提前30天书面通知”还是“提前3天电子邮件通知”。这类信息很具体,但往往埋在一个几千字的段落中间,人眼快速扫过时容易忽略数字和单位。大模型直接读取全文时,也会被前后无关内容干扰。
第二类是“多文档之间的矛盾”。主合同说“争议提交某仲裁委员会仲裁”,补充协议却说“由某法院管辖”;主体条款约定付款周期为30天,附件里的付款计划却写了45天。这类问题单看一份文档很难发现,必须把多份文件拉到同一个上下文里做交叉比对。
第三类是“非结构化信息与业务数据的错位”。合同里的金额、日期、税点,跟实际开票记录、付款回执能不能对上。这份工作需要把PDF里的一段文字,跟数据库里的一行记录对齐,纯靠向量检索做不到,纯靠规则正则也做不到。
所以整个方案不是“用一个RAG解决所有问题”,而是“先用RAG解决定位,再用结构化抽取解决字段抽取,最后用规则和人工复核兜底”。想清楚了这一点,后面所有设计都是围绕这个分层思路展开的。
1.2 为什么纯聊天式RAG解决不了这个问题
如果你用过通用RAG问答,应该会有体验:把合同文档切块、向量化,建一个问答接口,然后问“这份合同的违约金怎么约定”,大多数时候能回答出个大概。但一旦问“把所有含‘除双方另有约定外’字样的条款全部列出来”,或者“对比主合同和补充协议里争议解决条款是否一致”,通用RAG就抓瞎了。
原因在于:通用RAG的定位是“我能找到跟问题相关的内容并组织语言回答”,它不保证字段完整性,不保证枚举准确性,更不保证两个段落之间一致/不一致的判断逻辑稳定。聊天式RAG的输出是自然语言段落,没法直接进业务系统、没法做约束校验、没法逐字段复核。我在实测中就遇到过:同一个问题连续问三次,大模型分别给出“违约金为万分之五”“违约金为5%”“未约定违约金”三种答案,细查之下才知道,原条款里原文写的是“按未付金额的万分之五”,但因为切块边界把小数点弄丢了,模型就只能猜。
所以,结构化抽取的作用不是替代RAG,而是在RAG给出候选位置之后,把“找候选”和“结构化读字段”这两件事分开。前者靠向量相似度召回,后者靠精心设计的提示词和Schema约束逐字段提取。先让RAG把范围缩小到指定篇章,再让抽取模型拿着“显微镜”去读,准确率会高出不止一个量级。
1.3 技术选型的取舍逻辑
在方案选型上,我做过几轮对比。最初想直接用纯大模型零样本抽取,把所有合同丢给上下文窗口硬读。测试下来,几页的短合同效果尚可,超过20页的长合同,模型就开始丢尾部字段,还会把不同章节的同名字段串在一起。后来加了RAG做预筛,但发现RAG本身对“段落边界”的理解会影响抽取效果——如果切块切得太碎,一个完整条款被切成三段,模型就可能漏读条款后半部分的百分比。
最终落地路线是“先粗筛、再细读、后校验”:
- 文档解析层:PDF/Word统一转成带页码和章节号的纯文本,保留段落索引。
- 切块与向量化:按章节切块,而不是固定字符切块,这样能尽量保全条款语义完整性。
- 检索增强定位:针对“要抽取什么字段”构建检索Query,用向量召回Top-K个相关块,并带上章节上下文。
- 结构化抽取:把召回结果交给抽取模型,按预定义Schema输出JSON,字段缺失时输出null并附上原因。
- 规则校验层:用正则、日期解析、枚举映射对JSON字段做二次校验,标记风险项。
这套设计把“语义理解”和“硬规则校验”分开,模型只负责它擅长的语义归纳,不负责它不擅长的确定性计算。比如金额字段,抽取模型负责从文本中圈出“人民币壹佰贰拾叁万圆整”,规则校验负责把它转成1230000.00,并核对大小写是否一致。
2. 核心细节解析与实操要点
2.1 文档解析的预处理细节,决定了RAG的上限
很多人上手就把PDF转成文本丢给向量库,这是最容易踩坑的一步。扫描版PDF转出来的不是文本,是图片,不先做OCR,后面全白搭。就算是电子版PDF,直接抽取文本也常常丢失表格结构、页眉页脚、多栏排版顺序。
我在项目里用的是多层回退策略:优先取PDF内嵌文本层,若文本层的字数密度低于阈值,就触发OCR引擎做整页识别,再把识别结果按坐标还原为段落。Word文档单独处理,因为docx里的表格和文本框经常在纯文本转换时被丢到文档末尾,需要根据文档XML结构里的readingOrder顺序重排。
处理完成后,每一段文本我都记录了来源信息和页码:
{"doc_id": "CT-2024-001", "chap_no": "3", "par_no": "17", "page": 12, "title": "违约责任", "text": "除双方另有约定外,任何一方迟延履行的,每迟延一日应按未付金额的万分之五向守约方支付违约金..."}这个“带位置的元数据”在后面的检索和抽取里非常重要。抽取出一个字段时,能把“哪一份文件、第几章、第几节、第几页”一并带出来,这才叫可以复核的结果,而不是凭空冒出来的一句话。
提示:切块策略务必让每块内部尽量只包含一个完整条款或小节,宁可单块短一点,也不要跨多个不同主题。固定字符数切块会导致模型在抽取时被无关内容干扰,实测中Top-1召回命中率下降约15个百分点。
2.2 RAG检索Query的设计不要只用一句话
当我拿“违约金比例是多少”去向量检索时,检索效果往往一般。原因很简单:向量模型在编码短Query和长文档时存在语义鸿沟,用户口语化的问法跟合同书面语表达差别太大。
我采用了一个“伪文档”策略:对每个需要抽取的字段,构造三类检索Query,合并召回结果再去重。以“违约金比例”为例:
- 条款问法:违约金 比例 百分比 计算方式
- 当事人问法:乙方逾期 未付金额 按日 支付 违约金
- 同义扩展:迟延履行 罚则 滞纳金 违约成本
把三类Query的召回结果取并集,再按相关性得分排序。实测下来,同样的向量库,这个策略能把关键相关块的召回率从78%拉到93%左右。代价是检索次数多一点,但对于离线合同分析任务完全可以接受。
顺带说一句,索引字段里保留“章节标题”文本,查“违约责任”时,可以直接利用条目级别的匹配做预过滤,把范围限定在违约责任章节,再走向量召回。词法粗筛加向量细筛的组合,比单独用向量更稳。
2.3 Structured Extraction的核心是Schema设计
结构化抽取这一层,决定质量的不是模型,而是Schema定义和提示词约束。Schema要稳定,每个字段得包含“类型、描述、示例、取值约束、抽取来源”。
我做了一个通用合同要素Schema,节选如下:
{ "contract_basic": { "contract_name": {"type": "string", "desc": "合同名称", "source": "封面或首段"}, "party_a": {"type": "string", "desc": "甲方全称"}, "party_b": {"type": "string", "desc": "乙方全称"}, "sign_date": {"type": "date", "format": "YYYY-MM-DD", "desc": "签订日期"} }, "payment_terms": { "currency": {"type": "string", "enum": ["CNY", "USD", "EUR"]}, "total_amount": {"type": "number", "desc": "合同总金额,数字格式"}, "advance_ratio": {"type": "number", "desc": "预付款比例"}, "settlement_days": {"type": "integer", "desc": "结算周期天数"} }, "risk_clauses": { "liquidated_damages": {"type": "object", "attr": {"rate": "number", "unit": "string", "trigger": "string"}}, "jurisdiction": {"type": "object", "attr": {"authority": "string", "location": "string"}} } }Schema设计有几个关键点,都是实操里总结出来的:
- 尽量把所有金额字段明确为“数字类型”并同时要求输出“原始文本原文”。因为大模型在处理“百分之五”和“5%”时的数值理解有波动,保留原文供人工核对,数字转换交给规则层。
- 日期字段必须带“解析格式”和“未知值策略”,遇到“以实际到账日为准”这种非固定日期,必须允许输出描述性文本,不要用null糊弄过去。
- 枚举字段务必给出完整可选项,比如币种只允许CNY/USD/EUR,模型就不会凭空造出一串“RMB”。这不算限制自由度,而是把模型输出收敛到业务需要的口径里。
抽取提示词里除了Schema,我还会要求模型在输出JSON之外,同时输出一个“依据原文片段列表”,每个字段值后面都要附上一段原文引用,并且标明引用来自哪个文档哪一章。这样即使模型抽取错了,复核人员顺着引用就能找到原文位置,而不是面对一个孤零零的错误值。
2.4 多文档一致性比对的实现思路
“终结合同盲区”这个目标里,最关键也最难做扎实的是多文档比对。主合同和补充协议之间的矛盾,其实是这个项目里客户最在意的点。我的做法是:在RAG索引上按“合同文档组”维度做检索,一个组里包含主合同、附件、补充协议。
比对逻辑拆成三步:
第一步,抽取单文档结构化字段,得到每个文档自己的“合同要素表”。
第二步,按字段Key做跨文档水平对齐,同一个Key(比如“争议解决方式”)出现在不同文档里,就各自取值。
第三步,把同一Key的不同取值交给规则与模型联合判断:完全一致,标记“一致”;取值不同但语义兼容,标记“需人工确认”;取值冲突,标记“高风险冲突”。
这个第三步,我建议以硬规则为优先。比如说管辖法院,一方写“某仲裁委”,另一方写“某人民法院”,这属于枚举值不同,直接标记冲突,不需要让模型“解释”。只有字段值是自由文本、语义对等比较困难时,才交给模型判断,比如“甲方:某技术有限公司”和“甲方:某技术股份公司”这种差异,模型可以给出相似度判断,再由规则决定是否放行。
3. 实操过程与核心环节实现
3.1 环境和数据准备
这个原型在我本地跑通,使用方是一台普通开发机,配置不算高。技术栈选的是轻量组合:PDF处理用解析库,向量库用支持HNSW的内存数据库,LLM接口用通用API,语言用Python。为了完全复现,我把目录结构列在这里:
contract_ai/ ├── docs/ # 原始合同PDF/Word ├── parsed/ # 预处理后的文本JSON ├── chunks/ # 切块结果 ├── index/ # 向量库持久化目录 ├── schema/ # 抽取Schema定义 ├── scripts/ │ ├── parse_docs.py # 文档解析 │ ├── chunk_and_index.py # 切块与向量化 │ ├── retrieve.py # RAG检索 │ ├── extract.py # 结构化抽取 │ └── validate.py # 规则校验与报告准备测试数据时,我并没有用真实客户合同,而是用了某公司内部的模拟合同集X:12份典型合同,覆盖采购、服务、租赁、建设施工几种常见类型,并手动埋了8处矛盾点。这样做的好处是能精确评估系统到底找出了几处,而不是拿真实数据“凭感觉说效果好”。
3.2 文档解析与切块的完整操作
解析脚本里最核心的一步,是对段落做章节归类。我用一个小技巧:先利用文档结构特征,将包含“第一章/第一条/1.1”等模式的行识别为标题行,再用正则和行距特征把后续段落归到最近的标题节点下。这样生成的结构体天然具有层级:
node = {"h": "第三章 违约责任", "children": [ {"para": "3.1 违约金", "text": "..."}, {"para": "3.2 免责条款", "text": "..."} ]}切块时直接基于这个树递归,每一块记录完整路径,而不是简单的字符截断。切块的大小我控制在1000个中文字符左右,最长不超过1500。太短则语义不全,太长则向量精度下降。
chunk_and_index.py里加了过滤逻辑:页面页眉页脚、纯页码行、重复的抬头logo文本,都先识别并丢弃。这一步非常关键,否则向量库里会塞满“某某公司合同模板”这类无意义的高频文本,检索时疯狂干扰相关性打分。
3.3 RAG检索与候选召回的实测记录
检索的代码我单独抽出retrieve.py来跑。为了让效果直观,我写了一批测试Query,比如:
Q1: 违约金按未付金额的多少比例计算? Q2: 合同总金额是多少?预付款比例? Q3: 主合同和补充协议对管辖法院的约定是否一致? Q4: 合同续签需要提前多少天通知?针对Q1,单个Query召回的Top-5块里,包含“万分之五”的块排在第4位,差点没进上下文窗口。用了“三类伪文档”策略后,同一块排到了第1位。这个体验很直观:不是模型不行,是检索方式没喂饱它。
我把检索后拼给抽取模型的上下文格式做成如下样式:
[Doc:CT-2024-001 | Chap:3 | Page:12] 除双方另有约定外,任何一方迟延履行的... [Doc:CT-2024-002 | Chap:5 | Page:8] 在第3.1条约定的付款期限内...这种“带文档标识+章节标识”的上下文,对抽取模型识别“现在读的是哪份文件”有奇效。特别是多文档比对场景,如果不带标识,模型容易把两份文件的条款内容混在一起,抽取结果张冠李戴。
3.4 结构化抽取与JSON输出
抽取脚本的核心是提示词工程和JSON解析兜底。我用了两层JSON解析:第一层让模型直接返回JSON;若解析失败,则要求模型把JSON放在特定标记内,再用解析器剥离标记。不要迷信任何单一提示词格式,实测中不同模型对JSON的返回偏好不同。
这里贴一个核心抽取逻辑的片段:
SCHEMA_STR = json.dumps(contract_schema, ensure_ascii=False, indent=2) prompt = f""" 请阅读以下合同相关文本片段,按Schema抽取信息。 规则: 1. 只依据给定文本抽取,不要自行推断。 2. 每个字段输出时附带source_ref(原文引用)和doc_tag。 3. 数值字段输出数字,同时保留raw_text。 4. 无法抽取时输出null并在reason字段写明原因。 Schema: {SCHEMA_STR} 合同文本片段: {context} 输出JSON: """生成JSON后,我会用规则层做类型校验。比如日期字段检查是否可解析、金额字段检查raw_text里的中文字符与阿拉伯数字是否对得上、枚举字段检查取值是否在enum列表内。校验不通过的直接标记为“需人工复核”,而不是悄悄丢字段。
3.5 合同盲区报告的生成逻辑
最后一步,把所有抽取结果、校验结果、比对结果汇聚成一份“合同盲区报告”。报告的格式设计成了一个表格,机器和人都能快速定位问题:
| 风险类型 | 文档/章节 | 字段 | 抽取值 | 校验结论 |
|---|---|---|---|---|
| 冲突 | CT-2024-001 第8条 / CT-2024-002 第2条 | 争议解决 | 某仲裁委 / 某法院 | 冲突 |
| 格式异常 | CT-2024-003 第4条 | 违约金比例 | 5% / 万分之五 | 疑似单位不一致 |
| 信息缺失 | CT-2024-005 第7条 | 续签通知期限 | 未约定 | 缺失 |
| 语义兼容 | CT-2024-001 第3条 / CT-2024-002 第1条 | 付款方式 | 银行转账 / 转账 | 一致 |
报告同时导出JSON和CSV两个版本。JSON给下游系统做自动化对接,CSV给法务同事做人工复核。这一步虽然朴素,但价值很大——之前客户看技术方案时总觉得“是个黑盒”,看到输出表里每一条风险都带文档位置和原文引用,信任度立刻上来了。
4. 常见问题与排查技巧实录
4.1 召回不到关键条款怎么办
这是RAG类项目里最经常被问的问题。排查顺序我建议按下面几层来:
- 先看切块质量。如果切块里混入大量页眉页脚、目录页码,先清洗再做向量化。
- 再看Query构造。单一句子不行,就用3.2节里的伪文档策略,把“人话问法”和“条款问法”都召一遍。
- 接着看向量模型和领域适配度。通用向量模型在合同专用术语上表现一般,条件允许可以收集一批合同文本做领域微调,或者直接用更偏向法律语料的向量模型。实测换用领域微调模型后,Top-5命中率又提高了约8%。
- 最后还可以加一层关键词过滤保底:对“违约金、管辖、续签、付款”这类强关键词,做BM25词法检索,与向量结果取并集。这个方法土,但在Recall不足时非常管用。
4.2 模型抽取JSON格式不稳定怎么办
不同模型的JSON稳定性差异很大。我的处理方案是“三步走”:
- 提示词里给出明确的JSON Schema,并附带一个正确的示例。
- 要求模型只输出JSON,不输出前后缀。
- 解析失败时,用正则提取第一个
{到最后一个}之间的内容,再做二次解析。
如果仍然失败,就把这条记录划入“抽取异常队列”,等待人工处理,而不是反复重试浪费时间和成本。从工程角度说,与其跟模型较劲,不如接受一定的失败率并做好兜底。
4.3 多文档比对时“误报”和“漏报”的平衡
比对逻辑里,硬规则冲突(仲裁vs法院)几乎没有误报,但“语义兼容”判断容易出问题。比如一份合同写“银行转账”,另一份写“电汇”,这算不算冲突?模型判“一致”,但业务上可能觉得“电汇和转账还不是一回事吗?”这时候无需执拗于技术判断,我给系统设计了一个“决策阈值”参数:
- 相似度高于0.85:自动标为一致
- 相似度在0.65到0.85之间:标为“需人工确认”
- 相似度低于0.65:自动标为冲突
把阈值做成可配置项,让业务方根据自己风格的严苛程度调整。法务团队担心漏掉风险,可以把阈值调高;合同量太大需要快速过筛,可以把阈值调低。这种灵活性比一味追求模型自动判断更实用。
4.4 关于大模型幻觉的控制心得
所有做抽取的人都会遇到幻觉。我控制幻觉的三个原则是:给定位、限来源、留证据。
给定位是指抽取前通过RAG把候选范围缩小,绝不把整个合同库全都丢给模型。限来源是指在提示词里反复强调“只依据给定文本片段抽取”,并让模型输出抽取来源。留证据是指每个字段值都要求同时输出原文引用,以便复核。
单靠提示词不能根治幻觉,但通过“缩小范围+来源引用+人工复核”三重保障,至少能把风险控制到业务可以接受的范围内。
5. 实测效果复盘与个人经验沉淀
在模拟合同集上,最终跑出来的结果让我愿意把方案拿出去见人:12份合同里埋的8处矛盾点,系统完整找出了7处,另外1处是因为补充协议中的日期写法是“二零二四年三月”,主合同是“2024年3月”,模型没有把中文数字日期转成同一格式,被我自己的规则层误标为“需人工确认”。加了一条中文日期归一化规则之后,这一处也顺利识别出来了。
字段级抽取的准确率,在测试集上做了人工复核,合同基本信息、金额、日期、违约金比例等核心字段总体准确率接近95%。缺失字段也能正确输出null,而不是瞎编一个值。多文档一致性比对的误报率低于5%,主要误报集中在“语义兼容但表达不同”的边缘案例上。
我的体会是,这类项目真正决定成败的不是模型选型,而是数据链路的完整度:文档解析干不干净、切块规则合不合理、Schema定得细不细、校验规则跟不跟得上业务口径。每一个环节单独挑出来都不复杂,但串起来就形成了一套可以稳定交付的合同分析能力。尤其要把大模型的输出当成“半成品”,规则校验那一道工序,才是最终质量的守门员。
最后再分享一个操作层面的经验:别一上来就想把整套自动化做完。先挑一个最容易见效的场景,比如“争议解决条款的多文档一致性比对”,跑通闭环,让业务同事看到输出报告的价值,再逐步扩展字段和合同类型。需求这种东西,永远是做着做着自己变清晰的。只要数据链路搭得稳,后面加字段、加规则、加文档类型,只是水到渠成的事。