ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

法律.rar:构建本地法律检索库与合规检查接口实战

法律.rar:构建本地法律检索库与合规检查接口实战

简介:这份资源面向法律科技方向的开发者、Python 学习者与智能问答项目实践者,围绕一个基于中文预训练模型的法律问答应用展开,可用于搭建智能法律咨询原型、练习文本相似匹配与 Web 服务集成。压缩包共 25 个文件,约 371.77MB,以 png 图片、py 脚本、html 页面、js 脚本为主,另含 vsdx 流程图、csv 数据集、css 样式、bin 模型权重、json 配置、txt 词表与 ico 图标等,覆盖数据、模型、后端与前端资源。已有 139 人学习下载。包内提供 40000 条法律问答数据集,便于训练与评测;数据分析脚本可用于清洗与探索性分析;句子相似匹配脚本结合 chinese-bert-wwm-ext 预训练模型,实现语义检索与答案匹配;应用启动与主入口脚本串联核心逻辑,templates 与 static 目录则给出页面结构与交互样式,流程图文档帮助理解整体架构与业务流程,适合作为法律问答系统从数据到部署的完整参考。

1. 法律.rar:一个被误读的压缩包,藏着法律检索的本地化刚需

第一次看到「法律.rar」这个标题,我以为是某个律师把卷宗打包发错了地方。直到有做企业合规的朋友抱怨:每次查法条都要在几个网站之间来回跳,断网就抓瞎,历史版本更是找不到。我才意识到,这个压缩包背后真正的诉求,是把散落的法律文本做成本地可检索、可版本对比、可离线查阅的数据资产。

它解决的不是「有没有法条」的问题,而是「怎么让法条像代码一样被管理」的问题。适合三类人:需要频繁引用法条的法务和律师、做合规检查的产品经理、以及想用程序批量处理法律文本的开发者。核心动作就三步:把公开法律文本结构化落盘、建本地索引、封装成随手可查的接口。下面按这个路径拆开讲,每一步都给能直接跑的方案。

2. 法律文本从哪来、怎么落成结构化数据

2.1 公开法律数据的三个可靠来源与取舍

做本地法律库,第一步不是写代码,是搞清楚数据从哪来。我一般会从三个渠道取数,各有取舍。

第一个是官方公开渠道。各级人大、政府、法院的官网会发布法律、行政法规、司法解释的正式文本,通常以 HTML 或 PDF 形式挂出。优点是权威、准确,缺点是格式不统一,有的页面连正文和导航都混在一起,抓下来要花大量精力清洗。适合对准确性要求极高的场景,比如合同引用。

第二个是开放数据平台。一些高校和公益机构维护了法律文本数据集,提供 JSON 或 CSV 格式,字段相对规整。优点是省去解析 HTML 的功夫,缺点是更新可能滞后,且覆盖范围有限,冷门地方性法规往往没有。适合做原型验证和快速搭建。

第三个是自行整理的历史存档。如果你需要做法律版本对比,比如某条款在 2019 年和 2023 年分别怎么写的,公开渠道通常只给最新版。这时候需要从公开的修订说明、旧版文本存档里手动补齐。这是最费时但最有价值的部分,因为版本对比恰恰是本地库相比在线查询的核心优势。

提示:无论用哪个来源,都要保留原始文件的抓取时间和来源 URL,后面做版本追溯时这是唯一的后悔药。

2.2 用 Python 把法律文本切成结构化条目

拿到原始文本后,核心任务是把「第X条」这种自然语言结构解析成机器可读的字段。下面是一个最小可用的解析脚本,处理常见的「章-节-条」三级结构。

import re import json def parse_law_text(raw_text): """ 将法律文本解析为结构化条目列表。 输入:包含章、节、条的纯文本 输出:字典列表,每条包含 chapter, section, article_num, content """ lines = raw_text.split('\n') result = [] current_chapter = "" current_section = "" # 匹配章、节、条的正则 chapter_pattern = re.compile(r'^第[一二三四五六七八九十百]+章\s*(.*)') section_pattern = re.compile(r'^第[一二三四五六七八九十百]+节\s*(.*)') article_pattern = re.compile(r'^第[一二三四五六七八九十百]+条\s*(.*)') for line in lines: line = line.strip() if not line: continue chapter_match = chapter_pattern.match(line) section_match = section_pattern.match(line) article_match = article_pattern.match(line) if chapter_match: current_chapter = chapter_match.group(1) current_section = "" # 新章重置节 elif section_match: current_section = section_match.group(1) elif article_match: result.append({ "chapter": current_chapter, "section": current_section, "article_num": line.split('条')[0] + '条', "content": article_match.group(1) }) else: # 续行,追加到上一条 if result: result[-1]["content"] += line return result # 使用示例 with open('law_raw.txt', 'r', encoding='utf-8') as f: raw = f.read() parsed = parse_law_text(raw) with open('law_structured.json', 'w', encoding='utf-8') as f: json.dump(parsed, f, ensure_ascii=False, indent=2) print(f"共解析 {len(parsed)} 条")

