半结构化 .doc 到 FTS5:铁路规程条款切分与差异比对
2026/9/17 10:11:39 网站建设 项目流程

简介:这份资料是2021—2022年专题整理的《铁路技术管理规程》普速铁路部分文档,面向铁路运输、铁道工程、机车车辆等专业的学生、备考人员及一线技术人员,用于系统熟悉普速铁路的法规性技术要求,也可作为课程学习与岗位培训的对照依据。压缩包共收录1个doc文件,约6.58MB,正文按总则、技术设备、线路桥梁及隧道等编章组织,条理清晰、便于检索。其中技术设备部分涵盖基建制造与验收交接、限界与安全保护区、养护维修及检查、救援设备、灾害防护、行车安全监测设备等模块;线路部分则细化到线路平面及纵断面、路基、桥隧建(构)筑物、轨道以及道口交叉与线路接轨、安全线及避难线、防护栅栏、声屏障等内容,并延伸至信号、通信、联锁等章节。借助这些规范条文,读者可掌握从规划设计、施工验收到日常运维的安全标准与作业要求,理解限界、路基稳定性、轨道结构、监测设备配置等关键知识点。目前已有188人学习。

1. 《铁路技术管理规程》普速铁路部分 .doc:一份被当读物下载的半结构化数据集

很多人从资源站把这份 2021—2022 年的专题资料拖进硬盘,第一反应是"这是铁路从业者的教育资料",翻两页就放下了。真正卡人的场景在后面:想在几千条条款里定位"线路安全保护区"的数值,Ctrl+F 搜"保护区"能中,但把内容复制进笔记、或喂给检索脚本时就散架——目录页的点线页码混进正文,"轨 道""限 界"这类标题被全角空格拆成两半,附图里的限界尺寸是图不是字,条款编号还是中文数字。这份正文沿用 2014 年 7 月版本,结构是三编十九章:第一编技术设备管到第九章铁路用地,第二编行车组织从第十章排到第十四章,第三编信号显示收在第十五至十九章,后面还挂着两张附图和八份附件。它更适合按数据源对待:先建模层级,再清洗入库,最后才谈检索与比对。下面几步走完,新手能照命令复现,熟手能直接看到参数边界和踩坑点。

2. 三编十九章的层级建模:编、章、节、条的编号规则与切分锚点

2.1 编与章的映射要写死,不能靠猜

三编的边界并不等长:第一编技术设备覆盖第 1 至第 9 章,从"基本要求"一路到"铁路用地";第二编行车组织是第 10 至第 14 章;第三编信号显示是第 15 至第 19 章。这个映射关系建议直接写成常量表,别用"出现'第X编'就切一刀"的规则去推。原因很实在:正文里"编"字会以"编组列车""编挂""车流编组"等形态大量出现,按字面切分会把第十一章切得七零八落。检索侧同理,搜"编组"应该落到第十一章编组列车,搜"编"则命中的是一堆噪声,这本身就是分词粒度问题。

层级原文形态示例可用切分锚点
第X编 名称第二编 行车组织行首第[一二三]编\s
第X章 名称第十四章 列车运行第[一二三四五六七八九十]+章\s,且行内无点线
仅名称,无编号接车与发车 / 手信号无锚点,需目录白名单
第X条第 233 条第[零一二三四五六七八九十百]+条
1. 2. 3. 或 (一)行首编号行首\d+[.、]([一二三四五六七八九十]+)

节这一层是最难认的。它在排版上靠居中、字号和上下文区分,一旦转成纯文本,就只剩"接车与发车"这样一个孤零零的词。常见做法是从目录页把节名抽出来做成白名单集合,正文里逐行做精确匹配,命中后才升级为节节点。这套办法对付本规程够用,因为节名重复率极低。

import re CH_HEAD = re.compile(r'^第([一二三四五六七八九十]+)章\s*(.+)$') # 章行必须行首出现,且不能带目录点线;点线在 2.2 里处理 def parse_chapters(lines): chapters = [] for i, line in enumerate(lines): m = CH_HEAD.match(line.strip()) if m and '....' not in line: chapters.append({'no': m.group(1), 'title': m.group(2).strip(), 'line': i}) return chapters

这段代码的关键参数是^strip():章节标题在原文里前后各有一个全角空格,strip()之后第X章才能落在行首,否则正则永远不匹配。第二个参数是'....' not in line,它专门用来过滤目录页——目录里的章标题行尾必然跟着一串点和页码。

2.2 目录页与正文的重复命中

目录部分长得很有规律:总 则......................1第一章 基本要求..................2第十四章 列车运行...............91。如果直接按"第X章"切分,会先切出一批几乎没有正文的空块,紧接着又切出真正的章。判别特征很稳定:目录行末尾一定有连续点线加页码。

