有谷大脑
返回博客

合同和法律条款怎么 chunk 才能让 RAG 不翻车

通用 RAG 按 token 数切割,对法律文本是错的。条款是法律推理的最小单元——切断它就是断章取义。这篇说有谷在知识库里怎么处理合同类文档的切割问题。

用 RAG 问合同有个很常见的失败模式:用户问「我们的赔偿责任上限是多少」,系统信心满满地答「最高 12 个月的服务费」,但实际合同里,Section 9.3 写的是付款条款,赔偿责任上限在 Section 12.4,而且还有关键的例外条款紧跟在后半段。

模型没有撒谎,它说的是检索到的 chunk 里写的内容。问题在于,那个 chunk 刚好切到了 Section 9 的后半段和 Section 12 的前半段——一个拼接的残片,既不是完整的付款条款,也不是完整的赔偿条款,但看起来像是回答了问题。

这是 chunking 策略的问题,不是模型的问题。

通用 chunk 策略对法律文本为什么是错的

最常见的 chunk 策略:按固定 token 数切割,比如 512 tokens,相邻 chunk 之间有 50 tokens 重叠(overlap)。

这在大多数文档上够用。技术文档、新闻、书籍,一段话通常能包含一个相对完整的意思,切割在段落边界附近的概率也比较高。

合同不是这样。合同的结构是高度压缩的——一个句子往往承载了精确的法律含义,每个词的位置和相邻条款的关系都可能影响解读。

举几个具体的切割问题:

赔偿上限和例外的分离。合同的赔偿免责条款(Limitation of Liability)通常是这样的结构:「甲方乙方 的最大赔偿责任不超过 金额,但以下情况除外:(a) 因 甲方 故意或重大过失导致的损失;(b) 对第三方的知识产权侵权……」512 tokens 切下去,「上限」在一个 chunk,「例外」在下一个。用户问赔偿上限,检索到「上限」chunk,答了一个不完整的数字,掩盖了关键的例外情况。

定义的解引用失败。合同开头通常有定义块,定义「服务」「平台」「用户数据」的含义。后续条款里的「服务」不是日常用语,是被定义过的术语。如果合同被切成多个 chunk,用户问一个涉及「服务」定义的问题,chunk 可能不包含定义块,模型只能按日常语义理解「服务」,答案可能南辕北辙。

交叉引用悬空。「如第 9.3(a) 条所述的赔偿责任,适用于本条款」——chunk 里出现了一个引用,但 Section 9.3(a) 在另一个 chunk,不在当前 context window 里。模型无法展开这个引用,只能跳过,可能得出错误的结论。

子条款没有主语1.1(a) 的主语来自 1.1 的开头。只检索到 1.1(a) 而没有 1.1,子条款可能在语义上是孤立的——「…不得将前述权利转让给任何第三方」,没有 1.1 告诉你「前述权利」是什么。

法律文件结构解析

合同类文档有几个通用 chunk 策略没考虑到的结构特点:

层级数字编号1. → 1.1 → 1.1(a) → 1.1(a)(i),或者 Article I → Section 1.01 → (a) → (i),中文合同通常是「第一条 → 第一款 → (一)→ 1.」。这个层级是文档的骨架,也应该是 chunk 边界的依据。

定义块(Definitions):通常是文档开头独立的一节,格式高度规则——每一行是「术语定义」的结构。这一节需要特殊处理:不能和正文条款混在一起切,术语需要被提取出来建立单独的索引。

附件和附表(Schedules/Exhibits):主协议之外的附件,通常包含具体的价格表、SLA 指标、数据处理条款。主合同里的条款经常以「见附件 B」结尾,附件里的内容是补充定义,不是独立文档。

签名页:合同末尾的签名页只有名字、职位、日期,没有实质内容。从 chunking 角度应该过滤掉,否则会被当成有信息量的内容处理,占用向量空间。

有谷的处理思路

有谷对合同类文档的 chunk 策略和通用文档不同,核心是三个原则:

合同条款级 Chunking 架构:结构解析后分为定义索引、叶子条款 chunk 和交叉引用,检索时按需展开。

以条款为 chunk 边界,不硬切 token

解析时识别合同的层级结构,以条款(clause)而不是 token 数作为切割单位。

具体做法:用正则或规则解析器识别行首的编号模式(\d+\.\d*(a)第\d+条等),构建文档的层级树。每一个叶子条款(不再有子条款的最小条款单元)是一个候选 chunk。

Section 12. Limitation of Liability       ← 父节点,不是 chunk
  12.1 General Cap                        ← 如果没有子条款,是 chunk
  12.2 Exceptions                         ← 父节点
    12.2(a) Gross negligence              ← chunk
    12.2(b) IP infringement               ← chunk
    12.2(c) Confidentiality breach        ← chunk
  12.3 Mutual cap                         ← chunk

对这个例子,最终生成的 chunk 是 5 个(12.1、12.2(a)、12.2(b)、12.2(c)、12.3),而不是按 512 token 生成的 1-2 个混合 chunk。

每个叶子 chunk 携带「祖先路径」作为 metadata:{path: "Section 12 > 12.2 > 12.2(a)", level: 3},在检索结果里可以展示完整的层级位置,让用户知道这段内容在合同哪里。

