尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

中文分词自建词典生成:从语料到dic文件的完整流程

中文分词自建词典生成:从语料到dic文件的完整流程 开篇先交代一下背景这是“幽冥大陆”系列项目的第九十七期记录对应整个仙盟体系里的“练气期”阶段。所谓练气期说白了就是整个系统刚起步、能跑但不追求极致的第一版。这一期主要是在做分词服务的前置训练工作核心产出是分词用的词典文件也就是标题里说的“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标签和不可见字符。用正则表达式把[^]直接抹掉然后逐字符过滤掉换行符、制表符之外的不可见控制字符。这里要注意一个细节去除控制字符时不要把正常的中文标点也误删了否则“丹炉、药鼎”会变成“丹炉药鼎”词边界信息丢失。第二步是统一全角半角。中文文本里的英文字母、数字、标点经常是全角形式比如 “”“”“”。需要用编码转换把所有全角字符映射回半角。这一步对后续词频统计很重要因为“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[i1])] 1 left_context[chars[i1]][chars[i]] 1 right_context[chars[i]][chars[i1]] 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_size200000): counter Counter() candidates sorted(candidates, keylambda x: (len(x), x), reverseTrue) 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, encodingutf-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(wordswords) app.on_event(startup) def load_dict(): global trie trie Trie() with open(output.dic, r, encodingutf-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机器默认编码是GBKPython读取时又没有显式传encoding参数导致字符串解析全乱。最终的修复方案非常简单但是极其重要所有语料文件在进入训练流程前统一转码为UTF-8读取代码里所有open函数都带上encodingutf-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时效率高非常多。最后再补充一点关于命名的心得项目代号“幽冥大陆”内部分模块用“东方仙盟”做域版本用“练气期、筑基期、金丹期”来标记其实是很方便的工程管理方式。每次迭代都知道当前阶段的目标底线是什么不会因为追求炫技而无限扩大范围。练气期阶段的底线就是“词典生成流程稳定、服务可用、问题可回溯”。只要保住这条底线后面的优化才真正有地基可打。
返回列表