想象你是一位乐高大师,面对一栋从未见过的城堡模型。图纸上没有这座城堡的编号,但你并不慌——因为你知道,任何复杂的建筑都是由标准积木块拼成的。你拆下几个有印象的模块:拱门、塔尖、窗棂,再组合起来,你就懂了这座新建筑。 这就是子词嵌入的核心理念。在自然语言处理(NLP)的世界里,模型会遇见过千上万次“没见过的词”,我们管这叫 OOV(Out-Of-Vocabulary,未登录词)。没有子词技巧之前,模型见到生词只能两手一摊。而有了子词嵌入,模型就像乐高大师一样,把生词拆成熟悉的小积木块,照样能理解它、表示它。 在这篇文章里,我们不用啃公式也能搞懂 BPE(字节对编码)、WordPiece 这些响亮的名字。你会看到:词嵌入如何“查表”,遇到生词如何“拆积木”,拆完又如何“拼回去”。读完你会明白,为什么今天几乎所有的预训练语言模型——从 BERT 到 GPT——都在用这套“积木哲学”。

岗位关联

对于 NLP 工程从业者,掌握子词嵌入是处理开放词汇的必修课。它能让你快速定位 OOV 事故根源,为线上模型加上“容错机制”,真正做到词表有限而词汇无限。

目录
  1. 可视化演示
  2. 词嵌入困境
  3. 子词核心思想
  4. BPE算法
  5. WordPiece算法
  6. 子词嵌入表示
  7. 工程实践落地
  8. AI 应用场景
  9. Python 演示
  10. 常见误区
  11. 面试题

可视化演示

动态演示子词嵌入解决OOV难题全解的核心机制,通过概览/流程/对比/趋势四种模式展示关键概念与交互流程。

本节配有交互动效(进入视口后自动加载,约 3KB)。

子词嵌入解决OOV难题全解:子词嵌入 子词嵌入与OOV概述 子词嵌入与OOV原理 核心机制可视化 子词嵌入解决OOV难题全解整体架构与关键组件

词嵌入困境

在子词出现之前,NLP 模型处理单词的方式,就像城堡里的乐高仓库:每个单词对应一个固定编号的积木箱,箱子里装着这个词的向量表示。模型把句子中的每个词换算成编号,到仓库里“查表”取出对应的向量。这套体系很直观,但它有一个致命前提——仓库必须认识这个词。

技术上讲,这个仓库就是“固定词表 + 嵌入矩阵”。词表大小为 V 时,嵌入矩阵是一个 V×d 的矩阵,第 t 个词的向量就是矩阵的第 t 行:

其中 \(E\in\mathbb{R}^{V\times d}\) 是嵌入矩阵,\(u_t\) 是词 \(word_t\) 在词表中的编号索引。

问题在于,真实世界的词汇是开放的:新品牌、网络新词、专业术语、拼写变体会源源不断地冒出来。固定词表一旦建立就被画地为牢。遇到词表外的词(OOV),模型只能用特殊的 UNK 标记顶替,而 UNK 的向量是一个“模糊的平均脸”,没有任何语义信息。这就好比乐高大师拿到一块陌生的零件,仓库里查不到编号,只能扔进“杂物箱”——这块积木在这个模型里从此失去了意义。

ewordt=E[ut]e_{word_t} = E[u_t]
公式含义:词的向量 = 从嵌入矩阵中按编号“抽”出的一行数据。
  1. 嵌入矩阵模型预先学习好的大表格,每一行是某个词的向量表示。
    E∈RV×dE\in\mathbb{R}^{V\times d}
  2. 词编号每个词在词表中的位置序号,本质是查表的“门牌号”。
    utu_t
  3. 查表取值用编号检索矩阵行,得到当前词的稠密向量,供下游任务使用。
    ewordt=E[ut]e_{word_t} = E[u_t]

子词核心思想

乐高大师的智慧在于:从没见过整座城堡不要紧,只要认得出拱门、塔尖这些标准件就够了。子词嵌入的思路一模一样——把单词拆成更小的、反复出现的“标准积木块”,用这些积木块的组合来表示任意单词。这样词汇表就从“词级”降到了“子词级”,规模被大幅压缩,同时几乎不再有真正“未知”的输入。