如果某个条款本身超过 embedding 模型的 token 上限(通常是 512 或 8192 tokens——复杂的 IP 保护条款或数据处理条款有时会触达这个限制),则沿子条款边界切割,子 chunk 仍然带父条款的完整文本作为前缀。

定义块单独建索引

识别出定义块后,把每一条定义提取成 {term: "...", definition: "..."} 的结构化记录,建立独立的定义索引。

检索时,如果用户的查询包含定义过的术语(通过精确匹配或模糊匹配),把对应的定义注入 context,而不是期望向量检索在几十个 chunk 里碰巧找到定义那个 chunk。

这个机制解决了「定义的解引用失败」问题:无论用户问的问题涉及哪个被定义术语,定义都会出现在 context 里,模型不需要猜测术语的含义。

交叉引用存 metadata,按需展开

识别条款文本里的引用格式(see Section 9.3(a)as defined in clause 2.1详见附件 B 第 3 项),提取引用路径,存在当前 chunk 的 cross_references 字段里。

{
  "path": "Section 12.1",
  "text": "...不超过过去12个月内乙方已实际支付的服务费(如第9.3(a)条所定义)...",
  "cross_references": ["Section 9.3(a)"]
}

检索时,如果检索到了一个包含交叉引用的 chunk,可以选择:把引用的目标 chunk 也拉进 context window,让模型能展开引用。这是一个可选的「引用展开」步骤,不是默认行为——对简单问题增加不必要的上下文会影响回答质量,只在用户明确需要完整条款链时启用。

怎么识别一份文档是「合同类」

有谷需要在入库时判断一份 PDF 是普通文档还是合同类文档,来决定走哪个 chunk 策略。

目前的识别方式是启发式规则 + 轻量分类:

  • 文件名特征:包含「合同」「协议」「SLA」「NDA」「服务条款」「License Agreement」等关键词
  • 结构特征:检测前 3 页是否包含高密度的编号条款(\d+\.\d+ 模式的行数占比 > 某阈值)
  • 术语密度:检测「甲方」「乙方」「违约」「赔偿」「本协议」等法律术语的出现频率

三个特征组合判断,置信度高的走合同 chunk 路径,置信度低或无法判断的走通用 chunk 路径。

这个分类是最保守的一种做法:宁可把普通文档误判为合同文档(代价是 chunk 数量可能比通用策略多,但不影响准确性),也不要把合同文档误判为普通文档(代价是 chunk 被破坏性切割,影响检索准确性)。

这和普通文档的实际差异

一个实际对比:同一份 60 页的 SaaS 服务协议:

  • 通用 512-token 切割:约 90-120 个 chunks。约 30% 的 chunk 跨越了条款边界,包含来自两个不同条款的片段。向量化后,这些「混合 chunk」在 embedding 空间里的位置不清晰,既靠近 A 条款的查询,又靠近 B 条款的查询,但两边都答不完整。
  • 条款级切割:约 55-70 个 chunks,取决于合同的条款数量。每个 chunk 对应一个有明确法律含义的条款单元,向量表示聚焦,检索时信噪比更高。

chunk 总数少了,但准确率更高。这个「更少的 chunk」不是损失了覆盖范围,而是消灭了不应该存在的模糊 chunk。

什么时候合同 RAG 最容易出问题

以下几种情况是重灾区,如果你在用通用 chunk 策略,可以去验证一下:

问「除外条款」或「例外情况」:这类问题特别依赖完整的条款——上限在前半句,例外在后半句。512 tokens 很容易把这两部分切开。

问需要多个条款联合才能回答的问题:比如「如果我们延迟付款超过 30 天会怎样」——惩罚条款在 Section 7,但触发条件在 Section 9,条款间有逻辑链。通用 RAG 检索到的 chunk 可能只包含链条的一环。

问涉及定义术语的问题:「平台」「授权内容」「保密信息」这类在合同里有精确定义的词,如果 RAG 没有把定义块和查询关联起来,模型只能猜。

问附件里的具体数字:价格表、SLA 指标、API 限额都在附件里,主合同正文只有「见附件 B」。如果附件没有被正确关联到主合同,「SLA 是多少」会返回空或者错误答案。

合同知识库的典型使用场景

有谷里合同类文档的使用场景大致分两类:

个人/小团队:自由职业者、律师助理、或者需要频繁对照合同条款的人。把几份常用合同传进去,碰到问题直接问,不用每次翻 PDF 找对应条款。这类场景对准确率要求高,但查询复杂度相对低,主要是「这个条款写了什么」类的直接查找。

企业合规管理:多份框架协议、数据处理协议、保密协议集中管理,问「我们所有的 SaaS 供应商里,有哪些对数据驻留有限制」这样的跨合同分析问题。这类场景需要 RAG 在多份合同里做横向检索,对每份合同的 chunk 完整性要求更高——一份合同的数据驻留条款被切坏,跨合同比较就会产生遗漏。

无论哪类场景,合同的核心价值是精确性——模糊的回答比没有回答更危险。这也是为什么在 chunking 这个「底层」环节多花功夫是值得的:准确的 chunk 是准确回答的前提,这里省出来的工程投入,会在每一次查询上节省掉对着模型输出的怀疑和反复核实。