☰
中文NLP分词实战:SentencePiece子词切分与词表构建指南
2026/10/16 7:03:58 网站建设 项目流程

做了很久中文文本任务,我一直绕不开一个问题:词表到底怎么建才合适。早年在某中文信息抽取项目里,我用通用语料训了一套整词词表,规模堆到二十多万,结果一上垂直领域还是大片落空。专有名词、组合术语、型号缩写、网络新词,随便来一个就是 OOV。后来换成 SentencePiece 做子词切分,这些问题才真正被兜住。这篇文章就把我从原理到实操踩过的路完整梳理一遍,包括它内部怎么工作、训练参数怎么调、接入 pipeline 时有什么坑,最后给一份可以直接用的决策参考。

1. 为什么我最后还是把分词这件事交给 SentencePiece

先说说之前那套方案的痛。做中文 NLP 时,很多人第一反应是用分词工具把句子切成词,再套 word2vec 或者 Bert 之类的模型。问题在于中文“词”本身没有一个统一标准。同一个词在不同工具里的边界不一样,同一套语料在不同版本的分词器下结果也可能变化。更要命的是领域数据里全是长尾词:产品型号“XT-3000Pro”、品牌名“麦乐迪”、中英混拼“蓝牙Mesh网关”。通用词表里根本不可能预见到这些东西。

有人会说那就扩大词表。可词表扩到几十万之后,embedding 层的参数量肉眼可见地膨胀,训练速度下降,低频词仍然学不到什么有效表示。而且整词方案无论怎么扩,都追不上开放词汇的增长速度。另一个极端是做纯字级模型,一个字一个 token。这样覆盖率确实上去了,但丢掉了明显的组词信息,“自然语言处理”被拆成六个孤零零的字,模型要自己重新学出“自然语言”是一个概念。

子词方案恰好站在中间。它把高频出现的字组、词根、前后缀保留成独立 token,罕见的复合表达则拆成若干常见子词。SentencePiece 属于这类方案里设计得比较完整的一个,它同时实现了 BPE 和 Unigram 两种算法,而且从训练到部署一体搞定,不依赖任何预设分词规则。我后续好几个项目都直接拿它当标准前置组件。

有一类观点认为 SentencePiece 只是某种模型配套的分词器,这个理解窄了。它本质上是一个独立的文本切分工具,既能给传统机器学习用,也能给深度模型用。我更愿意把它看成“语言无关的文本压缩器”:给定语料、目标词表大小、字符覆盖率,它输出一套最优片段集合,再拿这套片段去编码任意新文本。

2. 核心机制拆解:子词、空格标记与往返一致

2.1 子词的粒度到底意味着什么

用最直白的话说,子词就是介于“字”和“整词”之间的文本片段。英文里“unbelievable”可能被切成“un”、“believ”、“able”,中文里“自然语言处理”可能被切成“自然”、“语言”、“处理”,也可能切出“自然语言”这个更长片段。具体切成什么样,取决于语料中片段的出现频率和词表容量。

为什么这种粒度有效?因为真实语言的词汇量是开放集。电影名、代码变量、商品型号、网络新词,理论上无限多。整词级词表永远无法封口,字级又太碎。子词恰好把“频繁出现的字组”保留下来,罕见复合词自动退化为若干高频子词,覆盖率自然就高。这也是为什么子词几乎成了神经机器翻译和预训练语言模型的默认选择。

2.2 那个“▁”符号到底是什么

SentencePiece 有一个容易让新手困惑的设计:文本里的空格会被替换成一个可见符号“▁”,也就是 Unicode 的 U+2581。这个符号不是乱码,它在训练和编码时被当作普通字符处理,等解码时再还原回空格。

这个设计解决了一个根本问题:传统英文分词流程是空白切分后再做子词,中文又根本没有空格,两套流程没法统一。SentencePiece 坚持不给语言预设:整行文本就是一个连续字符流,空格信息用特殊符号保留。于是中英混排场景下,模型可以直接在“中文+英文+数字”的混合流里统一建模,“▁”让英文单词边界得以保留,中文则无需显式分词。

2.3 “往返一致”为什么重要

我选分词工具时有一条硬性标准:编码再解码,必须逐字还原原始输入。SentencePiece 具备这种往返一致性。因为空格只是被映射成了“▁”token,解码就是逆映射;只要词表没有冲突,文本就能无损还原。

这一点在实际工程里非常关键。下游模型经常需要把预测结果映射回原始文本位置,比如抽取实体、做序列标注、做文本纠错。如果分词器吞掉了连续空格或者折叠空白,定位就会错位,整个评价指标都会失真。以前用某些基于正则的分词器时就吃过这个亏,切完再拼回来,原文对不上了,调试非常痛苦。

2.4 训练目标:自动学习一套最优切分规则

