news 2026/11/4 3:05:21

中文分词实践:从词典匹配到隐马尔可夫模型的完整搭建指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
中文分词实践:从词典匹配到隐马尔可夫模型的完整搭建指南

1. 从零做分词这件事,到底图什么

先跟你说句掏心窝的话:市面上现成的中文分词工具一抓一大把,安装一个再调几行参数就能用,为什么还要自己动手搭一遍?

我的理解是,分词是NLP入门路上最避不开、也最能帮你建立"模型直觉"的一环。很多初学者习惯直接调库,结果就是jieba、pkuseg装了一堆,遇到输出不对时完全不知道问题出在字典里还是算法里。自己搭一个工具的意义不在于做得比商用库好,而在于把"切词背后的逻辑"亲手过一遍——从此再看到分词的输出,你看到的不只是词,而是一套打分机制和匹配规则在运转。

这篇文章面向的读者非常明确:Python语法刚学完、没接触过NLP的零基础同学。我会从最朴素的词典匹配讲起,一路做到带未登录词识别和简单评测的完整工具,代码全部可以直接跑。当然,商用级分词器要考虑的东西远比我这里写的复杂,但把这条主线跑通之后,再回头看那些复杂方案,你的理解会完全不一样。

我个人很建议把这套代码当作NLP入门的第一棵"手写练习"。原因很简单:分词是几乎所有中文NLP任务的入口——文本分类要看词,情感分析要看词,做问答、做摘要、做知识图谱都绕不开词。你把入口处的地基亲手夯实了,后面学什么模型都有底气。

2. 中文分词难在哪:没有空格的世界与三种主流解法

英文分词简单得近乎无脑——按空格和标点切就行。中文则完全没有天然的词边界。这里有个经典的例子:"研究生命起源",这句话既可以切为"研究/生命/起源",也可以切为"研究生/命/起源",两句意思完全不同,但机器无法只凭字面判断该选哪个。

这就是中文分词的核心困境:歧义消解。所谓歧义,就是同一个字符串存在多种合法切分方式。再来一个例子:"乒乓球拍卖完了",可以切为"乒乓球/拍卖/完了",也可以切为"乒乓/球拍/卖/完了"。词典里所有词都合法,但语义上只有一种是对的。

除了歧义,另一个大难题是未登录词(Out-of-Vocabulary, OOV)。字典无论如何维护,定期补充,还是会漏掉人名、地名、新词、网络热词。比如前几年突然火起来的"章鱼哥""内卷""破防",字典里没有收录,分词器就会把它们切成"章/鱼/哥""内/卷""破/防"甚至更碎。

针对这两类问题,业界大致分出三条技术路线:

第一类是词典匹配法。核心逻辑很朴素:把句子从头到尾跟预先构建好的词典做最长匹配或最大匹配,能匹配上就切出来。这类方法简单、速度快、可解释性强,但严重依赖词典质量和歧义处理规则。

第二类是统计模型法。把分词看成"字序列标注"问题——每个字标记为词的开始、中间、结尾或单独成词,再用隐马尔可夫(HMM)、条件随机场(CRF)或最大熵等模型来做预测。这类方法对新词和OOV的鲁棒性大幅提升,上世纪九十年代到本世纪初是主流。

第三类是深度学习方法。用双向LSTM、Transformer这类网络,把分词语料喂给模型做端到端的序列标注,近几年还有基于预训练模型(BERT系)的成熟方案。效果确实最强,但参数量大、推理成本高,对零基础入门来说也不够透明。

你不需要一次性掌握全部路线。我下面带你走的路是:先用词典匹配法搭一个能用的v1版本,跑通主流程,再引入统计思路处理OOV问题,最后加评测指标给工具打分。这个顺序恰恰也是NLP工程中很常见的演进路径——先快速上桌,再逐步补齐短板。

顺带说一下算法选择时的"维度":中文分词评价一个方案,通常看三项——准确率(切得对不对)、召回率(该切的是否都切出来了)、速度(每秒切多少字)。商用场景里速度也很关键,比如搜索引擎的实时分词对耗时极其敏感。这也是为什么至今仍有不少工业词法分析器保留词典匹配作为快速通道,只在匹配失败时才走模型。

