引子

你敲下 woaizhongguo,输入法瞬间吐出「我爱中国」。中间那道没有空格的拼音串,是怎么被「切」成「我 / 爱 / 中 / 国」四个词的?

这件事远比看上去难。汉语书面语没有词边界,而拼音作为汉字的转写,同样没有边界——声母、韵母可以无休止地黏连成一长串字母,且大量合法的切分方式同时存在。拼音切词(pinyin segmentation)本质上要解决两层问题:先把这串字母切成合法的音节,再把音节序列解码成最像人话的汉字与词

本文顺着「切音 → 解码 → 分词」的链路逐层拆开,并用可运行的最小代码把歧义和消歧都演示出来。

一、第一层:拼音音节切分(切音)

拼音的最小单位是音节:一个音节由「声母(可空)+ 韵母」构成,如 zh + ang = zhang,或零声母的 aner。切音的目标,是把无空格的字母串切成一串「每一个片段都是合法音节」的序列。

做法很朴素:准备一张合法音节词典,再用动态规划(或回溯枚举)把所有合法切分找出来。下面这张图是切音的执行模型——它像在一串字符上不断「试切」,只要切出来的片段在词典里就放行:

1
2
3
4
5
6
7
拼音串:  x i a n
│ │ │ │
▼ ▼ ▼ ▼
尝试每种切法,片段必须 ∈ 合法音节词典


合法切分集合: { 先 , 西安 , 西阿嗯 , 虾嗯 , … }

关键结论:切音是「组合爆炸」问题。字符串越长,合法切分数量指数级增长。机械切音只能保证「每段都合法」,却保证不了「切出来的是人话」。

二、歧义从哪来:一个字符串两种语义

最经典的坑是 xian:它既是「先」(xian),也是「西安」(xi + an)。用户打字时通常省略隔音符号和声调,于是纯字符串 xian 同时对应至少两个完全不同语义的词。下面用代码枚举它的全部合法音节切分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
# -*- coding: utf-8 -*-
# 合法拼音音节集合(简化版,覆盖常见音节即可演示)
SYLLABLES = set("""
a ai an ang ao ba bai ban bang bao bei ben beng bi bian biao bie bin bing bo bu
ca cai can cang cao ce cen ceng cha chai chan chang chao che chen cheng chi chong
chou chu chuai chuan chuang chui chun chuo ci cong cou cu cuan cui cun cuo
da dai dan dang dao de deng di dian diao die ding diu dong dou du duan dui dun duo
e ei en eng er fa fan fang fei fen feng fo fou fu
ga gai gan gang gao ge gei gen geng gong gou gu gua guai guan guang gui gun guo
ha hai han hang hao he hei hen heng hong hou hu hua huai huan huang hui hun huo
ji jia jian jiang jiao jie jin jing jiong jiu ju juan jue jun
ka kai kan kang kao ke ken keng kong kou ku kua kuai kuan kuang kui kun kuo
la lai lan lang lao le lei leng li lia lian liang liao lie lin ling liu long lou lu lv luan lue lun luo
ma mai man mang mao me mei men meng mi mian miao mie min ming miu mo mou mu
n na nai nan nang nao ne nei nen neng ng ni nian niang niao nie nin ning niu nong nou nu nv nuan nue nuo
o ou pa pai pan pang pao pei pen peng pi pian piao pie pin ping po pou pu
qi qia qian qiang qiao qie qin qing qiong qiu qu quan que qun
sa sai san sang sao se sen seng sha shai shan shang shao she shen sheng shi shou shu shua shuai shuan shuang shui shun shuo si song sou su suan sui sun suo
ta tai tan tang tao te teng ti tian tiao tie ting tong tou tu tuan tui tun tuo
wa wai wan wang wei wen weng wo wu
xi xia xian xiang xiao xie xin xing xiong xiu xu xuan xue xun
ya yan yang yao ye yi yin ying yo yong you yu yuan yue yun
za zai zan zang zao ze zei zen zeng zha zhai zhan zhang zhao zhe zhen zheng zhi zhong zhou zhu zhua zhuai zhuan zhuang zhui zhun zhuo zi zong zou zu zuan zui zun zuo
""".split())

def all_segmentations(s, cap=2000):
"""回溯枚举所有合法音节切分(cap 防止超长串爆炸)"""
results = []
def dfs(i, path):
if len(results) >= cap:
return
if i == len(s):
results.append(path[:])
return
for j in range(i + 1, len(s) + 1):
if s[i:j] in SYLLABLES: # 片段合法才继续往下切
path.append(s[i:j])
dfs(j, path)
path.pop()
dfs(0, [])
return results

print(all_segmentations("xian"))
print(all_segmentations("nanhai"))

输出:

