资讯详情 法研杯相似案例匹配:检索+排序全链路实战与避坑指南
📅 2026/10/9 2:59:33
简介这份资源是法研杯2019相似案例匹配赛道的第二名完整解决方案面向具备一定NLP与机器学习基础、希望深入法律文本相似度匹配的开发者与竞赛选手。包内以cail2019-master项目为核心涵盖模型训练、推理预测、评测打分与容器化部署等环节可帮助读者理解从文本预处理、特征工程到深度学习建模的完整技术链路。资源共22个文件包含6个Python脚本、3个Shell脚本、3个Dockerfile、3个Markdown文档及若干配置与说明文件压缩包约192KB体积轻量但结构完整便于快速复现与二次开发。目前已有266人学习下载。借助其中的数据集、代码与文档读者可掌握相似案例匹配的建模思路、评价指标设计与调参策略并了解司法AI在辅助决策与提升法律服务效率方面的实际应用潜力。1. 相似案例匹配这道题为什么值得用检索排序的完整链路来啃做法研杯相似案例匹配最容易翻车的地方不是模型不够深而是把「相似」当成一个二分类问题硬做。2019 年这道赛题给的是成对的法律文书要求判断两段案情描述是否相似表面看是文本匹配实际是一个带强领域先验的检索排序问题。很多队伍一上来就 BERT 双塔加交叉熵线下 AUC 冲到 0.9换到真实案由分布上一跑就崩因为负样本采样方式和法律文本的长尾分布完全对不上。CAIL 系列赛道从 2018 年做到 2021 年司法考试赛道冠军团队沉淀下来的那套方案核心思路就是把「召回」和「精排」拆开先用字面语义做粗召回再用交互式模型做精排最后用规则兜底。这套链路对做相似案例匹配、类案检索、裁判文书推荐的从业者都能直接复用数据集和文档齐全的话两三天就能跑通 baseline。下面按我实际复现的顺序把选型理由、可抄的代码和踩过的坑一条条讲清楚。2. 数据到手先别急着训模型CAIL 相似案例匹配的数据结构拆解2.1 三件套字段与相似标签的真实分布法研杯 2019 相似案例匹配的数据一般以 json 行或 csv 形式给出每条样本包含 query 案情、candidate 案情和一个 0/1 相似标签。字段命名各版本略有差异常见的是query、candidate、label也有用sentence1、sentence2的。拿到手第一件事不是写 Dataset而是统计三件事正负样本比例、案情长度分布、案由分布。我见过太多人直接train_test_split就开跑结果测试集里某类案由一条没有模型学了个寂寞。import json from collections import Counter def load_cail(path): data [] with open(path, r, encodingutf-8) as f: for line in f: item json.loads(line) # 兼容不同版本的字段命名 q item.get(query) or item.get(sentence1) c item.get(candidate) or item.get(sentence2) y int(item.get(label, 0)) data.append((q, c, y)) return data data load_cail(cail2019_similar.jsonl) labels Counter(y for _, _, y in data) print(正负比:, labels) lens [len(q) for q, _, _ in data] print(query 长度分位:, sorted(lens)[len(lens)//2], max(lens))这段代码做的是最朴素的加载和统计。label转 int 是为了后面直接喂给 BCE 损失长度分位用来决定max_length设 256 还是 512。法律文书动辄上千字直接截断会丢关键事实我一般会先看 95 分位超过 512 的做分段或摘要而不是无脑截。2.2 负样本构造随机采样为什么会让模型学废原始数据里负样本往往是随机配的随机负样本和 query 的案由、当事人、金额可能八竿子打不着模型只要抓住「有没有相同关键词」就能分对泛化极差。正确做法是做难负样本挖掘用 BM25 或句向量先召回一批候选把和 query 字面重叠高但标签为 0 的挑出来当负样本。这一步是 CAIL 方案里拉开差距的关键冠军团队在文档里也强调了课程学习式的负样本难度递进。from rank_bm25 import BM25Okapi import jieba def build_hard_negatives(queries, candidates, labels, topk5): corpus [list(jieba.cut(c)) for c in candidates] bm25 BM25Okapi(corpus) hard_negs [] for q, y in zip(queries, labels): if y ! 1: continue scores bm25.get_scores(list(jieba.cut(q))) # 取分数最高但标签为 0 的候选作为难负样本 ranked sorted(range(len(scores)), keylambda i: -scores[i]) picked [i for i in ranked if labels[i] 0][:topk] hard_negs.extend([(q, candidates[i], 0) for i in picked]) return hard_negstopk控制每个正样本配几个难负样本一般 3 到 5 就够太多会让训练集极度不均衡。jieba.cut是中文分词法律文本里专有名词多可以加载自定义词典把「买卖合同」「连带责任」这类词切准否则 BM25 的召回质量会明显下降。2.3 案由分层切分别让测试集泄漏训练分布切分数据集时按案由分层保证训练集和验证集里各类案由比例接近。如果某类案由样本极少宁可合并到相近大类也不要在验证集里留一两条否则指标抖动大到没法判断模型好坏。我一般用sklearn的StratifiedKFold思路先按案由分组再切。from sklearn.model_selection import train_test_split def stratified_split(data, case_types, test_size0.2, seed42): # case_types 是每条样本对应的案由标签 train, dev train_test_split( data, test_sizetest_size, random_stateseed, stratifycase_types) return train, devstratify参数是这里的关键少了它切分就是随机的小类案由可能全跑进验证集。seed固定住方便复现和对比不同模型。3. 召回层怎么搭BM25 与句向量双路并行的最小实现3.1 BM25 召回法律术语的字面匹配仍然能打很多人觉得有了 BERT 就不需要 BM25 了这是典型的踩坑。法律文本里「合同解除」和「合同终止」语义接近但字面不同BM25 抓不住可「原告」「被告」「违约金」这类高频实体词BM25 的精确匹配反而比稠密向量稳。我的做法是双路召回再合并去重BM25 负责字面句向量负责语义。from rank_bm25 import BM25Okapi import jieba jieba.load_userdict(law_dict.txt) # 法律领域自定义词典 def bm25_recall(query, corpus, topk50): tokenized [list(jieba.cut(doc)) for doc in corpus] bm25 BM25Okapi(tokenized) scores bm25.get_scores(list(jieba.cut(query))) ranked sorted(range(len(scores)), keylambda i: -scores[i])[:topk] return ranked, scoreslaw_dict.txt里放的是从训练集里统计出的高频法律术语加载后分词粒度更合理。topk设 50 是召回层的常见量级太小会漏掉精排能救回来的样本太大精排压力大。scores返回出来是为了后面和向量分数做加权融合。3.2 句向量召回用对比学习把案情压到同一空间句向量这路我一般用text2vec或者自己拿 BERT 做 Sentence-BERT 式的对比学习微调。核心是构造正负对用 InfoNCE 损失把相似案情拉近。CAIL 方案里冠军团队用了多任务学习把相似度回归和案由分类一起训效果比单任务稳。import torch import torch.nn.functional as F from transformers import AutoModel, AutoTokenizer class CaseEncoder(torch.nn.Module): def __init__(self, model_namehfl/chinese-roberta-wwm-ext): super().__init__() self.bert AutoModel.from_pretrained(model_name) self.proj torch.nn.Linear(768, 256) def forward(self, input_ids, attention_mask): out self.bert(input_ids, attention_maskattention_mask) cls out.last_hidden_state[:, 0] # 取 [CLS] return F.normalize(self.proj(cls), dim-1) def info_nce(q_emb, c_emb, temperature0.05): logits q_emb c_emb.t() / temperature labels torch.arange(q_emb.size(0), deviceq_emb.device) return F.cross_entropy(logits, labels)temperature设 0.05 是句向量对比学习的常用值太大梯度平太小容易震荡。proj把 768 维降到 256 维一是省显存二是低维空间里余弦相似度更稳定。F.normalize保证向量在单位球面上点积直接等于余弦相似度。3.3 双路分数融合加权还是 RRF看你的召回规模两路召回结果合并时如果两路分数尺度差很多直接加权会翻车。我一般用 RRFReciprocal Rank Fusion只依赖排名不依赖分数鲁棒性好。def rrf_fusion(rank_lists, k60): scores {} for ranks in rank_lists: for pos, doc_id in enumerate(ranks): scores[doc_id] scores.get(doc_id, 0) 1.0 / (k pos 1) return sorted(scores.items(), keylambda x: -x[1])k是平滑常数经验值 60调大调小影响不大。rank_lists是 BM25 和向量两路各自返回的 doc_id 列表。融合后取前 100 进精排这个量级在单卡上跑得动。4. 精排模型怎么选交互式 BERT 与特征工程的取舍4.1 交叉编码器把 query 和 candidate 拼一起喂 BERT精排阶段最稳的是交叉编码器把两段文本用[SEP]拼起来让 BERT 的注意力在两边自由交互。缺点是推理慢只能对召回后的少量候选做。CAIL 方案里精排用了 BERT 多特征拼接纯文本不够还要加人工特征。from transformers import BertTokenizer, BertForSequenceClassification tokenizer BertTokenizer.from_pretrained(hfl/chinese-roberta-wwm-ext) model BertForSequenceClassification.from_pretrained( hfl/chinese-roberta-wwm-ext, num_labels2) def encode_pair(query, candidate, max_len512): enc tokenizer( query, candidate, truncationTrue, max_lengthmax_len, paddingmax_length, return_tensorspt) return encmax_len设 512 是 BERT 的上限法律文本超长时优先截 candidate 保留 query因为 query 是检索意图所在。num_labels2做二分类也可以改成回归输出相似度分数看评测指标是 AUC 还是 F1。4.2 人工特征案由、金额、当事人重叠度别丢纯语义模型对数字和实体不敏感「借款 10 万」和「借款 100 万」在向量空间里可能很近。我一般会补几维人工特征案由是否一致、金额差值、当事人名称 Jaccard 重叠、法条引用重叠。def overlap_features(q, c): q_set, c_set set(jieba.cut(q)), set(jieba.cut(c)) jaccard len(q_set c_set) / max(len(q_set | c_set), 1) # 金额抽取用正则法律文本里常见「人民币XX元」 import re q_amt re.findall(r(\d(?:\.\d)?)元, q) c_amt re.findall(r(\d(?:\.\d)?)元, c) amt_diff abs(float(q_amt[0]) - float(c_amt[0])) if q_amt and c_amt else -1 return [jaccard, amt_diff]jaccard衡量字面重叠amt_diff为 -1 表示至少一方没抽到金额训练时当缺失值处理。这些特征拼到 BERT 的[CLS]向量后面过一层 MLP 再输出AUC 通常能涨 1 到 2 个点。4.3 损失函数Focal Loss 处理难样本法律数据里难负样本占比高普通 BCE 会被易分样本主导。换成 Focal Loss 让模型聚焦难样本是 CAIL 方案里常见的操作。import torch.nn.functional as F def focal_loss(logits, targets, alpha0.25, gamma2.0): bce F.binary_cross_entropy_with_logits(logits, targets, reductionnone) p torch.sigmoid(logits) p_t p * targets (1 - p) * (1 - targets) loss bce * ((1 - p_t) ** gamma) return loss.mean()gamma设 2.0 是原论文默认值越大越聚焦难样本。alpha平衡正负样本正样本少时调大。这两个参数别一起大改先固定 gamma 调 alpha。5. 避坑与排查相似案例匹配里最容易翻车的 5 个点5.1 现象线下 AUC 0.95线上评测掉到 0.7原因训练集负样本是随机采的和线上真实候选分布不一致模型学到了「随机负样本」的捷径特征。解决用 BM25 或向量召回构造难负样本训练集负样本分布向线上对齐必要时做课程学习先易后难。5.2 现象模型对长文本预测全是同一类原因max_length截断把关键事实切掉了或者 padding 太多导致注意力被稀释。解决先统计长度分位超长文本做分段编码再池化或者用 Longformer 类模型。padding 用attention_mask屏蔽掉别让模型看到填充位。5.3 现象验证集指标抖动大换个 seed 差 5 个点原因验证集太小或案由分布不均小类样本主导了指标波动。解决按案由分层切分验证集至少每类 50 条不够就合并案由。多 seed 跑几次取平均别信单次结果。5.4 现象精排模型推理慢召回 100 条要跑 10 秒原因交叉编码器逐对推理没有 batch也没用 FP16。解决候选按 batch 拼一起推理开torch.cuda.amp混合精度或者蒸馏一个小模型做精排。实在慢就把召回 topk 降到 30。5.5 现象人工特征加进去反而掉点原因特征尺度没归一化或者特征和文本语义冲突模型学混了。解决数值特征做标准化类别特征做 embedding加特征前先单独训一版看增益别一股脑全塞。6. 从第二名方案里能偷的进阶技巧多任务与模型融合CAIL 相似案例匹配的第二名方案以及后来司法考试赛道冠军团队公开的思路有一个共同点不把鸡蛋放一个篮子里。多任务学习是第一个能偷的技巧把相似度判断和案由分类、法条预测一起训共享底层 BERT任务间的梯度互相正则小样本案由上提升明显。实现上就是给 BERT 加几个 head损失加权求和权重用不确定性加权自动学。class MultiTaskModel(torch.nn.Module): def __init__(self, encoder): super().__init__() self.encoder encoder self.sim_head torch.nn.Linear(768, 1) self.case_head torch.nn.Linear(768, 20) # 假设 20 个案由 def forward(self, input_ids, attention_mask): out self.encoder(input_ids, attention_maskattention_mask) cls out.last_hidden_state[:, 0] return self.sim_head(cls), self.case_head(cls)sim_head输出相似度 logitcase_head输出案由分类 logit两个损失按 1:0.3 加权案由任务当辅助。案由类别数按实际数据改别写死。第二个技巧是模型融合但别用简单的投票。我一般把 BERT 精排、LightGBM 特征模型、句向量相似度三路分数做 stacking用逻辑回归学融合权重。验证集上做 5 折每折的 out-of-fold 预测拼起来训融合器避免过拟合。import numpy as np from sklearn.linear_model import LogisticRegression def stack_fusion(oof_preds, labels): # oof_preds: [n_samples, n_models] clf LogisticRegression() clf.fit(oof_preds, labels) return clfoof_preds必须是 out-of-fold 的不能拿训练集预测直接训融合器否则融合权重会偏向过拟合的模型。LogisticRegression简单可解释权重能看出哪路模型贡献大。最后一个技巧是后处理规则兜底。相似案例匹配里如果两段案情的案由不同、金额差一个数量级、当事人完全不重叠基本可以判不相似不管模型输出多少分。我一般设几条硬规则命中就直接覆盖模型结果线上能挡掉一批明显错误。def rule_override(query, candidate, model_score): # 案由不同且金额差 10 倍以上直接判不相似 if case_type(query) ! case_type(candidate): if amt_ratio(query, candidate) 10: return 0.0 return model_scorecase_type和amt_ratio按你的数据实现规则别设太多三五条够用多了会和模型打架。这套链路我从数据清洗跑到融合单卡 2080Ti 大概两天出结果关键是把召回和精排的边界划清楚别指望一个模型端到端解决所有问题。做相似案例匹配最深的教训是先把数据分布摸透再谈模型不然调参调到天亮也是玄学。希望帮到你。本文还有配套的精品资源点击获取