DOT_LEADER = re.compile(r'[.·]{3,}\s*\d+\s*$') def toc_end(lines): """返回目录区最后一行下标;找不到则返回 -1""" idx = [i for i, l in enumerate(lines) if DOT_LEADER.search(l)] if not idx: return -1 # 保险:目录区里章标题至少出现 19 次,否则判定为误判 cand = idx[-1] hits = sum(1 for l in lines[:cand] if l.strip().startswith('第') and '章' in l[:8]) return cand if hits >= 19 else -1

[.·]{3,}同时兼容半角点和中点两种点线写法,\s*\d+\s*$把页码锁在行尾。那个hits >= 19是回退保护:万一正文某张表格也用了点线对齐,最后一个"目录行"可能落在正文深处,用章标题计数能把这个错误挡掉。目录区判定失败时,退而求其次,去找"总 则"第二次出现的行号,因为它在正文里必然再出现一次。

2.3 条款切分与中文数字归一化

条款是这份规程的最小可引用单元,所有检索、比对、题库生成都建立在它上面。用捕获组切分可以保留分隔符,切完再判断每一段是"条款头"还是"条款体"。

CN = {'零':0,'一':1,'二':2,'三':3,'四':4,'五':5,'六':6,'七':7,'八':8,'九':9} ART = re.compile(r'(第[零一二三四五六七八九十百]+条)') def cn2int(s): s = s.strip('第条 ') if s in CN: return CN[s] if s == '十': return 10 if '十' in s: a, _, b = s.partition('十') return (CN.get(a, 1) * 10 + (CN.get(b, 0) if b else 0)) if '百' in s: a, _, b = s.partition('百') base = CN.get(a, 1) * 100 tail = cn2int('第' + b + '条') if b else 0 return base + tail return None def split_articles(text): parts = ART.split(text) out, cur = [], None for seg in parts: m = ART.fullmatch(seg) if m: if cur: out.append(cur) cur = {'article_no': cn2int(seg), 'body': ''} elif cur is not None: cur['body'] += seg if cur: out.append(cur) return out

cn2int要把"第一百二十三"这类数字转成整数,才能做后续的断号检查和按条号排序。百位以上用递归处理尾部,比写死映射表干净。切分完成后务必跑一次连续性自检:article_no排序后如果出现跳号,多半是某个条款头被正文里的引用(比如"按第 233 条规定")误识别,或者某条被跨页截断,需要用上下文长度做二次筛除——引用型出现的"第X条"后面通常紧跟"规定""要求"这类词,而真正的条款头后面是完整句子。

3. .doc 到可检索文本:格式转换、清洗与 FTS5 索引

3.1 转换路线对比与选择依据

.doc是 OLE 复合文档,不是 zip 包,python-docx这类库只吃.docx,直接读会抛异常。所以第一步几乎一定是格式转换。

工具输入格式表格保留段落顺序适用场景
LibreOffice headlessdoc / docx保留为表格结构保留首选,一次转 docx 再解析
antiworddoc退化为空格对齐保留无桌面环境、只要纯文本
pandocdocx保留保留需要转 Markdown 时顺手
python-docxdocx保留保留需要读样式判断节标题
catdocdoc基本保留应急,中文编码需额外指定

我一般的做法是先把.doc转成.docx,再用python-docx读段落和样式,因为节标题的层级信息只存在于样式里,纯文本转换会把这个线索彻底丢掉。

# --headless 无界面运行;--convert-to 指定目标格式 # -env:UserInstallation 给独立配置目录,避免批量并发时互相抢锁 soffice --headless \ -env:UserInstallation=file:///tmp/lo_profile \ --convert-to docx --outdir ./out \ "专题资料《铁路技术管理规程》普速铁路部分.doc"

批量处理时给每个进程分配不同的UserInstallation目录,能避免"另一个实例正在运行"导致的静默退出。转换完成后建议立刻校验输出文件大小,如果转出来的 docx 不到原文件的十分之一,多半是编码识别失败,需要换antiword -m UTF-8.txt兜底再比对行数。

3.2 段落级清洗规则

转换只是开始,真正决定检索质量的是清洗。这一步的原则是:原文保留一份,归一化字段另存一份,检索走归一化字段,展示走原文字段。这样既能搜到"轨道",又不会破坏原文里"轨 道"的排版形态。

噪声类型原文示例处理方式
全角空格轨 道限 界短标题去空格后写入title_norm
罗马数字页码信号通信章节页脚的IIIIII按行长度 ≤ 3 且匹配^[IVX]+$删除
页眉文字每页首行重复的规程名统计高频行首,出现次数 > 50 的直接剔除
全半角括号混用(一)(一)unicodedata.normalize('NFKC', s)
跨页断行条款被页边界切成两段若下一段不以条款头/项号开头则拼接
import re, unicodedata ROMAN = re.compile(r'^[IVX]{1,5}$') def normalize(s: str) -> str: s = unicodedata.normalize('NFKC', s) # 全角转半角,统一括号与数字 s = s.replace('\u3000', ' ').replace('\xa0', ' ') # 全角空格与不换行空格 s = re.sub(r'\s+', ' ', s).strip() return s def clean_line(s: str) -> str: if ROMAN.match(s.strip()): # 罗马数字页码行 return '' return normalize(s)