1
2
3
4
# xian  -> [['xi', 'a', 'n'], ['xi', 'an'], ['xia', 'n'], ['xian']]
# 合法但无意义: 西阿嗯 / 虾嗯;有意义: 西安=['xi','an'], 先=['xian']
# nanhai -> [['n', 'a', 'n', 'hai'], ['n', 'an', 'hai'], ['na', 'n', 'hai'], ['nan', 'hai']]
# 有意义: 南海 / 男孩 = ['nan', 'hai']

看,xiannanhai 都各自对应多个「读得通」的词。纯切音无法决定选哪个——它只能把候选摊开。这正是拼音切词和英文分词(靠空格)最本质的区别:英文的边界是显式的,拼音/汉字的边界是隐式的、需要语义来推断的。

三、第二层:拼音到汉字的解码

切出音节后,要决定每个音节对应哪个汉字。这一步是个**解码(decode)**问题:把「拼音序列」当作观测信号,反推最可能的「汉字序列」这个隐状态。

形式化地,输入法是要在所有候选汉字序列 C 里找:

1
argmax_C  P(C) · ∏ P(pinyin_i | char_i)
  • P(pinyin_i | char_i)发音模型:一个汉字读某个音的概率(多音字在此体现,如「重」读 zhong/chong)。
  • P(C)语言模型:汉字序列本身「像不像话」的概率。

单看发音模型,xian 仍是「先」和「西安」平手——因为两者发音都对。所以要靠语言模型打破平手。

四、第三层:汉字到词与语言模型消歧

真正的输入法不会先定汉字再分词,而是把「音节切分 + 汉字选择 + 成词」联合用语言模型打分。最小可用版本,是用汉字 bigram(二元文法)给候选路径打分:相邻汉字越常共现,路径分越高。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from math import log

# 候选汉字(隐状态)与其发音(此处每字唯一对应一个拼音,故发射概率=1)
# "xian" 的两种读法:单字「先」,或双字「西」+「安」
# start_w: 句首单字先验权重;trans_w: 汉字 bigram 转移权重
start_w = {"先": log(0.4), "西": log(0.6)}
trans_w = {("西", "安"): log(0.9)} # “西安”是强共现词,权重给高

def score(chars):
s = start_w.get(chars[0], log(0.01))
for i in range(1, len(chars)):
s += trans_w.get((chars[i - 1], chars[i]), log(0.01))
return s

print("先 score =", round(score("先"), 3)) # 单字路径
print("西安 score =", round(score("西安"), 3)) # 双字路径

输出:

1
2
# 先   score = -0.916
# 西安 score = -0.616 # 分数更高(log 域越大越好)→ 模型选「西安」

西安 因为「西→安」是强共现词,总分压过了单字「先」,于是消歧成功。真实输入法用的就是同一套思想,只是把 bigram 换成上亿条语料训练出的 N-gram 或神经网络语言模型,并用 Viterbi / 束搜索(beam search) 在指数级候选里高效找最优路径。整条链路长这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
无空格拼音串


┌─────────────┐ 候选音节序列
│ ① 音节切分 │──────────┐
│ (切音) │ │
└─────────────┘ │

┌─────────────┐ 候选汉字序列
│ ② 拼音→汉字 │──────────┐
│ (解码) │ │
└─────────────┘ │

┌─────────────┐ 最终分词结果
│ ③ 汉字→词 │──────────┐
│ (语言模型消歧)│ │
└─────────────┘ │

用户看到的句子

五、不同方法怎么选

从规则到神经,三代方案的取舍一目了然:

方法 代表实现 优点 缺点 典型场景
规则 / 词典(最长匹配) 早期输入法、离线工具 极快、可解释、零语料 歧义无能、未登录词差 嵌入式 / 弱网
统计(HMM + N-gram + Viterbi) 经典拼音输入法 用上下文消歧、成熟稳定 需大语料、高阶 N-gram 稀疏 通用输入法
神经(seq2seq / Transformer) 现代云端输入法 长依赖建模强、准确率高 算力大、黑盒难调试 云端 / 端侧大模型

工程落地还有两个常踩的坑:

  • 无声调拼音放大歧义:用户通常不打声调,xian 到底是 xiān(先) 还是 xī'ān(西安) 只能靠语言模型兜底,这也是为什么同样敲 xian、前面跟「我去了」和跟「我比」会出不同结果。
  • 多音字必须在解码阶段解决zhong 在「重重量」里是两个不同字,发音模型要把多音字概率摊开,交给语言模型在路径打分时归位,不能提前拍板。

六、本质一句话

拼音切词 = 在指数级「都合法」的切分里,用语义 / 统计约束,挑出最像人话的那一条路径。 切音只负责「穷举合法」,真正拍板的永远是语言模型——这和中文分词、语音识别、机器翻译本质上是同一个思想:观测序列到隐状态序列的最优解码。

下回敲拼音出错别字,多半不是切音错了,而是你当下的上下文把语言模型「带偏」了——换个前后文,它往往就自己改对了。