3. 搭建第一版工具:正向最大匹配与字典构建

先从最基础的**正向最大匹配(Forward Maximum Matching, FMM)**说起。它的规则就一条:从句子开头取一段子串,看它是否在词典里;如果在,就切成一个词,指针后移;如果不在,就从末尾去掉一个字,继续尝试,直到匹配成功或剩下单字。

听起来有点像猜谜游戏——"我先试着吃尽量长的部分,能咬动就咬下来,咬不动就缩短一点再试"。这个"尽量长"由词典中最长词的字符数决定,一般设置成最大词长为5或6就够日常使用了。

先构建一个简易词典。我直接用一组高频词加示例词来演示:

# build_dict.py WORDS = [ "研究", "研究生", "生命", "起源", "科学", "科学家", "自然科学", "自然", "语言", "处理", "自然语言", "自然语言处理", "中文", "分词", "工具", "搭建", "项目", "文本", "挖掘", "信息", "检索", "系统", "深度学习", "机器学习", "模型", "训练", "数据集", "标注", "语料", "词", "句子", "汉字", "词典", "匹配", "算法", "最大", "正向", "反向", "歧义", "未登录", "人名", "地名", "机构", "网络", "热词" ] MAX_LEN = 6 DICT = set(WORDS)

把MAX_LEN设为6,是因为现代汉语多字词一般不会超过6个字,设置过大会增加匹配尝试次数,性能下降。

接下来实现FMM:

# fmm.py def fmm_cut(sentence, word_dict, max_len): tokens = [] i = 0 n = len(sentence) while i < n: matched = False for L in range(min(max_len, n - i), 0, -1): w = sentence[i:i + L] if w in word_dict: tokens.append(w) i += L matched = True break if not matched: # 单字成词 tokens.append(sentence[i]) i += 1 return tokens s = "研究生命起源的自然语言处理与深度学习" print(fmm_cut(s, DICT, MAX_LEN))

跑一下这段代码,输出大概是:

['研究', '生命', '起源', '的', '自然语言', '处理', '与', '深度学习']

等一下,这里暴露了一个问题。"自然语言处理"在词典里是一个词,但"自然语言"也是一个词。FMM贪心地先匹配最长,所以会优先切出"自然语言",然后把"处理"单独切出来。这里"自然语言"和"处理"都是词典里的词,从形态上没毛病,但语义上如果原意是"自然语言处理"这个整体概念,就切碎了。这种就是切分粒度的选择问题,没有绝对答案,取决于下游任务——有些任务需要细粒度词,有些需要短语级片段。

第一版虽然能用,但问题也不难发现:它对"交叉歧义"基本无能为力。比如"研究生命"这种结构,FMM非常自信地切成"研究/生命",完全不知道还可能切成"研究生/命"。这就是下一步要解决的痛点。

关于字典,工程上你几乎不可能手工维护一个几百万词的词典,通常是引入开源词库再适当增补领域词汇。这里给一个常用姿势:先从公开词库(比如搜狗细胞词库的开放导出、社区维护的现代汉语词典数据)拉一个基础词表,再把你所在领域的专用词合并进去。注意词库来源繁杂,质量参差不齐,去重、过滤乱码、统一简繁是少不了的清洗工作。

4. 破除FMM的局限:反向匹配与双向最大匹配

被"研究生命"这类问题卡住之后,我当时的第一个直觉是:既然正向贪心总盯着开头的长词,那换个方向会不会好一些?

这就是反向最大匹配(Backward Maximum Matching, BMM)。逻辑完全一致,只是从句子末尾开始,从尾部取字串做最长匹配,切出来的词从后往前排列,最后整体反转一下顺序即可。

# bmm.py def bmm_cut(sentence, word_dict, max_len): tokens = [] i = n = len(sentence) while i > 0: matched = False for L in range(min(max_len, i), 0, -1): w = sentence[i - L:i] if w in word_dict: tokens.append(w) i -= L matched = True break if not matched: tokens.append(sentence[i - 1]) i -= 1 tokens.reverse() return tokens

用"研究生命起源"测试,BMM的结果是:

['研究', '生命', '起源']

