2026/10/17 12:16:11

RAG+结构化抽取:构建可落地的合同审阅智能体

RAG+结构化抽取:构建可落地的合同审阅智能体 先说个直白的结论把一份五十页的合同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示例下面是我实际在用的精简版SchemaJSON结构可以直接套用{ 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条 page5 这里是召回的原文 /fragment fragment source第8条 page5 这里是召回的原文 /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换掉、切片策略微调一下核心链路不用改这就是这套架构最大的杠杆。