资讯详情

法律.rar处理链:乱码修复、法条切分与FTS5引用溯源

📅 2026/11/6 3:57:42 | 华诺云谱 👁 阅读
法律.rar处理链:乱码修复、法条切分与FTS5引用溯源
简介面向Python开发者与法律信息化学习者的智能法律问答应用开发资料包围绕四万条法律问答数据集构建从数据处理到Web交互的完整链路。压缩包共25个文件、约371.77MB涵盖csv问答数据、4个py脚本、2个html页面、2个js交互文件及css样式另有png、gif、ico界面素材json与bin模型配置权重、vsdx流程图和说明文本类型覆盖数据、代码、模型与前端资源。已有140人学习适合用于法律咨询Demo、课程设计或NLP问答练习。读者可参考数据分析脚本完成数据清洗与探索借助句子相似匹配与中文BERT预训练权重实现问题检索和语义理解再通过应用启动入口、模板及静态资源搭建可交互页面流程图还能帮助梳理系统架构与业务走向减少从零搭建环境的试错成本。1. 法律.rar 不是一堆文件而是一条从压缩包到可引用条款的处理链拿到一个叫“法律.rar”的压缩包多数人的第一反应是解压、打开、搜索。真动手就会发现事情没那么简单文件名是乱码目录里 doc、docx、PDF 混在一起PDF 有的是双层文本、有的是整页扫描正文里“第X条”和“依照第X条规定”混在同一行。你想搜“某类情形怎么处理”搜索引擎只能给你整篇文档没法告诉你“第几页第几条”。这篇笔记拆的就是这条链路解压与编码修复、格式分流、法条切分、索引与引用溯源以及每一步会翻车的地方。适合手里已经有一包法律资料、想做成离线可检索库里的人也适合正在评估“要不要投入做这件事”的团队。2. 解压与格式分流法律.rar 落地前先过文件名乱码这一关2.1 压缩包里的中文名为什么会变成一堆问号ZIP 格式早期没有强制规定文件名编码只在通用标志位里留了一个 bit 11 表示“文件名是 UTF-8”。Windows 中文环境下很多压缩工具不置这个位直接把 GBK 字节写进去解压端如果按默认的 CP437 解码中文就变成乱码。Python 的zipfile正好是这个行为没看到 UTF-8 标志就按 CP437 解。知道这个前提修复思路就很直接把已经解错的字符串按 CP437 编回字节再按 GBK 或 GB18030 解一次。GB18030 的覆盖范围比 GBK 大生僻字和旧字形更稳所以我一般优先试它失败再退回 GBK。有一类包不能这么干压缩方已经正确置了 UTF-8 标志位你再转一次反而会把好名字转坏。所以判断顺序必须是先看flag_bits 0x800置位就原样返回。另一个容易忽略的点是目录分隔符有些工具写进去的是反斜杠解压后会在输出目录里生成一堆带反斜杠的奇怪文件名需要在落盘前统一替换成os.sep。2.2 用 zipfile 批量解压并回写正确文件名import os import zipfile import hashlib def fix_zip_name(info: zipfile.ZipInfo) - str: raw info.filename # bit 11 置位压缩方已声明 UTF-8直接用 if info.flag_bits 0x800: return raw.replace(\\, /) # 否则按 CP437 还原字节再尝试中文编码 for enc in (gb18030, gbk, big5): try: return raw.encode(cp437).decode(enc).replace(\\, /) except (UnicodeEncodeError, UnicodeDecodeError): continue return raw.replace(\\, /) def safe_extract(zip_path: str, out_dir: str, max_total_mb: int 2048): os.makedirs(out_dir, exist_okTrue) total 0 with zipfile.ZipFile(zip_path) as zf: for info in zf.infolist(): if info.is_dir(): continue total info.file_size # 压缩比异常或总量超限多半是坏包或压缩炸弹 if info.file_size 200 * 1024 * 1024 or total max_total_mb * 1024 * 1024: raise RuntimeError(f可疑条目: {info.filename}) name fix_zip_name(info) data zf.read(info) digest hashlib.sha1(data).hexdigest()[:12] base, ext os.path.splitext(os.path.basename(name)) # 文件名追加内容哈希避免不同目录同名文件互相覆盖 target os.path.join(out_dir, f{base}_{digest}{ext}) with open(target, wb) as f: f.write(data) return out_dir这段代码做了三件事修名字、防覆盖、防坏包。flag_bits 0x800是判断编码的唯一可靠依据gb18030放在候选编码第一位是因为它对简体中文的兼容面最宽文件名追加sha1前 12 位是因为法律资料包里“某某办法.docx”在不同子目录重复出现太常见直接按原名落盘后一个会把前一个覆盖掉而且是静默覆盖等发现少文件时已经很难追。max_total_mb和单文件 200MB 的上限是保险丝正常资料包不会触发触发就说明包有问题先人工看一眼再继续。2.3 按文件头分流docx、doc、双层 PDF 和扫描件扩展名在这类包里基本不可信有人把 doc 改成 docx有人把扫描版 PDF 命名成“文字版”。可靠的做法是读文件头。docx 和普通 zip 都以PK\x03\x04开头必须再进 zip 看内部有没有word/document.xml才能确认doc 是 OLE2 复合文档头是D0 CF 11 E0 A1 B1 1A E1PDF 是%PDF。import zipfile MAGIC [ (bPK\x03\x04, zip), (b\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1, ole2), (b%PDF, pdf), ] def sniff_format(path: str) - str: with open(path, rb) as f: head f.read(8) for sig, kind in MAGIC: if head.startswith(sig): if kind zip: try: with zipfile.ZipFile(path) as z: names set(z.namelist()) return docx if word/document.xml in names else zip except zipfile.BadZipFile: return unknown return doc if kind ole2 else kind return unknown分流之后走不同解析器docx 用python-docx但只遍历paragraphs会丢掉表格里的条文必须按文档体顺序同时取段落和表格doc 老格式没有纯 Python 的稳定解析器常见做法是调soffice --headless --convert-to docx先转一遍转完再按 docx 处理PDF 用 PyMuPDF 抽文本层抽不出足够字符的页再转 OCR。from docx import Document from docx.table import Table from docx.text.paragraph import Paragraph from docx.oxml.ns import qn def iter_blocks(doc): body doc.element.body for child in body.iterchildren(): if child.tag qn(w:p): yield Paragraph(child, doc) elif child.tag qn(w:tbl): yield Table(child, doc) def docx_to_text(path: str) - str: doc Document(path) lines [] for block in iter_blocks(doc): if isinstance(block, Paragraph): text block.text.strip() if text: lines.append(text) else: for row in block.rows: cells [c.text.strip().replace(\n, ) for c in row.cells] lines.append( | .join(cells)) return \n.join(lines)iter_blocks直接遍历body的子元素按w:p和w:tbl分派保证段落和表格的相对顺序不乱。表格按行拼成|分隔的文本是因为法条附表、处罚幅度表这类内容一旦拆散后面切条款时会跟正文错位。这里不追求还原版式只追求文本顺序和条款归属正确。2.4 扫描件 OCR 的触发阈值与最小配置PDF 抽文本后按页统计字符数平均每页低于 50 个字符基本可以判定这一页是扫描图或纯图排版。这个阈值不要设太高有些 PDF 文本层只包含页眉页脚正文全是图字符数会有几十个的“假阳性”也不要设太低否则正常页会被反复 OCR白白拖慢进度。import fitz import numpy as np from paddleocr import PaddleOCR ocr PaddleOCR(use_angle_clsTrue, langch, show_logFalse) def pdf_page_text(path: str, page_no: int, min_chars: int 50, dpi: int 300): doc fitz.open(path) page doc[page_no - 1] text page.get_text(text).strip() if len(text) min_chars: return text, text pix page.get_pixmap(dpidpi, colorspacefitz.csGRAY) img np.frombuffer(pix.samples, dtypenp.uint8).reshape(pix.height, pix.width) result ocr.ocr(img, clsTrue) lines [item[1][0] for block in result if block for item in block] return \n.join(lines), ocrdpi300是中文正文 OCR 比较稳的下限再低小号字容易糊csGRAY转灰度能减少通道数、降低内存use_angle_clsTrue处理扫描时页面轻微旋转的情况。OCR 结果一定要和“来源页”一起落库否则后面引用溯源就断了。实际跑的时候同一份 PDF 里文字页和扫描页混排很常见按页判断比按整份文件判断更靠谱。3. 法条切分把长文本拆成能被引用的“条-款-项”3.1 法条编号的三种写法和对应正则法条编号看着简单真正写正则时会遇到三种写法普通条“第X条”带“之一”的“第X条之一”以及正文里引用性质的“依照第X条规定”。前两种是要切的锚点第三种是正文内容不能被当成新条目切开。最省事的约束是要求条号出现在行首并且条号后面跟的正文长度超过一个下限。目录页是另一个坑目录里往往一行连着十几个“第X条”如果不加长度约束会把目录切成几十个空壳条目。import re # 行首的条号支持中文数字、阿拉伯数字和“之一” ART_RE re.compile( r^[ \t ]*第([一二三四五六七八九十百千零〇\d])条(?:之[一二三四五六七八九十])?[ \t ]*, re.M, ) # 项号中文括号或英文括号包住的中文数字 ITEM_RE re.compile(r^[ \t ]*[(]([一二三四五六七八九十])[)][ \t ]*, re.M) def split_articles(text: str, min_len: int 20): marks list(ART_RE.finditer(text)) articles [] for i, m in enumerate(marks): start m.start() end marks[i 1].start() if i 1 len(marks) else len(text) body text[start:end].strip() if len(body) min_len: # 目录行、空壳条号直接跳过 continue items [x.group(0).strip() for x in ITEM_RE.finditer(body)] articles.append({ article_no: m.group(0).strip(), text: body, items: items, char_len: len(body), }) return articlesre.M让^匹配每一行行首这是把正文里的“依照第X条规定”排除掉的关键。min_len20是个经验值正常一个条文很少短于 20 字目录行和页眉里的孤立条号会被这个条件挡掉。items只做粗提取目的是让“第X条第二项”这种引用能定位到项一级具体项内容仍然保留在text里。3.2 切分脚本与条款字段设计切出来的条款要落库字段设计决定了后面能不能溯源、能不能去重、能不能标版本。我一般固定这几个字段来源文件、物理页码、条号、条款全文、项列表、内容哈希、入库时间。物理页码必须存因为 PDF 的打印页码和阅读器页码经常差几页只存打印页码回溯时会对不上。import sqlite3 import hashlib import re def norm_text(s: str) - str: s re.sub(r\s, , s) s s.replace(, ().replace(, )) s s.replace(, :).replace(, ;) return s def init_db(db_path: str): conn sqlite3.connect(db_path) conn.execute( CREATE TABLE IF NOT EXISTS clauses( id INTEGER PRIMARY KEY AUTOINCREMENT, doc_id TEXT NOT NULL, source_file TEXT NOT NULL, page INTEGER NOT NULL, article_no TEXT NOT NULL, text TEXT NOT NULL, items TEXT, content_hash TEXT NOT NULL, created_at TEXT DEFAULT CURRENT_TIMESTAMP ) ) conn.execute(CREATE INDEX IF NOT EXISTS idx_hash ON clauses(content_hash)) conn.execute(CREATE INDEX IF NOT EXISTS idx_doc ON clauses(doc_id, page)) return conn def save_articles(conn, doc_id, source_file, page, articles): for art in articles: h hashlib.sha1(norm_text(art[text]).encode(utf-8)).hexdigest() conn.execute( INSERT INTO clauses(doc_id, source_file, page, article_no, text, items, content_hash) VALUES (?, ?, ?, ?, ?, ?, ?) , (doc_id, source_file, page, art[article_no], art[text], |.join(art[items]), h)) conn.commit()norm_text先去掉所有空白再统一全角半角括号和标点是为了让同一段条文在不同来源、不同 OCR 结果下能算出同一个哈希。doc_id用文件内容哈希而不是文件名因为同一份法规在不同目录、不同批次里出现是常态。page存物理页前端展示时再叠加一个“打印页码偏移”配置这个偏移量按文件单独配。3.3 条款归一化、去重与版本冲突标记去重不能只看条号不同法规都有“第一条”必须doc_id article_no一起看。真正需要处理的是两种情况同一份文件在包里出现了两次内容完全一致直接按content_hash丢掉重复同一部法规有两个版本条号相同但正文不同这时不能覆盖要保留两条并打上冲突标记。def mark_conflicts(conn): rows conn.execute( SELECT doc_id, article_no, COUNT(DISTINCT content_hash) AS c FROM clauses GROUP BY doc_id, article_no HAVING c 1 ).fetchall() conflicts {(r[0], r[1]) for r in rows} conn.execute(ALTER TABLE clauses ADD COLUMN conflict INTEGER DEFAULT 0) for doc_id, article_no in conflicts: conn.execute( UPDATE clauses SET conflict 1 WHERE doc_id ? AND article_no ?, (doc_id, article_no), ) conn.commit() return len(conflicts)冲突条数是一个很有价值的质量指标正常资料包里应该很少如果某份文件冲突率超过 20%多半是 OCR 质量差或者切分把不该切的地方切了。发现冲突不要急着合并先把冲突条款抽出来人工看几条判断是版本差异还是解析错误这个动作比事后修索引便宜得多。4. 索引与引用溯源让每次命中都能回到页码和条号4.1 中文法条检索为什么不能直接上默认分词SQLite 的 FTS5 默认unicode61分词器对中文基本是按字切搜“行政处罚”会命中大量只含“政”字的条款召回高但准确率很难看。直接上trigram分词器能缓解但对“第X条”这类短查询又不够稳。比较务实的做法是入库前先用 jieba 的搜索模式切一遍用空格连起来存进 FTS5查询时用同样的方式切词。这样切出来的词元粒度可控还能把“行政处罚”“从轻处罚”这类法律术语当成整体词处理。另一个必须做的预处理是术语归一。法律文本里“其它”和“其他”、“做出”和“作出”混用很常见OCR 还会把“条”识别成“条”以外的形近字。建一个小的同义词表在切词前做替换检索命中率会有肉眼可见的提升。4.2 用 SQLite FTS5 搭一个离线倒排索引import sqlite3 import jieba def build_fts(conn: sqlite3.Connection): conn.execute( CREATE VIRTUAL TABLE IF NOT EXISTS clause_fts USING fts5(article_no, body, content, tokenizeunicode61) ) def index_clause(conn, row_id: int, article_no: str, text: str): body .join(jieba.cut_for_search(text)) conn.execute( INSERT INTO clause_fts(rowid, article_no, body) VALUES (?, ?, ?), (row_id, article_no, body), ) def search_clause(conn, query: str, limit: int 10): q .join(jieba.cut_for_search(query)) sql SELECT c.id, c.source_file, c.page, c.article_no, snippet(clause_fts, 1, [, ], …, 24) AS snip, bm25(clause_fts) AS score FROM clause_fts JOIN clauses c ON c.id clause_fts.rowid WHERE clause_fts MATCH ? ORDER BY score LIMIT ? return conn.execute(sql, (q, limit)).fetchall()content是外部内容表模式FTS 只存索引不存原文原文仍然在clauses表里省空间也避免两份数据不一致。jieba.cut_for_search比精确模式切得更细适合召回bm25返回的是负值越小越相关所以排序用ORDER BY score不加DESC。snippet的四个参数分别是列号、前缀、后缀、省略号和片段长度把命中词用方括号标出来前端可以直接高亮。索引一定要和条款入库放在同一个事务边界里先插clauses拿到rowid再插clause_fts。中途失败要么整条回滚要么留下有原文没索引的孤儿条款后者排查起来非常费劲。4.3 引用锚点三元组与向量召回的兜底每条检索结果都必须能回答“这句话从哪来”。最小可用的锚点是三元组文件标识、物理页码、条号。文件标识用内容哈希页码用 PDF 物理页条号用切分时识别出的原文条号。展示时拼成“某文件 · 第 12 页 · 第X条”用户点进去能直接定位。只给文件名不给页码等于没有溯源只给页码不给条号用户还得自己数。纯关键词检索在两种场景下会漏用户描述的是情形而不是法条原词比如“运输途中货物损坏谁承担”条文表述和口语差异很大。这时可以加一路向量召回兜底用本地小模型把条款切成定长片段、编码成向量查询时同样编码取余弦相似度前若干条再和 BM25 结果做融合。def rrf_fuse(bm25_ids, vec_ids, k: int 60, top_n: int 10): scores {} for rank, doc_id in enumerate(bm25_ids): scores[doc_id] scores.get(doc_id, 0) 1.0 / (k rank 1) for rank, doc_id in enumerate(vec_ids): scores[doc_id] scores.get(doc_id, 0) 1.0 / (k rank 1) return sorted(scores.items(), keylambda x: -x[1])[:top_n]RRF 的好处是不用调两路分值的量纲k60是常见默认值两路各取前 50 条融合即可。向量召回只做兜底不单独出结果相似度阈值设 0.55 左右低于阈值的直接丢否则会把语义相近但法条不适用的内容推上来反而干扰判断。4.4 查询接口与结果排序参数查询接口返回的字段建议固定成命中片段、文件、页码、条号、融合得分、来源类型关键词/向量。排序默认按融合得分但给用户留一个“按条号排序”的开关因为查同一部法规时按条号顺序读比按相关度读更符合习惯。分页不要用OFFSET深翻条款量到十万级以后会明显变慢。常见做法是记住上一页最后一条的得分和rowid下一页从这组值之后取。这个改动不大但能把翻到几十页之后的响应时间压住。检索日志也要留记录查询词、命中条号、用户最终点开的条号这批数据是后面调同义词表和评估召回率的原料。5. 避坑与排查法律.rar 处理链路上最容易翻车的五个细节5.1 解析阶段的三条血泪经验现象一解压后文件数比压缩包里看到的少。原因几乎都是同名覆盖不同子目录下的“某某办法.docx”落到同一个输出目录后写的把先写的顶掉而且不报错。解决方式就是 2.2 里的做法文件名追加内容哈希已经解压过的包用文件内容哈希做一次全量比对能快速发现丢了哪些。现象二docx 里的处罚幅度表在切条款时整段消失。python-docx的doc.paragraphs只返回段落表格是独立对象。很多规范性文件把裁量基准放在表格里只取段落等于把这些内容扔了。解决方式是按文档体顺序同时遍历w:p和w:tbl表格按行拼成文本并在切分前保留一个“表格开始/结束”标记方便人工复核。现象三PDF 抽出来的文字顺序是乱的条号跑到段尾。双栏排版、页眉页脚、脚注都会干扰文本流。PyMuPDF 默认的get_text(text)按内容流顺序出字遇到双栏就会左右栏交错。解决方式是改用get_text(blocks)按块的y坐标排序后再拼接同时把每页顶部和底部固定高度区域内、重复出现的短行识别为页眉页脚并剔除。这个处理不做后面法条正则的命中率会掉一大截。5.2 索引与引用阶段的两个隐蔽坑现象四搜索结果里条文都在但点进去页码对不上。PDF 阅读器显示的页码和纸面打印页码经常差封面、目录那几页OCR 批次不同还会再偏一次。原因是入库时只存了一种页码。解决方式是物理页和展示页分开存展示页 物理页 每份文件单独配置的偏移量偏移量在入库时人工确认一次写进文件元数据表不要写死在代码里。现象五同一个查询昨天能搜到今天搜不到。最常见的原因是索引和原文表不一致条款更新了但没有重建 FTS 行或者批量导入时事务中途失败留下了孤儿索引。排查顺序是先用clauses表直接LIKE查一遍确认原文在不在再查clause_fts里有没有对应rowid。解决方式是把“写原文 写索引”包在同一个事务里并提供一个一致性校验脚本定期比对两边的行数和哈希。提示这五个问题里前三个在解析阶段暴露后两个要到检索阶段才显现。建议每处理完一批文件就跑一次一致性校验不要等全部入库完再查那时候定位成本会高很多。6. 进阶用回指测试集和增量清单把质量守住链路跑通只是开始真正决定这套东西能不能长期用的是两件事怎么知道检索质量在变好还是变差以及资料包更新时怎么避免全量重跑。第一件事靠回指测试集。从已入库条款里抽 50 条每条手工写一个“用户会怎么问”的口语化查询标注正确条款的doc_id article_no。每次改分词、改同义词表、改融合参数都跑一遍这 50 条看两个指标前 5 条里有没有正确条款Recall5以及第一条命中是不是正确条款Top1 命中率。这个集合不需要大50 条足够发现明显退化。我一般会把测试集存成一张表查询和标注都用同一套字段跑评估的脚本直接读表不手工维护 JSON。def eval_recall(conn, cases, top_k5): hit, top1 0, 0 for query, gold_doc, gold_art in cases: rows search_clause(conn, query, limittop_k) keys [(r[1], r[3]) for r in rows] if (gold_doc, gold_art) in keys: hit 1 if keys[0] (gold_doc, gold_art): top1 1 return hit / len(cases), top1 / len(cases)第二件事靠增量清单。每次拿到新的“法律.rar”先解压、算内容哈希和上次的清单比对只处理新增和变化的文件。条款表按doc_id article_no做 upsert旧版本不删加一个valid_from字段冲突标记沿用 3.3 的逻辑。这样既能追溯“某条规定什么时候改过”又不用每次全量重建索引。指标建议阈值低于阈值时先查什么Recall50.85同义词表、分词粒度Top1 命中率0.70融合权重、条号正则条款冲突率0.05OCR 质量、切分锚点索引一致性100%事务边界、重建脚本这套东西我从最早“解压完直接扔进全文搜索”的做法一路改到现在按条款落库、按锚点回指最大的教训是解析阶段的偷懒最后都会以“搜不到、对不上、不敢信”的形式在检索阶段还回来。先把文件名和页码这两件小事做扎实后面的切分和索引才有意义。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