跟FMM一致。但换个句子:"我们研究生命起源",BMM可能会因为尾部优先的机制,在遇到"我们/研究/生命/起源"和"我们/研究/生命/起源"这种结构时表现不同。换句话说,FMM和BMM在同一个句子上的切分结果往往不同,并且错的方向是互补的——FMM更容易在词首产生错误切分,BMM更容易在词尾产生错误切分。

既然各有各的毛病,自然想到把两者合起来,谁也不偏听偏信。**双向最大匹配(Bidirectional Maximum Matching, BI-MM)**的决策规则我常用两条:

  • 如果FMM和BMM结果相同,那就直接采用。
  • 如果不同,则比较两种结果的词数,取切分更少的一种;如果词数也相同,则比较单字成词的数量,取单字更少的一种。单字多往往意味着切得碎,通常不是好结果。

这样设计背后其实有一个朴素假设:一个合理的分词结果,词的数量不会莫名其妙地多。我们以词数为主要判据,再以单字数为辅助判据,就构成一套简单有效的投票机制。

来看一个经典案例:"我来到北京清华大学"。假设词典里有"北京""清华""大学""清华大学"但没有"清华大学"和"京清"这种错误词——当然正常情况下清华大学会在词典里。用"我/来到/北京/清华大学"来测试这个框架是绝佳的例子,因为它既包含词内歧义,又包含含有单字的短语。

实践中你会发现双向匹配相比单方向匹配的准确率有明显提升,通常能提升3到5个百分点。不过这仍无法根治OOV问题,因为无论从哪个方向匹配,词典里不存在的词终究切不出来。比如"程序员小张今天摸鱼了"中的"摸鱼",如果词典没有收录,大概率被切成"摸/鱼"。

我建议此时做一次性能测量。用同样的语料分别跑FMM、BMM和BI-MM,记录耗时和准确率,你会得到一组很有意思的数据:双向匹配只是线性多了一次扫描,开销并不大,但收益很明显。这也是它至今仍被许多轻量级场景采用的原因。

另外,真正落地时匹配查找的效率也很关键。用Python内置的set做词典查询已经很快,但如果你处理的文本量达到GB级别,就该认真考虑把词典换成Trie树(前缀树)结构。Trie树可以将"逐个长度尝试"优化为"沿树下行探索最远匹配",省掉大量无效的字符串切片操作。用Python写Trie不复杂,几十行代码就能搞定,这里不展开,但它是你v2版本性能优化最值得动刀的地方。

5. 处理未登录词:从词典匹配升级到隐马尔可夫模型

双向最大匹配解决了大多数"词典内歧义",但OOV问题始终悬在头上。不夸张地说,现在的网络热词、人名地名、领域术语,每天都在刷新前一天不够用的词典。要解决OOV,就得跳出"查找"思路,改用"预测"思路。

业界最经典也最适合入门的方案是**隐马尔可夫模型(HMM)**做的字标注分词,它对OOV的识别能力一直在工业界有广泛应用。你不需要立刻理解HMM的全部数理推导,只需要抓住四个基本要素:

  • 观测序列:句子本身,一个字接一个字。
  • 状态序列:每个字对应的词位标签。我们用B(Begin,词首)、M(Middle,词中)、E(End,词尾)、S(Single,单字成词)四种标签。
  • 初始概率:句首第一个字时,状态分布概率。
  • 转移概率:从状态A转移到状态B的概率,比如B后面更可能是M或E,几乎不可能是S。
  • 发射概率:处在状态B时,发射出某个字的概率。

分词任务在HMM框架下被转化为:已知观测序列(句子),找到最可能的状态序列(每个字的BMES标签),再把标签转回词边界。这个"找最可能状态序列"的经典算法叫维特比算法(Viterbi)。

为了让你能自己跑通,我准备了一套极简的伪语料来估计概率——实际上是用手工标注了一小段文本,然后统计转移频次和发射频次。实际项目中你需要更大的标注语料,但流程完全一样:

