news 2026/10/31 7:53:49

17494条成语词表清洗与落地:从原始文本到接龙数据集

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
17494条成语词表清洗与落地:从原始文本到接龙数据集

手上要做一个语文类的成语接龙小工具,第一步就卡住了——去哪儿弄一份干净的成语列表。网盘里流传的那些词库,要么几千条残缺不全,要么混着大量四字短语和广告词;付费接口按次计费,做离线小工具根本不划算。折腾了一圈,最后是从某个词典数据包的成语子字典里把词条导出来,得到一份 17494 个成语的列表,纯词条、无释义、一行一个。这份东西看着朴素,但它是很多中文小工具的地基:接龙、填空、纠错、分词、检索、题库生成,全都能在它上面长出来。这篇就把它当成一个真实项目来复盘——从原始文本到能进库的结构化数据,中间要清洗什么、补全什么、怎么校验、怎么落地,以及我踩过的那些看着不起眼但能耗掉半天时间的坑。适合手上正好有一份类似词表想用起来的人,也适合刚接触中文数据处理、想找个规模不大又不失真实感的练手项目的读者。

1. 先搞清楚这17494条到底是什么

1.1 为什么是“成语子字典”而不是一本成语词典

“子字典”这个词很关键。它说明这份数据不是一本独立的成语词典,而是某个更大的词典数据包中,按词条类型划出来的一个子集。这个出身决定了它的三个特点,也决定了你后面要用什么姿势对待它。

第一,它是词条清单,不是知识库。里面大概率只有成语本身,没有释义、没有出处、没有例句、没有近反义。你拿到的是骨架,肉得自己长。

第二,它的收录标准跟着母体走。母体是通用词典,那成语子表里就会混进一些“四字熟语”“惯用语”“俗语”,比如带逗号的长句谚语,或者像“不尴不尬”这种介于词和成语之间的条目。你如果做的是严格的成语应用,得自己再筛一道。

第三,它的字段极简。常见就是一列词条,最多加个拼音或首字。这意味着几乎零解析成本,但也意味着所有结构化工作(长度、首尾字、拼音、索引)都得你自己补。

我见过不少人拿到这类数据第一反应是“太简陋了,没用”。恰恰相反,字段越少,噪音越少,清洗成本越低。一份带释义的成语数据,你要处理的是释义里的 HTML 标签、断句错误、嵌套括号、来源标注混排,那才叫真的累。

注意:成语四字本身属于公共语言素材,但词典里的释义、例句、辨析往往是编者原创的编排成果。只用词条、释义自己写或者用公版语料,是最省心的做法;整段搬运商业词典的释义和例句,风险不在技术层面。

1.2 17494这个数字是怎么构成的

很多人拿到列表第一件事就是数长度分布。这一步别省,它决定了你后面所有设计。按我导出的这份跑下来的情况,量级大致是这样(不同版本的数据包会有出入,只能当参考,不能当标准答案):

字数大致条数占比典型例子处理建议
3 字约 1000.6%闭门羹、莫须有单独标记,接龙和填空要区别对待
4 字约 1680096%胸有成竹、杯弓蛇影主力数据,正常处理
5 字约 4002.3%一去不复返、无风三尺浪保留,但 UI 展示要留宽度
6 字约 1500.9%五十步笑百步、挂羊头卖狗肉保留,注意是否为俗语
7 字及以上约 400.2%此地无银三百两多数其实是谚语,建议单独归池

这张表最值得注意的不是四字占了 96%,而是剩下那 4% 会给你带来 80% 的边界 bug。你的挖空逻辑按 4 字写死,遇到 3 字就数组越界;你的 UI 卡片按 4 字定宽,遇到 7 字就换行错位;你的接龙规则假设首尾各一字,遇到带逗号的长条目就直接断链。所以第一件事永远是:按长度分组,分别定策略,而不是一把梭。

另外一个容易被忽略的构成维度是异形词。同一个成语常有几种写法:“名副其实 / 名符其实”、“莫名其妙 / 莫明其妙”、“每况愈下 / 每下愈况”。这类条目在列表里往往是并存的,你如果直接拿去做去重统计,会发现“成语总数”和“语义条目数”对不上。做接龙游戏时它们会同时出现,让玩家觉得系统在灌水;做题库时它们会让答案不唯一。这个坑后面单独讲。

1.3 这份数据的影响半径:能用在哪,绝不能用在哪儿

判断一份数据值不值得投入,我一般看它的“影响半径”——能撑起多少种用法,以及哪些用法是它撑不起来的。

