☰
中文分词实战:从语料生成自定义词典的完整流程
2026/10/22 8:09:32 网站建设 项目流程

我最近在折腾幽冥大陆的文本处理服务,被卡在中文分词这件事上好几天。说白了,通用分词器处理日常文本还行,一旦碰到仙侠题材的内容,一律抓瞎。东方仙盟那帮人把游戏剧情、角色名、地名、技能名全塞进语料库之后,我做的分词服务把"练气期修士"切得乱七八糟,一开始我还以为是自己代码写错了,查了两天发现根子在词典上。

这篇文章想记录的是:我怎么把一套分词服务训练的源码跑通、把自定义词典(dic)从无到有生成出来的完整过程。项目进度现在还在"练气期"阶段,很多坑我刚踩完,正好趁热写下来。如果你在做中文文本处理、游戏文案检索、小说内容分词这类工作,这篇东西能帮你省不少调试时间。下面直接进正题。

1. 分词服务为什么非要一套自己的词典

1.1 通用分词器在仙侠文本面前的崩溃现场

先把问题说清楚。开源分词器(我用的是结巴分词)默认自带的核心词典大概有几万条常用词,日常新闻、聊天记录这类文本切起来没什么大问题。但幽冥大陆的文本属于典型的强领域内容,词表分布跟通用语料完全不是一回事。你猜"练气期"会被切成什么?

实测结果:默认词典下,"练气期修士踏入幽冥大陆"这句话被切成了"练/气/期/修士/踏入/幽冥/大陆"。练气期被拆得七零八落,幽冥大陆被切成幽冥和大陆,整体语义全乱。这不是分词器笨,是它的词典里根本没有"练气期"这个完整概念——它只能靠内部算法去猜,猜出来的结果自然惨不忍睹。

更麻烦的是一堆专有名词:角色姓名比如萧尘、罗刹女,地名比如葬魂渊、忘川谷,功法名比如九转玄天功、太虚剑诀,丹药名比如凝气丹、筑基丹。这些词在通用词典里全不存在,用户搜索或者做词频统计的时候就会极其痛苦。比如玩家搜"筑基丹",如果分词把"筑基"和"丹"切开,检索系统查"筑基丹"三个字就连不上,最终结果就是召回率爆降。这种事在内容型产品里属于直接影响用户体验的硬伤。

1.2 统计分词靠的不是算法,是词典

很多人有个误区,觉得分词效果好是算法牛的功劳。实际上统计类分词器的核心是词典,算法只是辅助。结巴的工作方式可以简单理解成:先在词典里找最长的词进行切分(最大匹配),切不出来的片段再交给HMM模型靠字与字的转移概率去猜。

所以有一个公认的结论:领域词典质量决定分词效果的上限,算法只决定接近这个上限的程度。你给分词器喂一套覆盖了领域词、标好了词频的词表,它立刻就能切对;你不喂,它就是把HMM模型训练到天荒地老也猜不出"太虚剑诀"是一个词。这就好比让一个只读过现代汉语词典的人去读仙侠小说,每个字都认识,但"洞府""灵根""法相"连在一起,他根本不知道说的是什么。

明白了这个前提,后续所有工作都围绕一件事:把幽冥大陆语料里的领域词找出来,算好词频,生成成可加载的自定义词典文件。这一步做完,分词服务的效果能明显上一个台阶。

1.3 这套"训练源码"到底在训什么

标题里说的"训练源码",指的其实是词典的生成流水线。在NLP领域,从语料中自动提取词表并计算词频的过程,技术上叫无监督词典构建,本质上跟语言模型训练的第一步是同一个动作:统计共现频率。只是我们不训练神经网络,而是用统计方法把候选词筛出来,所以叫"训练源码"也说得过去。

流水线大概是这样的:语料采集 → 文本清洗 → n-gram频次统计 → 新词发现(PMI互信息) → 词性标注 → 人工审核 → 词频平滑 → 输出dic文件 → 加载验证。每一步都直接影响最终词典的可用性,后面我会按顺序把每一步拆开讲。