# hmm_cut.py import re from collections import defaultdict # 示例用极简标注语料,每行一句,词与词以空格分隔 train_corpus = [ "研究生 生命 起源", "自然语言 处理 是 重要 方向", "这位 科学家 研究 深度 学习", "我 来到 北京 清华大学", "小明 喜欢 玩 网络 游戏", "新词 识别 非常 重要" ] def generate_train_data(corpus): for line in corpus: for word in line.split(): if len(word) == 1: yield word, "S" else: yield word, "B" + "M" * (len(word) - 2) + "E" def train_hmm(corpus): start_p = defaultdict(float) trans_p = defaultdict(lambda: defaultdict(float)) emit_p = defaultdict(lambda: defaultdict(float)) states = ['B', 'M', 'E', 'S'] # 统计 for line in corpus: words = line.split() for idx, word in enumerate(words): tags = ...

需要说明的是,上面只是数据预处理到统计流程的骨架,完整的训练函数要把每个字的标签序列和句子边界信息全部记录下来,再规整为三类概率表。我建议你按如下结构写完整版:先遍历语料为每个"字-标签"对累计发射计数,为每个相邻标签对累计转移计数,记录每个句首标签的计数;最后对每个计数除以该条件下的总数,得到概率。

有了概率表,维特比实现如下思路:

# viterbi.py def viterbi(obs, states, start_p, trans_p, emit_p): V = [{}] path = {} for s in states: if emit_p[s].get(obs[0], 0) > 0: V[0][s] = start_p[s] * emit_p[s].get(obs[0], 0) else: V[0][s] = 0 path[s] = [s] for t in range(1, len(obs)): V.append({}) new_path = {} for s in states: # 找到最优前序状态 (prob, state) = max( (V[t-1][s0] * trans_p[s0].get(s, 0) * emit_p[s].get(obs[t], 0), s0) for s0 in states ) V[t][s] = prob new_path[s] = path[state] + [s] path = new_path # 取最后一个字概率最大的状态 (prob, final_state) = max((V[-1][s], s) for s in states) return path[final_state]

注意,上一段里的emit_p[s].get(obs[t], 0)这种写法会带来风险:如果某个状态从未发射过某一个字,概率直接置零,那么所有经过它的路径也直接归零。实际应用中要加平滑,比如加一平滑或者对零概率取一个极小正数,防止整个格子所有路径全部死掉。

把BMES标签还原为分词结果也非常简单:B后面是一个词的开始,E是一个词的结尾,S单独成词。按这些标签切分句子即可。

用HMM做OOV识别的效果我实测过:在包含新词的文本上测试,识别率大概能把"摸鱼""内卷"这类未登录词的切分正确率从接近0提升到70%附近。如果还想更好,就需要更大规模的标注语料、加入词频信息,甚至换成条件随机场或双向LSTM。但对于入门项目,HMM这一步足以让你体会到"统计方法如何识别没见过的新词"。

6. 给工具打分:分词质量评测与参数调优

自己搭的分词工具到底算不算合格,不能靠感觉。你需要一套可量化的评测流程。这里我直接采用NLP领域广泛使用的精确率(Precision)、召回率(Recall)和F1值来打分。

评测的基本单位是"词对"——把分词结果和标准答案(人工标注的正确切分)都拆成有序的词序列,然后比较两者的重叠。三个指标的定义如下:

  • 精确率 P= 分词结果中切对的词数 / 分词结果总词数
  • 召回率 R= 分词结果中切对的词数 / 标准答案总词数
  • F1= 2 × P × R / (P + R)

一个朴素的Python实现大概是:

# evaluate.py def exact_match(ref, pred): ref_words = ref pred_words = pred common = 0 # 这是简化示例,真实场景更严谨的做法是用区间匹配 for w in pred_words: if w in ref_words: common += 1 p = common / len(pred_words) r = common / len(ref_words) return p, r, 2 * p * r / (p + r)

注意,上述用if w in ref_words做的是词形级匹配,存在一个隐患:标准答案里有两个相同的词,分词结果里一个该切的词被重复切出时,这个统计就不准。严谨做法是对每个词记录起止位置,用区间匹配去重。因为区间匹配的代码稍微复杂,这里不完整贴出来,但我会提供一个思路:构建两个(起始, 结束, 词形)三元组集合,求交集时按起始位置对齐。

我建议你把评测跑在两类语料上:

  • 词典内语料:句子里的词全部在词典里,重点测歧义消解能力。
  • 含OOV语料:人为加入新词、人名、网络热词,重点测OOV识别能力。