SentencePiece 的训练过程可以理解成“找一组片段和切分方式,使语料编码总长度足够短”。这和压缩算法思路相似:频繁出现的组合会被吸收进词表,低频内容则保留为较小片段。词表越大,片段越细;词表越小,切分越粗。训练结束后生成的不仅是词表,还包含一个固定切分规则,后续编码任何新句子都按这套规则走。

3. BPE 和 Unigram:两种切分思路的取舍,以及我怎么选

3.1 BPE 是贪心的自底向上合并

BPE 全称 Byte Pair Encoding,思路非常直观。从单字符集合开始,统计语料中所有相邻字符对的共现频次,把频次最高的一对合并成一个新片段,写进词表;然后重复这一过程,直到词表达到目标大小。

举个例子,中文语料里“自然”出现频率特别高,BPE 会先把“自”和“然”合并成一个 token;接下来如果“自然语”也频繁,可能把“自然”和“语”再合并成“自然语”。每一次合并都是局部最优,所以叫贪心。合并过程中,整个语料的 token 数不断减少,最终词表里留下的都是共现关系非常强、信息量很高的片段。

BPE 的一个优点是行为可解释:每一次合并都有明确的频次依据,词表的层级关系像一棵合并树,排查问题相对容易。缺点是它只考虑相邻 pair 的局部频次,不考虑全局切分概率,某些上下文里会切出看起来笨拙的片段。

3.2 Unigram 是全局概率模型做的词表裁剪

Unigram 算法走了完全相反的路线。它先构造一个超大候选片段池,可能包含所有单字、所有常见 n-gram、以及各种统计上可能成词的候选;然后用 EM 类方法估计每个片段在语料中的概率,再按照对整体损失的影响逐个裁剪低贡献片段,直到词表收缩到目标大小。

切分新句子时,Unigram 对每个句子枚举所有可行的切分路径,用 Viterbi 动态规划找到概率最大的一条。所以它得到的不是局部贪心最优,而是全局概率最优。

一句话记住差别:BPE 是先合并再固定;Unigram 是先枚举再裁剪。这个本质差异带来不同的切分风格。BPE 更偏向稳定、保守,Unigram 则对低概率边界更敏感,单字 token 的保留比例往往更合理。

3.3 我的实测选择倾向

在不同任务上我做过不少对比,习惯大致如下:

场景推荐算法理由
中英翻译、多语生成BPE合并行为稳定,词表结构好解释
中文 NER、信息抽取Unigram边界更细腻,单字保留合理,OOV 表现更稳
极小词表(3k~8k)Unigram受限词表下能保留更多高频信息
需要严格复现调试BPE合并树清晰,容易定位切分原因

这里必须强调,上述只是我自己的倾向,不代表绝对优劣。同一份语料、同一个词表大小,两种算法切出来的风格确实不同。我建议有条件的项目把两种模型都训出来,直接在目标任务上跑一轮小实验再定,比凭空猜测可靠得多。

4. 动手训练一个 8K 中文词表:命令、参数与验证

4.1 语料准备有哪些讲究

训练 SentencePiece 只需要一个纯文本文件,每一行是一个样本或一段文本。个人经验,语料质量比数量更重要。拿通用百科语料配上领域文本混合,效果比单用几倍量的单一语料好很多。

几个预处理习惯:

  • 清洗 HTML 标签、不可见控制符、异常字符。
  • 超长文本按句切行,方便后续采样和训练。
  • 统一换行为\n,避免\r\n混入。
  • 不要把语料全部丢进去硬跑,先抽样检查一遍是否有乱码或编码混杂。

数据量方面,vocab_size=8000这种规模,几十 MB 纯文本就能训出一个可用词表;想训得扎实,500MB 到 2GB 都是合理范围。太大的语料注意设置采样参数,避免训练耗时过长。

4.2 训练命令与关键参数

安装和训练命令如下:

pip install sentencepiece spm_train \ --input=corpus.txt \ --model_prefix=zh_sp \ --vocab_size=8000 \ --character_coverage=0.9995 \ --model_type=unigram \ --max_sentencepiece_length=16 \ --byte_fallback=true \ --pad_id=0 --unk_id=1 --bos_id=2 --eos_id=3

逐个说这些参数:

  • vocab_size=8000:目标词表大小。注意这个数值包含特殊 token,实际可用片段会比 8000 少 4 个左右。
  • character_coverage=0.9995:需要覆盖语料里多少比例的字符。中文通常用 0.9995,因为汉字数量多,要留少量未登录字符的余地。
  • model_type=unigram:可选 unigram、bpe、char、word。常用前两个。
  • max_sentencepiece_length=16:单片段最大字符数。中文 16 比较合适,既能保留较长的完整词组,又不会把噪声片段收进来。
  • byte_fallback=true:遇到完全不在词表里的字符时,拆成 UTF-8 字节级的<0xXX>token,避免直接丢成 unk。
  • pad_id=0 --unk_id=1 --bos_id=2 --eos_id=3:固定特殊 token 的 id,防止后续和模型配置对不上。

