简介:这是一份面向法律智能问答应用开发者的完整项目资料包,围绕四万条法律问答数据的清洗、相似匹配与Web服务搭建展开,适合有一定Python基础、希望入门中文自然语言处理或构建法律咨询机器人的人群。压缩包共25个文件,主要包含Python数据分析脚本、预训练中文BERT模型文件、Web前端页面与交互脚本、Visio流程设计图以及CSV格式的法律问答数据集。其中模型与前端模板可直接复用,能够快速启动演示应用。资源整体约371.77MB,已有139人学习下载。通过阅读代码和数据分析过程,可以掌握数据预处理、句子相似度计算、模型加载与调优、前后端联调等关键环节的实施思路;流程图为理解系统整体工作流提供了直观参考,从用户提问、语义匹配到答案返回的完整链路一目了然。 法律.rar 这类资源,拿到手最先要解决的其实不是“内容”,而是文件形态、编码和完整性。半个 G 到一个多 G 的压缩包,里面经常混着 txt、docx、pdf 甚至嵌套的 zip,文件名还常常是乱码,直接解压再按目录去读,基本等于把整理资料的耐心提前耗尽。这篇文章不会讲具体法条内容,而是把“法律.rar”当作一份需要落地的文本语料,从解压前的检查、清洗、切分、去重、建立检索索引,一直讲到每条数据的边界条件。适合两类人:做文档检索或 NLP 的工程师,以及需要从本地资料库快速查旧条文、旧裁定的业务人员。
2. 包内摸底:先看清单、编码和完整性,再谈清洗
2.1 为什么第一步不是解压,而是“看压缩包的目录”
很多人拿到法律.rar的第一反应是双击解压到桌面,然后发现几千个文件堆在一起。真正的问题是:你根本不知道里面是什么级别的资料。可能是散装 txt,可能是已经做成目录结构的标准文本,也可能混了大量扫描版 PDF,后者在文本提取阶段就会卡住。
我一般会把“看目录”当作第一步,而且是在不解压的情况下连清单带校验一起看。这一步能直接回答三个问题:文件总量有多大、扩展名分布怎么样、有没有分卷。压缩包本身不是黑匣子,rarfile 库可以把每个文件的文件名、大小、CRC 校验值都读出来,CRC 值先记下来,解压完成后能用来做一致性核对。
2.2 搭好拆包环境:rarfile + unrar 的组合与常见报错
Python 侧最顺手的库是rarfile,但它并没有内置 RAR 解压实现,还是得依赖外部 unrar 工具。在 Windows 上装了 WinRAR 后用它的 unrar.exe,在 Linux 上装unrar或p7zip都行。环境变量路径写错了最常见,会直接抛找不到解压工具的异常。
import rarfile from collections import Counter, defaultdict # 指定外部 unrar 工具路径,Windows 下常写成: # rarfile.UNRAR_TOOL = r"C:\Program Files\WinRAR\UnRAR.exe" rarfile.UNRAR_TOOL = "/usr/bin/unrar" def inspect_archive(path: str): stat = defaultdict(Counter) with rarfile.RarFile(path) as rf: for f in rf.infolist(): if f.isdir(): continue name = f.filename ext = name.rsplit(".", 1)[-1].lower() if "." in name else "(noext)" stat[ext]["count"] += 1 stat[ext]["size"] += f.file_size print(f"{f.file_size:>10} {f.CRC:08X} {name}") return stat if __name__ == "__main__": stat = inspect_archive("法律.rar") for ext, s in stat.items(): print(f"{ext:10} {s['count']:5d} 个 {s['size'] / 1024 / 1024:.1f} MB")这段脚本里有两个点值得注意。一个是f.isdir(),rarfile 继承了 zipfile 的文件信息模型,目录项也会出现在 infolist 里,不跳过会把目录当空文件处理;另一个是f.CRC,它是 RAR 头里记录的 32 位校验值,打印出来只为了留个底,真正做一致性比对还是等解压完成后用输出文件的 md5 去比。第一次跑这种脚本,输出里如果出现大量.doc和.pdf,后面清洗的工作量基本可以提前确认了。
2.3 文件名乱码的根因与修复脚本
解压后的文件名变成“锟斤拷”或一堆 ASCII 符号,几乎每个拆包的人都遇到过。原因在压缩工具:旧版 RAR 在中文 Windows 下写文件名时按 GBK 编码,而 Python 的 rarfile 在读取头信息时默认按 Unicode 处理,两端对不上,名字就花了。
def decode_rar_filename(raw: str) -> str: # raw 是 rarfile 解出来的字符串 # 中文本地环境常见场景:实际上是 cp437/latin-1 读出来的 GBK 字节 try: return raw.encode("cp437").decode("gbk") except (UnicodeDecodeError, UnicodeEncodeError): return raw这段代码的思路是先还原出原始字节,再用 GBK 解一次。典型的例子是“ǎ”被拆成“æ³å”这种,一翻就能回到正确中文。要注意的是较新的 RAR5 文件头里带了 Unicode 标记,这招不一定生效;所以我只把它放在清洗脚本的最前面,作为第一道兜底。文件名能正确显示了,后面的清洗脚本才好往下写。
3. 把 rar 变成结构化条文库:解压、清洗与切分流水线
3.1 解压策略:整包解压还是按需抽取
目录摸底做完之后才考虑解压。对于几百 MB 的包,我通常是整包解压到独立目录,因为后续文本切分还是要扫所有文件;但如果包里明显混着大量不需要碰的 PDF,就按扩展名过滤,避免让 OCR 之类的重活拖垮整条流水线。
import os import rarfile def extract_selected(archive_path: str, out_dir: str, exts=(".txt", ".doc", ".docx")): os.makedirs(out_dir, exist_ok=True) with rarfile.RarFile(archive_path) as rf: for f in rf.infolist(): if f.isdir(): continue if not f.filename.lower().endswith(exts): continue rf.extract(f.filename, path=out_dir) print("extract done:", out_dir)这里的rf.extract(f.filename, path=out_dir)是 rarfile 的常规用法,第二个参数是目标目录。解压时如果文件名是加密的,还需要传入密码参数pwd。实际上很多法条资料包都会套一层密码,密码错误时 rarfile 抛的不是权限异常而是一个 BadRarFile,排查时要能认出这个表象。
3.2 文本清洗的两个硬指标:全角半角统一与不可见字符
解压完的文件不能直接当语料用,尤其是从网页或其他工具里导出的文本,全角引号、全角数字、行尾的软回车、零宽空格都混在里面。法律文本还很爱用全角括号和全角逗号,如果不统一,分词效果和后续关键词匹配都会出差错。
import re def clean_legal_text(raw: str) -> str: # 1. 全角转半角:全角字符在 U+FF01~U+FF5E,映射到 U+21~U+7E out = [] for ch in raw: code = ord(ch) if code == 0x3000: # 全角空格 code = 0x20 elif 0xFF01 <= code <= 0xFF5E: # 全角 ASCII 可视字符 code -= 0xFEE0 out.append(chr(code)) text = "".join(out) # 2. 去掉控制字符和零宽字符 text = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f\u200b-\u200d\ufeff]", "", text) # 3. 合并多余的空白和空行 text = re.sub(r"[ \t]+", " ", text) text = re.sub(r"\n{3,}", "\n\n", text) return text.strip()参数说明:0xFEE0是全角 ASCII 区与半角区之间的偏移量,因为全角字符按顺序排布,所以直接减这个偏移量就能整体映射回去。控制字符范围里比较容易漏的是\u200b零宽空格和\ufeffBOM,后者出现在文件开头时经常让首条记录多出一个隐形字符。这段清洗我一般放在所有格式转换之后、分词之前,顺序反了的话,docx 里带的 XML 残留会把正则彻底带偏。
3.3 按“第X条”切分条文,保留可追溯的元信息
法律文本最直观的结构化维度是“章”和“条”。把每个完整文件按条文粒度切出来,后续检索的召回粒度才够细。切分规则看着简单,但真写起来要照顾到中文数字和阿拉伯数字混编的情况。
import re from pathlib import Path ARTICLE_RE = re.compile(r"^(第[一二三四五六七八九十百零0-9]+[条章])\s*(.*)$") def split_articles(text: str): records = [] cur_title, cur_body = "", [] for line in text.splitlines(): m = ARTICLE_RE.match(line) if m: if cur_title: records.append({"title": cur_title, "body": "\n".join(cur_body)}) cur_title = m.group(1) cur_body = [m.group(2)] if m.group(2) else [] else: cur_body.append(line) if cur_title: records.append({"title": cur_title, "body": "\n".join(cur_body)}) return records这段函数的关键点在正则表达式:^第[一二三四五六七八九十百零0-9]+[条章],锚定了行首,又同时接受中文数字和阿拉伯数字。切分后每条记录带title和body两个字段,前者是“第X条”这种索引值,后者是正文。后续做召回时,title 字段权重应该比 body 高,因为条文号本身是最强的索引键。处理大文件时建议按文件为单位调用这个函数,再存入 jsonl,别把所有文件一次性拼成超大字符串再切,内存会先爆。
4. 为条文建立可检索索引:法律分词、停用词与去重
4.1 为什么通用分词在这里“翻车”
jieba 默认词典面向新闻和日常文本,法律文本里大量术语和固定搭配会被切得七零八落。“违约责任”可能被切成“违约”和“责任”,“不可抗力”可能被切成两个词。搜索引擎可以靠索引召回兜底,但本地检索不行——查“违约责任”时,如果索引里根本没有“违约责任”这个完整 token,BM25 这类方法很难把它和“违反合同义务应承担的责任”关联起来。
所以我会先准备一个法律领域的自定义词典,不需要很大,几百条足够。把高频出现的法律术语、文书类型、法条引用方式都加进去。格式是每行一个词,后面跟词频和词性:
违约责任 10 n 法定代表人 10 n 合同义务 8 n 不可抗力 5 n 裁判文书 10 n词频权重在这里不用太精确,优先让长词在 match 时可以整体命中。加载后检查一下效果:
import jieba jieba.load_userdict("legal_dict.txt") text = "一方不履行合同义务或者履行合同义务不符合约定的,应当承担违约责任。" print(list(jieba.lcut(text))) # 预期输出里能看到“合同义务”“违约责任”被保留为整体如果切出来还是把“违约责任”拆开,多半是自定义词典里边的词频被默认词典压过了,把词频改成默认词典里的更高值会好很多。不过切分词表只是第一步,真正影响检索效果的是停用词列表。
4.2 停用词不要随便删:法律文本里的语义边界
通用停用词表看到“不得”“应当”“以下”直接删,但这种做法放在法律文本上会出事。“应当”在法律语义中是义务性描述,“不得”是禁止性描述,把它们从索引里删掉,用户查“禁止”或者“必须”时,召回结果会偏移。我用的清洗规则是只删语气助词和连接词,比如“的、了、与、和、在”这类通用高频字,动词性的约束词全部保留。
stop_words = set("的 了 吗 呢 与 和 在 中".split()) def tokenize(doc: str): tokens = [] for w in jieba.cut(doc): if w.strip() and w not in stop_words: tokens.append(w.strip()) return tokens注意上一章清洗完的文本,这里直接分词就行,不用再做统一。此外像“第X条”这种条文标题里带数字的词,我不会把数字单独拆成 token,否则检索“第三编”时会出现奇怪的偏斜。
4.3 全文去重的三种做法:精确、近似到语义
法律.rar 这类资料包收集时间跨度大,同一个文件在不同文件夹里重复出现很常见,重复比例超过 20% 都不意外。第一阶段先做精确去重,这是成本和收益比最高的一步:
import hashlib import re def normalize_for_dedup(text: str) -> str: # 去除所有空白和标点,只保留中文字符用于比较 return re.sub(r"[\W_]+", "", text).lower() def file_md5(path: str, block_size=2 ** 20) -> str: h = hashlib.md5() with open(path, "rb") as f: while True: block = f.read(block_size) if not block: break h.update(block) return h.hexdigest()这里的normalize_for_dedup做了两件事:去掉所有非汉字内容,再把剩下的统一小写。这样“第一章”和“第 1 章”这类格式差异不至于产生两个 hash。精确去重之后,如果还发现大量“看起来像但 hash 对不上”的文档,才轮到近似去重,通常是基于 MinHash 或 SimHash 来算文本间相似度。这一步在几千条记录规模下不会太慢,量级再往上走就建议先用 minhash 做粗筛,再用向量相似做精排。绝大多数本地资料场景到精确去重已经足够,不必一上来就上向量化。
5. 避坑记录:拆法律.rar 时最容易踩的五个问题
5.1 文件名变成“锟斤拷”或“æ³å”
现象:解压出来的文件名在 Windows 资源管理器里是乱码,在命令行里更难看。
原因:RAR 文件头里的文件名按 GBK 编码写入,但解压工具没有识别到对应代码页,把字节当成了 cp437 或 UTF-8 去解码。更糟的是,有些压缩包在生成时就已经把文件名写坏,解压工具怎么调都救不回来。
解决:在解压时用decode_rar_filename先做一次转换,或者用带编码选项的压缩工具重压一遍。如果包是别人打包的,重压不现实,只能靠转换函数兜底。
5.2 解压中段报 “Cannot find RAR archive”
现象:解压到 80% 时突然报找不到归档文件,或者只解出部分文件。
原因:早年网盘分享又喜欢拆分的习惯,把大文件拆成.part1.rar、.part2.rar多个分卷,下载时只下到部分分卷,或者重命名漏掉.part2。rarfile 在遇到缺少分卷时会直接中断,而不是自动跳过。
解决:先把所有分卷放在同一目录,确认命名连续,再用unrar t 法律.rar完整跑一遍完整性验证。校验通过后再用 Python 解压。如果校验失败但业务上急着用,用unrar lb打印卷成员列表,人工看缺哪块。
5.3 docx 被当成纯文本清洗后全是 XML
现象:清洗脚本跑完,输出里出现大量</w:p>和<w:r>。
原因:docx 本质是 zip 容器,直接按文本模式读取会把打包的 XML 当成正文。
解决:用docx2txt转纯文本后再进清洗函数,而不是自己手撕 XML。老式.doc文件更麻烦,需要调用antiword,或者用 LibreOffice 批量转换:
soffice --headless --convert-to txt:Text --outdir ./converted ./原始目录/*.doc这条命令是一次性把所有.doc转成 txt,转换完后注意文件名可能会加后缀,用--outdir指定独立输出目录,避免覆盖原始文件。
5.4 正则按“第X条”切分时误伤正文句子
现象:正文里出现“第一百七十九条”,整段被当成条文标题单独切开。
原因:正则^第[一二三四五六七八九十百零0-9]+[条章]只锚定了行首,老文本里有些正文段落恰好以“第X条”开头,尤其是转述条文的时候。
解决:加一个长度限制,条文标题部分一般不超过 12 个汉字。还要检查匹配到条文标题后,下一行是否真的像正文。我一般会在切分循环里加一个判断:如果匹配到的“第X条”后面没有内容且下一条记录里也没有有效行,就把它合并回前一条记录的 body。
5.5 全量加载导致内存溢出
现象:清洗脚本跑在 4G 内存的机器上,处理到一半报 MemoryError。
原因:用Path.read_text()读整个文件列表,再拼成一个大字符串,几千个文件同时进内存。jieba 分词还会把临时 DAG 结构再翻几倍。
解决:改成逐文件、逐条切分、逐条写出的流水线,核心是记忆“只保留当前文件的结果”。如果确实需要做大数组排序,比如全文相似度计算,先对文本做分段并切片写入临时文件。
6. 进阶用法:清洗完成之后,用 BM25 在本地做条文快速召回
6.1 从 TF-IDF 换到 BM25 的理由
条文清洗成结构化记录之后,最实用的场景是快速召回:给你一句案情描述,找出最相关的法律条文。TF-IDF 对短文本没问题,但法律条文长度差异很大,有的条文几十字,有的几百字,直接用 TF-IDF 会让长条文在归一化时被惩罚过度。BM25 的 k1 和 b 参数能调节词频饱和度与文档长度惩罚,对长短混合的语料更稳。rank_bm25 已经实现了经典参数版本,开箱即用。
6.2 一个可直接跑的召回脚本
import pickle import jieba from rank_bm25 import BM25Okapi # docs 是切分清洗后的记录列表,每条包含 title 和 body with open("legal_records.jsonl", "r", encoding="utf-8") as f: docs = [json.loads(line) for line in f if line.strip()] corpus_tokens = [] for d in docs: corpus_tokens.append( list(jieba.lcut(d["title"] + " " + d["body"])) ) bm25 = BM25Okapi(corpus_tokens, k1=1.5, b=0.75) def recall(query, topk=5): q_tokens = list(jieba.lcut(query)) scores = bm25.get_scores(q_tokens) top_idx = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)[:topk] for i in top_idx: print(f"score={scores[i]:.3f} | {docs[i]['title']} | {docs[i]['body'][:40]}") recall("一方不履行合同义务,另一方可以要求赔偿损失吗")脚本里的 k1 和 b 是 BM25 的两个核心参数,常见默认 k1=1.5、b=0.75。k1 越高,词频对分数的影响越早进入饱和;b 越高,对长文本的惩罚越重。法律条文整体偏长,如果发现长条文总是排在前面,可以把 b 调高一点,比如 0.8;发现有价值的短条文反而排后,则下调到 0.6。调参时先跑一次基准查询,再小步改,不要一上来就动默认值。
跑通这段脚本后,整个法律.rar就成了一个可检索的本地知识库。从那以后,我每次拿到这类“xx.rar”资源,都会强制先走一遍清单检查、编码判断、CRC 校验,再决定要不要写清洗脚本。宁可多花十分钟摸底,也不想再被半截文件和一整屏乱码浪费两个小时。希望这篇拆包经验能帮到你。
本文还有配套的精品资源,点击获取