这个想法有坚实的语言学根基:英语等语言有丰富的形态学结构。playing = play + ing,unhappiness = un + happi + ness。词根承载核心语义,前后缀承载语法和词性变化。子词切分天然地把这些构词单元当作最小积木,等于让模型自己学会了“词根词缀词典”。

组合爆炸在这里是朋友的化身:假设只有 3 万个子词单元,任意两三个拼起来就能覆盖绝大多数英语单词。词表的词汇覆盖面不再是“背了多少个词”,而是“积木类型有多少种”。有限的小积木,拼出无限的词汇,这正是子词方案最迷人的地方。

BPE算法

BPE(Byte Pair Encoding,字节对编码)是子词切分里最经典、最接地气的算法。它不依赖任何语言学知识,只靠一条朴素的统计直觉:如果两个字符频繁地挨在一起,它们就应该被合并成一个积木块。整个训练过程像一位学徒在反复整理积木箱——哪两块积木总被一起用,就把它们粘成一块。

算法从字符级语料出发,每一轮统计当前所有相邻符号对的频次,选最高频的一对合并,形成新的子词符号,重复直到词表达到预设规模。这一过程得到的是一串“合并操作”,也就是合并规则表:

训练完成后,模型拿到一张合并规则表。面对新词时,同样从字符开始,一遍遍套用规则表中“能合并就合并”的操作——注意,这里不需要重新统计频率,规则表是现成的。比如训练时见过 low 和 lower,学会了合并 er,那么推理时遇到生词 lowestness,也能自动把它切分成 low + est + ness。这就是 BPE 解决 OOV 的机制:不与生词硬碰硬,而是把生词拆成训练时见过的熟积木。

score(p,q)=count(p,q)\mathrm{score}(p,q) = \text{count}(p,q)
(p∗,q∗)=arg⁡max⁡p,q score(p,q)(p^*,q^*) = \arg\max_{p,q}\ \mathrm{score}(p,q)
公式含义:每个相邻符号对的分值就是它们在语料中共同出现的次数,每轮挑次数最多的一对进行合并。
  1. 候选对当前词汇表中的两个相邻符号(可能是单字符,也可能是子词)。
    (p,q)(p,q)
  2. 频次统计在整个语料中数一数这对符号挨着出现的次数。
    count(p,q)\text{count}(p,q)
  3. 选出最优取频次最高的相邻对,作为本轮合并的对象。
    (p∗,q∗)=arg⁡max⁡p,q count(p,q)(p^*,q^*)=\arg\max_{p,q}\ \text{count}(p,q)
  4. 迭代合并把这对符号粘合成新符号,更新词表,重复上述过程直到目标词表规模达成。

WordPiece算法

WordPiece 是 BPE 的“概率版升级”。它同样反复合并相邻符号,但选择合并对象的标准从“出现次数”换成了“语言模型似然增益”。打个比方:BPE 的学徒只看“这两块积木是不是总被一起拿”,而 WordPiece 的师傅会问:“把这两块粘起来,拼出正确模型 的概率提升多少?”

WordPiece 为每个候选子词维护一个概率,合并 \(p,q\) 得到 \(pq\) 时,要求满足:

合并后序列的语言模型概率应当比合并前更高。每轮选择能使似然提升最大的候选对:

WordPiece 与 BPE 最主要的差别就在这:一个是“谁出现得多就合并谁”,一个是“谁合起来更顺就合并谁”。实际效果上,WordPiece 倾向于生成语言上更合理的子词边界,但它需要额外的语言模型来计算概率,训练开销更高。BERT 的词表正是用 WordPiece 构建的,所以它面对生词时,能切分得更“润物细无声”。

