跳到主内容

合同审核 RAG 准确率从 61% 到 92% 实战 ​

问题背景:合同审核为什么不能只做普通 RAG ​

企业合同审核对 RAG 的要求与普通知识库问答不同。用户问“付款逾期后能否暂停服务”,答案可能同时依赖合同首部的主体定义、第 4 条付款周期、第 7 条违约责任、附件 SLA 以及补充协议。若只按 512 token 切块并做单路向量召回,最常见的问题是:片段语义相似但法律要件不完整;定义条款与义务条款被拆开;附件表格被切碎;模型看到相似片段后给出看似合理却缺少前提的结论。

在一次采购合同审核试点中,基线方案采用固定窗口切块、单一 embedding、top-k 相似度召回,50 个标注问题的条款定位准确率只有 61%。主要错误集中在三类:跨条款引用占 42%,否定与例外条件漏召回占 31%,附件或定义缺失占 18%。优化后,我们引入多粒度向量嵌入、分层召回、交叉编码器重排序和答案约束,条款定位准确率提升到 92%,幻觉式审核意见明显减少。

方案对比:单路切块、长上下文与多粒度分层召回 ​

第一种方案是继续扩大 top-k。它实现简单,但会把大量相似但无关的段落塞进上下文,增加模型比较成本,也不能保证命中定义条款。第二种方案是直接使用长上下文模型,把整份合同放入 prompt。长上下文适合短合同或终审,但企业合同常有几十到几百页,还包含扫描件、表格和多版本文件;成本、延迟和“中间遗忘”都会成为问题。

第三种方案是多粒度向量嵌入加分层召回:保留合同、章节、条款、句子四个层级,既嵌入完整条款,也嵌入标题、定义和关键风险句。查询时先用问题召回高相似条款,再沿父子结构补全章节上下文,同时用元数据过滤合同类型、版本和当事方,最后用 rerank 模型精排。它的优势是召回率、可解释性和成本更均衡。

审核问题查询改写条款级向量高召回粗排父子结构补全章节与定义Rerank精排截断风险句向量违约/解除/赔偿BM25 关键词编号/金额/日期审核结论引用条款

数据建模:先解析合同结构,再谈 vector-embedding ​

合同解析不要只输出纯文本。建议把 DOCX、PDF 或 HTML 解析为层级 JSON:合同级元数据包含合同名称、版本、甲乙方、生效日期;章节级包含章标题;条款级包含条号、标题、正文、父章节;句子级保留风险类型和引用关系。条款编号、金额、期限、“不得”“除非”“不限于”等词应作为特征保留。

下面示例使用本地 sentence-transformers 与 rank-bm25,避免强绑定某个商业 API。首次运行会下载开源模型,之后可直接复用。

python
# pip install sentence-transformers rank-bm25 jieba
from dataclasses import dataclass, field
from typing import List, Dict
import re, math
import jieba
from sentence_transformers import SentenceTransformer, CrossEncoder
from rank_bm25 import BM25Okapi
import numpy as np

@dataclass
class Clause:
    id: str
    no: str
    title: str
    content: str
    chapter: str
    parent_id: str = ""
    risk_tags: List[str] = field(default_factory=list)

contracts: List[Clause] = [
    Clause("c1", "第1条", "定义", "“供应商”指甲方书面确认的云服务提供方。“工作日”不含法定节假日。", "一般条款"),
    Clause("c2", "第4.2条", "付款", "甲方应在收到合规发票后30个工作日内支付当期服务费。", "商务条款"),
    Clause("c3", "第7.1条", "违约责任", "甲方逾期付款超过15日的,乙方有权暂停服务,且不承担因此产生的责任。", "法律条款", ["逾期", "暂停服务"]),
    Clause("c4", "第7.3条", "责任限制", "任何一方在本合同项下的累计赔偿责任不超过过去12个月已收服务费。", "法律条款", ["赔偿上限"]),
]

embedder = SentenceTransformer("BAAI/bge-small-zh-v1.5")
reranker = CrossEncoder("BAAI/bge-reranker-base")

# 多粒度文本:标题强化结构,正文保留语义,风险标签强化法律场景
def clause_text(c: Clause) -> str:
    return f"{c.chapter} {c.no} {c.title}\n{c.content} 风险标签:{'、'.join(c.risk_tags)}"

texts = [clause_text(c) for c in contracts]
dense_embeddings = embedder.encode(texts, normalize_embeddings=True)

tokenized = [list(jieba.cut(re.sub(r"\s+", "", t))) for t in texts]
bm25 = BM25Okapi(tokenized)

def hybrid_search(question: str, top_k: int = 8) -> List[tuple]:
    q_vec = embedder.encode([question], normalize_embeddings=True)[0]
    dense_scores = dense_embeddings @ q_vec

    q_tokens = list(jieba.cut(re.sub(r"\s+", "", question)))
    sparse_scores = bm25.get_batch_scores(q_tokens, list(range(len(contracts))))

    # 分数归一化后融合;金额、条号类问题可提高 sparse_weight
    def minmax(x):
        x = np.array(x, dtype=float)
        return (x - x.min()) / (x.max() - x.min() + 1e-8)

    sparse_weight = 0.35 if re.search(r"\d+|第.+条|金额|日期", question) else 0.2
    fused = 0.65 * minmax(dense_scores) + sparse_weight * minmax(sparse_scores)
    ids = np.argsort(fused)[::-1][:top_k]
    return [(contracts[i], float(fused[i])) for i in ids]