训练结束后目录里会出现zh_sp.model和zh_sp.vocab。前者是二进制模型文件,加载时用它;后者是纯文本词表,每行一个片段加分数,便于人工检查。

4.3 用 Python 做一轮快速验证

import sentencepiece as spm sp = spm.SentencePieceProcessor(model_file="zh_sp.model") text = "自然语言处理在工业界应用很广,例如Bert和GPT系列模型。" pieces = sp.encode(text, out_type=str) print(pieces) print(sp.decode(pieces) == text)

输出大致是:

['▁自然', '语言', '处理', '在', '工业', '界', '应用', '很', '广', ',', '例如', 'B', 'e', 'r', 't', '和', 'G', 'P', 'T', '系列', '模型', '。']

注意decode(pieces) == text返回 True,说明往返一致没有丢字符。英文单词“Bert”被拆成了单字母 token,这是因为 8K 词表比较小,常用英文整词没有足够位置;如果你的领域里英文词很重要,可以适当增大词表或者提高英文语料的混合比例。

4.4 检查词表文件里到底有什么

打开zh_sp.vocab,你会看到类似这样的片段排列:

▁ -0.123 ▁自然 -1.234 ▁语言 -1.501 处理 -2.011 ▁自然语言 -3.123 ...

带▁前缀的表示这个片段出现在词首位置,也就是单词(对于中文来说就是词首 token),不带前缀的可能是词中片段或独立的字。这个细节在后续拼接文本时有用:解码器看到▁会还原成空格。

5. 参数敏感度实测:哪些设置决定成败

5.1 character_coverage:调小会漏字符,调大会留垃圾

character_coverage默认 0.9995,意思是训练时只保留语料中最高频的 99.95% 字符,剩余极低频字符会被视为未登录字符。

如果设成 0.98,语料里那些出现一两次的生僻字、特殊符号就可能进不了词表,编码时全部变成 unk。反过来设成 1.0,所有字符都强制进词表,混杂的噪音符号也会被留进来,浪费词表位置。

对简体中文,我的经验是 0.9995 稳妥;如果语料里包含大量人名地名生僻字,可以降到 0.998 左右,但要有心理准备,真实罕见字仍然可能落在覆盖范围之外。

5.2 max_sentencepiece_length:太长太短都别扭

这个参数限制单个片段的最大长度。16 是默认值,但也不是万能。

  • 设成 4~6:切分非常碎,几乎接近字级,词表利用率低。
  • 设成 32 以上:容易把长而低频的噪声组合整段收进来,比如“自然语言处理中”这种 8 字组合,实际复用价值低。
  • 对中文 8K~32K 词表,16 是个稳的选择。对英文,16 也够覆盖绝大多数词根和后缀。

5.3 byte_fallback:要不要给未知字符留后门

默认关闭。开启后,如果输入文本里有某个字符完全不在词表里,分词器会把它按 UTF-8 字节拆成<0xE4>这种 token。

我几乎总是建议打开。原因很简单:NLP 系统在生产环境会遇到各种预料之外的字符,表情符号、特殊标点、用户乱码。没开 byte_fallback,这些全部变成 unk,模型既学不到信息,下游也难定位;开了之后至少保留了字节级信息,模型还有机会从表面拼写中学到东西。

5.4 特殊的 id 分配千万别随缘

SentencePiece 默认的 token id 分配可能和你预期不同。如果你不显式设置,unk 可能是 0,bos 是 1,eos 是 2,pad 默认是 -1 表示禁用。

几乎所有深度学习框架都需要 pad token,所以一定要显式指定:

--pad_id=0 --unk_id=1 --bos_id=2 --eos_id=3

顺序可以按项目调整,但训练时的设定必须和后续加载时的配置保持一致。我在某个项目里就因为这个没对齐,模型训练时 pad token 用的是 id 2,分词器输出的 eos 也是 id 2,两个角色碰撞,直到 loss 异常才发现,白白浪费了两天训练时间。

5.5 其他容易忽视的参数

  • add_dummy_prefix:默认 True,会在每句开头加一个虚拟空格,所以第一个片段通常带▁前缀。如果你需要精确控制解码输出,要了解这个行为。
  • remove_extra_whitespaces:默认 True,会合并连续空白。需要严格保留原文格式时请关闭。
  • normalization_rule_name:默认 nfkc,会把全角字母、兼容字符归一化。对中文语料没问题,但如果你处理的是代码或歧义文本,请先确认归一化是否影响语义。
  • split_by_whitespace:默认 True。对中文来说,空格仍然作为片段分隔候选保留,关闭后所有空白都消失,可能会改变切分行为。
  • train_extremely_large_corpus:语料超过几 GB 时建议打开,内部会切换处理方式。