NFKC这一步别省,它会把"("统一成"("、全角数字统一成半角,后续正则只写一套就够。注意NFKC也会把某些特殊符号改形,如果原文里有限界示意图用的特殊符号,务必在清洗前后各存一份快照做抽样比对,别一次性覆盖原文件。

3.3 条款表与 FTS5 全文索引

落库用 SQLite 就够了,单文件、可离线、能直接丢进任何脚本。表结构按第 2 章的层级来,clauses存正片,clauses_fts存索引。

CREATE TABLE clauses ( id INTEGER PRIMARY KEY, part TEXT, -- 编,如 '第二编 行车组织' chapter TEXT, -- 章,如 '第十四章 列车运行' section TEXT, -- 节,无则为空 article_no INTEGER, -- 条款号,已归一化为整数 body TEXT, -- 原文 body_norm TEXT -- 归一化文本,供检索 ); CREATE INDEX idx_art ON clauses(article_no); CREATE VIRTUAL TABLE clauses_fts USING fts5( body_norm, content='clauses', content_rowid='id', tokenize='trigram' -- 要求 SQLite >= 3.34 ); INSERT INTO clauses_fts(rowid, body_norm) SELECT id, body_norm FROM clauses;

先跑sqlite3 --version确认版本。trigram分词器支持任意 3 个字符以上的子串匹配,对"安全保护区""列车运行监控装置"这类术语效果很好,代价是索引体积大约是原文的 3 倍。它有个必须提前知道的限制:少于 3 个字符的查询词匹配不到,像"闭塞""限界"这种两字词会直接失配。

提示:检索词普遍在 2 个字左右时,把tokenize换成默认unicode61,并在入库前用分词工具把body_norm切成空格分隔的词串。unicode61 会把整段连续汉字当成一个 token,不预分词的话搜什么都搜不到。

3.4 切分质量的自检查询

入库后先别急着做检索界面,跑几条自检 SQL 看数据是否可信。

-- 每章条款数:第十六、十七章条款密度明显高于第十九章,偏离太多说明切分有问题 SELECT chapter, COUNT(*) AS n, MIN(article_no) AS lo, MAX(article_no) AS hi FROM clauses GROUP BY chapter ORDER BY lo; -- 断号检查:用窗口函数找出条款号不连续的位置 SELECT article_no, LEAD(article_no) OVER (ORDER BY article_no) - article_no AS gap FROM clauses WHERE gap > 1;

第一条查询里lohi能帮你在几秒内确认章与条号区间是否对得上,比如第十四章列车运行的条号区间和第十一章编组列车的区间必然是紧邻的,中间出现大段空白就说明有条款被漏切。第二条用窗口函数算相邻条号差值,gap > 1的行就是缺口的起点,人工回去核对这几处原文即可。

4. 参数检索实战:限界数值、闭塞方式与信号显示的定位写法

4.1 限界数值藏在附图里还是正文里

附图 1"客货共线铁路建筑限界"分了两档,一档是v≤160 km/h,另一档是v>160 km/h,还有一张是双层集装箱运输的装载限界与建筑限界。这些图在转换后只剩一个图题,尺寸数字全部丢失,所以别指望从文本里抠出限界值。

可行的做法是把附图当成独立实体入库,并建立引用关系。

import re # 正文里凡是提到"按附图 1""见附图 2"的条款,建立关联 FIG_REF = re.compile(r'(附\s*图\s*([12]))') ATT_REF = re.compile(r'(附\s*件\s*([1-8]))') def extract_refs(body: str): figs = {m.group(2) for m in FIG_REF.finditer(body)} atts = {m.group(2) for m in ATT_REF.finditer(body)} return {'figures': sorted(figs), 'attachments': sorted(atts)}

正则里的\s*是必需的,原文中"附 图"中间夹着全角空格,只在清洗后的body_norm上跑会更稳。抽出来的关系存成clause_refs(clause_id, ref_type, ref_no),检索限界相关条款时就能一次性把引用附图 1 的所有条款捞出来,再回到原图核对数值。如果确实需要把数字变成可计算的数据,只能走 OCR,而且必须人工复核,因为限界图上的标注线密集,自动识别错一位数后果很实在。

4.2 闭塞方式检索的误召回