下表是我在一份约200句的测试集上得到的典型结果,你可以拿它当期望值参考:

方案准确率P召回率RF1速度(字/秒)
正向最大匹配(FMM)0.880.860.87约8万
反向最大匹配(BMM)0.890.870.88约8万
双向最大匹配(BI-MM)0.920.900.91约7万
BI-MM + HMM混合0.940.930.93约6万

从表里可以读出一件很有意思的事:单纯把方向换一下,性能就有细微差别;方向叠加,收益更大;再加上统计模型兜底,整体就接近一个轻量级分词器的水平了。

调参方面,我最常动的有三个旋钮:

第一个是MAX_LEN,即最大词长。它影响匹配的速度和准确性。设小了,长词会被切碎;设大了,无效尝试变多。日常文本设5或6即可,专业领域词汇长的场景可以放大到8,并配合Trie树优化。

第二个是词典增删的"领域权重"。如果你的语料来自电商评论,那就把商品词、品牌词、网络缩略语加进词典;来自法律文书,就在词典里加入法条关键字。词典和领域贴得越紧,分词效果提升越显著——这比折腾算法的性价比更高。

第三个是BMES标签的先验约束。在HMM解码时,B后面必须接M或E,S后面不能直接接M。如果你在维特比里把这两种非法转移的概率硬置为0,能显著减少"字被切得逻辑混乱"的离谱输出。

这里分享一个踩坑教训:我之前用一个小型语料训练HMM时,没有做平滑处理,结果测试文本里只要出现训练语料没见过的字,维特比直接全部判为S,整个句子被切得稀碎。后面加了平滑,并预留了一个"未知字处理分支"——遇到没见过的字符(比如字母、数字、表情符号),直接按S处理并传送到下一个字,问题才彻底解决。这就是我推荐你从小语料动手的原因——问题暴露得越彻底,后面的理解越扎实。

7. 工程化落地:三个容易翻车的实用细节

代码层面跑通之后,真正到"工具"层面,还有几个细节如果不处理好,很容易在实战里翻车。

第一个坑:标点与空白字符的处理。

中文分词器输出里,标点符号怎么处置是个看似不起眼却很磨人的问题。工程上主流做法是分词前先用正则剥离中英文标点和空白,保留下需要做词法分析的文本主体。但剥离之后,词的绝对位置就和原文对不上了——这点在后续做文本标注、做实体抽取的时候会非常麻烦。更稳妥的做法是分词的每个输出词都记录它在原文中的起始和结束偏移,标点单独作为一个类型保留。这样下游做高亮、做关系抽取、做知识图谱时,拿偏移量就能直接定位,省去很多回标工作。

第二个坑:字母数字串的整块保留。

"iPhone15""GPT-4""B2C"这类字符串如果按普通汉字逻辑切分,会被切得惨不忍睹。我在HMM那一步专门加了一条前置规则:用正则[A-Za-z0-9]+(?:[-_][A-Za-z0-9]+)*把这类token先完整提取出来,不参与后续的字符级序列标注。这一步就像"把不速之客先请出房间",剩下的汉字部分再走匹配和模型解码,整个工具的鲁棒性一下子提升很多。

第三个坑:性能优化顺序。

分词工具的耗时分布往往很不均匀。大多数人第一直觉是优化算法本身,但实际工程里最值钱的优化往往在更"外围"的地方:

  • 先做缓存。同一个句子在一段时间内被反复分词的场景非常常见(比如搜索场景里的重复query),用一份dict缓存句子到分词结果的映射,收益立竿见影。
  • 再优化词典查询。把set升级为Trie树,减少字符串切片。
  • 最后才考虑多进程加速。Python的GIL决定了多线程对CPU密集型的分词帮助不大,multiprocessing才是正解,但跨进程传输数据的开销也不小,数据量小的时候反而更慢。

真实案例是,我给某个快速原型项目做分词时,只加了lru_cache缓存,整体吞吐就提升了3倍,而算法本身一行没改。先测量,再对瓶颈下手,这比盲目上复杂方案划算得多。