分层召回:从风险句命中到父条款补全 ​

粗召回不能只返回向量最相似的小块。合同审核需要“证据链”:命中风险句后,应回溯完整条款;命中义务条款后,应补充定义条款;命中违约条款时,应联动付款、交付、解除和责任限制。可以维护父子关系与规则邻接关系,召回候选后扩展上下文。

python
parents: Dict[str, Clause] = {}
# 示例中每条合同即一个父条款;真实系统中父节点通常是章节或完整条
for c in contracts:
    parents[c.id] = c

def expand_context(hit: Clause) -> List[Clause]:
    result = []
    if hit.parent_id and hit.parent_id in parents:
        result.append(parents[hit.parent_id])
    result.append(hit)

    # 法律审核联动:违约问题补定义、责任限制;付款问题补违约
    joined = hit.title + hit.content
    if any(k in joined for k in ["违约", "逾期", "赔偿", "解除"]):
        result.extend([x for x in contracts if "定义" in x.title or "责任限制" in x.title])
    if "付款" in joined:
        result.extend([x for x in contracts if "暂停服务" in x.content])

    # 去重并保持条号顺序
    unique = {x.id: x for x in result}
    return sorted(unique.values(), key=lambda x: x.no)

question = "甲方付款逾期后,乙方能不能暂停服务?赔偿上限是多少?"
candidates = hybrid_search(question, top_k=5)
expanded = []
for clause, score in candidates:
    expanded.extend(expand_context(clause))

expanded = list({c.id: c for c in expanded}.values())

pairs = [(question, clause_text(c)) for c in expanded]
rerank_scores = reranker.predict(pairs)
ranked = sorted(zip(expanded, rerank_scores), key=lambda x: x[1], reverse=True)
final_context = ranked[:5]

for clause, score in final_context:
    print(round(float(score), 3), clause.no, clause.title)

提示词约束:让模型只基于证据给审核意见 ​

合同审核助手的输出不能只回答“可以/不可以”,还应说明依据、前提、风险等级和修改建议。提示词必须要求引用条号,并在证据不足时明确说明,而不是套用通用法律常识。

text
你是企业合同审核助手。请仅依据【合同证据】回答,不得引用证据外的事实。
输出 JSON:
{
  "conclusion": "明确结论,包括成立条件",
  "risk_level": "高/中/低",
  "evidence": ["条号+原文关键句"],
  "missing_info": ["仍需确认的前提"],
  "revision": "可直接替换的合同条款建议"
}
若证据之间存在冲突,先列冲突,不得给确定结论。

评测方法:用条款级标注集而不是凭感觉调参 ​

建议建立 100 条以上的审核问题集,覆盖付款、违约、知识产权、保密、解除、不可抗力、数据安全、自动续签等主题。每条样本标注“必须命中的条款”和“可接受答案条件”。检索层看 Recall@k、MRR、条款引用准确率;生成层看结论正确率、证据一致性和拒答正确率。调参时不要只看相似度分数,因为向量分数在不同问题上的尺度并不稳定。

避坑总结:合同 RAG 落地的 6 个关键细节 ​

  1. 固定切块切断“第 X 条所述”引用链:现象是模型能找到违约责任,却不知道“验收合格”的定义。修正方法是按条号和标题解析,保留 parent_id、cross_refs,召回后做父条款与引用条款扩展。

  2. 只使用向量召回导致编号和金额问题失效:例如询问“30 个工作日”时,embedding 可能召回“30 日”“付款周期”等语义近邻。修正方法是加入 BM25 或稀疏向量,并对包含日期、金额、条号的问题提高关键词权重。

  3. rerank 后直接截断,丢掉否定词和例外条件:很多高价值句子含“不得”“除非”“但”“不承担责任”,单独看相似度未必最高。修正方法是先扩展完整条款,再把完整条而非孤立句子交给 CrossEncoder。

  4. 多版本合同串库造成错误引用:测试版、盖章版、补充协议混在同一索引中,模型可能引用已删除条款。修正方法是将合同 ID、版本号、文件状态写入元数据,召回时强制过滤,默认只检索最新有效版本。

  5. 表格和附件只按阅读顺序抽取:PDF 表格容易被抽成零散数字,召回时完全丢失行列关系。修正方法是对 DOCX 表格保留“字段-值”文本,对扫描件先 OCR 并做版式校验,附件条目要带上主合同编号。

  6. 没有拒答机制,模型用通用模板补全:当合同未约定单方解除权时,模型可能回答“通常可以”。修正方法是在提示词和评测中加入“证据不足”类别,并要求输出 missing_info;上线后还应记录低置信问题,持续补充解析规则和评测样本。

多粒度 embedding 与分层召回并不是复杂概念堆叠,而是在尊重合同结构的前提下,把“语义相似”升级为“法律证据完整”。对企业合同审核场景,稳定的条款定位、可追溯引用和可控拒答,往往比单次回答流畅更重要。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。