能撑起来的:成语接龙、成语填空、看图猜成语的答案池、中文输入法的候选词、搜索的同义词扩展、文本校对里的“疑似成语误用”提示、分词器的自定义词典、大模型输出校验时的白名单。这些场景的共同点是对“释义准确性”要求不高,对“词条覆盖面”和“查询速度”要求高,正好对上这份数据的强项。

撑不起来的:成语词典 App 的核心内容、需要出处溯源的学术用途、需要辨析近反义的教学场景。这些必须要释义和语料,光有词条就是空中楼阁。

还有一类是看上去能用其实不该用的:拿这 17494 条去做“成语使用频率”分析。它只是个收录清单,没有频次信息,收录多的不代表用得多,收录少的也不代表是生僻词。想分析频率,得另外找带频次的语料,比如用大规模文本统计每条成语的出现次数。这一点我在早期项目里吃过亏,拿着词表条数当热度排序,结果推荐出来全是“噤若寒蝉”“噤若寒蝉”这种书面词,用户体验很差。

规模层面做个定量估算,心里有个数:17494 条 × 平均 4 字 ≈ 7 万个汉字,UTF-8 下中文按 3 字节算,纯文本主体约 210KB;加上分隔符、索引、拼音字段,一个带索引的 SQLite 文件大概 3 到 5MB。这意味着什么?意味着它可以全量常驻内存,可以直接打进前端包——gzip 之后中文重复率高,压缩比通常在 2.5 倍以上,落到 100KB 以内很轻松。这是个非常重要的结论:你不需要为它设计任何分布式方案、不需要上缓存中间件,一个数组、一张表就够了。很多项目把简单问题复杂化,就是没做这一步规模估算。

2. 原始列表进库前的清洗与结构设计

2.1 表结构先定,别一边写代码一边改字段

我在数据量小于十万条的项目里有个习惯:先写建表语句,再写解析代码。理由很实在——字段一旦定下来,解析脚本要产出什么就清楚了,不会写着写着发现“哎我还需要一个首字字段”,然后回头改三处代码。

这份成语数据我用的结构是这样的:

CREATE TABLE idiom ( id INTEGER PRIMARY KEY, word TEXT NOT NULL UNIQUE, -- 原始词条 word_norm TEXT NOT NULL, -- 归一化后的词条 length INTEGER NOT NULL, -- 字数 first_char TEXT NOT NULL, -- 首字,接龙索引用 last_char TEXT NOT NULL, -- 尾字,接龙索引用 pinyin TEXT, -- 带调拼音,空格分隔 pinyin_abbr TEXT, -- 拼音首字母,如 xcyz variant_of TEXT, -- 异形词指向的主词条 source TEXT, -- 来源标记 freq INTEGER DEFAULT 0 -- 预留频次位 ); CREATE INDEX idx_first ON idiom(first_char); CREATE INDEX idx_last ON idiom(last_char); CREATE INDEX idx_len ON idiom(length); CREATE INDEX idx_abbr ON idiom(pinyin_abbr);

几个字段的取舍说一下。first_char和last_char是冗余字段,完全可以从word现算,但我坚持落库,因为接龙查询是这个数据集最高频的操作,每次现算substr(word,1,1)意味着索引失效、全表扫描。17494 条确实扫得动,但一旦你把它做成 API,QPS 上来就是灾难。冗余两个字符换一个索引命中,这笔账很划算。

pinyin_abbr单独存是因为它撑得起一个很实用的功能:输入xcyz直接出“胸有成竹”。这个体验在移动端特别好用,成本几乎为零。

variant_of是我后来加的。异形词不能简单删掉,因为用户可能就用另一种写法搜;但也不能平等对待,否则接龙会重复、题库会歧义。用一列指向主词条,查询时默认只出主词条,搜索时两种都能命中,两全其美。

2.2 归一化:那些“看起来一样但其实不相等”的字符

这是整篇文章里我最想强调的一段。中文文本清洗里最阴的坑不是乱码,是长得一样但码位不同的字符。

最常见的是 CJK 兼容区字符。比如“⼀”(U+2F00,康熙部首一)和“一”(U+4E00,汉字一),肉眼完全一样,但'⼀' == '一'在 Python 里返回False。数据源在导出、转换、跨平台传递的过程中,很容易把部分汉字替换成兼容区或部首补充区的等价字符。后果是:去重去了个寂寞、查询查不到、报错信息还看不出问题。

解决办法是 Unicode 归一化,用 NFKC:

import re import unicodedata def normalize_word(line: str) -> str: # NFKC 会把兼容字符、全角字符、部首补充区字符映射到标准形式 s = unicodedata.normalize("NFKC", line) # 清理零宽字符和不间断空格,这两个是隐藏杀手 s = s.replace("\u200b", "").replace("\ufeff", "").replace("\u00a0", "") # 全角空格、制表符、换行一律清掉 s = re.sub(r"[\s\u3000]+", "", s) return s.strip()

这段里NFKC是关键。它顺手帮你解决了三个问题:全角字母数字转半角、康熙部首转标准汉字、部分异体字统一。一行顶十行。

第二个藏得深的坑是零宽字符(U+200B 零宽空格、U+FEFF BOM)。从网页或 PDF 复制出来的词条,经常在开头或中间夹着这些不可见字符。你肉眼看着是 4 个字,len()出来是 5,然后所有按长度做的逻辑全线崩。我现在的习惯是任何外部文本进来,第一步先跑一遍不可见字符清理,再谈别的。

第三个是括号残留。不少词表里条目长这样:名副其实(名符其实)、杯弓蛇影[也作杯弓蛇影]。你要么把括号内容拆出来当异形词,要么整条剔除,绝对不能原样入库——否则“名副其实(名符其实)”会变成一个 9 字的“成语”,接龙直接断在“)”上。

BRACKET = re.compile(r"[((\[【].*?[))\]】]") def split_variants(raw: str): main = BRACKET.sub("", raw).strip() tail = BRACKET.findall(raw) variants = [] for t in tail: v = re.sub(r"[((\[【))\]】]", "", t).strip() # 有些会写成“也作:xxx”,把前缀切掉 v = re.sub(r"^(也作|又作|亦作|同)[::]?", "", v) if v: variants.append(v) return main, variants

2.3 去重、异形词与“数量对不上”的焦虑

清洗完一定会掉数量。17494 进去,17000 多出来,很多人这时候会慌,觉得数据丢了。其实分三种情况,得分开看。

第一种是完全重复。多来源合并、多次导出叠加,同一行出现两三次。这种直接靠UNIQUE约束或set()干掉,掉了纯赚。

第二种是空格和标点差异。胸有成竹和胸有成竹(尾随空格)在字符串比较里是两条。归一化之后自动合并。

第三种是异形词。这个不能删,要标记。判断异形词的粗暴办法是:长度相同、首字相同、编辑距离为 1,且两条都出现在表里。用这个规则跑一遍,人工过一下候选列表,通常能捞出几十组。像“莫名其妙/莫明其妙”(第二个字不同)、“走马观花/走马看花”(第三个字不同)这种,规则能覆盖大部分。

import itertools def find_variants(words): by_head = {} for w in words: if len(w) == 4: by_head.setdefault(w[0], []).append(w) pairs = [] for _, group in by_head.items(): for a, b in itertools.combinations(group, 2): diff = sum(1 for x, y in zip(a, b) if x != y) if diff == 1: # 只差一个字,且往往是同义近义 pairs.append((a, b)) return pairs

跑出来的pairs不能全信——“心服口服”和“心服口服”这类只有真差异的才算,而且有些只差一个字的两条根本是两个不同的成语。所以这一步一定要出人工复核清单,别自动 merge。我一般导成 CSV,一列原词、一列候选、一列建议动作,过一遍半小时,比后面擦屁股划算。

2.4 繁简、生僻字与存储编码

繁简问题取决于你的数据源。有些词典数据包里混着繁体条目,比如“一見鍾情”“風花雪月”。如果你做的是简体产品,这些条目搜不到、接不上、显示还突兀。

处理方式有两种。一是统一转简体,用 OpenCC 的t2s配置,转完再去重——因为“一見鍾情”转完就是“一见钟情”,会和原有条目撞车,正好合并。二是双语并存,两个都留,加个script字段标记。前者简单,后者适合做港台向产品。绝大多数项目选前者。

from opencc import OpenCC cc = OpenCC("t2s") word_s = cc.convert(word)

生僻字是另一个话题。成语里生僻字比例不低,“醍醐灌顶”“饕餮”“龃龉”“踟蹰”这类,很多落在 CJK 扩展 A 区甚至 B 区(码位超过 U+FFFF)。这带来两串连锁问题:

数据库层面,MySQL 必须用utf8mb4,utf8(其实是 utf8mb3)存不下四字节字符,插进去会变成?或者直接报错。这个坑太经典了,我见过不止一个项目在这上面翻车——而且是那种“本地测试全好,上线才发现有一批成语查不出来”的翻车。