P(pq)>P(p)×P(q)P(pq) > P(p) \times P(q)
(p∗,q∗)=arg⁡max⁡p,q[log⁡P(pq)−(log⁡P(p)+log⁡P(q))](p^*,q^*) = \arg\max_{p,q}\left[\log P(pq) - (\log P(p) + \log P(q))\right]
公式含义:用概率乘积作比较基准,只有“粘在一起更顺理成章”的相邻对才值得合并。
  1. 基准衡量把合并前的两个符号看作独立事件,期望共现概率是两者概率的乘积。
    P(p)×P(q)P(p)\times P(q)
  2. 合并后概率子词 \(pq\) 在语言模型中的实际出现概率。
    P(pq)P(pq)
  3. 增益计算合并后的对数概率与合并前基准的差值,反映合并带来的“流畅度提升”。
    log⁡P(pq)−log⁡P(p)−log⁡P(q)\log P(pq) - \log P(p)-\log P(q)
  4. 挑增益最大每一轮都选增益最大的一对合并,直到词表规模达标。

子词嵌入表示

拼好积木只是第一步,模型真正要的是“向量”。子词嵌入的完整流程是:先把生词拆成子词序列,查子词向量表拿到每个子词的向量,再把它们合成一个词向量。合并不是简单相加,而是取平均,让每个子词贡献均匀的语义分量。

设生词 \(w\) 被切分为子词 \(s_1, s_2, \ldots, s_k\),每个子词对应向量 \(g(s_i)\),则词向量为:

这种表示方式让 OOV 词不再“无家可归”。比如 unknowable 切为 un + know + able,它的向量就由“否定前缀 + 知道 + 形容词后缀”三块积木的向量平均而成——模型即便第一次见它,也能大致感知到它和 known、unknown 有千丝万缕的关系。需要提醒的是,即使是子词也可能有极低频的情况,实践上会用特殊标记兜底,保证任何输入都有向量可查。

ew=1k∑i=1kg(si)e_w = \frac{1}{k}\sum_{i=1}^{k} g(s_i)
公式含义:词的向量 = 各子词向量取平均。子词向量表是模型训练时学好的,生词因此也能“拼”出平滑的向量。
  1. 子词切分序列模型把生词按合并规则切成 k 个子词单元。
    s1,s2,…,sks_1, s_2, \ldots, s_k
  2. 子词查表每个子词从子词向量表中获得一个稠密向量。
    g(si)g(s_i)
  3. 求和把所有子词向量逐维度相加,语义分量汇总。
    ∑i=1kg(si)\sum_{i=1}^{k} g(s_i)
  4. 取平均除以子词数量,防止长词向量过大,保持尺度稳定。
    1k∑i=1kg(si)\frac{1}{k}\sum_{i=1}^k g(s_i)

工程实践落地

理论落地到工程,有三个关键动作:加载子词模型、控制词表规模、缓存分词结果。目前最主流的工具是 SentencePiece,它把 BPE / WordPiece / unigram 等算法统一封装成“一句代码加载、一句代码切分”的开箱工具。无论是训练自己的词表,还是直接加载 BERT 的 WordPiece 词表,流程都极为标准。

词表规模的选择需要权衡:词表太小,子词碎片化,序列变长,模型计算量上升;词表太大,又回到了“词级嵌入”的老路,OOV 问题复萌。经验上,英文模型常用 32K 或 50K 的词表,中文则在 20K~80K 之间调优。另一个工程细节是缓存:子词切分在同一轮训练中重复出现频率极高,把“字符串→token 序列”的映射结果缓存下来,能省下大量重复计算。

子词方法对中文、日文这类没有自然空格的语言同样适用——这恰恰是它最神奇的地方。中文的“积木”天然是字或词,SentencePiece 可以直接按字符建模,再学习高频相邻字的合并。一个统一的子词管线同时服务中英文,意味着算法工程师再也不用为每种语言手写一套分词器了。

AI 应用场景

搜索引擎Query纠错与召回

阶段:文本预处理 / 查询理解

解决的问题:用户搜索时输入拼写错误、中英混输或新词(如“iPhone 15 Pro Max 哈苏”),传统固定词表检索直接匹配失败,召回率为零。子词嵌入把 query 拆成子词,生词也能映射到向量空间,与文档向量的相似度计算依然可行。