提示:这一步想的不是"我要加哪些词",而是"如何让机器从语料里自动告诉我哪些词值得加"。目标不同,做法完全不一样。

2. 词库从哪来:语料采集与新词发现的实操路径

2.1 语料来源与预处理

我的输入是三块:幽冥大陆的世界观设定文档、主线剧情对话脚本、道具技能清单。加起来大约300万字的中文文本,全部是内部产出,没有版权问题。如果你要做类似的事情,最理想的语料就是你真实业务场景里的文本——游戏项目的剧情库、小说的全量正文、社区的用户发帖,越贴近真实使用越有效。反过来,如果你拿人民日报语料去生成游戏词典,那出来的词表一定跟实际业务脱节,白折腾。

拿到语料之后第一件事不是做词频统计,而是清洗。清洗主要干三件事:去掉标点符号(分词器的标点切分跟词典无关,留着只会污染n-gram统计)、统一全角半角(中文引号和英文引号混在一起会制造大量无效片段)、去掉重复的模板文本(比如系统提示框的固定文案,不然这些高频噪声会污染词频统计)。清洗完的语料我用换行符按段落存成一个纯文本文件,编码固定UTF-8,这是后边所有统计的数据基础。

2.2 新词发现的两种可靠手段

接下来要从语料里找出"人眼认识、机器词典没有"的领域词。我用两个方法组合,双保险。

第一个是基于频次和边界熵的n-gram统计。原理很简单:一个真正的词在语料里一定会重复出现,而且它的左右邻居是丰富且不固定的。我统计所有长度2到8的连续字符串,保留出现次数超过阈值(我用的阈值是5次)的候选。边界熵用来过滤"之、的、了"这类把无关字符黏起来的脏候选。比如"的丹"这种片段,左边永远是"的",边界熵极低,系统会自动把它淘汰掉。

第二个是基于点互信息PMI的词语搭配强度计算。PMI衡量的是两个字同时出现的概率比各自独立出现的概率高多少。公式是log2( P(x,y) / (P(x)P(y)) )。这个值越高,说明两个成分黏在一起形成词的可能性越大。比如说在语料里"葬魂"这两个字单拎出来可能哪都不挨着,但它们一起频繁出现,PMI就会冲到十几,远超阈值。

两种方法筛出来的候选词取交集,再用一个基于词边界信息的分值做排序,能得出一个相当靠谱的初始候选表。我实际从300万字语料里筛出了大概1.2万个候选,这一步把大量噪声挡在了门外。

2.3 人工审核是绕不开的一环

筛出来的1.2万候选不可能直接进词典,里面充斥着碎片和错误切分。比如"入幽冥""气期修""的丹"这种明显是截断出来的垃圾串,统计模型分不清,但人一眼就能看出来。我按字数和词性做了几轮过滤,最后把剩下的大约6000个候选导成表格,分给东方仙盟文案组的同事人工过了一遍。

这一步跟算法调优一样重要。人工审核主要做三件事:删掉明显错误的碎片词、补充统计模型漏掉的人名地名(比如某个只出现了一次但必须作为专名处理的角色)、给每个词确认词性标签(人名nr、地名ns、机构名nt、其他专名nz)。别想着跳过这步,统计模型可以缩小范围,但不能替代人对业务的理解。尤其是那种出场率极低但搜索权重极高的角色名,只有人知道该保。

2.4 词性标注与筛选原则

在给候选词标词性的时候,我总结出三条实用原则:

  • 专有名词优先保障:人名、地名、功法名、道具名,这些是检索和下游任务最需要的,词频可以人为提高。
  • 通用动名词别乱加:像"修炼""突破"这种词,通用词典本来就有,不用重复加,加了反而可能跟默认词冲突,造成不必要的匹配歧义。
  • 长度控制:2到6个字是我这边的合理区间。超过6个字的短语,比如"东方仙盟练气期弟子",应该让分词器切成长度更合适的组合,硬塞进词典容易让整句切分成一个超长词,损失语义单元,下游做关键词提取时还会拿不到有效片段。