这段代码的逻辑很直白:逐行扫描,遇到「章」「节」「条」就切换当前上下文,遇到普通文本就追加到上一条的正文里。参数上唯一需要调的是正则里的中文数字范围,如果你的文本里出现「第一百零一条」这种,需要把「零」也加进字符集。另外,续行追加的逻辑假设正文不会以「第X条」开头,如果某条正文里恰好引用了其他条款,需要加一个判断:只有行首匹配才算新条目。

跑完之后你会得到一个 JSON 数组,每条记录都带着章、节、条号和正文。这个结构直接可以喂给后面的索引环节。

2.3 数据清洗里最容易翻车的两个细节

第一个坑是空白字符。从 HTML 抓下来的文本里混着 、全角空格、零宽字符,直接入库会导致检索时明明看着一样却搜不到。我一般会在解析前统一做一次替换:把\u3000、\xa0、\u200b全部换成普通空格,再压缩连续空格。

第二个坑是条款编号的变体。有的文本用「第一条」,有的用「第 1 条」,中间带空格。正则里要允许空白,或者先做一次规范化把「第 1 条」转成「第一条」。这个细节不处理,后面按条号查询时会漏掉一半数据。

3. 本地索引怎么建:让法条检索快过在线查询

3.1 选 SQLite FTS5 而不是 Elasticsearch 的理由

很多人一提到全文检索就上 Elasticsearch,但对于个人或小团队的法律库,这是典型的杀鸡用牛刀。我选 SQLite 的 FTS5 扩展,理由有三:零依赖,一个文件就是整个数据库,拷贝走就能用;启动即查,没有服务进程要维护;中文分词虽然需要额外配置,但法律文本的检索模式相对固定,用 trigram 分词器足够。

具体做法是在 SQLite 里建一张虚拟表,把上一步的 JSON 数据灌进去。下面是对应的建表和导入脚本。

import sqlite3 import json # 连接数据库,不存在则创建 conn = sqlite3.connect('law.db') cur = conn.cursor() # 创建 FTS5 虚拟表,使用 trigram 分词器支持中文子串匹配 cur.execute(''' CREATE VIRTUAL TABLE IF NOT EXISTS law_articles USING fts5( chapter, section, article_num, content, tokenize='trigram' ) ''') # 读取上一步的结构化数据 with open('law_structured.json', 'r', encoding='utf-8') as f: articles = json.load(f) # 批量插入 cur.executemany( 'INSERT INTO law_articles (chapter, section, article_num, content) VALUES (?, ?, ?, ?)', [(a['chapter'], a['section'], a['article_num'], a['content']) for a in articles] ) conn.commit() print(f"已导入 {len(articles)} 条") conn.close()

这里的关键参数是tokenize='trigram'。FTS5 默认的分词器对中文不友好,会把整句当成一个词。trigram 把文本切成三个字符的滑动窗口,这样搜「劳动合同」时,只要正文里出现这四个字就能命中,不需要预先分词。代价是索引体积会大一些,但法律文本总量有限,完全在可接受范围内。

3.2 查询接口的封装与三个必调参数

建好索引后,查询本身很简单,但有几个参数直接决定检索体验。下面是一个封装好的查询函数。

def search_law(keyword, limit=10, highlight=True): """ 在本地法律库中检索关键词。 keyword: 检索词 limit: 返回条数上限 highlight: 是否返回高亮片段 """ conn = sqlite3.connect('law.db') cur = conn.cursor() if highlight: # snippet 函数返回匹配片段,<b> 标记命中位置 sql = ''' SELECT article_num, snippet(law_articles, 3, '<b>', '</b>', '...', 20) as snippet, chapter FROM law_articles WHERE law_articles MATCH ? LIMIT ? ''' else: sql = ''' SELECT article_num, content, chapter FROM law_articles WHERE law_articles MATCH ? LIMIT ? ''' cur.execute(sql, (keyword, limit)) results = cur.fetchall() conn.close() return results # 使用示例 for row in search_law('劳动合同'): print(row[0], '|', row[2]) print(row[1][:100]) print('---')

三个必调参数:limit控制返回条数,法律检索通常不需要翻很多页,10 到 20 条足够;snippet的第四个参数控制片段长度,默认 20 个 token,法律条文较长时可以调到 30 到 40,让上下文更完整;highlight的标记符号我习惯用<b>,前端直接渲染就能看到命中位置。