具体案例:某电商搜索系统接入基于 BPE 的子词模型后,对“加绒冲锋衣女加厚”这类长尾 query,分词稳定性显著提升。即使出现新品名“防晒冲锋衣pro版”,系统也能通过 防晒 + 冲锋衣 + pro + 版 的子词组合定位到正确品类,搜索曝光量提升约 15%。

多语言机器翻译的冷启动

阶段:词嵌入 / 序列到序列建模

解决的问题:小语种语料稀缺,直接训练词级嵌入会频遭 OOV 轰炸。子词嵌入将不同语言共享的字符、音节作为积木,让模型在资源丰富的语言上学会的“拼积木”能力迁移到低资源语言上。

具体案例:某出海翻译产品要支持印尼语与马来语互译,两者拼写相似但语料极少。工程师加载多语言预训练模型(使用 SentencePiece 子词词表),零微调即可完成基础翻译;微调 1 万条平行语料后,BLEU 值达到可用水平。生词不再导致整句翻译崩溃。

情感分析中的新词鲁棒性

阶段:特征表示 / 分类建模

解决的问题:社交媒体充满“绝绝子”"yyds"“破防了”这类网络新词,传统模型只能丢 UNK。子词嵌入能把新词拆成已有子词(如 yyds 拆为 yy + ds),向量依然落入语义空间,分类器不至于失灵。

具体案例:某舆情分析系统对微博评论做情感识别,接入子词嵌入后,“这个价格我真的会谢”这类反讽新表达也能被正确分到“负面”。对比原词级模型,OOV 词的分类准确率从 52% 提升到 78%。

Python 演示

python76 行
# -*- coding: utf-8 -*-from collections import defaultdict, Counter class BPE:    def __init__(self, num_merges=10, end_token='_'):        self.num_merges = num_merges        self.end_token = end_token        self.merges = {}        self.word_end = {}     def get_pairs(self, word):        return set(zip(word, word[1:]))     def learn_bpe(self, corpus):        freq = Counter()        for w in corpus:            chars = list(w) + [self.end_token]            self.word_end[tuple(chars)] = self.word_end.get(tuple(chars), 0) + 1        vocab = {tuple(list(w) + [self.end_token]): c for w, c in self.word_end.items()}        for i in range(self.num_merges):            pairs = Counter()            for tokens, count in vocab.items():                for p in self.get_pairs(tokens):                    pairs[p] += count            if not pairs:                break            best = max(pairs, key=pairs.get)            self.merges[best] = '(' + best[0] + ',' + best[1] + ')'            new_vocab = {}            for tokens, count in vocab.items():                merged = self.merge_word(tokens, best)                new_vocab[merged] = new_vocab.get(merged, 0) + count            vocab = new_vocab     def merge_word(self, tokens, pair):        out, i = [], 0        while i < len(tokens):            if i < len(tokens) - 1 and (tokens[i], tokens[i+1]) == pair:                out.append(tokens[i] + tokens[i+1])                i += 2            else:                out.append(tokens[i])                i += 1        return tuple(out)     def tokenize_word(self, word):        tokens = tuple(list(word) + [self.end_token])        while len(tokens) > 1:            pairs = self.get_pairs(tokens)            best_key = None            for p in pairs:                if p in self.merges:                    if best_key is None or pairs_order(p) < pairs_order(best_key):                        best_key = p            if best_key is None:                break            tokens = self.merge_word(tokens, best_key)        return [t for t in tokens if t != self.end_token]  def pairs_order(pair):    return 0  def main():    corpus = ['low', 'lower', 'lowest', 'new', 'newer', 'newest', 'newerest']    bpe = BPE(num_merges=8)    bpe.learn_bpe(corpus)    print('merge rules:', bpe.merges)    for w in ['lowestness', 'newerest', 'unknown']:        pieces = bpe.tokenize_word(w)        print(w, '->', pieces)  if __name__ == '__main__':    main()
目的:用最简单的方式实现 BPE 训练与 OOV 分词,展示子词切分的全流程。
  1. from collections import defaultdict, Counter引入计数器,为频次统计做准备。
  2. self.merges = {}合并规则表,训练的核心产出物,推理阶段的“积木图鉴”。
  3. self.word_end[tuple(chars)] = ...给每个单词末尾加结束标记,让词尾模式被正确建模。
  4. pairs[p] += count对每个词内相邻字符对计数,权重是该词在语料中的频次。
  5. best = max(pairs, key=pairs.get)挑出本轮最高频的字符对,这是 BPE 的决策核心。
  6. self.merges[best] = ...把合并规则存入字典,记录这个“粘积木”动作。
  7. tokens = self.merge_word(tokens, best)把当前词中所有出现该对的位置全部合并。
  8. pairs = self.get_pairs(tokens)推理时重新获取相邻对,逐对查规则表。
  9. if p in self.merges只合并训练中学过的规则,未学过的相邻对保持原样。
  10. [t for t in tokens if t != self.end_token]去掉结束标记,输出最终子词序列。

