简介:这份资源面向想入门或实践智能法律问答系统的开发者与学习者,围绕一个基于Python的法律咨询应用展开,涵盖从数据处理到前端展示的完整链路。压缩包共25个文件,以png截图、py脚本、html页面、js与css前端资源为主,另含csv数据集、vsdx流程图及BERT模型权重文件,整体约371.77MB,目录结构清晰便于按模块查阅。核心内容包括4万条法律问答数据集、数据分析与句子相似匹配脚本、应用启动与主入口代码,以及中文预训练BERT模型,可用于语义理解与答案检索;配套的模板页面、静态资源与流程图则帮助理解系统架构与交互设计。已有140人学习下载,适合希望动手搭建法律问答应用、研究文本相似度匹配或复用数据集与模型的中级学习者参考。
1. 法律.rar:一个压缩包名字背后的检索工程问题
第一次看到「法律.rar」这个标题,多数人以为是某个法规汇编的下载包。真把它当成一个检索系统来做,你会发现它指向的是一类很具体的工程需求:把散落在不同来源、不同格式、不同更新节奏的法律文本,压成一个可查询、可增量更新、可追溯来源的本地知识库。压缩包只是外壳,里面装的是文本清洗、结构化切分、字段对齐和检索排序这一整套活。
我做过几个类似的小型法规检索 Demo,最大的感受是:难点不在模型,在数据治理。同一部法规,官方版本、转载版本、修订前后版本混在一起,字段名五花八门,直接丢进向量库,召回结果能把人看懵。这篇笔记就按「先立住数据模型,再跑通最小检索,最后处理版本与更新」的顺序讲,适合想自己搭一个法规查询工具的后端或数据工程师,也适合需要做合规文本比对的法务技术岗。
2. 把法规文本拆成可检索结构:字段设计与切分策略
2.1 为什么不能直接按段落切
法规文本有强层级:编、章、节、条、款、项。一条法条往往包含多个款,每一款又可能引用其他条文。如果按固定字数切块,会出现「第某条」被拦腰截断、引用关系丢失的情况,检索时召回一个半截条文,用户还得自己翻原文核对,体验很差。
常见做法是以「条」为最小检索单元,款和项作为条内的子结构保留。一条法条通常几十到几百字,正好落在嵌入模型比较舒服的长度区间。遇到超长条文(个别条文上千字),再按款二次切分,并在元数据里记录父条 ID,保证能回溯。
字段设计上,我一般会保留这几列:
| 字段 | 类型 | 说明 |
|---|---|---|
| doc_id | string | 法规唯一标识,建议用「法规简称+版本年份」 |
| article_no | string | 条号,如「第十二条」 |
| chapter_path | string | 所属编章节路径,用于展示层级 |
| content | text | 条文正文 |
| effective_date | date | 施行日期 |
| status | enum | 现行有效 / 已修订 / 已废止 |
| source_url | string | 来源标识,便于追溯 |
status这个字段是血泪经验。早期我没加,结果检索「劳动合同解除」时,把已经废止的旧条款排到了前面,用户拿去用会出大问题。法规检索里,时效性比相关性更优先,这一点和通用文档检索完全不同。
2.2 用 Python 做条文级切分的骨架
下面这段是切分逻辑的核心,输入是已经按章节整理好的纯文本,输出是条文列表。正则只处理中文条号,实际项目里还要兼容「第X条之一」这种补充条款。
import re # 匹配「第X条」开头,X 支持中文数字和阿拉伯数字 ARTICLE_PATTERN = re.compile(r'^(第[一二三四五六七八九十百零〇\d]+条(?:之[一二三四五六七八九十]+)?)\s*(.*)$') def split_articles(text: str, doc_id: str, chapter_path: str = ""): """按条切分法规文本,返回结构化列表""" lines = text.splitlines() articles = [] current_no, current_buf = None, [] for line in lines: line = line.strip() if not line: continue m = ARTICLE_PATTERN.match(line) if m: # 遇到新条号,先把上一条落盘 if current_no: articles.append({ "doc_id": doc_id, "article_no": current_no, "chapter_path": chapter_path, "content": "\n".join(current_buf).strip(), }) current_no, current_buf = m.group(1), [m.group(2)] else: # 非条号开头的行,视为上一条的续行(款、项) if current_no: current_buf.append(line) if current_no: articles.append({ "doc_id": doc_id, "article_no": current_no, "chapter_path": chapter_path, "content": "\n".join(current_buf).strip(), }) return articles逻辑说明:逐行扫描,命中条号就开新条,没命中就并入当前条。这样款和项自然保留在content里,不会被拆散。chapter_path需要在上层按「第X章」再扫一遍传入,这里为了聚焦切分逻辑做了简化。
参数上要注意两点。一是ARTICLE_PATTERN里的\d和中文数字混用,是因为不同来源的文本格式不统一,有的用「第十二条」,有的用「第12条」,正则必须都覆盖。二是空行处理,法规原文里条与条之间常有空行,直接continue跳过即可,但如果某条内部本身有空行分段,就要改成「连续两个空行才算条间分隔」的策略,具体看数据源。
2.3 切分后的自检:三个必看的数字
切完不要急着入库,先跑三个统计:条文总数、平均条长、超长条(超过 800 字)占比。条文总数和官方目录对不上,说明条号正则漏匹配;平均条长异常偏短,多半是把款误判成了条;超长条占比高,说明二次切分策略要提前设计。我一般会把超长条单独导出人工过一遍,这类条文往往是引用密集的核心条款,切坏了影响最大。
3. 本地跑通最小检索:嵌入、索引与查询命令
3.1 选型:为什么先用 BM25 再加向量
很多人一上来就上向量检索,觉得语义匹配更高级。法规场景里,关键词精确匹配的价值被严重低估。用户查「竞业限制补偿金」,BM25 能精准命中含这几个词的条文,而向量检索可能召回一堆「限制」「补偿」相关的泛化内容。我的做法是混合检索:BM25 负责精确召回,向量负责语义扩展,两路结果用倒数排名融合(RRF)合并。
嵌入模型选中文效果稳定的开源模型即可,本地跑不需要 GPU 也能接受,条文级数据量通常几千到几万条,建索引几分钟的事。别在这上面纠结太久,法规检索的瓶颈在数据质量,不在嵌入模型选型。
3.2 用 rank_bm25 和 sentence-transformers 搭最小管线
from rank_bm25 import BM25Okapi from sentence_transformers import SentenceTransformer import numpy as np # 假设 articles 是上一节切分出的列表 corpus = [a["content"] for a in articles] tokenized = [list(c) for c in corpus] # 中文按字切,简单但有效 bm25 = BM25Okapi(tokenized) model = SentenceTransformer("paraphrase-multilingual-MiniLM-L12-v2") embeddings = model.encode(corpus, normalize_embeddings=True) def hybrid_search(query: str, top_k: int = 10): # BM25 路 bm25_scores = bm25.get_scores(list(query)) bm25_rank = np.argsort(bm25_scores)[::-1][:top_k] # 向量路 q_vec = model.encode([query], normalize_embeddings=True)[0] vec_scores = embeddings @ q_vec vec_rank = np.argsort(vec_scores)[::-1][:top_k] # RRF 融合,k 取 60 是常用经验值 rrf = {} for rank, idx in enumerate(bm25_rank): rrf[idx] = rrf.get(idx, 0) + 1 / (60 + rank + 1) for rank, idx in enumerate(vec_rank): rrf[idx] = rrf.get(idx, 0) + 1 / (60 + rank + 1) ordered = sorted(rrf.items(), key=lambda x: x[1], reverse=True)[:top_k] return [(articles[i]["article_no"], articles[i]["content"][:80]) for i, _ in ordered]逻辑说明:中文按字切 token 是最省事的做法,对法规这种用词规范的文本够用;如果追求更好效果,可以换成 jieba 分词,但要注意法律术语被切碎的问题,比如「不可抗力」被切成「不可」「抗力」反而降低匹配。RRF 的k=60是文献里的常用值,作用是压低排名靠前结果的权重差距,避免某一路结果独大。
参数说明:top_k两路各取 10 再融合,实际返回也取 10,这个比例在几千条数据上比较稳。如果数据量上万,两路各取 30 再融合,召回更充分。normalize_embeddings=True必须开,否则点积不等于余弦相似度,排序会失真。
3.3 查询结果的展示要带上下文
检索命中一条后,展示时至少带上article_no、chapter_path和status。用户看到「第十二条」但不知道属于哪一章,定位成本很高。status非现行有效的,要在结果里明确标注,最好默认过滤掉已废止条文,提供一个开关让用户主动查看历史版本。这个细节决定了工具能不能真正被用起来。
4. 版本与增量更新:法规库最容易翻车的地方
4.1 修订识别:靠什么判断一条法规变了
法规更新有两种形态:整部替换和局部修订。整部替换好处理,换doc_id的版本号重新入库即可。局部修订麻烦,官方可能只发布一个「修改决定」,里面写着「将第X条修改为……」。如果只抓正文不抓修改决定,库里的条文会悄悄过期。
常见做法是维护一张修订映射表,记录「哪部法规的哪条,在哪个日期,被哪个文件修改」。抓取时优先解析修改决定类文档,把变更落到具体条文上,同时把旧条文标记为历史版本而不是删除。删除是后悔药都买不回来的操作,历史版本留着,做合规比对时价值很大。
4.2 增量入库的幂等设计
def upsert_article(conn, art: dict): """按 doc_id + article_no + effective_date 做幂等写入""" cur = conn.execute( "SELECT id FROM articles WHERE doc_id=? AND article_no=? AND effective_date=?", (art["doc_id"], art["article_no"], art["effective_date"]), ) row = cur.fetchone() if row: conn.execute( "UPDATE articles SET content=?, status=?, chapter_path=? WHERE id=?", (art["content"], art["status"], art["chapter_path"], row[0]), ) else: conn.execute( "INSERT INTO articles(doc_id, article_no, chapter_path, content, effective_date, status) " "VALUES(?,?,?,?,?,?)", (art["doc_id"], art["article_no"], art["chapter_path"], art["content"], art["effective_date"], art["status"]), ) conn.commit()逻辑说明:唯一键用doc_id + article_no + effective_date三元组,而不是只用前两个。原因是同一条文在不同施行日期下内容可能不同,三元组能天然保留历史版本。更新时只改内容和状态,不动主键,保证外部引用稳定。
参数上,effective_date必须来自数据源而不是抓取时间,抓取时间只能作为辅助字段。我见过把抓取时间当施行日期的做法,结果同一批数据重复入库,检索结果里出现多条一模一样的条文,排查了半天才发现是时间字段用错了。
4.3 更新后的索引重建策略
条文内容变了,向量索引必须重建,但不必全量重建。按doc_id维度做增量:只对发生变更的法规重新编码,其余条目沿用旧向量。BM25 索引同理,可以整库重建,因为它的构建成本低。这里的关键是给每条记录维护一个content_hash,比对哈希判断是否真的变了,避免因为格式微调触发无谓的重建。
5. 避坑与排查:法规检索落地时的五个真实问题
5.1 检索结果里出现重复条文
现象:同一个条号在结果里出现两三次,内容几乎一样。原因:增量入库时唯一键没覆盖施行日期,或者抓取任务重复跑了同一批数据。解决:按 4.2 的三元组唯一键重建表,加唯一索引兜底,抓取侧记录每批数据的来源批次号,重复批次直接跳过。
5.2 条号匹配漏掉「之一」类补充条款
现象:条文总数比官方目录少,缺的集中在「第X条之一」这种补充条款上。原因:正则只匹配「第X条」,没考虑「之一」后缀。解决:把正则扩展成第[数字]+条(?:之[数字]+)?,并在切分后做一次条号连续性检查,发现跳号就人工核对原文。
5.3 向量检索把废止条文排到前面
现象:查现行规定,结果第一条是已废止的旧条款。原因:向量相似度只看语义,不看时效性。解决:检索后加一层过滤,status != '现行有效'的结果降权或默认隐藏;或者在嵌入时把状态拼进文本,让模型感知时效信息,但这只是辅助,硬过滤更可靠。
5.4 长条文被切碎后引用关系断裂
现象:用户查到某款内容,但不知道它属于哪一条,回溯困难。原因:二次切分时没保留父条 ID。解决:切分时给每个子块加parent_article_id字段,展示时按父条聚合,检索命中子块也能带出完整条文。
5.5 中文分词把法律术语切坏
现象:查「不可抗力」,BM25 召回很差。原因:分词器把术语切成了更小的词,精确匹配失效。解决:维护一个法律术语自定义词典,分词前加载;或者干脆用按字切 token 的方案,牺牲一点语义粒度换稳定性。两种方案我都用过,数据量不大时按字切更省心。
6. 让检索结果可信:来源追溯与人工校验的最小闭环
法规检索工具和普通搜索最大的区别,是用户会拿结果去做实际决策,所以「可信」比「好用」更优先。我后来养成一个习惯:每条检索结果都必须能一键回溯到原始来源,并且展示该条文的版本历史。实现上不复杂,source_url字段在入库时就填好,历史版本靠 4.2 的三元组自然保留,查询时按doc_id + article_no拉出所有版本按日期排序即可。
更进一步,可以做一个轻量的人工校验队列。检索日志里记录用户点击了哪些结果、有没有触发「查看原文」动作,把高频查询但低点击的结果挑出来,人工核对一遍切分和字段是否正确。这个闭环不需要多复杂,一张日志表加一个定时任务就够,但它能持续暴露数据质量问题,比事后用户投诉再修要主动得多。
还有一个具体技巧:给每条条文生成一个稳定的短指纹,比如doc_id加条号的哈希前八位,展示在结果卡片角落。用户反馈问题时报这个指纹,排查时能直接定位到具体记录,省去大量来回确认。这个做法是我从一次线上问题里学来的,当时用户只说「某条查出来不对」,没有指纹,翻了半天日志才锁定。
做这类工具,我的教训是别急着堆功能,先把数据模型和版本管理做扎实。模型可以换,索引可以重建,但数据一旦脏了,后面每一步都在还债。希望帮到你。
本文还有配套的精品资源,点击获取