6. 集成进 NLP pipeline 时容易踩的坑

6.1 不要在每句话里重复加载模型

SentencePieceProcessor的加载过程不是零成本,一次可能要几十毫秒。实际工程里常见错误是在每条样本的处理函数里 new 一个 processor,结果数据处理速度慢到怀疑人生。

正确做法是把 processor 设成全局单例,进程启动时加载一次,后续所有分词请求复用。我自己写过一个简单的封装,所有并发线程共享同一个 processor,实测没有出现数据竞争问题。

import sentencepiece as spm _SP = None def get_tokenizer(): global _SP if _SP is None: _SP = spm.SentencePieceProcessor(model_file="zh_sp.model") return _SP def encode_text(text: str): return get_tokenizer().encode(text, out_type=str)

6.2 和 Transformers 生态集成时的对象匹配

如果你想把 SentencePiece 模型接进 Hugging Face 的PreTrainedTokenizerFast,可以直接把 processor 对象传进去:

from transformers import PreTrainedTokenizerFast import sentencepiece as spm sp = spm.SentencePieceProcessor(model_file="zh_sp.model") tokenizer = PreTrainedTokenizerFast( tokenizer_object=sp, unk_token="<unk>", bos_token="<s>", eos_token="</s>", pad_token="<pad>" )

注意tokenizer_object需要提供tokenize和convert_tokens_to_ids之类的接口,SentencePieceProcessor 正好满足。如果你用的是tokenizers库,也可以从zh_sp.vocab文件直接构造 Unigram tokenizer。

6.3 pad token 和 unk token 的 id 对齐

前面训练时指定了pad_id=0,后续加载时也要保证 pad token 的 id 是 0。Hugging Face 的 tokenizer 在初始化时会根据pad_token="<pad>"自动查找对应 id,但前提是zh_sp.vocab里确实存在<pad>这个词条,且 id 为 0。

如果两者不一致,典型症状是:模型训练正常,但推理时生成结果里夹杂大量 pad token,或者 loss 曲线异常。排查方法很简单,加载后打印一遍特殊 token 的 id 做对照:

print(tokenizer.pad_token_id) print(tokenizer.unk_token_id) print(tokenizer.bos_token_id) print(tokenizer.eos_token_id)

6.4 解码时空格和多出的虚前缀

用 SentencePiece 编码中文句子时,句子开头的 token 可能带▁前缀,这正是add_dummy_prefix的行为。解码时 processor 会把这个虚拟前缀还原掉,所以decode(encode(text)) == text依然成立。

但如果你自己写拼接逻辑,比如只取一部分 token 来 decode,就可能出现开头多一个空格的问题。这时候需要用sp.decode(tokens)而不是手动拼接字符串。

6.5 批处理和性能优化

编码长文本时,批处理能明显提升吞吐。SentencePiece 有encode的批处理用法,可以先sp.encode_as_ids(text),然后统一做 padding。要注意 padding 时别把真实 token 序号和 pad id 混在一起,建议先截断再填充。

我实测过,批量编码 100 条短文本和逐条编码相比,速度能快 3~5 倍。所以数据预处理环节务必走批量路线。

7. 尾声:几个实测结论与决策速查

这篇文章写到这里,核心内容基本覆盖完了。最后分享一下我多次实验后沉淀下来的几条结论。

第一,SentencePiece 的“语言无关”不是一句空话。中文不需要额外分词,英文不需要预切分,中英混排也能直接处理。这个特性让数据清洗链条短了一大截,bug 也更少。

第二,词表大小不是越大越好。嵌入层参数随词表线性增长,训练和推理都要额外开销。小模型配超大词表,样本量不足以充分学习每个 token 的表示,反而容易过拟合。

第三,参数组合没有银弹。同一个项目里,1.5 万词表和 3 万词表在 NER 任务上可能只有零点几个点的差异,但在生成任务上差异明显。最好的办法是训练多组候选,在目标任务上做小规模验证再定最终配置。

第四,遇到 unk 先别慌。先检查是不是character_coverage太低、是不是特殊符号没归一化、是不是 byte_fallback 没开。多数情况下通过这三个开关就能解决。

最后,如果你刚开始接触 SentencePiece,建议先用一个小的垂直领域语料训一个 8K 的 Unigram 词表,把spm_train跑通,再用sp.encode手测几段真实业务文本,观察切分是否符合直觉。这个工具最值钱的地方在于它把“分词”从经验活变成了可复现的工程流程,值得花时间掌握。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询