注意:词典不是越全越好,更不是词越长越好。词典的目的是帮助分词器识别领域词边界,不是替代语法分析。过度自定义的词典会降低分词泛化能力,这是个很容易踩的坑。

3. dic文件格式细节与词频计算原理

3.1 自定义词典的标准格式

以结巴为例,自定义词典的格式是每行一个词条,三列用空格分隔:

幽冥大陆 100 n 东方仙盟 80 nt 葬魂渊 50 ns 练气期 120 n 萧尘 60 nr 筑基丹 45 n 九转玄天功 30 nz

第一列是词语,第二列是词频(出现次数),第三列是词性。词频字段可以省略,省略之后分词器会自己估算一个默认值,但我强烈建议你自己算好填进去,因为词频直接影响切分的优先级。分词器做最大匹配时,如果同一个位置能切出多种组合,它会优先选择总词频更高的路径。词频设置不合理,词典效果直接打折。

文件编码必须是UTF-8且不带BOM。这一点我栽过跟头,后面问题排查部分详细说。换行符建议统一用LF,不要混用CRLF,跨平台部署的时候能省很多事。文件名后缀用.dic还是.txt都可以,加载器看的是文件内容格式,不认扩展名。

3.2 词频的科学计算方法

我在生成词典脚本里对词频做了两层处理。第一层直接用语料中的原始出现次数,这个是最客观的。第二层做平滑:原始频次低于5的词,统一抬升到10,避免加载后因为频次过低在动态规划里输给通用词的组合切分。比如"凝气丹"在语料里出现3次,如果不做平滑,分词器可能更倾向切成"凝气/丹"(因为凝气和丹各自的词典频次很高),做了平滑之后"凝气丹"整体的词频才有竞争力。

再说一个细节:自定义词典的词频跟默认词典的词频要放在同一个量级才有意义。结巴的默认词频是从大规模语料统计出来的,动辄上万,如果你自定义词全都写个位数,加载了也压不过默认词。所以我在生成脚本里加了归一化步骤,把自定义词典的词频统一除以语料规模的系数,再乘上默认词典的平均词频,保证两边可比。这套经验是从一次线上失误里学到的,具体现象我在第5节会讲。

3.3 词性标签的选择

词性这块不需要太复杂,做文本分词够用就行。我主要是四个值:nr表示人名,ns表示地名,nt表示机构名或团队名,nz表示其他专有名词。普通名词甚至不标,留空或者写成n都可以。词性标签如果标错不会导致程序崩溃,但下游如果接命名实体识别,就容易被标签误导。所以人工审核那一步里,词性确认不能省。

多词性词条的处理也要留个心眼。有些词在幽冥大陆文本里出现多个词性,比如"太虚"既是地名前缀也是功法名的组成部分。我一开始只标了一个词性,结果下游实体识别把地名场景里的"太虚"全当成了功法。后面的做法是同一个词写多行,分别标不同词性,让下游自己根据上下文选择。这个细节在这类题材的词库里很常见,处理好了能省很多返工时间。

4. 训练源码核心实现:从语料到dic文件

4.1 整体代码结构

我自己写的这版训练源码分三个模块,跑起来比较清爽:

  • corpus_reader.py:负责读语料、清洗、分段。
  • word_extractor.py:负责n-gram统计、PMI计算、候选筛选。
  • dic_generator.py:负责词性写入、词频平滑、输出最终dic文件。

主入口是一个main.py,按顺序调用三个模块。下面我把核心逻辑贴出来,代码不复杂,关键点我都加了注释。之所以拆成三个文件而不是一个脚本糊到底,是因为前两步经常要单独调试——清洗规则的增删和新词发现的参数实验频率远高于预期,拆开之后改哪块都不会影响其他模块。

4.2 候选词提取的完整代码