常见误区

误区BPE 是统计最优的,切出来的子词就是语言学上最合理的,比如一定能把 `unknowable` 切为 `un+know+able`。

正解BPE 的合并规则纯粹由频率驱动,不看词根词缀。它可能学出 unk + now + able 这类在语言学上“奇怪”却统计上合理的切分。子词的目标不是语言学正确,而是让模型拿到稳定、可复用的表示单元。就像乐高拼法不止一种,能稳固支撑模型的就是好拼法。

为何常见:人天然期望切分结果符合直觉,但统计算法关心的是“压缩效率”与“表示稳定性”,二者不是同一件事。

误区WordPiece 就是把 BPE 换个名字,两者没区别,学一个就够。

正解两者框架相似但准则不同:BPE 按相邻符号对的出现频次合并,WordPiece 按语言模型概率增益合并。BPE 更简单高效,WordPiece 更倾向语义完整的边界,但需要额外维护语言模型概率。类比来说,BPE 是“看谁总一起出现就粘谁”,WordPiece 是“粘完要更顺口才算数”。

为何常见:两者接口高度相似,最终产物都是子词词表,不深究内部准则时很容易混淆。

误区用了子词嵌入之后,模型就永远不会再遇到未知输入了,OOV 问题彻底消失。

正解子词把“词级 OOV”变成了“子词级 OOV”,极大缓解但无法消灭。极端情况下,单个未知字符仍可能出现在输入里。工程上会为未知字符预留专门的占位符号,保证模型不崩溃。更重要的是,OOV 的定义从“单词没见过”变成了“字符组合没见过”,概率大幅下降但不为零。

为何常见:看到子词方案对绝大多数生词都有效,就容易过度乐观地认为它是终极解法。

误区子词嵌入就是把几个子词向量简单地加在一起,加得越多语义越强。

正解直接相加会让长词的向量范数偏大,在计算相似度时产生偏差。标准做法是取平均,保证不同长度词向量的尺度一致。平均池化的隐含假设是各子词对词义的贡献均等,虽然粗糙但实验效果好,后续也有加权和 attention 等变体改进。

为何常见:“叠加”符合直觉,而平均看起来像多此一举,容易被忽视。

面试题

Q1. BPE 算法每一轮合并操作的选择标准是?

单选题 · easy

  • A.出现频次最高的相邻符号对← 正确答案
  • B.随机挑选一个相邻符号对
  • C.长度最短的相邻符号对
  • D.语言模型概率增益最大的一对

答案:A。BPE(字节对编码)的核心是贪心策略:每轮统计语料中所有相邻符号对的频次,合并出现次数最多的那一对。选项 D 是 WordPiece 的准则,B 未利用统计信息,C 与频率标准无关。

Q2. 遇到 OOV(未登录词)时,传统的词级嵌入会怎么做?

单选题 · easy

  • A.自动学习一个新向量给该词
  • B.用特殊标记 UNK 统一替换← 正确答案
  • C.直接把该词从句子中删除
  • D.用所有词的平均向量替换

答案:B。传统词级模型词表固定,遇到词表外的词只能映射为统一的 UNK 标记,这个标记的向量是共享的、没有词义信息。A 是子词方案的思路;C 会破坏句子结构;D 虽看似合理但并非标准做法。

Q3. 关于 WordPiece 与 BPE 的区别,下列说法正确的是?