注意:trigram 分词器对英文和数字的匹配是按字符滑动的,搜「2023」会命中所有包含这三个字符的文本。如果库里混有大量英文,建议对英文部分单独建一张表用 unicode61 分词器。

3.3 版本对比:把修订历史变成可查询的字段

本地库相比在线查询最大的优势是版本对比。做法是在表里加一个version_date字段,每次法律修订就插入新版本而不是覆盖旧版。查询时按article_num分组,就能看到同一条款在不同时间点的表述。

-- 查询某条款的所有历史版本 SELECT version_date, content FROM law_articles WHERE article_num = '第十条' ORDER BY version_date DESC;

这个查询返回的结果可以直接做 diff,看看修订到底改了哪个字。对于合规检查来说,这比重新读一遍全文高效得多。

4. 避坑与排查:法律.rar 落地时最容易踩的五个坑

4.1 现象:检索结果里出现大量无关条文

原因:trigram 分词器把短词切得太碎,搜「劳动」时命中了所有包含「劳」和「动」的文本,哪怕它们不相邻。解决:在查询时用引号包裹短语,强制 FTS5 做短语匹配,写成"劳动"而不是劳动。或者在应用层对结果做一次过滤,只保留包含完整关键词的条目。

4.2 现象:导入数据后查询报「database disk image is malformed」

原因:SQLite 在批量插入时如果中途断电或进程被杀,数据库文件会损坏。解决:导入前先备份,或者用事务包裹批量插入,每 1000 条提交一次。已经损坏的话,用sqlite3 law.db ".recover"尝试恢复,但血泪经验是恢复出来的数据可能丢条,所以备份才是后悔药。

4.3 现象:中文检索时好时坏,同样的词有时能搜到有时搜不到

原因:原始文本里混入了全角空格和零宽字符,导致索引里的字符序列和查询词不一致。解决:在解析阶段统一做 Unicode 规范化,用unicodedata.normalize('NFKC', text)把全角转半角,再剔除零宽字符。这一步不做,后面所有检索都是玄学。

4.4 现象:条款正文被截断,只显示了前半句

原因:解析脚本把续行追加到了上一条,但遇到空行时跳过了,导致跨段落的条文丢失后半部分。解决:不要用空行作为条目分隔符,而是用「下一条款编号出现」作为上一条结束的标志。具体做法是先把所有行读进来,找到所有条款起始行的索引,然后按索引区间拼接正文。

4.5 现象:数据库文件越来越大,查询变慢

原因:FTS5 的 trigram 索引本身占空间,加上每次修订都插入新版本,数据量膨胀很快。解决:定期用INSERT INTO law_articles(law_articles) VALUES('optimize')优化索引;对超过两年的旧版本做归档,单独存到另一张表,主表只保留最近三个版本。查询历史版本时再关联归档表。

5. 进阶技巧:把法律库变成可编程的合规检查接口

走到这一步,你已经有了一个本地法律库和检索接口。但真正的价值在于把它变成可编程的合规检查工具。我常用的一个技巧是:把产品文案或合同条款喂进去,自动匹配相关法条并给出风险提示。

具体做法是写一个简单的匹配函数,对输入文本做关键词提取,然后逐条检索法律库,返回命中条款和相似度排序。下面是一个最小实现。

import jieba # 需要 pip install jieba from collections import Counter def compliance_check(text, top_n=5): """ 对输入文本做合规检查,返回最相关的法条。 text: 待检查的文本 top_n: 返回条数 """ # 提取关键词,过滤单字和停用词 words = [w for w in jieba.cut(text) if len(w) > 1] word_freq = Counter(words) # 对每个关键词检索,累计命中次数 article_hits = Counter() for word, freq in word_freq.most_common(20): results = search_law(word, limit=5, highlight=False) for article_num, content, chapter in results: article_hits[article_num] += freq # 返回命中次数最高的条款 return article_hits.most_common(top_n) # 使用示例 contract_text = "本协议约定试用期为六个月,试用期工资为正式工资的百分之八十。" for article_num, hits in compliance_check(contract_text): print(f"{article_num} (相关度: {hits})")

这个函数的逻辑是:先用 jieba 把输入文本切成词,统计词频,然后拿高频词去法律库里检索,命中次数越多的条款排得越靠前。参数上top_n控制返回条数,most_common(20)里的 20 控制参与检索的关键词数量,文本长的时候可以调大。

实际用的时候,我会把返回结果和原文一起展示,人工确认是否真的相关。这个方案不能替代律师,但能把「翻遍法条找依据」的时间从半小时压缩到几秒钟。我自己的习惯是每次法律库更新后,拿几份历史合同跑一遍回归,看看新法条有没有被正确命中。这个习惯帮我逮到过好几次索引没更新的问题。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进