前端层面,字体要能覆盖扩展区。系统默认字体对扩展 B 区的覆盖率参差不齐,某些生僻字会显示成方框。做 C 端产品的话,要么内嵌一个覆盖广的子集字体,要么在渲染时检测并给降级方案。

文件层面,CSV 导出必须带 BOM,否则用表格软件双击打开就是乱码。这不是编码错了,是那个软件默认按本地编码猜。加个encoding="utf-8-sig"就解决。

3. 从原始文本到可查询数据集的完整落地

3.1 解析:先摸清原始文件的真实格式

动手写解析代码前,一定要先用十六进制或者file命令看一眼原始文件。我见过的“文本文件”包括但不限于:真 UTF-8、GB18030、UTF-16 带 BOM、Windows 换行混 Unix 换行、每行前面带序号(1. 胸有成竹)、每行是“词条+制表符+释义”的混合、甚至整份是 JSON 数组套一层。

先做个探测:

from pathlib import Path raw = Path("idioms_raw.txt").read_bytes() print(raw[:4]) # 看 BOM:EF BB BF = UTF-8 BOM,FF FE = UTF-16LE for enc in ("utf-8", "gb18030", "utf-16"): try: text = raw.decode(enc) print(enc, "OK", len(text.splitlines()), "行") break except UnicodeDecodeError as e: print(enc, "FAIL", e.start)

gb18030几乎能解任何中文老文件,如果utf-8报错就试它。注意gb18030解码成功不代表内容对——它容错性太强,可能把 UTF-8 中文解成一堆乱码汉字。所以解码完要抽样看前 20 行、看有没有大量非常用字,人工确认一遍。

行内清理用前面那套normalize_word,然后按行过滤:

CJK_ONLY = re.compile(r"^[\u4e00-\u9fff\U00020000-\U0002a6df]+$") def load_words(path): words, dropped = [], [] for i, line in enumerate(Path(path).read_text(encoding="utf-8-sig").splitlines(), 1): w = normalize_word(line) w = re.sub(r"^\d+[.、\s]+", "", w) # 去掉行首序号 if not w: continue if not CJK_ONLY.match(w): dropped.append((i, w)) continue if not (2 <= len(w) <= 12): dropped.append((i, w)) continue words.append(w) return words, dropped

这里的dropped列表千万别扔,一定要导出来看。你会从里面发现“带括号的异形词”“带释义的混排行”“繁体条目”“纯符号噪音”,这些信息反推回来能帮你改进规则。丢掉不看的项目,最后都会在用户反馈里重新见到它们。

3.2 补全拼音:带调和不带调是两回事

拼音字段的价值不只是展示,更是一个排序维度和检索维度。这里有个很容易混淆的点:注音和朗读变调不是一回事。

做索引、做首字母检索、做拼音排序,你要的是标准注音,也就是每个字查字典得到的基础读音。“一”永远是yī,“不”永远是bù,不管它在词里读第几声。这样同一批成语的拼音才是稳定的、可比的。

做朗读、做儿歌、做语音合成,你要的才是语流变调。“一”在去声前读阳平(“一目了然”里其实读 yí),“不”在去声前读阳平(“不尴不尬”读 bù 还是 bú 有讲究)。这块规则复杂,各家处理也不同。

绝大多数项目只需要前者。用拼音库批量生成:

from pypinyin import lazy_pinyin, Style def build_pinyin(word): full = " ".join(lazy_pinyin(word, style=Style.TONE, errors="ignore")) plain = "".join(lazy_pinyin(word, style=Style.NORMAL, errors="ignore")) abbr = "".join(lazy_pinyin(word, style=Style.FIRST_LETTER, errors="ignore")) return full, plain, abbr

errors="ignore"很重要,遇到不认识的字符直接跳过而不是抛异常,17494 条批量跑的时候不会因为某一条失败中断。

多音字是这里唯一的真难点。“长”在“长驱直入”里读 cháng,在“长幼有序”里读 zhǎng;“重”在“重整旗鼓”里读 chóng。单字查音一定会错一批。解决办法是给整个成语建一个读音纠正字典,把查错的条目手工改掉。我一般这么干:先跑一遍,把所有含多音字的成语导出来(判断依据是该字有多个常用读音),人工过一遍,只改错的那部分。多音字常用字就那么几十个,成语里涉及的可能一千多条,一小时能过完,一次投入长期受益。

