
幽冥大陆修行日志更新到第九十七回东方仙盟的练气期弟子们终于开始炼制自己的第一件法器——分词服务。在许多人的认知里分词这事儿无非是拿个现成库调个接口跑出结果就完事了。可真正上手之后才会发现一个能从语料里训练出生词、生成定制词典dic的分词服务和只会拿通用词表硬切的服务差距就像练气期修士和完全没有气感的凡人一样明显。这篇内容想说的就是我从头到尾做分词服务训练源码 dic 生成这套链路的完整思路从语料清洗、候选词统计、阈值过滤到最终把词典加载进在线分词服务每一步踩过的坑和调参心得都摊开讲。这套东西适合谁适合所有还在练气期阶段、刚接触中文分词的朋友也适合已经在做搜索、推荐、后台文本处理但老觉得固有分词器不懂本领域黑话的工程师。只要你能拿到一批带领域属性的文本这篇文章就能帮你把属于自己的词典炼出来。1. 明确目标练气期弟子先学会养词典1.1 词典在分词链路里到底卡在哪一环分词服务看起来简单输入一段话输出一串词可它内部往往同时跑着好几层逻辑先有最基础的词典匹配再做基于统计概率的动态规划选最优路径最后可能还要接一层新词发现乃至命名实体识别。词典在这个链路里不是可有可无的配置文件它直接决定了初始候选集长什么样。候选集如果缺词后面任何高深的算法都无能为力因为你连这个组合原本是个整体都不知道。我见过不少团队分词效果一不好就急着换分词器从 jieba 换到 HanLP再换到自研模型结果问题根本不在算法上而是通用词典里压根没有他们领域的词。比如你在幽冥大陆世界观下做文本检索幽冥大陆东方仙盟练气期这些词如果不在词典里就会被切得七零八落下游的标签抽取和向量检索自然全乱套。所以对练气期阶段的工程师来说最值得投资的第一件事不是研究模型结构而是把数据养好、把词典养起来。词典就是分词的丹田丹田不稳再花哨的招式都发挥不出来。1.2 通用词表不够用领域词典从哪来通用词典当然不差覆盖了日常语言里绝大多数通用词但领域词是它的盲区。每个领域都有自己的黑话、人名、地名、特殊概念这些词要么是长尾词要么是跨词组合通用词表不可能收录完整。更麻烦的是同一个词在不同领域的切分方式可能完全不同。从这部分开始就要引入训练语料的概念。我理解的分词服务训练并不是一定要跑一个多么高深的神经网络而是用朴素但有效的统计方法从你在真实业务场景里收集的大批量文本中挖掘出高频且有独立性的字符串组合再经过阈值过滤、词性标注和人工抽检生成一份属于你自己业务的用户词典也就是 dic 文件。通用词典和自建词典的定位不一样通用词典负责兜住日常用语自建词典负责抓住领域内的自定义表达。两套配合起来分词服务才真正算得上练气大成。接下来的每一节都是围绕怎么从语料到 dic这个核心流程展开的。2. 语料清洗与预处理词典的原料车间2.1 语料按这个标准来收很多人第一步就栽在语料上要么数据量太少要么脏数据太多。做词典生成语料就是原材料原材料不干净后面统计出来的候选词自然一堆噪声。我自己的经验是领域语料起码要有 100 万字起步低于这个量级很多低频但真实存在的领域词根本冒不出来。语料来源按场景分三档如果你做小说或内容平台直接拿站内正文就行如果是问答社区或客服场景就导用户问题、标准答案、知识库条目如果是搜索场景那就把用户搜索词沉淀日志拿出来这类数据尤其宝贵因为搜索词本身高度精炼几乎是天然的短语候选集。我见过有些团队嫌日志脏不愿意处理实际上搜索日志经过清洗后对词典生成的贡献远大于随便爬来的所谓同类文章。有一个容易被忽略的标准语料要尽量贴近线上真实输入分布。什么意思如果线上用户输入的是口语化、碎片化文本就不要只用规范书面语去训练词典否则生成出来的词在真实文本里依然识别不准。2.2 清洗脚本与分句逻辑拿到原始语料之后第一步永远是清洗。我习惯把清洗和分句放在一起做因为后续统计 n-gram 时依赖的是干净的句子序列而不是整段长文本。清洗分四步走第一去标签。原始文本里常有 HTML 标签、Markdown 标记、特殊符号这些对分词训练没有任何帮助全部直接剔掉。第二统一编码。强烈建议一步到位统一成 UTF-8省得后面乱码问题。我在处理老数据时遇到过 GBK 编码的文本不转码直接跑统计出来的候选词全是乱码排查半天才发现是编码问题。第三去噪声字符。保留中文字符、数字、英文字母和常用中文标点其余的控制字符、表情符号、特殊符号一律清除。注意这里不是把英文和数字删掉而是把它们保留下来作为候选词的组成部分比如练气期第97回这种文本第97回本身值得成为一个词典条目。第四按标点切分句子。中文分句通常以句号、感叹号、问号、逗号、分号作为边界切完句子之后太短的片段比如长度小于 2 的直接丢弃。我给一段可以直接用的 Python 清洗代码import re from collections import Counter, defaultdict def clean_and_split(raw_text): # 去标签 text re.sub(r[^], , raw_text) # 保留中英文、数字和常用标点 text re.sub(r[^\u4e00-\u9fa5a-zA-Z0-9。、“”‘’\n], , text) # 按中文标点做分句 sentences re.split(r[。、]|\n, text) result [] for sent in sentences: sent sent.strip() if len(sent) 2: result.append(sent) return result这套代码简单粗暴但胜在够用。如果你语料里本身有大量英文缩写比如产品名、API 名建议保留字母和数字之间的边界规则避免把DBSCAN聚类这种混合词错误合并或错误拆散。清洗做完之后我建议顺手按句子量做个分布统计。假如发现某一段文本反复出现相同的长句子可能是模板内容或广告噪声可以在预处理阶段直接按相似度去重避免这些重复句子把候选词的频次拉高。3. 训练源码核心候选词发现与 dic 生成3.1 判断这是不是一个词的两把尺子从语料里挖候选词本质上是回答一个问题一段连续的字符组合凭什么说它是个独立的词我的判断标准通常看两把尺子内部凝聚度和边界自由度。内部凝聚度用互信息来衡量。简单说如果幽冥大陆这四个字总是作为一个整体出现而幽冥大陆各自单独出现的场景也很稳定那这四个字符绑在一起的额外信息量就很高说明它们内部足够抱团不应该随便切开。真正起作用的指标是最小点式互信息也就是把任意一个切分点切开后计算整个组合出现概率相对于两个子串独立出现概率的增益取所有切分点里最小的那个值。这个值越高说明整个字符串被任何位置切开都会受伤也就越像是一个完整词。边界自由度用左右熵衡量。左邻字集合越丰富右邻字集合越丰富说明这个字符串能出现在各种不同的语境里边界非常自由是独立词的概率就越大。比如练气期前面可以接进入突破渡过漫长的后面可以接的修士阶段弟子左邻右邻五花八门这种词就非常干净反过来如果一个组合只出现在固定搭配里比如如获至后面永远只有宝那它就更可能是短语碎片而不是独立词。两把尺子缺一不可。只看互信息会把那些永远绑定出现但只是句法固定搭配的垃圾串选进来只看左右熵又会把那些单独使用很频繁但内部结构松散的并列短语放进来。我的经验是先按最小点式互信息过滤掉一批抱团不够紧的候选再按左右熵的最小值过滤掉边界僵化的候选剩下的质量就很稳了。3.2 可复用的候选词统计源码接下来是实际训练源码的核心部分。为了兼顾灵活性和易读性我用 Python 直接实现一个基于 n-gram 统计的候选挖掘脚本流程分为三步统计所有 bigram 和 trigram 的频次、计算每个候选的互信息和左右熵、按阈值筛选并输出 dic。import math from collections import defaultdict, Counter def build_ngram_counter(sentences, max_n3): ngram_counter Counter() for sent in sentences: length len(sent) for n in range(2, max_n 1): for i in range(length - n 1): gram sent[i:in] ngram_counter[gram] 1 return ngram_counter def calc_pmi(gram, ngram_counter, total_ngrams): p_gram ngram_counter[gram] / total_ngrams min_pmi float(inf) for split in range(1, len(gram)): left gram[:split] right gram[split:] p_left ngram_counter.get(left, 0) / total_ngrams p_right ngram_counter.get(right, 0) / total_ngrams if p_left 0 or p_right 0: return 0.0 pmi math.log2(p_gram / (p_left * p_right)) min_pmi min(min_pmi, pmi) return min_pmi def calc_entropies(gram, sentences, ngram_counter): left_ctx Counter() right_ctx Counter() for sent in sentences: idx 0 while True: pos sent.find(gram, idx) if pos -1: break if pos 0: left_ctx[sent[pos-1]] 1 if pos len(gram) len(sent): right_ctx[sent[poslen(gram)]] 1 idx pos 1 def entropy(counter): total sum(counter.values()) if total 0: return 0.0 e 0.0 for count in counter.values(): p count / total e - p * math.log2(p) return e return entropy(left_ctx), entropy(right_ctx)这个实现不是为了在大数据量下追求极致性能而是把逻辑讲透。如果你拿到的语料在千万字级别以上建议用字典累加代替字符串 find 扫描或者直接上多进程按分片统计再合并 Counter。我在处理千万级语料时就是按 1 万行一个分片做并行统计最后再合并频次速度能提升好几十倍。注意p_left 和 p_right 在 bigram 统计里可以直接查表但在 trigram 计算时子串可能不在同一个 n-gram Counter 里比如计算幽冥大陆这种四字词时需要同时统计过 bigram 和 trigram。上面的代码里 build_ngram_counter 已经同时灌入了 2-gram 和 3-gram所以子串一般在表里能查到。如果查不到直接按 0 处理也就是直接跳过这样能避免除零问题。3.3 阈值怎么定dic 文件怎么落盘算出指标之后真正决定词典质量的反而是阈值怎么定。我的默认起点是词频下限至少出现 5 次设定太低会把偶然拼接的噪声放进来最小点式互信息大于 3.0这是一个经过多领域验证比较稳妥的起点左右熵最小值大于 1.5如果语料偏小可以放宽到 1.0但会漏掉一些边界不丰富的领域词。阈值不是死的。语料越干净、体量越大这些阈值可以适当提高语料越口语化、噪声越多阈值反而要比理论值更严格。我常给的建议是第一次先按默认阈值跑一遍把结果按频次从高到低扫一遍看看排在前面的候选到底是真正的领域词多还是明显无意义的噪声多然后决定是上调还是下调。筛选完成后就是落盘生成 dic 文件。格式上我沿用最通用的词语 频次 词性三列结构中间用空格分隔UTF-8 编码每行一个词。关于词性刚起步时可以直接统一标 nz专有名词如果后续需要做命名实体识别再按规则细化为 nr人名、ns地名、nt机构名。比如幽冥大陆可以标 ns东方仙盟标 nt但这些规则需要额外的词表支撑一开始不必追求精细。def generate_dic(candidates, output_pathcustom_dic.txt): with open(output_path, w, encodingutf-8) as fout: for gram, freq, min_pmi, min_entropy, pos in candidates: fout.write(f{gram} {freq} {pos}\n)落盘这一步看起来简单却是我踩过坑最多的地方。最典型的问题是 Windows 环境默认会往文件里写 BOM 头加载分词器时第一个词会带一个不可见字符导致词典里的第一个词永远匹配不上。解决方法是写文件时指定 encodingutf-8-sig或者写完之后用命令行工具去掉 BOM。还有换行符问题分词器加载词典时一般按行读取行尾不要有额外空格否则词条会被拆出空格导致匹配失败。4. 让分词服务真正吃上这份 dic4.1 dic 格式约定与词性处理一个合格的 dic 文件除了内容有效格式还要能让分词器直接识别。通用分词器对自定义词典的格式约定大同小异都是文本文件一行一个词条列之间用空格或 Tab 分隔。第一列是词语本身第二列是词频第三列是词性。第三列在部分分词器里是可选字段但我建议不要省因为词性不光影响词性标注结果还会影响后续命名实体识别和句法分析。词频这一列直接理解成这个词在当前领域里的出现频次。分词器加载词典后会用这个频次去参与后续的最优路径计算。如果你给的频次太大比如直接写成训练语料里的几万次分词器就可能把包含这个词的句子路径全部倾向到这个词上导致东方仙盟虽然被正确切出来了但东方绝对不再单独出现当文本里出现东方这个地名时就会被强行合并成东方仙盟的一部分。所以我在实际项目里会把频次做一个归一化处理设定一个上限比如超过 1000 的按 1000 写入低于 5 的不要。这样一个平衡既能保证领域词被正确识别又不会破坏分词器原本的统计概率结构。4.2 加载方式与词频对切分路径的影响以 jieba 为例加载自定义词典非常简单import jieba jieba.load_userdict(custom_dic.txt) text 幽冥大陆上一段尘缘东方仙盟弟子正在修炼练气期心法。 print(jieba.lcut(text))执行之后分词器会优先把幽冥大陆东方仙盟练气期这些词识别出来。但如果你的自定义词典词频写得过大这段文本可能会被切得过于膨胀举个例子如果幽冥大陆词频是 1000它和上一段的边界就不太稳定分词器可能把幽冥大陆上切在一起。这种问题不在少数我调试过很多次最后发现把每个词条的频次都压到和通用词典同一量级反而效果最稳定。另一种常用加载方式是把自定义词典追加到默认词典数据里。这需要你在服务启动时读取 dic 文件用 add_word 接口逐词添加。和 load_userdict 相比add_word 更灵活可以动态调整词频和词性还能在加载时统一做归一化。两种方式没有绝对的优劣看你的工程约束。如果词典文件在训练流水线里频繁重新生成就用 load_userdict 保持简单如果需要在内存里做更多定制就在初始化时逐词 add_word。4.3 在线服务热更新的工程姿势分词服务一旦上线词典不可能永远不变。领域术语更新很快比如幽冥大陆剧情推进新增了地名、功法名、人物名如果每次都要重启服务才能生效线上体验就很差。我常用的热更新方案是后台一个定时任务每隔几分钟检查 dic 文件的最新修改时间如果发现文件有变化就在子线程里重新调 load_userdict然后切换新旧词典引用。但这里有一个并发问题分词器在加载词典的过程中如果主线程正好在分词可能出现短暂的不一致。大多数时候 jieba 这种实现因为加载过程只是更新内存字典速度很快影响不明显。为了稳妥我习惯在热更新时加一个读写锁让正在切词的请求用旧词典快速结束切词请求排队量很小几乎无感。这样既保证了词典更新时效性又不会让线上分词的 QPS 抖掉一个尖峰。另外提醒一句热更新只适用于处理量不大、对一致性要求不那么极端的场景。如果你做的是高并发毫秒级接口词典更新就不能每五分钟一次而应该走灰度发布流程先在预发环境加载新词典对比分词语料效果之后再平滑上线。词典这个东西属于一次升级、全局影响的类型越是看似不起眼的小改动越要在更新流程上稳一点。5. 实操排坑与调优心法5.1 常见问题速查表我把实践过程中最常遇到的几个问题整理成一个速查表按现象、原因、解法三列直接对照省得大家在同一条路上反复折腾。常见问题现象原因解法自定义词典不生效加载后分词结果没有任何变化dic 文件带 BOM 头或编码不是 UTF-8用 utf-8-sig 重新生成文件检查首个词条是否有不可见字符词被错误合并东方仙盟被切成东方仙盟没问题但东方单独出现时永远被并进去dic 中词频远大于通用词典词频干扰了最优路径计算对频次设上限统一压到 1000 以内或改用 add_word 并动态归一化词典里全是噪声词候选词列表里大量无意义组合比如的了是我清洗不彻底或者阈值太低提高最小词频到 10 以上提高左右熵阈值到 2.0对候选词做抽检过滤生成候选词漏掉真实领域词练气期没有出现在候选词里语料量不够或频率低于 min_freq 而被滤掉扩大语料单独把低频但用户反馈明确的词写成人工补充表合入 dic多音字词性标注全错同一个词在不同语境词性不同dic 里却只有一个词性词性标注规则过于粗暴词典只负责候选和频次词性交给上线前的 NER 模型或规则层二次修正第 1 行的 BOM 问题真的可以称得上是新手村第一杀手。排查方法很直接用十六进制编辑器看一眼 dic 文件开头三个字节如果是 EF BB BF就是 BOM直接去掉重存。我写过不下十次类似的坑现在已经条件反射了凡是词典加载不生效第一个查看项永远是编码和 BOM。5.2 三条亲测有效的调优经验第一条经验跟通用词表做个交集去重。生成候选词之后先把候选和通用词典比对一遍把通用词典里已经收录的词删掉。留存下来的这部分才是真正属于领域的新词增量。这个步骤看似多此一举却能显著减少 dic 体积让它只解决通用分词解决不了的问题避免两套词表在加载时打架。第二条经验对拿不准的词用词频低一点写入。我自己筛选候选词时会分三类非常肯定是领域词的高置信候选、看起来像是但不太确定的低置信候选、明显是噪声的垃圾候选。高置信候选保留高词频低置信候选写入低词频垃圾候选直接丢弃。这样即使低置信候选偶尔切错对整体分词结果的影响也小方便后期通过线上抽样来决定要不要删掉。第三条经验每次生成新 dic都要做一轮回归对比。流程是准备固定数量的测试句子用旧词典切一遍再用新词典切一遍把切分结果逐条对比重点关注那些被强制合并的词。我一般会随机抽 100 条线上真实输入文本统计合并词数量的变化。如果某些词的合并反而破坏了语义说明词典里的词频还是设置得太激进需要回调。这三条经验的核心逻辑其实很简单词典生成不是一锤子买卖它是一个持续迭代的过程。每次更新词典都要把它当成一次上线变更来对待有测试、有对比、有灰度才算真正掌握了一套可以长期运行的分词服务训练链路。走到这一步语料清洗、候选统计、阈值过滤、dic 落盘、服务加载、热更新、回归对比这条完整链路就串起来了。我个人在实际操作中的体会是分词服务的修炼之路练气期真正要练的不是多复杂的算法而是对数据和细节的掌控力。一个新词从语料里被发现到最终稳定地切分正确背后是无数细微的工程决策在起作用。建议你也找一份自己领域内的语料先按这套流程跑通一遍再把阈值和词频调到自己的业务节奏上。等你亲眼看着幽冥大陆东方仙盟这些词被自己的分词服务稳稳切对的时候就会明白这一趟练气期的功课没有白做。