# word_extractor.py import math from collections import defaultdict from typing import List, Tuple def count_ngrams(text: str, min_n: int = 2, max_n: int = 8): """统计文本中所有n-gram的出现次数""" freq = defaultdict(int) for n in range(min_n, max_n + 1): for i in range(len(text) - n + 1): gram = text[i:i + n] # 过滤包含空格或特殊符号的片段 if gram.strip() == gram and len(gram.replace(" ", "")) == n: freq[gram] += 1 return freq def pmi_score(word: str, char_freq: dict, gram_freq: dict, total: int) -> float: """计算两个字符之间的点互信息""" if len(word) != 2: return 0.0 p_xy = gram_freq.get(word, 0) / total p_x = char_freq.get(word[0], 0) / total p_y = char_freq.get(word[1], 0) / total if p_xy == 0 or p_x == 0 or p_y == 0: return 0.0 return math.log2(p_xy / (p_x * p_y))

这段代码的核心是三个数据结构:gram_freq存所有n-gram的频次,char_freq存单字频次,pmi_score函数计算双字组合的相互预期强度。有一点要提醒:PMI对低频组合很敏感,两个各自出现次数极低的字凑在一起,PMI会虚高。所以使用时必须加"两个字各自频次不能太低"这个前置条件,我这边是把单字频次低于20的组合直接排除。原理上讲,PMI在数据稀疏时方差很大,不设门槛会混进来一堆几乎不出现的伪词。

4.3 候选过滤与筛选

def filter_candidates(gram_freq: dict, pmi: dict, min_freq: int = 5): """综合频次和PMI过滤候选词""" candidates = [] for word, freq in gram_freq.items(): if freq < min_freq: continue if len(word) == 2 and pmi.get(word, 0) < 3.0: continue if len(word) > 2: # 长词要求内部任意双字组合都具备一定黏性 inner = [word[i:i+2] for i in range(len(word) - 1)] if min(pmi.get(x, 0) for x in inner) < 2.5: continue candidates.append((word, freq)) return candidates

这个过滤策略是我调了几轮之后定下来的。双字词看各自PMI,要求不低于3;多字词要求内部每一对相邻双字的PMI都不能太低,等于要求整个串从头到脚都有黏性。这样能有效过滤掉"之乎者也"黏出来的脏词。实际跑下来,这个条件的误杀率很低,漏网的也基本能被后面的频次阈值拦掉。PMI阈值这里没有放之四海皆准的数,跟语料规模和领域都有关系,我的建议是先放宽跑一轮看候选质量,再逐步收紧到误杀率可接受为止。

4.4 生成dic文件

# dic_generator.py import codecs from typing import List, Tuple def generate_dic(candidates: List[Tuple[str, int, str]], base_freq: int, output: str): """ base_freq: 归一化后的基础词频, candidates: [(word, raw_freq, pos), ...] """ with codecs.open(output, "w", encoding="utf-8") as f: for word, raw_freq, pos in candidates: # 低频词抬升,高频词按比例缩放 freq = max(10, int(raw_freq * base_freq)) f.write(f"{word} {freq} {pos}\n")

base_freq这个参数我解释一下。语料规模是300万字,原始词频最大值大概在5000多,而结巴默认词典里很多常用词频在几万到几十万。为了让自定义词典的词频不至于被默认词典全面压制,我把所有原始词频放大一个倍数,目标是让领域核心词(比如幽冥大陆)的词频落在跟默认高频词差不多的档位。这个倍数不是拍脑袋定的,是先跑了一遍默认分词看哪些词总是被切错,然后针对性地调到效果稳定为止。

4.5 加载验证与效果对比

生成完dic文件后,加载进去验证:

import jieba jieba.load_userdict("ycm_dic.dic") text = "练气期修士萧尘踏入幽冥大陆,在葬魂渊偶遇罗刹女。" print("/".join(jieba.lcut(text))) # 加载前: 练/气/期/修士/萧尘/踏入/幽冥/大陆/在/葬魂/渊/偶遇/罗刹/女 # 加载后: 练气期/修士/萧尘/踏入/幽冥大陆/在/葬魂渊/偶遇/罗刹女