MULTI_PRONOUN_FIX = { "长幼有序": "zhang3 you4 you3 xu4", "重整旗鼓": "chong2 zheng3 qi2 gu3", # 只列真正查错的,别把对的也写进去 }

提示:别用“把拼音字段当成排序键”这种偷懒做法直接排,多音字错一条,排序就会错一位,用户翻到那一页会觉得很怪。排序可以用拼音,但必须建立在纠正过的数据上。

3.3 建库、导表、做索引进内存

数据清完、拼音补完,就该落库了。SQLite 是这类规模的最优解,单文件、无服务、Python 自带驱动、跨平台,还能直接打包发给别人。

import sqlite3, json from pathlib import Path conn = sqlite3.connect("idioms.db") conn.execute("PRAGMA journal_mode=WAL") # ... 建表语句见 2.1 rows = [] for w in words: full, plain, abbr = build_pinyin(w) rows.append((w, w, len(w), w[0], w[-1], full, abbr, None, "subdict", 0)) conn.executemany( "INSERT OR IGNORE INTO idiom " "(word, word_norm, length, first_char, last_char, pinyin, pinyin_abbr, variant_of, source, freq) " "VALUES (?,?,?,?,?,?,?,?,?,?)", rows, ) conn.commit() print(conn.execute("SELECT COUNT(*) FROM idiom").fetchone())

INSERT OR IGNORE配合UNIQUE约束,是去重的最后一道保险。哪怕前面清洗漏了,也不会插进重复数据。

导出给前端用的话,JSON 比 SQLite 合适,因为浏览器里没 SQLite(虽然有 WASM 版,但为一万条数据上 WASM 太重)。压缩一下体积:

data = [{"w": r[0], "p": r[6]} for r in conn.execute("SELECT word, pinyin_abbr FROM idiom")] Path("idioms.min.json").write_text( json.dumps(data, ensure_ascii=False, separators=(",", ":")), encoding="utf-8", )

字段名缩写到一两个字母、去掉空格,这一套下来 JSON 能比原始文本还小。实测 17494 条走完这套,产物在 200KB 上下,服务器 gzip 后传到浏览器不足 100KB,完全可以和主包一起加载,连懒加载都不用做。

3.4 质量校验:跑一份能被复现的体检报告

数据清洗最怕的是“感觉干净了”。我的做法是每次清洗完固定跑一份统计报告,存成文件跟着数据一起版本管理。下次改规则,对比两份报告,就知道动了什么。

def report(conn): q = lambda sql: conn.execute(sql).fetchall() print("总条数:", q("SELECT COUNT(*) FROM idiom")[0][0]) print("长度分布:", q("SELECT length, COUNT(*) FROM idiom GROUP BY length ORDER BY length")) print("首字种类:", q("SELECT COUNT(DISTINCT first_char) FROM idiom")[0][0]) print("尾字种类:", q("SELECT COUNT(DISTINCT last_char) FROM idiom")[0][0]) print("重复词条:", q("SELECT word, COUNT(*) c FROM idiom GROUP BY word HAVING c>1")) print("首字Top20:", q("SELECT first_char, COUNT(*) c FROM idiom GROUP BY first_char ORDER BY c DESC LIMIT 20"))

这份报告里我最关注两个数:首字种类和尾字种类。前者决定接龙的“可选起点”有多丰富,后者决定“能接下去的面”有多宽。如果尾字种类明显少于首字种类,说明这份数据里以生僻字结尾的成语偏多(“醍醐灌顶”的“顶”还好,“龃龉”的“龉”就基本接不动),做接龙产品时就得提前设计“无解提示”的 UI,而不是等用户卡住再临时补。

更精细的一个指标是死路率:尾字在整个首字集合里不存在的成语占比。算法很简单,把所有首字做成集合,然后统计尾部不在集合里的条数。我这份数据跑下来在两成上下,看着高,但这是正常现象——大量成语以“止”“尔”“焉”“哉”这类虚字或生僻字收尾,本来就不适合当接龙的起点。知道这个数,你设计游戏规则时就会考虑“换一个字”或者“允许跳过一次”,而不是傻等。

4. 场景落地:这17494条能撑起什么

4.1 成语接龙:别急着建图,先想清楚查询模式

拿到首尾字字段,很多人的第一反应是建一张有向图,17494 个节点、几十万条边,然后跑路径搜索。这个思路在小规模数据上其实过度设计了。

想清楚接龙的真实查询模式:用户给出一个成语,你要返回若干条以它的尾字开头的成语。这是一个单跳查询,用first_char索引一条 SQL 就完事:

SELECT word FROM idiom WHERE first_char = ? AND word <> ? ORDER BY freq DESC, RANDOM() LIMIT 20;

word <> ?是为了防止“自接”(同一个成语首尾同字的情况,像“防不胜防”这种,理论上能自己接自己,但游戏里会显得很怪)。ORDER BY freq DESC优先给常见成语,避免一上来就抛“龃龉”这种劝退词。没有频次数据时,用长度或者随机都可以。

什么时候才需要真建图?两种场景。一是要做“最长接龙链”这类离线计算,那是图算法问题;二是要做“给一个起点,找出所有能接满 N 步的路径”,那也得建。但只要你的交互是一步一步来的,按需查询永远比建图划算——建图的时候你得处理几十万条边的内存和构建时间,而按需查询的响应时间在毫秒级。

真要建图也有轻量做法:不做全量边,只建“尾字 → 前驱列表”和“首字 → 后继列表”两个倒排索引,本质上还是查表,只是提前把分组结果缓存了。这个优化在有索引的数据库上收益很小,只在前端纯内存实现时才值得。

做前端纯内存版时,构造方式是这样:

// 首字倒排:{ "胸": ["胸有成竹", ...], ... } const byFirst = new Map(); for (const it of data) { if (!byFirst.has(it.w[0])) byFirst.set(it.w[0], []); byFirst.get(it.w[0]).push(it.w); } function nextWords(word, limit = 20) { const pool = byFirst.get(word[word.length - 1]) || []; return pool.filter(w => w !== word).slice(0, limit); }

一次构建,之后每次查询都是哈希查找,几万条数据下延迟可以忽略。这就是前面算过“能全量塞进内存”带来的红利。

4.2 文本校对:用白名单反查“疑似成语误用”

这是个我觉得被低估的用法。手上有全量成语表,等于有了一份“合法成语白名单”,反过来就能做一层很实用的校对:找出一段文本里形似成语但不在表内的四字组合。

思路是滑动窗口取所有四字组合,逐个查表,查不到的先不报警,而是再做一步相似度判断——如果它和表内某条只差一个字,而且那个字是同音字,那大概率是错别字。比如“一愁莫展”(应为“一筹莫展”)、“默守成规”(应为“墨守成规”)、“迫不急待”(应为“迫不及待”),这三组里错的字都同音,规则能抓到。

def check_idiom_like(text, word_set, homophone_map): hits = [] for i in range(len(text) - 3): seg = text[i:i+4] if seg in word_set: continue for j in range(4): for repl in homophone_map.get(seg[j], []): cand = seg[:j] + repl + seg[j+1:] if cand in word_set: hits.append((seg, cand, i)) return hits

同音字表可以用拼音反查构建:把表内所有字的拼音建个倒排,读音相同的字归一组。这个数据从前面补的拼音字段里直接能生成,不用额外依赖。

这个方案的定位要说清楚:它是提示,不是判定。文本里出现四个字的普通短语(“今天天气”)也会被扫到,所以必须加上“相似才报”的门槛,宁漏勿错。真要做成语误用检测,还得往上看语义搭配——那是另一个量级的工作,靠词表做不到。

4.3 分词器的自定义词典:让四字成语不被切碎

中文分词器处理四字成语时经常切得很碎,“胸有成竹”可能被切成“胸/有/成竹”或者“胸有/成竹”。这在搜索召回、关键词提取、文本摘要里都会出问题——用户搜“胸有成竹”,切碎了就召不回。

通用做法是把成语表转成分词器能吃的自定义词典格式,一行一条,格式通常是词 频次 词性:

胸有成竹 3000 i 杯弓蛇影 2000 i 醍醐灌顶 2000 i

词性给i(idiom)是个约定俗成的做法,很多分词器支持这个标签。频次给高一点,是为了压过默认词典里的歧义切分——如果你的分词器倾向于把“胸有成竹”切成两个两字词,把频次调到几千就能纠正过来。频次别给太离谱(比如十几万),会干扰其他词的切分边界。

代码上,有的库支持load_userdict(路径)一次性加载,有的要循环add_word:

import jieba jieba.load_userdict("idioms_dict.txt") # 或者动态添加 for w in words: jieba.add_word(w, freq=2000, tag="i")

一万多条循环添加大概几秒,放在服务启动时做一次完全可以接受。但如果你的服务有冷启动时间要求,就换成文件加载,快得多。

加完之后有个副作用要注意:成语被粘成一个词之后,全文检索的召回率会下降——用户如果只记得成语里的两个字(搜“成竹”),可能就搜不到了。所以正规做法是双路:分词用成语词典保证不被切碎,索引用双字切分(bigram)保证模糊可召回。这两者不冲突,是配合关系。

4.4 题库生成:挖空容易,保证答案唯一难

成语填空是这份数据最直接的变现路径之一,但坑比想象中多。

第一个坑是挖哪个字。全随机是下策——“一”字在成语里出现频率极高,挖掉它之后“x目了然”“x见钟情”“x鸣惊人”这种,答案几乎必然不唯一。我的策略是优先挖非首字、非高频虚字的位置,且优先挖实词字:

STOP_CHARS = set("一不之无其所以于而为也矣焉哉") def pick_blank_positions(word, k=1, rng=None): cand = [i for i, ch in enumerate(word) if ch not in STOP_CHARS and i > 0] if not cand: cand = [i for i in range(1, len(word))] return rng.sample(cand, min(k, len(cand)))

第二个坑是干扰项。四个选项从哪来?从表里随机取同长度的成语不行,因为选项之间长度不同、结构不同,一眼就能排除。好的干扰项要满足:同字数、同位置的字尽量不同、且替换之后的结果不在词表内(否则会出现两个正确答案)。

def make_distractors(word, pos, word_set, pool, n=3): right = word[pos] out = [] for cand in pool: # pool 是同长度的成语池 if len(out) >= n: break new = word[:pos] + cand[pos] + word[pos+1:] if cand[pos] != right and new not in word_set: out.append(cand[pos]) return out

这段逻辑里new not in word_set那一句是整个题库质量的命门。没有它,你会生成出“胸有成竹”和“胸有成林”同时在选项里的题——用户选哪个都对,体验直接崩。

第三个坑是异形词。前面标记的异形词如果不排除,题库会出现两条一模一样答案的题。所以在出题前先把variant_of非空的条目过滤掉。

4.5 给大模型做输出校验的白名单

这两年很实用的一类需求:让模型“用一个成语概括下面这段话”,但模型时不时吐出“一言难蔽”“深有感触之”这种不存在的四字组合。有了这 17494 条,就可以做一层零成本的后置校验——模型输出的四字串,不在表里就打回重生成。

def verify_idiom_output(text, word_set): segs = re.findall(r"[\u4e00-\u9fff]{4}", text) unknown = [s for s in segs if s not in word_set] return unknown

注意别做过头。模型输出里正常的四字短语(“本次会议”“相关工作”)也会被扫出来,所以校验范围要收窄——只在“明确要求输出成语”的任务里用,或者只对含成语引导词(“可谓”“正所谓”)后的四字串做校验。

另一个用法是把成语表当约束词表塞进提示词。但要注意 17494 条全塞进去,光词条就七万字,token 成本不低,而且模型的注意力会稀释。更实际的做法是:先按场景做子集(比如只取高频的 500 条),或者先做检索再塞——用户输入一段话,用 embedding 召回 20 个候选成语塞进提示词,让模型从中选。这比全量硬塞效果好,也更省钱。

5. 踩过的坑和排查速查表

5.1 常见问题速查

现象大概率原因排查方法处理
导入后条数比预期少很多UNIQUE约束挡住了归一化后重复的条目查INSERT OR IGNORE前后 count 差正常现象,去重是预期行为
某些成语查不到,肉眼看着表里有康熙部首/兼容区字符,码位不同len(w)和逐字符ord比对全量跑 NFKC 归一化
条数对但长度统计里出现 5 字、9 字括号里的异形词没拆看长度分布的长尾条目正则拆括号,异形词单列
CSV 用表格软件打开全是乱码缺 BOM,软件按本地编码猜用文本编辑器打开看是否正常导出加encoding="utf-8-sig"
数据库插入报错或出现问号字段编码不是 utf8mb4看表的字符集定义建表时显式指定 utf8mb4
拼音排序结果乱多音字注音错抽查含“长/重/行/乐”的条目建读音纠正字典
接龙点几下就卡死尾字生僻,无后继条目统计尾字不在首字集合里的占比UI 加“换一条”“提示”兜底
前端渲染出现方框字体不覆盖扩展区汉字用扩展 B 区汉字测一遍子集字体或降级提示
分词切碎成语自定义词典未加载或词频太低对单条成语分词看结果提高词频,加载词典
题库出现两个正确答案干扰项替换后构成合法成语校验替换结果是否在表内加not in word_set判断

5.2 几条不太好写进文档的经验

第一条,永远保留原始文件和中间产物。我习惯建三个目录:raw/放原始导出、clean/放清洗后、out/放最终产物,每一步都用脚本生成而不是手工改。手工改一次,下次数据更新你就再也复现不出来了。17494 条这个规模,人工改个几十条完全可行,但一定要把那几十条改动落到一个可执行的补丁脚本里。

第二条,清洗规则要写“为什么”,不要只写“做什么”。我见过太多脚本里一行w = w.replace("(", "")没有任何注释,半年后没人知道为什么加。写成# 括号内的异形词单独入库,主词条去掉括号避免长度判断出错,维护成本天差地别。

第三条,先做数量少的验证集。不要一上来就跑全量 17494 条。先手动挑 50 条覆盖各种情况的(四字常规、三字、长条目、带括号、繁体、生僻字、零宽字符),在这个小集合上把规则调对,再跑全量。全量跑一遍看统计,比在 50 条上看细节要快得多。

第四条,接龙类产品的体验命门不在算法在兜底。用户卡在“龉”字上不是数据问题,是你没给出口。留一个“换一个字”按钮,或者干脆在出题时就避开死路尾字——从尾字能接上的条目里挑起手词。这个改动只需要一句 SQL 的条件,但用户满意度差别巨大。

第五条,数据版本要跟着代码一起管。这份词表半年后可能更新到 18000 条,你得知道新增了哪些、删掉了哪些。用 Git 管文本文件(配合.gitattributes避免自动换行转换),每次更新跑一次 diff,新增条目单独审一遍——新词条往往是新坑,比如突然冒出一批带标点的长条目。

最后分享一个我在这个项目里长期保留的小习惯:把清洗脚本写成可重入的,输入原始文件、输出最终产物,中间不留任何需要人工干预的断点(除了那个读音纠正字典和一个异形词人工确认清单)。这样下次数据源换版本,我只需要跑一条命令,五秒钟之后就知道这次更新值不值得跟进。数据活起来的关键不是清洗得多干净,是能不能低成本地再来一遍。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/31 7:53:15

Redis高并发实战:缓存三兄弟与秒杀系统解决方案

1. Redis核心问题解析与黑马点评实战经验作为一款高性能的内存数据库&#xff0c;Redis在现代互联网架构中扮演着越来越重要的角色。我在参与黑马点评项目开发过程中&#xff0c;深刻体会到Redis在解决高并发、分布式系统问题时的独特价值。本文将系统梳理Redis在该项目中的典型…

作者头像 李华
网站建设 2026/10/31 7:53:14

X-Plane 11飞行数据监控实战:从UDP取数到落地评分卡

简介&#xff1a;StableApproach 是面向 X-Plane 11 飞行模拟器用户的飞行数据监控&#xff08;FDM&#xff09;辅助工具&#xff0c;主要服务于希望评估进近稳定性、改进操纵精度的飞行爱好者与模拟飞行训练者。它围绕稳定进近这一核心场景&#xff0c;记录并分析速度、高度、…

作者头像 李华
网站建设 2026/10/31 7:52:58

物流管理系统前后台设计:运单状态机、计费规则与轨迹查询实现

简介&#xff1a;这份资源是面向Java Web初学者与课程设计者的物流管理系统完整源码包&#xff0c;覆盖前台客户下单、查询、货物跟踪与后台订单处理、仓库管理、调度分配等核心模块&#xff0c;可帮助读者理解前后台分离的业务流程与系统架构。压缩包共约2000个文件、65.88MB&…

作者头像 李华
网站建设 2026/10/31 7:52:43

异步编程核心原理与实践困境解析

1. 异步编程的本质与价值 异步编程是一种让程序在等待耗时操作&#xff08;如I/O、网络请求&#xff09;时不阻塞主线程的执行模式。想象你在餐厅点餐——同步方式就像站在柜台前等厨师做完才离开&#xff0c;而异步方式则是拿到取餐号后先去干别的事&#xff0c;等餐好了再来取…

作者头像 李华
网站建设 2026/10/31 7:51:31

动态库热加载技术原理与跨平台实现详解

1. 动态库热加载技术概述 动态库热加载是一种在程序运行时动态更新代码逻辑的技术手段&#xff0c;它允许开发者在不重启主程序的情况下替换或更新功能模块。这项技术在游戏开发、插件系统、长期运行的服务端程序中有着广泛的应用场景。 我第一次接触这个技术是在开发一个24小…

作者头像 李华
网站建设 2026/10/31 7:51:28

堆栈数据结构在背包问题求解中的应用与实现

1. 背包问题与堆栈数据结构的基础认知 背包问题作为经典的组合优化问题&#xff0c;在计算机科学领域有着广泛的应用场景。简单来说&#xff0c;它描述的是在给定容量限制下&#xff0c;如何从一组物品中选择最有价值的子集。这个看似简单的问题背后&#xff0c;却蕴含着深刻的…

作者头像 李华