第十三章行车闭塞里并列了四种方式:自动闭塞、自动站间闭塞、半自动闭塞、电话闭塞,另有"电话中断时的行车"。这里有个很典型的子串陷阱:半自动闭塞里完整包含自动闭塞这四个字,用模糊匹配搜"自动闭塞",半自动闭塞的条款会全部混进来。

检索词期望命中容易误召回判别写法
自动闭塞第十三章自动闭塞节半自动闭塞、自动站间闭塞排除含"半自动闭塞"的行
电话闭塞电话闭塞节电话中断时的行车按 section 字段过滤而非全文搜
列车调度指挥第三章相关条款调度集中系统用完整术语+同义表
-- 先按全文索引捞,再用 NOT LIKE 排掉子串污染 SELECT id, chapter, section, article_no FROM clauses WHERE id IN (SELECT rowid FROM clauses_fts WHERE clauses_fts MATCH '自动闭塞') AND body_norm NOT LIKE '%半自动闭塞%';

这条 SQL 的执行顺序很关键:FTS 先出候选集,NOT LIKE只作用在候选集上,成本可控。如果把NOT LIKE单独用在大表上,等于全表扫描。同义词这一层建议单独维护一张synonyms(term, alias)表,比如"列控系统"对"CTCS-2 级列控系统",查询前先做一次词条扩展,命中率会明显好过手工拼 OR。

4.3 信号显示与附件的编号关联

第三编信号显示覆盖第十五到十九章,从固定信号、移动信号及手信号一路排到听觉信号,附录里还挂着路票、绿色许可证、红色许可证、调度命令、出站/跟踪调车通知书、轻型车辆使用书、调度命令登记簿、书面通知这八份附件。做培训题库时,最常见的需求是把"信号显示"和"对应凭证"配对,这时候靠正文关键词搜很容易漏。

# 附件 1 路票、附件 2 绿色许可证……建立凭证字典 ATTACHMENTS = { '1': '路票', '2': '绿色许可证', '3': '红色许可证', '4': '调度命令', '5': '出站/跟踪调车通知书', '6': '轻型车辆使用书', '7': '调度命令登记簿', '8': '书面通知', } def link_attachments(conn): cur = conn.cursor() for cid, body in cur.execute("SELECT id, body_norm FROM clauses").fetchall(): for m in ATT_REF.finditer(body): cur.execute( "INSERT OR IGNORE INTO clause_refs VALUES (?,?,?)", (cid, 'attachment', m.group(2))) conn.commit()

附件号到名称的映射写死在这里,是因为原文里"附件 1"和"路票"并不总在同一段落出现,只靠共现统计会漏掉相当一部分。把映射固定下来之后,用户搜"路票"能直接反查到引用它的条款,搜"附件 5"也能拿到名称。

5. 条款级差异比对:专题资料与 2014 版的增量定位

这份 2021—2022 年专题资料的正文主体沿用 2014 年 7 月版本,但整理过程中难免有增删。做版本比对时最容易犯的错是拿行号做 key——一旦中间新增一段,后面所有行号全部漂移,diff 结果会变成一片红。正确做法是以article_no为 key,行号只用来定位。

import difflib def diff_by_article(old: dict, new: dict): """old/new: {article_no: body_norm}""" added = sorted(set(new) - set(old)) removed = sorted(set(old) - set(new)) changed = [] for no in sorted(set(old) & set(new)): if old[no] != new[no]: ratio = difflib.SequenceMatcher(None, old[no], new[no]).ratio() changed.append((no, round(ratio, 3))) return added, removed, changed

SequenceMatcher.ratio()返回 0 到 1 的相似度,实践中低于 0.85 的多半是实质性修改,0.85 到 0.98 之间通常是标点、全半角或分词的差异,可以直接过滤掉,剩下的再人工看。这个阈值不是定死的,标点规范化做得越干净,阈值可以压得越低。

几类高频排错点值得单独记一笔。第一是跨页表格,原文在页边界处会断成两半,第二页开头往往有"续表"之类的标记,比对前要按标记把两段拼回去,否则会被判成条款被删。第二是 OCR 或转换引入的形近字符混淆,典型的是"〇"与"0"、"l"与"1"、"一"与"—",在条款编号和限界数值上破坏性最大,建议在归一化函数里把这些字符统一映射。第三是系列条款的重排,条号没变但顺序调了,diff_by_article看不出来,需要额外跑一次顺序校验。

-- 顺序校验:找出条号顺序与文档出现顺序不一致的位置 SELECT id, article_no, article_no - LAG(article_no) OVER (ORDER BY id) AS delta FROM clauses WHERE delta <= 0;

这条查询按插入顺序(即原文档顺序)看条号是否递减或持平,返回非空结果就说明有条款被移动过位置,delta <= 0的那几行就是切入点。跑完这条如果结果为空、条号连续性检查也干净,那么比对结果的可信度就足够了,可以直接把变更清单导出给业务方核对,不必再逐条人工通读。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询