从标题写作角度看不少人对中文分词的第一反应是“这有什么好写的不就是拿词典切一刀吗”。但等你真把“零基础入门NLP之搭建中文分词工具”这个项目做完你会发现事情没那么简单切法对不对、歧义怎么消、新词怎么发现、性能怎么提升每一个环节都有值得深挖的东西。这篇博文就按我自己从零搭一个中文分词器的过程来写尽量讲清楚每一步为什么这么做以及实际踩过的坑。整个项目只依赖Python标准库不需要安装任何第三方包代码量也不大。读完你可以得到一个可运行的分词器能处理常规文本也能理解后续那些工业级分词工具背后的核心逻辑。如果你正准备入门NLP或者需要给自己的小项目加一个分词模块这篇内容可以直接参考。1. 中文分词到底在解决什么问题1.1 中文比英文多出来的“分水岭”英文文本天然用空格把单词隔开了你做词频统计、做文本匹配按空格split一下就能得到不错的词序列。中文不一样“我爱自然语言处理”是一串连续字符中间没有任何边界机器第一眼看到的就是“我”“爱”“自”“然”这样的单个汉字。你说它是一句话还好但要进入词频统计、关键词抽取、文本分类这类下游任务就必须先把连续的字串切成一个一个“词”。分词结果直接决定下游效果。举个很直白的例子用户搜索“自然语言处理”如果分词器把它切成“自然/语言/处理”搜索引擎就能把它和包含这三个词的文档关联起来如果切成“自然语/言处/理”这个查询基本就废了。再比如情感分析“这个手机屏幕不错但电池不行”如果“不错”和“不行”没有被正确识别为两个独立的词模型很难学到正确的情感极性。所以分词这件事虽然只是NLP流程里的第一步却是一整条流水线的起点。起点错了后面所有环节都会跟着错。1.2 分词的三大难点很多初学者以为分词就是“拿词典查一查”真正动手才发现有三大难点绕不过去。第一个是歧义。同一个句子按不同方式切割都可能成立。比如经典的“研究生命科学”可以切“研究/生命/科学”也可以切“研究生/命/科学”两种结果语义完全不同。再比如“乒乓球拍卖完了”可以切“乒乓球拍/卖/完了”也可以切“乒乓球/拍卖/完了”。机器不像人没有上下文常识它只能靠概率和规则在多个可能切分里选一个“最合理”的。第二个是未登录词也叫OOVOut Of Vocabulary。词典再大也总有收不进去的词比如人名、地名、产品名、网络热词。你在一段新闻里看到“某网红主播今天在直播间说……”“某网红”很可能不在词典里而分词器如果识别不了它就会切成“某/网/红/主/播”整个词义就散了。第三个是粒度的把握。“词”本身没有绝对的标准边界比如“中华人民共和国”可以作为一个整体词也可以拆成“中华/人民/共和国”。同一个词在不同应用场景下需要不同的分词粒度。搜索引擎希望粗粒度便于召回机器翻译往往喜欢细粒度便于对齐。没有一套统一答案。明白了这三个难点你再看市面上的各种分词方案就不会觉得是玄学因为所有方案本质上都是在解决这三个问题。2. 方案选型三种主流分词思路怎么选2.1 词典匹配简单直观适合冷启动最朴素的分词思路就是查词典。给定一段文本从某个位置开始尝试匹配词典里长度最长的词匹配上就切一刀然后从词尾继续往后扫。这就是“最大匹配法”。它完全不依赖任何标注语料只需要一个词表和一组匹配规则。它的优点是简单、快速、可控性强。你可以随时往词典里塞领域词让它更贴合自己的业务。缺点也很明显歧义消解能力弱完全处理不了未登录词。但我不建议零基础的同学一上来就否定这个方案。因为词典匹配涉及的分词框架、匹配顺序、歧义消解策略是后续一切高级方案的地基。把地基打牢后面理解HMM、CRF会轻松得多。2.2 统计模型把分词当成“打标签”统计模型换了一个思路与其在词典里找词不如把分词看成一个“给每个字打标签”的任务。常见的标签体系是BMES——B表示词首、M表示词中、E表示词尾、S表示单字成词。给定“我爱自然语言处理”模型给每个字打标签我/S 爱/S 自/B 然/M 语/M 言/E 处/B 理/E然后按标签把字拼起来就能还原出词序列。这种思路下模型不再依赖固定词典它从语料里学到的是“字的组合规律”因此对一定的未登录词有识别能力。HMM和CRF是这一类里最经典的模型。缺点是需要一份标注好的训练语料语料规模和质量直接决定效果。2.3 深度学习方案性能好但成本也高再往上是基于深度学习的方案比如BiLSTM-CRF、基于预训练模型的序列标注方法。它们能捕捉更丰富的上下文语义在通用语料上的精度往往更高尤其擅长处理复杂歧义和未登录词。但代价也高需要大量训练数据、GPU资源、模型部署流程而且推理速度比词典匹配慢几个量级。对于零基础练手项目或者对实时性要求高的中间件场景没必要一上来就上这套方案。我个人的建议是第一步用词典匹配把流程跑通理解分词的痛点第二步用统计模型解决部分未登录词问题深度学习内容可以当作进阶方向了解不必在入门阶段硬啃。3. 手写一个基于词典的分词器3.1 词典准备与加载手写分词器第一步是准备词表。你可以从开放词库下载一份基础词典也可以把平时积累的高频词整理成一个文本文件每行一个词。初版不用贪多词汇量在几万级别就足够做实验。这里有一个问题值得注意词表质量比词表数量更重要。我试过直接用网上的超大词库结果里面混入大量错词、繁体词、生僻词反而把正确切分干扰了。所以拿到词库后建议做一轮清洗去空行、去特殊字符、去长度异常的噪声词。词典加载用Python的set结构就够了。查询时间复杂度是O(1)对初版来说完全没问题。def load_dict(path): word_set set() max_len 1 with open(path, r, encodingutf-8) as f: for line in f: word line.strip() if not word: continue word_set.add(word) if len(word) max_len: max_len len(word) return word_set, max_len多说一句max_len是最大词长。中文词语大部分在2到4个字之间5个字以上的词也有但数量少。设置最大匹配长度时不需要太大通常5到6就够太大会增加无效匹配次数。3.2 正向最大匹配实现正向最大匹配的规则一句话就能说清从文本左侧开始在当前位置尝试匹配词典中最长的词匹配成功切出这个词并跳到词尾匹配失败把当前位置的单字当成一个词切出再往后挪一位。class ForwardMaxMatch: def __init__(self, word_set, max_len5): self.word_set word_set self.max_len max_len def cut(self, text): result [] i 0 n len(text) while i n: matched False for L in range(min(self.max_len, n - i), 0, -1): word text[i:i L] if word in self.word_set: result.append(word) i L matched True break if not matched: result.append(text[i]) i 1 return result用一段文本实测一下。假设词典里有“研究”“研究生”“生命”“科学”“自然”“语言处理”输入“研究生命科学”正向最大匹配会优先尝试“研究生”而不是“研究”于是切出研究生 / 命 / 科学这就是“歧义”的一个经典体现。正向最大匹配的缺陷在于它只贪图“当前窗口最长”完全不看后面的字搭配起来是否合理。对于偏正结构“研究生命科学”这类句子它就容易选错。3.3 逆向最大匹配实现既然从左往右容易出错自然会想到“从右往左”会怎么样。逆向最大匹配就是从文本末尾开始往左匹配最长的词。class ReverseMaxMatch: def __init__(self, word_set, max_len5): self.word_set word_set self.max_len max_len def cut(self, text): result [] i len(text) while i 0: matched False for L in range(min(self.max_len, i), 0, -1): word text[i - L:i] if word in self.word_set: result.append(word) i - L matched True break if not matched: result.append(text[i - 1]) i - 1 result.reverse() return result逆向最大匹配在“研究生命科学”这个例子上也会出问题它同样倾向于匹配“生命”之类的长词。但它在另一类句式上表现更好比如“南京市长江大桥”这类从左往右容易让人误读“市长”的文本逆向后“大桥”先被匹配出来整体结果往往比正向合理一点。单一方向的匹配本质都是“目光短浅”所以实战中很少有人只用单向匹配。3.4 双向最大匹配与歧义处理双向最大匹配的思路更稳妥同时用正向和逆向各切一遍再根据规则选择其中一个结果。常用规则有三条正向结果和逆向结果词数不同取词数更少的那个词数相同取单字数量更少的一个按上面规则仍无法决策默认取逆向结果为什么词数更少更好因为能合并成词的情况下词数少通常意味着切分更“整句”单字碎片越少说明分词质量越高。class BidirectionalMaxMatch: def __init__(self, word_set, max_len5): self.fmm ForwardMaxMatch(word_set, max_len) self.rmm ReverseMaxMatch(word_set, max_len) def cut(self, text): f_res self.fmm.cut(text) r_res self.rmm.cut(text) if len(f_res) ! len(r_res): return f_res if len(f_res) len(r_res) else r_res f_single sum(1 for w in f_res if len(w) 1) r_single sum(1 for w in r_res if len(w) 1) if f_single ! r_single: return f_res if f_single r_single else r_res return r_res双向匹配比单向明显稳但也不是万能的。它仍然是启发式规则不是语义理解碰上复杂的交集型歧义照样出错。所以工业级分词器往往不再只用这种“硬规则”而是引入统计语言模型来判断哪个切分更自然。这也是下一节“未登录词识别”和“统计语言模型”的切入点。4. 进阶给分词器加入未登录词识别4.1 未登录词比想象中更常见词典匹配方案最大的硬伤就是未登录词。我用初版分词器跑了一段真实的新闻标题语料里有个主播的ID叫“某可爱”词典里没有结果被切成“某/可爱”。单独看每个字都对放在整句里意思全变了。中文的未登录词来源主要有几类人名、地名、产品名、网络热词。人名有“某伟”“某小芳”这种组合任何静态词典都收不全网络热词则随时在变化今天还在流行“某圈”明天就被新词替代了。所以一个实用分词器必须有一套机制来处理这类词。4.2 通过规则识别时间、数字和人名最简单的OOV处理是做规则过滤。比如数字和量词的组合可以先用正则把“2024年”“第3季度”“220元”这些片段整体抽出来不进分词流程因为数字类表达天然成词规则非常稳定。人名识别可以设计“姓氏触发词表”。中文姓氏集中在几百个常用字里比如“王”“李”“张”等。扫描到姓氏后向后看1到2个字如果组合出现频率高就可以把它识别为人名候选。这类规则不完美但对付新闻语料里的常见人名已经够用。要注意的是规则不要做得太激进。我一开始给“某个地名人名”做了超强规则结果把“东方之珠”这类正常词也切碎了。规则是双刃剑宁可漏一点也别误伤太多正常词。4.3 用互信息发现网络新词想更通用地发现未登录词一个实用思路是基于“互信息”的新词发现。互信息的直观理解是两个相邻的字同时出现得越频繁越不像偶然越可能是一个词。比如“某”和“平台”两个字“某”后面可能跟着“人”“事”“物”“平台”前面可能跟着“大”“平”“台”但“某平”组合出现频率极高且上下文环境相对固定那么“某平”就可能是候选新词这里只是示意实际词形要按语料统计。from collections import Counter def pmi(texts, target_pair): pair_cnt Counter() left_cnt Counter() right_cnt Counter() for text in texts: for i in range(len(text) - 1): pair_cnt[text[i:i 2]] 1 left_cnt[text[i]] 1 right_cnt[text[i 1]] 1 total_pairs sum(pair_cnt.values()) a, b target_pair p_ab pair_cnt[target_pair] / total_pairs p_a left_cnt[a] / sum(left_cnt.values()) p_b right_cnt[b] / sum(right_cnt.values()) if p_a 0 or p_b 0: return 0.0 import math return math.log2(p_ab / (p_a * p_b))p_ab / (p_a * p_b)大于1说明两个字的共现比独立出现更常见。你把语料里所有相邻二字的PMI算一遍取PMI较高的结果作为候选词再结合出现频次过滤就能发现一批词典里没有的新词。这个方法在真实项目里有多强我拿一批近一年的网络评论语料做过实验发现“某热梗”“某表情包”这类词典外高频词靠PMI基本都能捞出来。它唯一的缺点是需要语料规模足够大否则统计不稳定。5. 效果评估与调优实战5.1 用一套小测试集给分词器打分搭好分词器之后第一步不是继续加功能而是建立一个测试集。没有测试集你改一个规则根本不知道是变好还是变坏。自己建测试集不需要太大200到300个句子就够。我建议混入几个来源新闻报道、日常聊天截图转写、商品评论、产品说明书。每一类句子手工切好词存成“标准答案”。评估指标选最常用的精确率、召回率和F1。对分词任务来说“词”就是预测单元。def evaluate(gold, pred): gold_set set(gold) pred_set set(pred) correct len(gold_set pred_set) precision correct / len(pred_set) recall correct / len(gold_set) f1 2 * precision * recall / (precision recall) return precision, recall, f1注意我在这里用“集合”评估是比较粗的方式忽略了词在句中的位置。更严格的做法是计算“连续词序列”级别的匹配但入门阶段用集合算性价比最高。你至少知道自己的改动是让分词更准了还是更歪了。5.2 词典维护与更新策略在词典匹配体系里词典不是一次性产物而是需要持续迭代的资产。我在实际维护中总结出一套简单流程每次用分词器处理完一批新语料都人工抽看一部分分词结果把切错的词记录下来分门别类处理。是词典缺词就加词是歧义消解错误就检查有没有可能通过加词消除歧义还是需要交给统计模型处理。加词也讲究时机。有段时间我在词典里加了大量网络热词结果分词器在正式文本里把“打卡”这两个正常词全部打包连“打卡下班”和“每天打卡学习”中被当动词用的语境也切得僵硬。后来学乖了网络热词只加进“动态补充词典”先观察它在真实语料里的稳定性两次出现都符合预期再进正式词典。5.3 性能优化从set到Trie树当你的分词器要处理几万字的长文本时基于set的逐字匹配会明显变慢。原因在于每到一个位置都要枚举窗口长度切片后再查表字符串切片本身就是有开销的。工业界常用的优化方案是Trie树前缀树。把词典构造成一棵树从当前位置开始逐字往下走走不通就回溯。这样匹配过程是“边读边查”不需要反反复复做字符串切片和哈希查找。Trie树实现不复杂写一个基础版也不难class TrieNode: def __init__(self): self.children {} self.is_end False class Trie: def __init__(self): self.root TrieNode() def insert(self, word): node self.root for ch in word: if ch not in node.children: node.children[ch] TrieNode() node node.children[ch] node.is_end True构建好Trie之后正向最大匹配的查找过程就变成从根节点逐字符读取每读到一个is_endTrue的节点就记录一个候选词最后在候选词里选最长的一个。省去大量重复切片速度能快一个数量级。另外还有一个工程技巧先用正则把文本按标点、换行、数字分成小段再对每一段分词。因为词不可能跨越标点符号存在这个切分完全不影响正确性还能避免大文本里的边界干扰。6. 常见问题与排查实录搭建过程中我遇到过一系列问题有些问题不看报错根本想不到原因。整理成下面的速查表方便你直接对照。现象可能原因解决办法分词后单字泛滥几乎没有双字词词典过小或最大词长设置过大导致匹配失效扩充词典把max_len调整到5左右检查词表是否有编码问题英文单词和数字被切开没有对英文数字做预切分先正则抽离英文和数字片段再进行中文分词分词结果里出现“某/可/爱”这类碎片词典缺未登录词按4.2、4.3节的方法补充新词或做规则识别正向和逆向结果差异很大歧义复杂双向匹配规则失效引入Bigram统计模型辅助决策匹配速度很慢词典太大、窗口枚举过多改用Trie树加正则预分段词典加了新词后反而把好词切坏新词污染正式词典设置动态词表隔离观察多次表现后再决定是否收录繁体词和简体词混杂词典内码不统一统一文本编码切词前做繁简转换排除了这些“常规问题”之后再说一个容易忽视的坑max_len的设置。许多人为了追求“尽可能匹配长词”把max_len设成10甚至更大。结果词典里没有那么多长词每一次匹配都会白白扩大窗口长度性能下降且歧义消解不一定更好。经验值5到6就够特殊领域词可以单独抽取长词句段做规则处理。还有编码问题。中文分词器对编码极其敏感从文件里读取词库必须用统一的UTF-8一个错误的BOM头会让第一个词匹配永远失败。这类问题不会报错只会让你觉得“为什么这个词明明在词典里却匹配不上”。另一个经验来自统计模型部分。如果你想尝试在词典匹配基础上加一个Bigram语言模型辅助决策别急着收集大量外部语料。先用自己手上的测试集统计词与词的相邻共现频率在这个小模型上验证效果提升再考虑扩大语料。小语料上的反馈要快得多也更方便理解模型行为。Bigram的核心公式是一个词序列的合理性等于相邻词转移概率的乘积分词器在多个候选切分里选乘积最大的那个。这个思想本身很简洁但写成代码比词典匹配复杂不少一步一步来会更容易理解。最后再分享一点个人心得。完成这个项目之后你再去看任何成熟的开源分词工具底层思路都逃不出这几个范畴词典查词、动态规划、统计语言模型、序列标注。区别只在于它们把每层都做得很精细。而你亲手写过一遍就对分词这件事有了真正的体感——知道一个看似简单的“切词”问题背后为什么有这么多讲究。我做完这个项目后的习惯变化是遇到涉及文本处理的需求先想清楚“这个场景能容忍多少误差”再去选方案。分类任务可以容忍个别分词错误就用轻量方案搜索结果需要高质量召回就需要更复杂的统计模型参与。没有最好的分词器只有最适合当前场景的分词器。这套思考方式比单纯会调某一个库要值钱得多。