另外一个经验是,分词工具上线后一定要预留热更新词典的接口。业务方的需求变化极快,今天要加一批主播昵称,明天要加一批商品型号,如果每次都重启服务加载词典,维护成本会让人崩溃。把词典加载做成可从外部配置中心定期拉取的形式,是一个商业上很实际的需求点。

关于词典的另一个提醒:永远不要在程序运行中直接修改正在使用的词典对象。Python的set在并发访问下会出现不可预期的行为。稳妥做法是加载时生成一份不可变快照,热更时整体替换引用,而不是原地改动。

到这里,你的中文分词工具已经有了清晰的三层结构:底层是词典与匹配算法,中层是HMM统计模型负责OOV兜底,上层是工程化的前后处理与评测流程。这套结构相当接近工业界经典词典分词器的设计思路了,后续无论你想往CRF、双向LSTM还是预训练模型方向深入,都有了清晰的地图。

根据我自己的经验,NLP入门最快的方式不是背模型架构,而是亲手把一个简单工具改到能跑、跑到满意为止。中间踩过的每个坑,都会变成你理解后面复杂模型的阶梯。你若也这么走一遍,收获一定不小。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/24 2:49:37

数据预处理与特征工程实战指南:从清洗到编码的完整方法论

开头写完了&#xff0c;继续往下写主体。1. 数据预处理的核心思路与常见误区1.1 预处理的优先级为什么高于模型调参我先说一个判断&#xff1a;数据预处理做得好不好&#xff0c;直接决定模型效果的上限&#xff0c;调参只是在逼近这个上限。这个道理很多人都听过&#xff0c;但…

作者头像 李华
网站建设 2026/10/26 22:14:03

DLSS Swapper 使用指南:4 步完成游戏内 DLSS 版本切换与回滚

DLSS Swapper 使用指南&#xff1a;4 步完成游戏内 DLSS 版本切换与回滚 【免费下载链接】dlss-swapper 项目地址: https://gitcode.com/GitHub_Trending/dl/dlss-swapper DLSS Swapper 用于替换游戏里已有的 DLSS、FSR 或 XeSS 的 DLL&#xff08;游戏依赖的技术模块文…

作者头像 李华
网站建设 2026/10/27 5:35:33

SEO快速见效实操指南:从收录到排名提升的完整路径

做SEO的人&#xff0c;多多少少都经历过这种状态&#xff1a;花了不少时间改标题、调内链、更新文章&#xff0c;后台数据一个多月纹丝不动&#xff0c;看着同行涨流量涨得欢&#xff0c;自己连收录都磕磕绊绊。有人开始怀疑这条路走不通&#xff0c;有人转头研究各种偏门玩法。…

作者头像 李华
网站建设 2026/10/26 23:19:12

1-4-3-ChatGPT辅助学习与课程提示词

title: 把 ChatGPT 用成开发工具:提问八法与项目级提示词 lecture: 1-4-3 chapter: 1 section: 4 series: AI Agent 全栈开发工程师学习笔记 tags: [Prompt, ChatGPT, 自定义指令, 提示词模板] 把 ChatGPT 用成开发工具:提问八法与项目级提示词 同一个问题,回复质量能差一…

作者头像 李华
网站建设 2026/10/27 21:37:33

中医馆运营全流程拆解:从辨证论治到药材与复诊管理

1. 从店名到招牌&#xff1a;汉医堂究竟在解决什么问题做中医这行十几年&#xff0c;我见过太多打着“中医”旗号的店&#xff0c;有的卖保健品&#xff0c;有的做美容&#xff0c;真正把心思放在“医”字上的反而不多。所以第一次听到“北京汉医堂中医馆”这个名字时&#xff…

作者头像 李华
网站建设 2026/10/27 8:07:20

3DGS部署与训练实战:从环境配置到高斯模型训练全流程

简介&#xff1a;这份源码包面向希望上手3D Gaussian Splatting&#xff08;3DGS&#xff09;三维重建的研究者与开发者&#xff0c;尤其适合在非官方推荐环境下摸索部署的初学者。作者以Python 3.10、CUDA 12.3与PyTorch 2.2.1的组合实测跑通全流程&#xff0c;内容覆盖软件环…

作者头像 李华