前后对比非常直观。加载前断断续续,加载后整个句子干干净净,专名全都在。这一步建议写成自动化断言,每次生成词典后跑一批固定测试集,防止后续改参数把效果改回去。测试集里要覆盖人名、地名、境界、功法、丹药、剧情高频句这几类,每类至少10条。我的做法是把测试文本和期望切分结果放在一个配置文件里,生成词典后跑一遍对比,有任何一条不一致就报错,比肉眼抽检靠谱得多。

5. 常见问题与排查实录

5.1 问题速查表

现象原因解决办法
词典加载后分词没变化编码带BOM或词频过低转成UTF-8无BOM,检查词频量级
自定义词总是切不开文件多了换行符或空格检查行尾、列分隔符是否标准
某些专名还是被切开词频压不过默认组合调高该词词频并做归一化
一个长词把整句吞了超长词入词典限制词长在6个字以内
词性标签全丢了下游用了别的分词器加载确认加载器和格式对应

5.2 编码BOM的教训

我第一次生成的dic文件是用文本编辑器另存为的UTF-8格式,结果加载进去分词结果毫无变化。查了很久才发现文件开头多了三个字节的BOM头,分词器在读取第一行词条时把BOM字符跟第一个词黏在一起当成词了,导致第一个词条失效。后面的词条虽然能加载,但所有词的频次都被默认估算覆盖,等于自定义词典白挂。解决方案很简单:代码里用codecs.open输出时指定utf-8不带BOM,或者直接用二进制模式写。这个问题极其隐蔽,因为它不报错,只是默默失效。

5.3 词频量级不一致的坑

前面说的词频归一化问题,我是在一次线上切分事故里真正体会到的。当时我把自定义词频直接用语料原始次数写进去,看起来没问题,但"筑基丹"在语料里只出现45次,默认词典里"筑基"和"丹"分别有数万频次。分词器的动态规划一看总词频,"筑基/丹"的组合得分远高于"筑基丹",于是怎么调都不生效。后来我把自定义词频乘以一个放大系数,让领域核心词进入跟默认高频词同一量级,问题瞬间解决。所以记住:词频不能直接用原始统计值,必须考虑跟默认词典的可比性。这个坑在效果上表现为"加词了但没用",特别容易让人误判成代码没调对。

5.4 加载性能与内存占用

dic文件变大之后,加载时间会线性上升。我这里6000个词条的词典加载时间在毫秒级,但如果做全量词表扩充到几万条,就要考虑加载策略了。我的建议是:服务启动时一次性加载到内存,中间不热更新;词表版本通过配置中心的版本号控制,发新版词典时灰度加载,确认切分结果稳定后再全量切换。别小看这个细节,词典在线上直接动态替换容易导致正在处理的请求切出来一半旧词一半新词,脏数据就是这么来的。

6. 练气期阶段的收尾心得

这篇文章记录的是我这边第一版词典生成的全过程。从实际效果看,加载生成出的dic文件后,幽冥大陆文本在搜索场景的召回率提升明显,分词的准确率也从原来不到六成涨到了现在基本可用的水平。

我自己最有感触的三点:第一,分词这事,词典永远比算法重要,业务词的收集整理是绕不开的苦功夫,指望靠调参一劳永逸是不现实的;第二,词频不是一个可以随手填的数字,它直接决定分词器在相同位置的切分选择,必须按可比性去做归一层级;第三,人工审核流程一定要保留,算法能帮你把一万个候选压缩到六千,但最后那一刀人眼裁切才是最准的,尤其在专名和低频词上,人眼对业务的理解是不可替代的。

最后再分享一个后续扩展的小技巧:这套词表的生成脚本可以原封不动复用到新资料片的文本上,只要把语料源换成新内容就行。如果哪天文档量级涨到几千万字,还可以把n-gram统计换成词向量聚类的方式做候选召回,效果会再上一个台阶。这一版练气期词典就算正式出关了,下一步无非是继续迭代——词典这东西,永远没有做完的一天。

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

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

立即咨询