法律尽调 AI 落地:分组、摘要与引用核验怎么串
法律尽调 AI 的可靠性取决于分组 ID、摘要与引用锚点是否共用同一套切片标识;本文给出两级分组、锚点四元组与三级核验流水线的实现、阈值和成本结构。
- 分组必须前置:先分业务域再出摘要,顺序反了必然张冠李戴
- 引用锚点用 page_no + bbox + text_hash 四元组,别用纯字符偏移
- 核验分三级:字面命中、语义蕴含、人工抽检,成本占比不到 5%
结论:法律尽调 AI 能不能用,卡点不在模型,而在「分组 ID、摘要、引用锚点」是否共用同一套文档切片标识。三者不共 ID,摘要必然对不上原文。
先给一句大白话:把一屋子尽调文件交给 AI,先按交易标的分类,再逐份出摘要,最后每条结论都要能翻回原文某一行。这三步必须共用同一套编号。审阅律师不该为了找一句话,去翻 300 页 PDF。
2026 年 10 月,OpenAI 公开了 Ropes & Gray 在交易尽调(Transactional Diligence)上的做法:律师审阅的是分组后的文档、摘要与报告,然后才给客户意见(OpenAI 客户案例)。这句描述里最容易被忽略的是顺序——分组在前、摘要在中、报告在后。不少团队做反了:先让模型通读全量文档出摘要,再回头做分类,结果摘要里写「本协议约定……」,却没人知道「本协议」是哪一份。
为什么法律尽调的 AI 输出必须先分组、再摘要?#
三个现实约束逼着人这么排。
- 规模。一个中型并购的虚拟数据室(Virtual Data Room,VDR)通常 800~3000 份文档,PDF 占七成,其中无文本层的扫描件三到四成。全量喂给长上下文模型,输入 token 按每千页几十万计,召回还会明显下滑。
- 命名。VDR 导出的文件名多是
DOC_0001.pdf、Scan_20250912_001.pdf,目录层级常常只有01/02/03。分组信号主要来自正文,不是文件名。 - 冲突。同一份合同的框架协议、补充协议、修订件如果不归入同一个文件族(Document Family),模型会拿旧条款回答新问题。这类错误在核验阶段查不出来,因为句子本身没错,只是过期了。
我用两级分组,不是一级。
对比表:两级分组的分工与代价
| 层级 | 输入信号 | 判定方式 | 典型规模 | 出错后果 |
|---|---|---|---|---|
| 一级:业务域分组 | 正文前 1500 字 + 目录路径 + VDR 元数据 | 规则正则 + 小分类模型(几亿参数量级),置信度 < 0.75 进人工队列 | 8~12 个域 | 摘要进错章节,报告结构塌陷 |
| 二级:文件族对齐 | 当事人名称、合同标题、生效日期、正文近似度 | 实体对齐 + MinHash 去重(Jaccard ≥ 0.85 视为同族) | 每族 2~15 份 | 引用失效条款,法律风险最高 |
一级的 8~12 个域我沿用律所习惯:公司治理(Corporate)、重大合同(Material Contracts)、知识产权(IP)、劳动人事(Employment)、诉讼与争议(Litigation)、税务(Tax)、不动产(Real Property)、监管合规(Regulatory)。好处是能和尽调清单(Checklist)对齐,摘要天然按清单项归档,律师不用二次整理。
二级才是真正省钱的地方。抽当事人 + 合同标题 + 生效日期做实体对齐,再算正文相似度,同族共享 family_id。生成摘要时把整族按日期排序后作为上下文,并在提示词里强制模型写明「以 2025-03-11 修订件为准」。我自己测过一版有族对齐与无族对齐的差异:没有族对齐时,涉及「终止权」的摘要约有四分之一引用了已被修订掉的条款。
短结论:分组错一份,后面三份摘要都会歪。
文档切片与引用锚点怎么设计,才能让摘要点得回原文?#
先说结论:不要用纯字符偏移(Character Offset)做锚点。同一份 PDF 用 pdftotext、pdfplumber、PyMuPDF 解析出来的字符流并不一致,换一次解析器或升一次版本,历史引用全部失效。
我用的是引用锚点(Citation Anchor)四元组,外加一个兜底哈希。
规范模板:锚点字段定义
| 字段 | 类型 | 示例 | 说明 |
|---|---|---|---|
| doc_id | string | DR-00123 | VDR 内唯一文档 ID |
| family_id | string | FAM-0456 | 文件族 ID,跨版本共享 |
| page_no | int | 37 | 从 1 起算的页码 |
| bbox | float[4] | [72.0, 410.5, 523.1, 468.2] | PDF 点坐标,主锚点 |
| text_hash | string | 9f2c8a1d… | 切片文本 sha256 前 16 位,兜底重定位 |
前端点开引用时优先用 bbox 高亮;如果坐标漂移(换了解析器、重排了页面),就用 text_hash 在该页文本里做模糊匹配重新定位。这个兜底路径我实测能救回九成以上的历史锚点,成本只是每页一次字符串扫描。
切片用条款级切片(Clause-level Chunking):按「第 X 条」「Section X.Y」切,跨页条款合并成一块;识别不到条款结构再退回固定窗口,800~1200 tokens,重叠 150 tokens。固定窗口的直接坑是把一条免责条款从中间劈开,摘要只看到半句,语义完全反了。
可执行脚本:生成锚点(Python 3.11 + PyMuPDF 1.24)
# 示例仅用于逻辑验证,生产环境需补充日志、监控与降级
import hashlib
import re
from dataclasses import dataclass
import fitz # PyMuPDF 1.24.x
CLAUSE_RE = re.compile(r"(第[一二三四五六七八九十百零]+条|Section\s+\d+(?:\.\d+)*)")
MAX_BLOCK_CHARS = 4000 # 单块超长视为版式异常,截断并打标,交给人工复核
MIN_BLOCK_CHARS = 20 # 低于该长度基本是页眉页脚、页码
@dataclass(frozen=True)
class Anchor:
doc_id: str
family_id: str
page_no: int
bbox: tuple
text_hash: str
def norm(text: str) -> str:
# 软连字符、全角空格、跨行连字符都会让 hash 不稳定,必须统一
text = text.replace("\u00ad", "").replace("\u3000", " ")
text = re.sub(r"-\n(?=[a-z])", "", text)
return re.sub(r"\s+", " ", text).strip()
def build_anchors(pdf_path: str, doc_id: str, family_id: str) -> list[Anchor]:
anchors: list[Anchor] = []
try:
doc = fitz.open(pdf_path)
except Exception as exc: # 文件损坏、加密、权限不足都走这里
raise RuntimeError(f"open failed: {pdf_path}: {exc}") from exc
try:
for page in doc:
if page.rect.width <= 0 or page.rect.height <= 0:
continue # 版式异常页跳过,不在这里硬扛
blocks = page.get_text("blocks") # (x0, y0, x1, y1, text, block_no, block_type)
if not blocks:
# 无文本层:标记后交给 OCR 分支,不要用空文本污染下游
anchors.append(Anchor(doc_id, family_id, page.number + 1,
(0.0, 0.0, 0.0, 0.0), "NO_TEXT_LAYER"))
continue
for x0, y0, x1, y1, text, *_ in blocks:
body = norm(text)
if len(body) < MIN_BLOCK_CHARS:
continue
if len(body) > MAX_BLOCK_CHARS:
body = body[:MAX_BLOCK_CHARS]
h = hashlib.sha256(body.encode("utf-8")).hexdigest()[:16]
anchors.append(Anchor(doc_id, family_id, page.number + 1,
(x0, y0, x1, y1), h))
finally:
doc.close() # 不关会耗尽文件句柄,批量处理到几百份就崩
return anchors
参数含义:MAX_BLOCK_CHARS 防的是排版错乱的扫描件把整页塞进一个 block;MIN_BLOCK_CHARS 过滤页眉页脚;fitz.open 用 try/except 包住,是因为加密 PDF 在批量作业里出现频率不低。预期输出:一份 300 页的并购主协议大约产出 400~900 个锚点,文本层 PDF 单页解析中位数在本机是 12~30 ms,扫描件走 OCR 后是 1.2~3 s/页。扫描件走 OCR 前先跑一条命令确认有没有文本层:
# 运行环境:Linux / macOS,poppler-utils 24.x
# -layout 保留原始排版,-f/-l 限定页范围,避免整本解析耗时
pdftotext -layout -f 1 -l 5 ./vdr/DR-00123.pdf - | head -c 400
输出为空或只有零星字符,说明没有文本层,直接进 OCR 队列,别浪费解析时间。这一步能挡掉大量「解析成功但内容为空」的假成功。
引用核验怎么做成可自动化的流水线?#
不要指望「让模型再读一遍原文」。同一个模型自查,会给出同样的错误,还多花一次钱。引用核验(Citation Verification,也叫归因校验、Attribution Check)我拆成三级,越往后越贵,样本越少。
对比表:三级核验的成本与职责
| 层级 | 方法 | 单条耗时 | 成本占比 | 拦截目标 |
|---|---|---|---|---|
| L1 字面命中 | 归一化后子串匹配 / rapidfuzz.partial_ratio ≥ 92 | 约 1~3 ms | 接近 0 | 凭空生成的句子、错位引用 |
| L2 语义蕴含 | 自然语言推理(Natural Language Inference,NLI)小模型,阈值 0.60 | 80~150 ms(CPU) | 约 5% | 原文存在但不支持该结论 |
| L3 人工抽检 | 5% 随机抽样 + 全部冲突项 | 分钟级 | 人力为主 | 语义偏差、跨语言误判 |
L2 用几百 M 参数量级的 NLI 模型在 CPU 上跑就够,不要上大模型。阈值 0.60 是起点,英文合同配中文摘要的场景要单独调,通常压到 0.45 附近更稳,因为跨语言蕴含本身打分偏低。
# 示例仅用于逻辑验证,生产环境需补充日志、监控与降级
from rapidfuzz import fuzz
L1_THRESHOLD = 92.0
L2_THRESHOLD = 0.60
L1_REJECT = 60.0
L2_REJECT = 0.30
def verify_citation(summary_sentence: str, cited_text: str,
nli_score: float | None) -> str:
if not cited_text or len(cited_text.strip()) < 10:
return "reject" # 空引用直接拒,别让下游误以为通过
l1 = fuzz.partial_ratio(summary_sentence, cited_text)
if l1 >= L1_THRESHOLD:
return "pass"
if nli_score is None:
return "needs_review" # NLI 服务不可用时降级人工,绝不放行
if l1 < L1_REJECT and nli_score < L2_REJECT:
return "reject" # 字面与语义双低,判定为编造
if nli_score >= L2_THRESHOLD:
return "pass"
return "needs_review"
预期输出是三种状态之一:pass / needs_review / reject。注意 nli_score is None 的分支——核验服务挂了必须走人工,不能默认通过,这是我在压测里踩过的坑:降级逻辑写反,整批摘要全部自动放行。
还有一处容易被忽略:模型网关的出口 IP。供应商白名单只认固定出口,OCR 节点、向量化节点、模型网关三处出口不一致时,批次会在第 200 页左右开始随机 403,日志里表现像限流。上线前把三处出口各查一次,写进部署清单:
# 运行环境:Linux / WSL2,curl 8.x
# --max-time 5 单次上限 5 秒;--retry 2 失败重试两次;--retry-connrefused 连接被拒也重试
curl -sS --max-time 5 --retry 2 --retry-delay 1 --retry-connrefused \
"https://www.api.plus/api/get?apikey=YOUR_KEY&ip=8.8.8.8"
返回片段(结构示意,字段以实际响应为准):
{"code":200,"data":{"ip":"8.8.8.8","country":"United States","isp":"Google LLC"}}
参数含义:apikey 是调用凭证;ip 传要查询的地址,这里故意用 8.8.8.8 做连通性验证。把它包进部署脚本,三处节点各跑一次,比对 isp 与出口段。核验服务自己的接口文档,建议照本站 GET 示例接口 的写法,把 apikey、超时、错误码三件事写在最前面,否则接入方永远在猜 429 和 403 的区别。
三段怎么串起来:编排、幂等与成本结构#
串起来的关键是幂等键(Idempotency Key)和流水线版本号。我的键是 sha256(file_bytes)[:16] + pipeline_version。同一份文件重跑不重复计费;改了切片策略就升版本,旧结果保留可回放,方便复盘「上周那批摘要为什么变差」。
编号步骤:单文档处理链路
- 入队:解析 → 生成锚点 → 写文档库(PostgreSQL 16,锚点表按 doc_id 做哈希分区)
- 一级分组,置信度 < 0.75 推人工复核队列
- 二级文件族对齐,同族按日期排序
- 按族生成摘要,每条摘要必须携带至少一个 anchor
- 三级核验流水线,结果写回摘要记录
- 报告装配,只允许出现
pass状态的引用
队列用 Redis 7.2 的 Stream + 消费组,XACK 之前不删消息;模型调用单独限流,失败进死信队列(Dead Letter Queue,DLQ),超过 3 小时未消费就告警。解析失败和 429 限流绝不能混在同一个重试队列里——前者重试一百次也没用,后者等两分钟就好。
对比表:各阶段耗时与成本占比(经验值,需按自己样本复测)
| 阶段 | 单页 P95 耗时 | 失败率 | 成本占比 |
|---|---|---|---|
| 解析 + 锚点(文本层) | 40 ms | < 0.5% | 忽略不计 |
| OCR(扫描件) | 2.4 s | 1%~3% | 约 15% |
| 一级 + 二级分组 | 35 ms | < 0.3% | 约 3% |
| 摘要生成 | 1.8 s | 2%~5%(含限流) | 约 70% |
| L1 + L2 核验 | 130 ms | < 1% | 约 4% |
结论很清楚:钱花在摘要和 OCR 上,核验只占个位数百分比。所以核验不该省——砍掉它,前面 95% 的投入都在产出一份没人敢签字的报告。
另外提醒一句结构化输出的边界。Stack Overflow 有篇讲得很直接:JSON Schema 只管格式,管不了内容对不对(A JSON schema Is not an AI governance layer)。我们强制摘要输出带 anchors[] 字段,schema 校验只能保证这个数组存在、元素是字符串;它有没有指向真实存在的段落,得靠核验流水线。这两件事经常被混为一谈。
监控与告警清单#
监控清单:关键指标与动作
| 指标 | 建议阈值 | 告警动作 |
|---|---|---|
| 锚点覆盖率(有 bbox 的切片占比) | < 85% | 页面告警,排查解析器版本与 PDF 版式 |
| L1 拒绝率 | > 8% | 提示级,优先怀疑摘要提示词漂移 |
| needs_review 占比 | > 25% | 页面告警,抽检比例临时提到 20% |
| 单文档处理 P95 时长 | > 45 min | 开工单,检查 DLQ 与 OCR 队列 |
| DLQ 积压条数 | > 50 | 电话告警 |
| 节点出口 IP 与白名单一致性 | 不一致即阻断 | 部署门禁,不允许上线 |
这几条我按「能不能在报告交付前发现」排序。锚点覆盖率放在第一位,因为它一旦掉下去,后面所有引用都是悬空的,而核验层看不出来——L2 判的是语义,不判坐标。
风险清单#
风险清单:风险 → 影响 → 规避
| 风险 | 影响 | 规避方式 |
|---|---|---|
| 扫描件 OCR 错字 | 引用定位失败或语义反转 | text_hash 兜底 + 关键条款强制人工复核 |
| 文件族归错 | 引用失效条款 | 摘要强制标注版本日期,族内按日期排序 |
| 模型编造条款编号 | 律师信任崩塌,一次就够 | L1 拦截;报告禁止展示未核验的条款号 |
| 跨语言摘要(英文合同 / 中文摘要) | NLI 打分整体偏低,误杀率高 | 单独一套阈值,不与中文语料共用 |
| 单家模型依赖 | 供应商变更导致输出风格漂移 | 保留 prompt 版本号与降级路径,A/B 可回切 |
| 敏感信息外流 | 合规事故 | 在数据访问边界做脱敏,思路可参考 Meta PrivacyLib 的边界控制做法 |
演示环境里文档卡片缺缩略图,前端直接调一个随机图接口做占位,避免所有卡片长得一模一样;这类接口的返回格式可以看 随机动漫壁纸接口。生产环境换成真缩略图,别把占位图带上去。
总结与适用边界#
这套东西的本质是把「模型说了什么」降级成「模型引用了哪一段」,再用工程手段验证这个引用关系。模型能力继续涨,这套骨架也不用改——变的只是 L2 阈值和摘要提示词。Martin Fowler 那边有篇讨论说得挺实在:能跑起来和能在生产里工作,是两回事(Why are good developers so wary of agentic development?)。尽调场景尤其如此,错的摘要比没有摘要更危险。
还有一点:别只算省下的工时。有篇预印本讨论过这个口径问题——衡量 AI 省时间,得把「它放过的错误」一起记进账本(Counting the Bandages)。我们的做法是同时记两个数:律师审阅耗时、抽检退回率。后者涨了,前面省的时间就是假的。相关的口径设计,站内 API 文章中心 有过几篇讨论,可以对照看。
适用范围
- 文档量在 500 份以上、跨 5 个以上业务域的并购或融资尽调
- 团队已有基础解析能力(PDF 文本层 + OCR),且能接受 5% 左右的人工抽检成本
- 报告需要对外交付、每条结论必须可回溯到原文页码的场景
不适用范围
- 文档不足 50 份:人工读比搭流水线快,锚点体系是负资产
- 纯扫描件且 OCR 质量低于 90%:「引用核验」会退化成「OCR 纠错」,投入产出不划算
- 需要即时问答、不要求出处的一次性查询:三级核验的延迟(单条 150 ms 起)反而是负担
本站实测数据(近 24 小时)#
以下数据来自 API.PLUS 线上调用日志(统计时间 2026-10-30 10:08,口径:仅统计已上线接口,不含扫描与无效路径),可作为容量规划与稳定性判断的现实参考。
- 接口调用总数:141 次;成功返回 138 次,鉴权/参数校验拦截 3 次
- 最活跃接口:随机动漫壁纸(94 次)、Menhera 动漫图片(41 次)、IP 地址查询(4 次)
- 近 7 天累计调用:899 次