开篇先交代一下背景:这是“幽冥大陆”系列项目的第九十七期记录,对应整个仙盟体系里的“练气期”阶段。所谓练气期,说白了就是整个系统刚起步、能跑但不追求极致的第一版。这一期主要是在做分词服务的前置训练工作,核心产出是分词用的词典文件,也就是标题里说的“dic生成”。如果你正准备自己从零搭一套中文分词服务,或者想搞懂训练语料到最终词典之间的完整链路,这篇笔记应该能给你省下不少弯路。
整个“东方仙盟”体系里,分词服务属于底层公共能力。就像游戏里的基础功法,不显眼,但所有上层功能都依赖它——搜索、问答、文本标签、内容分类,全都要先过分词这一关。练气期版本的定位很简单:能对业务语料完成稳定、可解释的分词,词典可以手工迭代,不追求花里胡哨的模型调参。所以我选择了“语料统计 + 词典匹配”这条技术路线,而不是一上来就跑BERT或者条件随机场之类的模型方案。原因后面会展开说,但核心判断是:练气期阶段,可控性和可解释性比极端精度更重要。
1. 项目整体拆解:分词服务训练的是什么
1.1 从“源码训练”谈起,先搞清这阶段到底在干什么
很多人听到“训练源码”四个字,第一反应是要上深度学习模型。其实在中文分词领域,“训练”这个概念非常宽泛。练气期阶段要训练的,主要是两样东西:第一是词表本身,第二是词表里每个词条的权重值。
具体到我们的分词服务,训练流程大致是这样一条链路:原始业务语料 -> 文本清洗 -> 统计候选词 -> 计算词条权重 -> 生成dic词典文件 -> 服务启动时加载词典 -> 基于词典执行分词算法。整条链路的最终产物,就是那个被称为“dic”的词典文件。可以把它理解成一张表,记录了哪些字组合在一起可以被当做一个词,以及这个词在语料里的重要性。
为什么要走这条训练路线?因为业务场景里永远有通用分词器覆盖不好的词。比如游戏领域的“灵石矿脉”“丹炉火候”,再比如仙侠设定里特有的“筑基丹”“御剑术”,这些词在通用语料里出现频率低,甚至完全不出现。如果依赖现成分词工具,就会被切成“灵石/矿脉”或者“筑基/丹”,语义明显不对。而通过业务语料训练自建词典,恰好能解决这个问题。
另外,练气期阶段采用统计训练还有一个好处:整个流程是确定性的,给同一份语料,永远产出同一份词典。这让我在排查线上分词异常时,能非常快地定位到是语料问题、清洗规则问题还是权重计算问题,不需要猜测。
1.2 为什么不自研通用分词算法,而是“服务 + 词典”
这里需要说清楚一个定位问题。东方仙盟内部不是没有通用分词能力,而是通用能力无法满足业务侧的定制需求。比如运营同学提交了一批新活动词:“仙盟争霸”“跨服论道”“灵宠进化”,传统做法是运维手工往词典里加词,但词典一旦多起来,手工管理就是灾难:格式不统一、重复词条、权重混乱。
所以我这期做的分词服务,本质上把“训练生成词典”这件事固化成了一条流水线。服务本身只做两件事:加载dic词典,执行分词。词典的增补、迭代、回滚,全部由训练流程控制。这意味着业务同学提需求时,我只需要把对应语料扔进训练流程,重新生成词典,再触发服务热更新,整个环节半小时内能完成。
这其实是很多团队容易走偏的地方。一上来就想造一个十全十美的分词器,追求在所有测试集上刷分。但“练气期”的意思就是先修炼基础功法,把这个不停迭代词典的闭环跑通,再谈更高级的模型优化。记住一点:分词服务的核心竞争力不是算法本身的高深程度,而是词典与业务的贴合速度。
2. dic文件生成的完整原理与设计思路
2.1 dic文件的本质:它不是普通文本,是带权重的词表
在动手写训练代码之前,先得把dic文件的结构想清楚。我们用的是最经典的三列格式,用空格或者制表符分隔:词条、词频、词性。
“灵石矿脉 128 n”
词频这个字段很关键。在传统最大匹配算法里,词频并不直接影响匹配结果,但它会参与未登录词识别和歧义消解。更高阶的设计里,词频可以换算成概率,配合动态规划求全局最大概率路径,也就是最经典的“基于词典 + 概率动态规划”分词方案。练气期阶段虽然主要用最大匹配,但词典结构要预留升级空间,省得到筑基期再返工改格式。
需要特别提醒的一点是编码问题。dic文件统一使用UTF-8编码,千万别用GBK。我早期吃过这个亏,开发机Windows上另存词典时默认保存成了GBK,上了Linux服务器之后全部乱码,分词结果成了“锟斤拷”三连。这一条我会在后面的问题排查章节再详细说,但在这里先打一个预防针:代码里读取词典时,必须显式指定编码。
2.2 语料清洗是词典质量的第一道关卡
训练源码里最容易被低估的就是预处理模块。很多人觉得统计词频嘛,做个Counter不就行了?但原始语料里全是噪声:HTML标签、URL、表情符号、中英文混排、全角半角错乱。如果这些不清理干净,统计出来的候选词全是夹杂着标签碎片的垃圾词条。
我常用的清洗流程是这样的,按顺序执行:
第一步是去除HTML标签和不可见字符。用正则表达式把<[^>]+>直接抹掉,然后逐字符过滤掉换行符、制表符之外的不可见控制字符。这里要注意一个细节:去除控制字符时,不要把正常的中文标点也误删了,否则“丹炉、药鼎”会变成“丹炉药鼎”,词边界信息丢失。
第二步是统一全角半角。中文文本里的英文字母、数字、标点经常是全角形式,比如 “A”“b”“,”。需要用编码转换把所有全角字符映射回半角。这一步对后续词频统计很重要,因为“ABC”和“ABC”会被统计成两个完全不同的词条,导致词频分散。
第三步是处理网络文本特有的噪声。比如“emmmm”“hhhh”这种语气词,还有“666”这类数字串。练气期阶段我采用的策略就是保留中文字符、英文字符和数字,但规定一个词条里不能同时包含中英文混杂。这条规则很粗暴,但能挡住绝大多数垃圾词条。
2.3 候选词挖掘的统计方法:n-gram与凝固度
清洗完语料,下一步是挖掘候选词。针对领域词典训练,我不可能人工去读几千万字的语料,必须依赖统计方法自动找出“像词的字组合”。这里用到两个核心指标:内部凝固度和外部自由度。听起来玄乎,其实就是两个非常朴素的思想。
先看内部凝固度,通常用点互信息来表达。简单解释就是:字组合在一起出现的概率,远大于它们各自独立出现的概率乘积,说明这两个字之间有很强的绑定关系。比如“丹药”里的“丹”和“药”,单独在语料里出现的次数都不少,但“丹药”作为一个整体频繁出现,那它就是一个候选词。具体的公式是:
PMI(x, y) = log2(P(xy) / (P(x) * P(y)))
在实际工程里,概率直接用频率近似。P(xy)就是“xy”这个二元组出现的次数除以总二元组数,P(x)则是字“x”出现的次数除以总字数。PMI值越高,两字的绑定关系越强,我设置的阈值是经验值,一般取3.0左右作为候选词门槛。
再看外部自由度,用的是左右熵。一个词的左熵,描述的是这个词左边邻接字的丰富程度;右熵同理。如果“丹药”右边总是跟着“炼制”“服用”“配方”等一堆不同的字,说明“丹药”确实有独立的词边界,是一个自由词;反之,如果某个组合右边永远只有同一个字,那它大概率是更大词的一部分,不适合单独成词。
最典型的例子就是“仙”和“人”两个字。如果只看内部凝固度,“仙人”的PMI值可能非常高,因为这两个字在一块出现的次数很极端。但把左右熵加上去之后就会发现,“仙人”左边跟着“老”“小”“白”“得道”等各种字,右边也对应多变,两边熵都不错,所以它依然是个合格的词。而像“结丹”里的“丹”字,单独看“结丹”也是个组合,但右熵可能集中在“成”“了”等少数几个字,就需要降低它的候选等级。
两种情况要综合判断,所以我定的策略不是分别设阈值去卡,而是先把所有满足“内部凝固度达到阈值”的n-gram捞出来,按左右熵加权排序。加权公式可以根据语料特点调整,我用的比较简单:
score = PMI + log(左熵 + 1) + log(右熵 + 1)
对每个候选组合算好分数后,从高到低截取TOP N,然后与通用词典做差集过滤,去掉已经被标准词典收录的常见词。这样留下来的就是业务语料里真正有价值的新词候选。
2.4 词频权重与词典格式落盘
候选词定下来之后,还缺一个关键数据:词频。词频的计算不能直接用原始语料里该词串的出现次数。原因是n-gram统计阶段,我们把一个词内部的所有窗口都数了一遍,比如“炼丹炉”这个三元组,在统计二元组时可拆分成了“炼丹”和“丹炉”两个组合,它们的频次天然偏高,但这不代表“炼丹”一定比“炼丹炉”更常用。
我用的策略是经过一次“词槽投票”的过程:遍历语料中的每个位置,把能匹配上的所有候选词都找出来,多字词优先获得投票权,短词只能得到被长词覆盖之后剩余位置的投票权。这样每个词条的频次就不再是简单的n-gram窗口计数,而更接近真实语义边界。打个比方,就像扫地时每个垃圾只归最近的垃圾桶管,同一个垃圾不会被重复扔进多个桶。
最终落盘的dic格式保持极简原则:
丹药 16542 n 筑基丹 789 n 御剑术 1023 n 灵石矿脉 128 n第三列词性在练气期可以统一写成n(名词),但保留这个字段的位置,是因为后续如果升级到带词性的分词方案,可以直接复用同一份词典。而且同一名词条格式的兼容性好,国内几个主流分词工具都能直接用这种格式,万一以后团队想切换到其他分词器,迁移成本极低。
3. 训练源码的落地实现与核心代码解析
3.1 训练流程的代码结构设计
我把整个训练源码拆成了三个模块,职责非常清晰:
- preprocess.py:负责语料清洗,输出清洗后的纯净文本
- discover.py:负责n-gram统计、PMI计算、左右熵计算,输出候选词表
- gen_dic.py:负责词槽投票、词频统计,输出最终dic文件
模块拆分的好处是每一层都能单独测试。比如语料清洗这块,我可以单独抽几千条脏数据来跑,观察是不是所有HTML标签都被干掉了;候选词挖掘也能用一个小语料集来验证算法的敏感性,而不需要每次都跑全量训练。
3.2 语料清洗模块:先把垃圾从源头干掉
preprocess.py的核心代码不长,但每条规则都花了实打实的时间调优:
import re def clean_text(raw): # 去HTML标签 text = re.sub(r'<[^>]+>', '', raw) # 统一全角转半角 text = text.replace('\u3000', ' ') text = text.replace(',', ',').replace('。', '.').replace(';', ';') text = text.replace(':', ':').replace('!', '!').replace('?', '?') # 保留中文、英文、数字、常用标点 text = re.sub(r'[^\u4e00-\u9fa5a-zA-Z0-9,。!?、;:""''()\s]', '', text) return text这个版本没有做强语义处理,比如没有专门处理繁简体,但练气期阶段够用。遇到繁体语料,可以在清洗层叠加opencc做转换,那属于后续迭代节奏。清洗的目标非常明确:让统计层的输入尽量干净,而不是追求完美的语言规范化。
3.3 候选词发现模块:核心统计指标计算
discover.py是训练源码里含金量最高的一块。完整逻辑分四步:建立字符级别的二元组计数、计算单字频率、计算PMI、计算左右熵。
from collections import defaultdict, Counter def statistic_trigrams(texts): bigram = Counter() char_total = Counter() left_context = defaultdict(Counter) right_context = defaultdict(Counter) for text in texts: chars = list(text) char_total.update(chars) for i in range(len(chars) - 1): bigram[(chars[i], chars[i+1])] += 1 left_context[chars[i+1]][chars[i]] += 1 right_context[chars[i]][chars[i+1]] += 1 return bigram, char_total, left_context, right_context这里left_context和right_context是关键,如果只统计二元组频次而忽略了邻接字集合,后面熵的计算就没有数据支撑。注意代码里right_context用当前字作为key,记录右边出现过的字符集合,左边同理。把所有信息一次性统计完成,避免多轮遍历语料,几千万字的语料多轮遍历非常耗时。
PMI和熵的计算封装成两个独立函数,方便单测:
import math def calc_pmi(p_xy, p_x, p_y): if p_xy <= 0 or p_x <= 0 or p_y <= 0: return 0 return math.log2(p_xy / (p_x * p_y)) def calc_entropy(char_counts): total = sum(char_counts.values()) if total <= 0: return 0 entropy = 0 for cnt in char_counts.values(): p = cnt / total entropy -= p * math.log2(p) return entropy注意PMI计算时三个概率的归一化基准必须一致。我见过不少代码把P(xy)的分母用二元组总数,P(x)和P(y)的分母用单个字总数,算出来的PMI值没有可比性。这里的做法是,统一把基准对齐到“二元组出现的总次数”。P(x)理解为在所有二元组左位出现的概率,P(y)理解为右位出现的概率。换算公式为 P(x) = 左位x的总频次 / 总二元组数。这样三者的分母一致,PMI的数值才有意义,阈值3.0这个经验值才能在不同语料间复现。
3.4 词槽投票与词典生成
候选词挖掘完,进入最后一个环节:让每个位置上的词条票票落定。gen_dic.py的核心逻辑是先按词长从大到小排序候选词,然后在原文本上扫描,如果某个候选词出现在当前位置,则给它加一票,同时跳过这个词的长度,继续扫描后面。
def generate_dic(candidates, texts, vocab_size=200000): counter = Counter() candidates = sorted(candidates, key=lambda x: (len(x), x), reverse=True) for text in texts: i = 0 n = len(text) while i < n: matched = None for word in candidates: if text.startswith(word, i): matched = word break if matched: counter[matched] += 1 i += len(matched) else: i += 1 return counter每次从当前位置遍历候选词列表做startswith判断,如果要优化的可以改成字典树前缀匹配,减少无谓的字符串查找,几千个候选词规模下性能差异不大,但词表如果膨胀到几万量级,还是值得换成Trie树的。生成完计数器后,再过滤一遍词频低于阈值(比如小于5)的低频词,这些多半是噪声组合。
最终输出dic的代码要顺手完成排序和格式化,便于人眼检查:
with open('output.dic', 'w', encoding='utf-8') as f: for word, freq in counter.most_common(): if freq < 5: break f.write(f'{word}\t{freq}\tn\n')4. 分词服务的搭建与词典加载优化
4.1 选型判断:为什么先上最大匹配
词典生成完毕,接下来把它们加载进服务。在分词算法选型上,练气期阶段我没有上复杂的HMM或序列标注模型,而是选择了“正向最大匹配 + 词典树”的组合。原因简单直接:训练流程产出的词典覆盖了绝大部分业务词,剩下的边界情况,通过词典迭代持续处理即可。最大匹配算法逻辑简单,可解释性极强,出了任何分词错误都能快速回溯到词典和文本本身。
当然,纯最大匹配的缺点也很明显:歧义处理能力差。比如“研究生命起源”这个著名例子,“研究生/物/起源”是正向最大匹配的结果,但完全不是人话。练气期阶段我留了一个后门:正向最大匹配处理完后,对整句再跑一遍逆向最大匹配,两轮结果不一致的句子,标记为“疑似歧义句”,输出时走延迟策略,等待后续模型优化。
这个做法的取舍很清晰:不让歧义问题阻塞整体服务的交付,先把基本盘稳住。
4.2 用Trie树把匹配效率提上来
词典加载进内存之后,如果每次分词都遍历整个词表做字符串匹配,性能完全不可接受。所以必须用前缀树(Trie树)结构。每个词条插入Trie树时,从根节点开始按字符逐层建立分支。查询“御剑术”这个词时,只需要沿着“御”->“剑”->“术”路径访问三个节点,时间复杂度O(词长),跟词表大小完全不相关。
Trie树的Python实现可以用嵌套字典,简单直白:
class TrieNode: def __init__(self): self.children = {} self.is_word = False self.freq = 0 class Trie: def __init__(self): self.root = TrieNode() def insert(self, word, freq): node = self.root for ch in word: if ch not in node.children: node.children[ch] = TrieNode() node = node.children[ch] node.is_word = True node.freq = freq加载词典时逐行读入,把每个词条插进Trie树数组。分词时从句子当前位置开始,沿Trie树往下走,每走到一个有is_word标记的节点就记录当前位置和词长度,一直走到无法继续匹配为止,最终取能匹配到的最长词作为分词结果。
4.3 服务接口的封装与部署形态
分词服务对外暴露的形式选择了FastAPI的HTTP接口。为什么不是gRPC?因为练气期阶段调用方以内部小团队为主,HTTP接口最通用、调试最方便,浏览器里敲一个URL就能验证结果。等并发量真正上去再用gRPC替换不迟。
核心接口代码不长:
from fastapi import FastAPI from pydantic import BaseModel app = FastAPI() trie = None class SegRequest(BaseModel): text: str class SegResponse(BaseModel): words: List[str] @app.post("/segment") def segment(req: SegRequest): words = mmseg(trie, req.text) return SegResponse(words=words) @app.on_event("startup") def load_dict(): global trie trie = Trie() with open('output.dic', 'r', encoding='utf-8') as f: for line in f: parts = line.strip().split('\t') if len(parts) >= 2: trie.insert(parts[0], int(parts[1]))这个服务的启动时会一次性加载词典到内存,趁词典规模还不大,全量加载是最简单也最快的方案。但服务进程一旦运行起来,词典就不能随便改,必须要触发重新加载才能生效。所以我在接口层增加了一个“重新加载词典”的管理端点,让训练流程一键触发服务热更新。
4.4 服务内存与性能调优心得
词典加载进内存后,有几件事必须提前考虑。第一是内存占用。一个100万词条的词典,如果用嵌套字典实现的Trie树,每个节点都是一个字典对象,内存开销非常夸张,实测高峰期能占到2GB以上。练气期阶段词表在20万到50万之间还能接受,如果继续增长,要么换用数组实现的“双数组Trie”,要么把词典丢进Redis,利用其有序集合结构做前缀匹配。
第二是加载时间。50万词条的词典全量加载,Trie树插入过程大概需要10秒左右。这10秒如果放在服务启动时还没太大影响,但做热更新就意味着每次更新有10秒的阻塞期。我采用的是双缓冲方案:服务里维护一份旧Trie树引用,新Trie树在后台构建,构建完成后原子替换引用。整个更新过程对请求方完全无感。
第三是预热。第一次请求被分词的延迟会偏高,因为Python不是编译型语言,模块加载和JIT预热都需要时间。我的做法是在服务健康检查通过之前,主动把一段预置语料跑一遍分词,强制触发核心路径的预热。
5. 练气期常见问题与排查实录
5.1 编码错乱:最没有技术含量但最致命的坑
前面提过GBK导致“锟斤拷”的问题,这里说一个当时排查的完整过程。第一天训练完词典,服务部署上线,随手发了几个测试文本,结果返回的分词结果里全是乱码。我先是怀疑Linux服务器语言环境有问题,执行locale命令查看,发现LANG变量压根没设置。继续查发现整理语料的Windows机器默认编码是GBK,Python读取时又没有显式传encoding参数,导致字符串解析全乱。
最终的修复方案非常简单但是极其重要:所有语料文件在进入训练流程前,统一转码为UTF-8;读取代码里所有open函数都带上encoding="utf-8";词典文件头部加上UTF-8的BOM检测逻辑。这个三件套组合拳用一次之后就再也没犯过。顺便明确一点:线上Linux环境永远用UTF-8作为唯一编码标准,业务侧如果有GBK来源的接口文本,一律在网关层转码。
5.2 词典膨胀导致的服务内存爆炸
练气期阶段我加过一段时间的自动新词发现,每天跑训练脚本,词典规模从10万涨到100万,服务内存在一周之内翻了好几倍,频繁触发OOM。第一次遇到这种情况时我没有直接定位到词典规模,而是反复在优化Python内存模型浪费时间。
后来我统计了每个模块的内存占用,最后发现70%以上的内存都被Trie树节点结构吃掉了。嵌套字典的实现方式里,每个字符节点不仅要存children字典,还要存is_word标记和freq数字。优化方案有两个方向:一是把短词(比如1到2个字符的词条)从Trie树里摘出来,单独用哈希表存储,因为短词匹配扫描路径短,哈希表快且省内存;二是对长词建立紧凑的前缀数组,牺牲一点插入效率,但内存能压缩到原来的三分之一。最终我把这两招都用了,内存峰值从2GB降到了700MB。
5.3 新词太多,误切分正常词
自动新词发现模块最烦人的问题,不是发现不了新词,而是把正常句子里的词给切碎。比如“炼丹炉火候”这个短语,新词发现把“炉火”识别成词,结果整句切成“炼丹/炉火/候”,读起来非常怪。追根溯源是语料里“炉火”这个二元组的左右熵都达标了,它确实像一个独立词,但它和“炼丹”组合后的三元组表达反而更常见。
这类问题的解法只能在词槽投票阶段做了,给长词更高的优先级。我把vote阶段的match策略从“按候选词长度降序”改成了“按词长加权后降序”,具体加权系数用了一个简单的指数函数:len(word)的1.2次方。这样三元组竞争时更容易盖过二元组,趋势上偏向更完整的长词。效果立竿见影,误分率降了40%以上。
5.4 分词结果不一致的“幽灵”问题
还有一次很诡异的线上故障,同一个文本,在测试环境分词的输出结果和线上环境居然不一致。排查了半天,发现是两边加载的词典版本不同。训练流水线在测试环境跑出了一版新词典,但在部署时漏掉了线上数据卷的同步,导致线上还在用旧词典。这个案例的教训是:归档的时候词典文件必须带上版本号和校验值,流水线每次重新生成词典时,同时生成一个md5文件,服务的加载逻辑里保存当前词典版本号,并在接口日志中打印出来。排查此类问题的时间成本能够直接省掉一大半。
6. 练气期的总结与升级路线
写到这里,这一期的核心内容基本覆盖全了。我个人在实际操作中最大的体会是:分词服务的技术选型不一定要最前沿,但一定要让词典的迭代流程闭环。所有的算法复杂度都不会成为真正瓶颈,真正容易出问题的往往是编码、版本同步、内存管理这些工程细节。
从练气期走向筑基期,我计划做三件事。第一,把最大匹配升级为基于概率的动态规划分词,让词频权重真正参与全局路径决策。第二,把词典训练流程从离线改成增量式,支持小时级别的滚动更新。第三,引入在线反馈,让分词结果可以通过人工标注回流到训练语料中,形成持续优化的闭环。
另外分享一个小技巧:所有生成的dic文件,务必在生成时做一次通用词过滤。我用了搜狗词库和jieba默认词典做交集,把“我们”“但是”“因为”这类通用词从自定义词典里剔除掉,只保留业务词。这样不仅缩小了词典体积,也让分词结果的日志更干净,后续做人工review时效率高非常多。
最后再补充一点关于命名的心得:项目代号“幽冥大陆”,内部分模块用“东方仙盟”做域,版本用“练气期、筑基期、金丹期”来标记,其实是很方便的工程管理方式。每次迭代都知道当前阶段的目标底线是什么,不会因为追求炫技而无限扩大范围。练气期阶段的底线就是“词典生成流程稳定、服务可用、问题可回溯”。只要保住这条底线,后面的优化才真正有地基可打。