单选题 · mid

  • A.BPE 基于词频,WordPiece 基于字符长度
  • B.两者完全等价,只是名称不同
  • C.WordPiece 用语言模型概率选合并对← 正确答案
  • D.BPE 比 WordPiece 多了结束标记

答案:C。WordPiece 的核心是选择使语言模型似然增益最大的相邻对进行合并,而 BPE 只看频次。两者训练框架类似但准则不同,因此不是等价关系;BPE 也可以加结束标记,这不是两者区别。

Q4. 子词嵌入解决 OOV 的核心思想是什么?

单选题 · mid

  • A.无限扩大词表直到覆盖所有词
  • B.把生词拆成熟知的子词单元表示← 正确答案
  • C.为每个生词单独训练一个模型
  • D.放弃词向量改用字向量

答案:B。子词嵌入把生词拆分为训练中学过的子词单元,再组合其向量得到生词表示,实现“以有限积木拼无限词汇”。A 不现实;C 成本过高;D 字向量只是子词的特例,核心仍是拆与拼。

Q5. BPE 算法训练过程的正确步骤包括哪些?

多选题 · mid

  • A.初始化词表为所有单字符← 正确答案
  • B.反复统计并合并最高频相邻对← 正确答案
  • C.直接使用语言学词典切分单词
  • D.记录合并规则用于推理阶段← 正确答案

答案:ABD。BPE 从字符级词汇出发统计相邻对频次并贪心合并,同时把每一步合并规则保存下来供推理时复用。它不需要预先的语言学词典,因此 C 错误。

Q6. 子词嵌入中,一个 OOV 词的向量通过什么方式生成?

单选题 · mid

  • A.直接随机初始化一个向量
  • B.各子词向量取平均← 正确答案
  • C.选择最相似旧词的向量
  • D.取第一个子词的向量

答案:B。标准做法是先将生词切分为子词序列,查子词向量表后做平均池化得到最终词向量。随机初始化浪费了子词信息;选择最相似旧词或取首子词都丢弃了部分语义。

Q7. 下列哪些属于子词切分带来的工程收益?

多选题 · hard

  • A.词表规模得到有效控制← 正确答案
  • B.生词也能获得平滑的向量← 正确答案
  • C.模型推理速度必然提升
  • D.中英文共用一套分词管线← 正确答案

答案:ABD。子词切分压缩词表、消除绝大部分 OOV,让生词通过子词组合得到向量表示,SentencePiece 等工具对中英文都适用。C 并不必然成立——子词序列可能比单词更长,推理速度取决于具体场景。

Q8. 词级嵌入矩阵的维度通常如何描述?

单选题 · easy

  • A.词表大小 × 向量维度← 正确答案
  • B.向量维度 × 句子长度
  • C.词表大小 × 词表大小
  • D.句子长度 × 向量维度

答案:A。嵌入矩阵 \(E\in\mathbb{R}^{V\times d}\),行数等于词表大小 V,列数等于向量维度 d。每个词对应一行,查表即按编号取行。其他选项描述的是序列矩阵或方阵,不符合嵌入矩阵定义。

Q9. 关于 BPE 从训练到推理的过程,说法正确的是?

多选题 · hard

  • A.训练阶段统计语料中的字符对频次← 正确答案
  • B.推理阶段重新统计新词频次来决定合并
  • C.训练产出的合并规则表直接应用于推理← 正确答案
  • D.推理切分只需要查表套用已有规则

答案:AC。BPE 训练时统计语料得到合并规则表,推理时直接套用这些规则对生词进行切分,不需要重新统计频次。B 违背了 BPE 的设计初衷——推理阶段追求高效,不可能再做全语料统计。

Q10. 子词词表规模过大时,最可能出现的问题是?

单选题 · hard

  • A.子词碎片化严重,序列过长
  • B.词表向词级靠拢,OOV 复萌← 正确答案
  • C.模型训练速度显著加快
  • D.子词向量维度自动提升

答案:B。词表过大意味着合并次数多,最终切分结果接近完整单词,等于退化为词级嵌入,OOV 问题会重新出现。词表过小才会碎片化严重;词表大通常让训练变慢;向量维度是超参数,不会自动变化。