手上这份成语列表一共17494条,来源标注是成语子字典。第一次看到这个数字,我没有急着写代码,而是先把三件事想清楚:它到底是“词表”还是“词条库”,它的收录边界在哪里,以及它能不能直接支撑我要做的功能。这三件事决定了后面所有工作的走向——如果它只是一列干净的四字词,那它就是一个索引骨架,释义、例句、拼音都得另外挂;如果它每条都带释义和出处,那它本身就是一个小型词典,重点就变成清洗和查询优化。
我在不同项目里用过好几个版本的成语清单,规模从三千条到四万多条不等,踩过的坑高度重合:拼音标错、异形词重复、释义里混进波浪号和书名号、CSV 被英文逗号搞错列。所以这篇不谈虚的,就把“拿到一万七千多条成语之后,怎么把它变成能用的数据”这件事拆开讲清楚。适合正在做语言类应用、题库系统、接龙小游戏、文本分析工具的同行看,也适合只需要一份干净词表做检索的前端同学——后半部分的存储选型会告诉你,很多时候你根本不需要上搜索引擎。
1. 17494 这个数字背后的数据画像
1.1 “子字典”三个字已经透露了来源逻辑
看到“子字典”这个说法,基本可以判断它不是从零编纂的,而是从某个更大的词库里按词类筛出来的子集——原库可能同时收了词语、短语、惯用语,编纂者按“成语”这一类目做了一次切分,导出成独立的清单。这种做法在数据整理里很常见,好处是条目齐整、格式统一,坏处是筛选标准往往是原库的编纂方针,不是你项目的需求。
举个具体的例子:三字条目算不算成语?“莫须有”“闭门羹”“敲门砖”这类结构在语义上高度成语化,很多词典会收,但严格按“四字格”标准就会被剔掉。五字以上的“欲速则不达”“五十步笑百步”“巧妇难为无米之炊”同理。还有“实事求是”“与时俱进”这种介于成语和固定词组之间的条目,收与不收各家标准不同。
所以第一件事是统计字数分布,用数据说话,而不是靠猜:
import csv from collections import Counter rows = [] with open("idioms.csv", encoding="utf-8-sig", newline="") as f: for r in csv.DictReader(f): rows.append(r) word_col = "word" words = [r[word_col].strip() for r in rows if r.get(word_col)] print("总行数", len(rows), "有效词条", len(words)) print("去重后", len(set(words))) dist = Counter(len(w) for w in words) for k in sorted(dist): print(f"{k} 字: {dist[k]:>6} 占比 {dist[k]/len(words):.2%}")跑完你会得到一张分布表。正常情况四字条目应该占九成以上,如果三字和五字以上加起来超过一成,说明这份清单的收录尺度偏宽,后面做“联想候选”时就得考虑是否按字数过滤,否则用户输入两个字,候选里可能蹦出七个字的条目,体验会很怪。
1.2 一万七千条落在什么档位
业内一般的说法是:日常高频使用的成语三千到五千条,覆盖了绝大多数阅读和写作场景;中型成语词典收一万五到两万条;大型的能到三万到四万条。17494 正好卡在“中型偏全”的位置上,这个档位很有意思。
它的好处是够全:常见成语基本不会漏,做题库不容易出现“这个成语竟然没有”的尴尬;做接龙不容易频繁走进死路。它的坏处也很明显:生僻条目会污染推荐结果。比如用户只想要“形容高兴”的成语,你从全量清单里随机抽,抽到“一则以喜,一则以惧”这种半文言的条目,体验直接崩掉。
我的处理办法是给每条打一个使用频度分层,不追求精确,只要能把清单切成三档就够用:常用(约三到五千条)、一般、生僻。分层方法在第 5 节讲,先用字数分布和人工抽检把难度感建立起来。
1.3 动手前必须先回答的四个问题
这四条不是形式主义,每一条都会直接改变你的表结构:
| 问题 | 影响 | 只有词表时的应对 |
|---|---|---|
| 是否带释义 | 决定它是不是“词典” | 释义字段留空,靠自有内容或人工补 |
| 是否带拼音 | 决定能否做注音、首字母检索 | 自己注音,返工率最高的一步 |
| 是否已去重 | 决定要不要做异形词归并 | 必须自己做,重复率常在一到三个百分点 |
| 收录边界是否可接受 | 决定要不要做二次筛选 | 建 exclude 表,保留原始清单不动 |
提醒:不要在原文件上直接改。原始清单永远保留一份只读副本,所有清洗动作走脚本、输出新文件,出问题时能随时回溯到“出厂状态”。
如果拿到的是纯词表(没有释义、没有拼音),别急着失望。这类数据在工程上反而更好用——它没有版权包袱,体积小,可以整份塞进前端做本地检索。真正要做的是把它当作骨架,往后挂内容。
2. 把一份纯文本成语清单撑成可查询的数据表
2.1 最小字段集与扩展字段的取舍
字段不是越多越好,多一个字段就多一份维护成本和一个出错点。我一般按“三层”来设计:
| 层级 | 字段 | 是否必需 | 说明 |
|---|---|---|---|
| 核心 | word | 必需 | 词条本体,唯一键 |
| 核心 | length / first_char / last_char | 必需 | 派生列,接龙和索引全靠它 |
| 展示 | pinyin / py_initials | 强烈建议 | 注音与首字母检索 |
| 展示 | definition / example | 视场景 | 词典类应用必需 |
| 检索 | alias | 建议 | 异形词、简繁对应,检索时双命中 |
| 分类 | level / tags | 可选 | 难度分级、主题标签 |
| 溯源 | source / license / fetched_at | 建议 | 后期维护的救命字段 |
几个容易忽略的细节。first_char和last_char看起来能从word现算,但一旦数据量上万、又要频繁做接龙查询,把这两个值落库并建索引,比每次substring快得多,也省得索引失效。length同理——按字数筛选是最高频的操作之一,单独存一列整数比函数索引可靠。
alias建议存成 JSON 数组字符串或者独立的关联表。如果你的数据最终要塞进关系型数据库,用独立表idiom_alias(word_id, alias)更规范,检索时用UNION合并结果;如果只是脚本里用,JSON 字符串完全够。
2.2 拼音字段是整份数据里返工率最高的一列
这一块我必须重点讲,因为它是绝大多数人会栽跟头的地方。
逐字查表注音必错。汉字的读音在成语里会被词义锁定,而单字字典给出的往往是常用读音。下面是几个经典的反例,我拿它们当注音质量的试金石:
| 成语 | 易错字 | 正确读音 | 常见错误 |
|---|---|---|---|
| 叶公好龙 | 叶 | shè | yè |
| 虚与委蛇 | 蛇 | yí | shé |
| 自怨自艾 | 艾 | yì | ài |
| 阿谀奉承 | 阿 | ē | ā |
| 否极泰来 | 否 | pǐ | fǒu |
| 博闻强识 | 识 | zhì | shí |
| 暴虎冯河 | 冯 | píng | féng |
| 度德量力 | 度 / 量 | duó / liàng | dù / liáng |
| 解甲归田 | 解 | jiě | xiè |
除了多音字,还有变调问题。“一”在去声字前读 yí(一叶知秋 → yí yè zhī qiū 的标注之争),在阴平阳平上声前读 yì(一鸣惊人 → yì míng jīng rén),“不”在去声前读 bú(不胫而走 → bú jìng ér zǒu)。有的词典标本调,有的标变调,两种做法都有人认,但你不能混着来,必须统一,并在 README 里写清楚。
工程上的做法:
- 优先用词级注音词典(整条成语对应一串拼音),这是最省事的。
- 没有词级词典时,用带词语上下文的注音工具,别用单字表硬拼。
- 无论用哪种方式,都要跑一遍人工抽检:随机抽 200 条,逐条核对,错误率超过 2% 就整批重做。
存储上建议双列:pinyin存带声调符号的形式(给人看),pinyin_plain存无声调数字或纯字母形式(给检索和排序用)。前者如yī míng jīng rén,后者如yi1 ming2 jing1 ren2或直接yimingjingren。搜索时用后者做前缀匹配,命中率高且不受声调符号影响。
注意:
ü的处理要统一。有人写lv,有人写lü,有人写v。成语里带 ü 的不多(如“绿林好汉”的“绿”读 lù,反而容易误标),但一旦混用,检索结果就会莫名其妙地漏。
2.3 首字、尾字、字数、结构四个派生列的用法
first_char和last_char主要服务两个功能:首字联想和成语接龙。做首字联想时,用first_char建索引,用户输入“一”就返回所有以“一”开头的条目;做接龙时用last_char,把上一条的尾字当下一轮的首字查。
length除了筛选,还有一个隐蔽用途:生成填空题时保证挖空位置对应。挖掉第 2 个字和挖掉最后一个字,难度完全不同,靠长度分组能快速批量生成同难度的题。
至于structure(联合式、偏正式、动宾式、主谓式、补充式、紧缩式),我的建议是先别做。靠规则判断结构,准确率撑不住;靠人工标一万七千条,成本远超收益。除非你做的是语文教学类产品,需要讲语法,否则这个字段的投入产出比很低。真要做,就先用规则粗分,再人工抽查几十条看偏差方向,别指望一步到位。
3. 清洗阶段最容易卡住的五类脏数据
3.1 重复条目与异形词:该合并的与不该合并的
一万七千条里出现重复,几乎是必然的。重复分两种:
第一种是完全重复,词条逐字相同,只是行号不同。这种直接去重,同时统计重复数量——如果重复超过两百条,说明原始导出流程有问题,值得去看看是不是分页导出时重叠了。
第二种是异形词,同一个成语有两种通行写法。常见例子:莫名其妙 / 莫明其妙、一厢情愿 / 一相情愿、刻骨铭心 / 刻骨镂心、名副其实 / 名符其实。这类不能简单删掉一个,因为用户可能按任意一种写法来搜。
我的策略是主条目 + 别名:选一个通行度更高的作为主条目(word),其余进alias。展示时只显示主条目,检索时主条目和别名都能命中。判断通行度的办法很土但有效——看两个写法在自有语料里出现的次数,或者直接人工定一个,然后全库统一。
还有一种特殊情况要小心:形近但语义不同的条目绝不能合并。比如“不以为然”(不认为对)和“不以为意”(不放在心上),长得极像,意思完全不同,合并了就是灾难。这类条目在归并前必须比对释义,释义不一致就一律不合并。
3.2 释义字段里的残留标记
释义字段是脏数据重灾区,来源是词典排版时的各种约定符号。我遇到过的情况:
- 波浪号代替词条。词典排版时常用“~”代表本条目,比如“一鸣惊人:~比喻平时默默无闻,一下子做出惊人的成绩”。这种如果不替换回词条本身,用户看到的释义开头是个波浪号,很别扭。处理方式是全文把
~替换为word。 - 书名号夹注。“(见《XX》卷三)”这类引证标记,对普通用户没用,会撑长释义。用正则清理,但要小心别把释义里正常的书名号(比如典故出处本身)一起干掉。
- 序号与括注。①②③、[ ]、【 】、〔 〕混用,有的表示义项顺序,有的表示补充说明。统一成一种,或者干脆按第一个义项截断(如果只展示一句话)。
- 空白与换行。
\r\n、\r、全角空格、不间断空格混在释义里,看起来是空格,实际是四种不同字符,搜都搜不出来。统一\r\n→\n,全角空格和 → 半角空格,再折叠连续空白。
一个可复用的清理函数长这样:
import re def clean_def(text, word): if not text: return "" t = text.replace("\u00a0", " ").replace("\u3000", " ") t = re.sub(r"[\r\n]+", " ", t) t = t.replace("~", word) t = re.sub(r"[((]\s*见\s*《[^》]*》[^))]*[))]", "", t) t = re.sub(r"[①②③④⑤⑥⑦⑧⑨⑩]", " ", t) t = re.sub(r"\s{2,}", " ", t) return t.strip()写完先别急着全量跑,抽 50 条看输出,确认没有把正常内容误删,再批量处理。
3.3 生僻字、扩展区字符与编码
成语里有些字落在字符集的扩展区,一个字符要占四个字节。这在文件层面没问题,UTF-8 完全支持,但某些数据库的默认字符集只支持三字节编码,遇到四字节字符会截断或者直接报错。表现是:数据导入成功,但个别词条变成乱码或者只剩一半。
处理步骤:
- 全流程统一 UTF-8,读写文件都显式指定
encoding="utf-8",读带 BOM 的用utf-8-sig。 - 建库时确认字符集支持四字节编码,别用默认值蒙混过关。
- 做一次 Unicode 规范化,统一用 NFC,避免同一个字有两种编码形式导致去重失败。
- 如果真的不支持四字节,把这些条目单独导出成一个附属清单,主库跳过,README 里注明,别悄悄丢数据。
3.4 CSV 分隔符错位与 BOM 头
这两个问题看着低级,但每年都能坑到人。
BOM 头:文件开头有\ufeff,用utf-8读进来时,第一列的列名变成\ufeffword,于是row["word"]抛 KeyError,而你眼睛看文件觉得完全正常。解法就一句:读文件用utf-8-sig。
分隔符错位:释义里出现英文逗号、英文引号或者换行,用line.split(",")解析就会列数不齐,第 3000 行开始整列错位,释义跑到别名列里去了。这种错误不会报异常,只会让你在后续统计里发现“怎么有些词的释义是拼音”。解法也很明确:永远用 csv 模块,永远不用 split 解析 CSV。
排查链路是这样的,我一般按这个顺序走:
- 统计总行数,和预期的条目数对齐,先确认丢没丢行。
- 逐行用
split(",")算列数,打印列数不等于表头列数的行号,取前 20 个样本。 - 打开原始文件看这几行的字节,判断是多余逗号、转义引号还是字段内换行。
- 如果用 csv 模块解析后依然错位,说明文件本身的行结构被破坏了(比如字段内换行被写成了真实的换行),这时需要先用状态机修复引号包裹,再解析。
- 修复后重新统计列数分布,全表 100% 对齐才算通过。
提示:每次修复都输出一个新文件,文件名带上日期和后缀(如
idioms.clean.20240115.csv),不要原地覆盖。数据修复是个反复的过程,你会需要回退。
3.5 一份每次都能复用的校验清单
清洗完成后,我固定跑这几项检查,全部通过才进生产:
| 检查项 | 合格标准 | 不达标说明 |
|---|---|---|
| 完全重复率 | 0 | 去重没做干净 |
| 空词条 | 0 | 解析错位或源数据缺失 |
| 非汉字字符 | 0 | 混入了标点、数字、字母 |
| 四字条目占比 | 85% 以上 | 收录边界异常 |
| first_char 与 word 首字一致 | 100% | 派生列没重算 |
| 拼音长度与字数一致 | 100% | 注音漏字或多字 |
| 释义为空的比例 | 视来源,记录基线 | 后续靠这个判断增量质量 |
这张表的价值在于可重复。下一次拿到新版本清单,同样的脚本跑一遍,两次数值的差异就是变更内容,比人工翻文件靠谱得多。
4. 一万七千条该用什么存:规模决定架构
4.1 JSON、CSV、嵌入式数据库的分工
先算体积。17494 条,每条平均 4 个汉字,UTF-8 下汉字三字节,词条本身大约 210KB;加上拼音、释义、例句,平均每条 200 到 300 字节,整份数据 3 到 6MB。这个量级小得惊人——它小到可以直接全部加载进内存。
所以选型逻辑非常清楚:
| 方案 | 适用场景 | 优势 | 代价 |
|---|---|---|---|
| JSON | 前端本地检索、脚本处理、配置化数据 | 结构化、易读、直接解析 | 无索引,全量加载 |
| CSV | 交换、人工校对、导入导出 | 通用、体积小 | 无类型,易错位 |
| 嵌入式数据库 | 单机应用、需要复杂查询与持久化 | 有索引、支持 SQL、事务 | 需要建表与迁移 |
| 倒排索引服务 | 大规模模糊搜索、相关度排序 | 检索快、有评分 | 运维成本,远超本项目所需 |
我的判断很简单:**如果你的查询需求是“按首字查”“按字数查”“按拼音首字母查”这三类,嵌入式数据库足够,甚至 JSON 加内存字典就够。**只有当你需要错字容错、拼音混输、相关度排序,并且并发量不小的时候,才值得引入独立的检索服务。为了一万七千条数据起一个搜索引擎,属于典型的过度设计。
4.2 索引怎么建才真的用得上
用嵌入式数据库时,建库和索引大概是这样:
import sqlite3 con = sqlite3.connect("idioms.db") con.execute(""" CREATE TABLE IF NOT EXISTS idiom( id INTEGER PRIMARY KEY, word TEXT NOT NULL UNIQUE, pinyin TEXT, py_initials TEXT, length INTEGER NOT NULL, first_char TEXT NOT NULL, last_char TEXT NOT NULL, definition TEXT, example TEXT, level INTEGER DEFAULT 0 ) """) con.execute("CREATE INDEX IF NOT EXISTS idx_first ON idiom(first_char)") con.execute("CREATE INDEX IF NOT EXISTS idx_last ON idiom(last_char)") con.execute("CREATE INDEX IF NOT EXISTS idx_len ON idiom(length)") con.execute("CREATE INDEX IF NOT EXISTS idx_py ON idiom(py_initials)") data = [(w, py, ini, len(w), w[0], w[-1], d, ex, lv) for w, py, ini, d, ex, lv in source] con.executemany( "INSERT OR IGNORE INTO idiom(word,pinyin,py_initials,length,first_char,last_char,definition,example,level) " "VALUES(?,?,?,?,?,?,?,?,?)", data) con.commit()建完索引先做一件事:看执行计划。用EXPLAIN QUERY PLAN确认你的查询真的走了索引,而不是全表扫描。常见坑是写了WHERE word LIKE '%意%',前置通配符让索引彻底失效,一万七千行扫一遍虽然只要几十毫秒,但如果你在接口里循环调用,就变成了性能灾难。
正确的做法是:前缀匹配用LIKE '一%'(可以走索引),包含匹配用倒排或三元组索引,或者干脆在应用层用内存字典做一次过滤——一万七千条在内存里过滤,速度比数据库快一个数量级。
4.3 模糊搜索:先算清需求再决定上不上倒排
“用户输入 yimingjingren 要能搜到一鸣惊人”“用户打错一个字也要能搜到”——这类需求听着简单,做起来是另一个量级。
我的判断标准是三条:
- 是否需要相关度排序。只要求“能搜到”,内存过滤足够;要求“最匹配的排前面”,就需要评分机制。
- 候选规模是否巨大。一万七千条的候选集很小,全量打分毫秒级完成。
- 是否需要跨字段检索。同时搜词条、拼音、释义,用 SQL 的
OR会很难看,这时用倒排更舒服。
如果三条里有两条为真,再考虑引入检索组件。否则用“拼音首字母前缀树 + 内存过滤”就能覆盖九成场景:把每条成语的拼音首字母拼成一个串(一鸣惊人 →ymjr),建一个从首字母串到词条的反向映射,用户输入时做前缀匹配,再叠加一次子串过滤,实现不到一百行代码。
5. 清单落地:四个真能跑起来的方向
5.1 成语接龙:把它当图来算
接龙不是“列表 + 随机”能做到的,它本质是有向图:每个词条是节点,尾字到首字的匹配是边。先把邻接表建起来:
from collections import defaultdict by_first = defaultdict(list) for w in words: by_first[w[0]].append(w) def next_words(w): return by_first.get(w[-1], []) # 死路统计:以哪些字结尾会导致接不下去 dead = [w for w in words if not by_first.get(w[-1])] print("死路条数", len(dead), "占比", f"{len(dead)/len(words):.2%}") print("样例", dead[:15])死路占比这个指标很有用。如果死路太多,游戏体验会频繁中断,你得准备“重新开始”的容错,或者干脆在开局时只从上一步有出边的词里选。实测下来,一万七千条的清单里死路通常在两成以内,但集中在少数几个尾字上,比如以“么”“然”“者”结尾的条目,出边极少甚至为零。
同音接龙是另一个坑。有些玩法允许同音接龙(“意气风发”接“发人深省”没问题,但“省吃俭用”的“省”读 xǐng,如果按音节接龙,下一个词的读音应该是 xǐng 开头,而不是 shěng)。这就必须用到拼音字段,而且要用整条成语的注音,不能靠字面。我的建议是:默认只做字面接龙,规则简单、争议少;同音接龙作为可选玩法,用 pinyin 字段单独实现一套判断。
还有一个隐藏问题:环路。图里存在环(“人定胜天”→“天从人愿”→“愿者上钩”…最终可能绕回来),做“最长链”搜索时必须记录已访问节点,否则直接栈溢出。求最长链本身的复杂度不低,一万七千节点的图,用带剪枝的深度优先搜索可以做,但要限制搜索深度和耗时,别在前端同步调用。
5.2 填空题自动生成与干扰项校验
生成一道填空题的完整流程是:选词 → 选挖空位置 → 生成干扰项 → 校验 → 输出。
选词可以按level分档,也可以按length固定四字。挖空位置有权重:首字最难,因为要判断整个词的开头;尾字次之;中间字相对容易。别均匀随机,否则题库难度会飘。
干扰项生成最容易出错。朴素做法是“从其他成语的同一位置取字”,但这样生成的干扰项可能让替换后的串仍然是一个合法成语,题目就废了。所以必须做校验:
idioms = set(words) def make_blank(word, pos, pool_chars): chars = list(word) answer = chars[pos] for c in pool_chars: if c == answer: continue chars[pos] = c candidate = "".join(chars) if candidate in idioms: # 替换后仍是成语,弃用 continue yield candidatepool_chars可以从同长度成语的同一位置统计字频,取高频字。这样干扰项看起来“像那么回事”,但又不构成成语,题目质量明显更好。
经验:填空题的坑不在生成,在“答案唯一性”。有些成语挖掉某个字后,另一个字填进去读起来也通顺(语义上勉强说得通),这种题会被用户投诉。校验能拦住“仍是收录成语”的情况,拦不住“语义通顺但未收录”的情况。所以上线前一定要人工过一遍,至少抽检一百道。
5.3 文本成语密度统计
把词表当词典用,对一段文本做最长匹配扫描,统计成语出现的位置、数量、密度。这在文风分析、文本难度评估、内容审核辅助里都用得上。
maxlen = max(len(w) for w in idioms) def scan(text): hits, i, n = [], 0, len(text) while i < n: for L in range(min(maxlen, n - i), 0, -1): seg = text[i:i + L] if seg in idioms: hits.append((i, seg)) i += L break else: i += 1 return hits要点是最长匹配优先,否则“一鸣惊人”可能被切成“一鸣”+“惊人”这样的无效片段(如果词表里恰好有短条目)。另外要统计密度指标:成语字数占总字数的比例,或者每千字出现多少次。正常议论文的成语密度大概在千分之五到千分之十五之间,超过这个范围要么是刻意堆砌,要么是文本风格异常。
这个扫描算法是贪心的,对绝大多数中文文本够用。如果追求更准的分词边界,就上正规的分词工具,再把成语作为用户词典灌进去——这样能避免跨词边界的误匹配,比如“他一来一往地走”里,“一来一往”如果被误当成成语,语义就错了。
5.4 难度分级与分级词表
一万七千条如果不分级,几乎没法直接用。我用的分级方法是三个信号叠加:
| 信号 | 数据来源 | 权重 |
|---|---|---|
| 字频 | 常用字表覆盖情况,2500 常用字全覆盖为易 | 高 |
| 字数 | 四字为基准,五字以上加难度 | 中 |
| 人工抽检 | 每档抽 50 条校正 | 决定性 |
具体操作:先按“所有字都在常用字表内 + 四字”筛出一档,这部分通常有五六千条,人工过一遍挑出真正高频的三千条左右作为“常用级”;剩下按字数、生僻字数量再切两档。分级不需要绝对准确,只要能保证“常用级里不混进生僻条目”就够用了。
分级结果落到level字段,接口按 level 过滤,用户看到的内容质量立刻不一样。这是我认为投入产出比最高的一个字段,比结构标签实在得多。
6. 维护期的几个经验
6.1 来源记录与使用边界
词条本身是公共文化素材,一份成语清单的“词”这层没什么问题。但释义、例句、出处引文的编排是有版权的——词典编纂者的释义措辞、例句选择、义项排序都是创造性劳动。如果整段照搬某个词典的释义进你的产品,风险不小。
我的做法是:词表和拼音可以放心用,释义和例句要么自建,要么确认来源许可,要么只保留最短的客观描述(比如“形容非常高兴”这种程度的概括,独创性很低)。同时在数据里固定加三个字段:source(来源标识)、license(许可说明)、fetched_at(获取时间)。这三个字段平时没用,一旦有人问“这数据哪来的”,你能立刻答上来。
注意:别在数据表里留下“从某处抓取”这类内部备注字段。对外发布的数据包应该只有业务字段,来源信息写进 README 和独立的元数据文件里。
6.2 版本号、变更日志与增量合并
数据是会变的。新版本可能新增条目、修正注音、调整释义、补充别名。如果你的应用直接读文件,某天换了新版本,接口行为变了却没人知道,锅就来了。
我固定用语义化版本:1.0.0、1.1.0、1.1.1。新增条目升次版本号,修正错误升修订号,字段结构调整升主版本号。每次发布附一份 CHANGELOG,写明“新增 128 条,修正 46 条注音,删除 12 条重复”,同时给出文件的校验和。
增量合并用脚本做,逻辑是三路比对:
import json def diff(old, new): old_map = {r["word"]: r for r in old} new_map = {r["word"]: r for r in new} added = [w for w in new_map if w not in old_map] removed = [w for w in old_map if w not in new_map] changed = [w for w in new_map if w in old_map and json.dumps(new_map[w], sort_keys=True, ensure_ascii=False) != json.dumps(old_map[w], sort_keys=True, ensure_ascii=False)] return added, removed, changed输出的三个列表就是变更日志的原料。changed里通常混着大量“只改了一个空格”的噪声,所以比对前先对两条记录做一次规范化(去空白、统一标点),能省掉九成的无效 diff。
6.3 每次更新都跑的校验清单
数据更新最怕的不是改错,是改错了没人发现。所以我把校验做成脚本,更新流程固定为“合并 → 校验 → 人工抽查 → 发布”,任何一步不过就回滚。
具体校验项就是第 3.5 节那张表,另外再加两条:拼音与字数的逐条一致性(用注音的字符数判断,带声调符号时按空格切分计数),以及首尾字派生列的重算校验(重新从 word 取首尾字,和存储值比对,不一致说明有历史脏数据)。
抽查环节我固定抽 30 条,覆盖四字、非四字、含多音字、含生僻字四类。这个量不大,但足够发现系统性问题——如果 30 条里错了 2 条,整批就得重做。
最后分享一个我觉得最省事的小技巧:给每条记录算一个内容哈希,存进数据库。以后任何一次更新,只要哈希没变就说明这条没动过,可以跳过清洗和人工复核流程。一万七千条里,每次真正变动的通常只有几十到几百条,剩下的全靠哈希跳过,维护成本一下就降下来了。这个做法我在好几个数据维护项目里都用过,简单,但真的省时间。