AI 工程基础体系 · 第 11/100 篇。内容覆盖机器学习、深度学习与生成式 AI;模型、数据、评测、权限和成本会作为同一生产系统处理。

Tokenization 与 Embedding:词表、子词、位置、池化和语义空间

在 Transformer 系统中,文本不会直接进入 Attention。原始字符串通常经过以下数据流:

flowchart LR
    A[原始文本] --> B[规范化]
    B --> C[预分词]
    C --> D[子词 Tokenizer]
    D --> E[Token ID 序列]
    E --> F[Embedding 查表]
    E --> G[位置编码]
    F --> H[输入表示]
    G --> H
    H --> I[Transformer 层]
    I --> J[上下文表示]
    J --> K[池化或取指定位置]
    K --> L[分类/检索/相似度]

每一步都改变了数据的表示形式:

  • Tokenization 把字符串转换为离散的 Token 序列;
  • 词表 把每个 Token 映射到整数 ID;
  • Embedding 把整数 ID 映射到连续向量;
  • 位置表示 把顺序信息注入向量或 Attention;
  • Transformer 根据上下文更新每个位置的表示;
  • 池化 把多个位置的向量压缩为一个向量;
  • 语义空间 是这些向量按照距离、方向和任务目标组织出的几何空间。

如果混淆这些层次,就容易把“Token ID 当作向量”、把“输入 Embedding 当作句子语义”,或者误以为“向量相近”天然意味着“文本相似”。


一、从字符串到 Token ID

1. Token 不是固定意义上的“词”

Token 是 Tokenizer 输出的离散单元。它可能是:

  • 一个完整单词;
  • 一个词的一部分;
  • 一个汉字;
  • 多个汉字组成的片段;
  • 标点;
  • 空格或换行;
  • 字节;
  • 特殊控制符号。

因此,Token 不等于词,Tokenization 也不等于按空格切词。

例如,英文句子:

unbelievable models

可能被切成:

["un", "believ", "able", " models"]

也可能被切成:

["unbelievable", " models"]

中文文本:

向量检索

可能被切成:

["向", "量", "检", "索"]

也可能被某个词表切成:

["向量", "检索"]

不同模型的 Tokenizer 不兼容。即使两个模型都使用 Transformer,它们也可能使用不同的词表、规范化规则、特殊 Token 和 ID 编排方式。

2. Tokenization 的完整过程

一个实际 Tokenizer 往往不只是调用一个“切分函数”,而是由多个阶段组成:

  1. 规范化(normalization):处理 Unicode、大小写、重音符号、全角半角等;
  2. 预分词(pre-tokenization):按照空格、标点或语言规则划分较大的片段;
  3. 子词模型(subword model):把片段进一步拆成词表中的 Token;
  4. 特殊 Token 注入:加入起始、结束、分隔、填充或未知 Token;
  5. 编码(encoding):把 Token 转成整数 ID;
  6. 生成辅助信息:如 attention_masktoken_type_ids、字符偏移量。

一个常见的抽象结果是:

{
    "input_ids": [101, 2023, 2003, 1037, 2742, 102],
    "attention_mask": [1, 1, 1, 1, 1, 1]
}

其中:

  • input_ids 是词表索引;
  • attention_mask 通常用 1 表示真实 Token,用 0 表示 Padding;
  • 101102 可能是特殊 Token,但具体含义必须由该模型的 Tokenizer 定义,不能跨模型猜测。

3. 词表与 ID

设词表为:

V={t0,t1,,tV1}V = \{t_0, t_1, \dots, t_{|V|-1}\}

其中每个 tit_i 是一个 Token。词表函数把 Token 映射为整数:

id:tii\operatorname{id}: t_i \mapsto i

输入文本经过 Tokenization 后,得到:

x=(t1,t2,,tn)x = (t_1, t_2, \dots, t_n)

再编码为:

id(x)=(i1,i2,,in)\operatorname{id}(x) = (i_1, i_2, \dots, i_n)

这些 ID 只有索引意义。100 不比 10 更“接近”某个词,200 也不表示比 20 更强的语义。ID 的数值大小不承载距离关系。

这是一个重要反例:

Token A -> ID 10
Token B -> ID 11
Token C -> ID 1000

不能因此推断 A 与 B 的语义比 A 与 C 更接近。语义结构由后续学习出的向量决定,而不是由 ID 的整数大小决定。


二、为什么使用子词

1. 固定词表与未知词问题

如果为每个完整单词建立词表,词表会迅速膨胀:

  • 新词不断出现;
  • 人名、产品名、网址、代码标识符难以穷举;
  • 拼写变体造成大量稀疏词项;
  • 不同语言的词形变化增加词表压力。

如果词表太小,大量词会变成统一的 [UNK]。例如:

transformerization

如果整个字符串不在词表中,可能完全丢失内部结构。

子词方法尝试在两者之间折中:常见片段使用完整 Token,罕见词拆成多个已知片段。例如:

transformerization
-> ["transformer", "ization"]

这样,模型至少可以共享 transformerization 的统计信息。

2. BPE 的基本训练过程

Byte Pair Encoding,简称 BPE,最初用于压缩,后来被用于子词 Tokenization。其核心思想是:从较小单元开始,反复合并训练语料中最常见的相邻单元。

假设训练语料经过预处理后得到:

low low lower lowest

初始时可以把每个词拆成字符,并在词尾加入边界标记:

l o w </w>
l o w </w>
l o w e r </w>
l o w e s t </w>

统计相邻符号对的频率:

(l, o) 出现 4 次
(o, w) 出现 4 次
(w, </w>) 出现 2 次
(w, e) 出现 2 次
(e, r) 出现 1 次
(e, s) 出现 1 次
(s, t) 出现 1 次

如果选择合并 (l, o),得到:

lo w </w>
lo w </w>
lo w e r </w>
lo w e s t </w>

再选择合并 (lo, w),得到:

low </w>
low </w>
low e r </w>
low e s t </w>

继续学习若干合并规则后,词表中可能包含:

low
er
est

推理时,Tokenizer 按学习到的合并规则处理新词:

lower
-> ["low", "er"]

lowest
-> ["low", "est"]

这里的关键不是“把字符串切得越长越好”,而是在词表大小、序列长度和泛化能力之间取得平衡。

BPE 推理阶段必须使用训练阶段确定的合并规则。不能在服务端重新根据当前输入动态合并,否则同一个模型的 ID 序列会不稳定,Embedding 查表也就失去意义。

3. WordPiece 与 Unigram 不是同一个算法

常见子词方案包括:

BPE

BPE 通常根据相邻符号对的频率学习合并规则。许多实现会进一步使用字节级处理,以覆盖任意 UTF-8 输入。

WordPiece

WordPiece 也生成子词词表,但其训练目标和候选选择策略与标准 BPE 不同。它常见于 BERT 系列模型。某些实现中,非词首子词会用 ## 标识,例如:

playing -> ["play", "##ing"]

##ing 不是普通字符串前缀,而是该 Tokenizer 的边界标记约定。

Unigram

Unigram 模型先准备候选子词集合,再通过概率模型选择最优切分。对于同一个字符串,它可能存在多个候选分解,模型选择概率更高的路径。

因此,“使用子词”是总体思想,而 BPE、WordPiece 和 Unigram 是不同的实现与训练算法。加载模型时必须同时加载与模型匹配的 Tokenizer。

4. 字节级 Tokenization

字节级方法先将文本表示为 UTF-8 字节,再学习字节片段。它的优点是理论上可以表示任意字符串,包括:

罕见汉字
emoji
混合脚本
代码和二进制样式文本

代价是某些字符可能被拆成更多 Token,导致:

  • 序列更长;
  • Attention 计算量增加;
  • 输入和输出的 Token 成本增加;
  • 字符偏移量需要小心处理,因为字节偏移不一定等于 Unicode 字符偏移。

5. Tokenization 不一定可逆

理想情况下,解码函数满足:

decode(encode(s))=s\operatorname{decode}(\operatorname{encode}(s)) = s

但实际系统中可能存在例外:

  • 规范化改变了原始字符串;
  • 多个空白被折叠;
  • 特殊 Token 不属于普通文本;
  • 未知字符被替换为 [UNK]
  • 字节级解码与字符级显示存在差异;
  • 解码时跳过了特殊 Token。

因此,做高亮、引用定位、PII 脱敏或代码修改时,不能只依赖 Token 下标。应保存 Tokenizer 生成的字符偏移量,并验证偏移量对应的原始字符串区间。


三、序列长度、特殊 Token 和 Padding

1. 序列长度不是字符数

文本长度至少有三种口径:

  • 字符数;
  • 字节数;
  • Token 数。

Transformer 的 Attention 通常按 Token 数 nn 工作。标准全连接自注意力的时间和空间复杂度大致为:

O(n2d)O(n^2d)

其中 dd 是隐藏维度。于是,Tokenization 直接影响:

  • 能否放入上下文窗口;
  • Attention 计算量;
  • KV Cache 大小;
  • 训练和推理成本;
  • RAG 的切块边界。

同一段文本在不同 Tokenizer 下可能得到不同的 Token 数,因此不能直接用“字符数除以某个固定比例”估算上下文占用。

2. 特殊 Token

模型可能定义以下特殊 Token:

  • [CLS]:常被用于句首聚合;
  • [SEP]:分隔句子或标记结束;
  • <bos>:序列开始;
  • <eos>:序列结束;
  • <pad>:批处理中的填充;
  • <unk>:无法表示的未知 Token;
  • <mask>:掩码语言模型训练中的遮盖位置。

特殊 Token 的语义依赖模型训练方式。例如,某个模型有 [CLS],并不保证未经微调的 [CLS] 向量就是高质量句向量。特殊 Token 只有在训练目标赋予它相应职责后,才可能承担稳定的聚合功能。

3. Padding 与 Attention Mask

假设一个批次中有两条序列:

序列 A: [11, 12, 13, 14]
序列 B: [21, 22]

右侧 Padding 后:

input_ids:
[
  [11, 12, 13, 14],
  [21, 22,  0,  0]
]

attention_mask:
[
  [1, 1, 1, 1],
  [1, 1, 0, 0]
]

如果对序列 B 做平均池化,不能把两个 Padding 向量算进去。正确公式是:

p=i=1nmihimax(1,i=1nmi)p = \frac{\sum_{i=1}^{n}m_i h_i} {\max(1,\sum_{i=1}^{n}m_i)}

其中:

  • hiRdh_i \in \mathbb{R}^d 是第 ii 个位置的隐藏向量;
  • mi{0,1}m_i \in \{0,1\} 是 Attention Mask;
  • pp 是池化后的向量。

如果错误地直接平均:

pwrong=1ni=1nhip_{\text{wrong}} = \frac{1}{n}\sum_{i=1}^{n} h_i

Padding 越多,结果越会被无意义的填充位置污染。


四、Embedding:从离散 ID 到连续向量

1. Embedding 查表

设词表大小为 VV,Embedding 维度为 dd。模型维护矩阵:

ERV×dE \in \mathbb{R}^{V \times d}

ii 个 Token 的输入向量是:

ei=E[i]e_i = E[i]

如果输入 ID 序列是:

(5,2,9)(5, 2, 9)

那么 Embedding 输出是:

(E[5],E[2],E[9])(E[5], E[2], E[9])

这本质上是矩阵的行索引操作,而不是把整数 ID 进行数值缩放。

等价地,也可以把每个 ID 表示为 one-hot 向量 qiRVq_i \in \mathbb{R}^{V},则:

ei=qiEe_i = q_i^\top E

但工程实现通常不会真的构造巨大的 one-hot 矩阵,而是直接查表。

2. 一个小型查表例子

假设:

E=[0.10.20.30.40.50.60.70.80.9]E = \begin{bmatrix} 0.1 & 0.2 & 0.3 \\ 0.4 & 0.5 & 0.6 \\ 0.7 & 0.8 & 0.9 \end{bmatrix}

词表只有三个 Token:

ID 0 -> "猫"
ID 1 -> "喜欢"
ID 2 -> "鱼"

输入:

["猫", "喜欢", "鱼"]

编码为:

[0, 1, 2]

查表结果为:

[
  [0.1, 0.2, 0.3],
  [0.4, 0.5, 0.6],
  [0.7, 0.8, 0.9]
]

这些向量最初通常是随机初始化的。训练通过损失函数反向传播,逐渐调整它们,使模型能更好地完成语言建模、分类或其他任务。

3. Token Embedding 不等于上下文表示

必须区分两类向量:

输入 Token Embedding

这是查表得到的 E[i]E[i]。同一个 Token 在任何上下文中,其初始向量通常相同。

银行贷款
银行河岸

两处“银行”进入 Transformer 前可能使用同一行词向量。

上下文隐藏状态

经过 Transformer 层后,第 ii 个位置的表示为:

hi(L)h_i^{(L)}

它依赖整个可见上下文。于是“银行”在两句话中的最终表示可以不同。

在 Transformer 中,可以将第 00 层输入抽象为:

hi(0)=ei+pih_i^{(0)} = e_i + p_i

其中 eie_i 是 Token Embedding,pip_i 是位置表示。随后每层通过 Attention 和前馈网络更新这些向量。

因此,检索或分类通常使用 Transformer 输出的上下文表示,而不是直接使用输入词表矩阵 EE

4. Embedding 是如何学到关系的

以语言模型为例,给定上下文 x<tx_{<t},模型预测下一个 Token:

P(xtx<t)P(x_t \mid x_{<t})

交叉熵损失为:

L=tlogP(xtx<t)\mathcal{L} = -\sum_t \log P(x_t \mid x_{<t})

如果某些 Token 在相似上下文中经常出现,训练梯度会推动相关参数形成某种相似结构。但“共现相似”不等于“严格同义”。向量空间可能同时编码:

  • 主题;
  • 语法角色;
  • 词频;
  • 风格;
  • 语言;
  • 位置和上下文;
  • 任务标签。

Embedding 的几何关系由训练目标、数据分布、模型架构和后处理共同决定。


五、位置:为什么只使用 Token Embedding 不够

1. 没有位置时,Self-Attention 对顺序不敏感

设输入向量序列为:

X=[x1,x2,,xn]X = [x_1, x_2, \dots, x_n]

如果只做无位置的 Self-Attention,交换两个位置的输入,输出会以同样方式交换。换句话说,模型知道“有哪些 Token”,但不天然知道它们的先后关系。

例如:

狗咬人
人咬狗

包含的 Token 可能相同,但顺序改变了事件角色。位置表示就是为了让模型区分这些序列。

2. 绝对位置编码

最直接的方式是把位置向量加到 Token Embedding:

hi(0)=ei+pih_i^{(0)} = e_i + p_i

其中 pip_i 表示绝对位置 ii

学习式绝对位置 Embedding

维护一个位置矩阵:

PRLmax×dP \in \mathbb{R}^{L_{\max} \times d}

ii 个位置使用:

pi=P[i]p_i = P[i]

优点是实现简单、表达能力强。缺点是通常存在最大位置 LmaxL_{\max},超过训练或配置范围时需要额外处理;把位置表简单扩展到更长长度也不保证性能稳定。

正弦位置编码

原始 Transformer 论文使用固定函数:

PE(pos,2k)=sin(pos100002k/d)PE(pos, 2k) = \sin\left(\frac{pos}{10000^{2k/d}}\right)

PE(pos,2k+1)=cos(pos100002k/d)PE(pos, 2k+1) = \cos\left(\frac{pos}{10000^{2k/d}}\right)

其中:

  • pospos 是位置;
  • kk 是维度索引;
  • dd 是隐藏维度;
  • 偶数维使用正弦;
  • 奇数维使用余弦。

不同维度使用不同频率,使每个位置拥有不同的周期组合。正弦函数的一个直觉是,位置之间的相对偏移可以通过三角函数关系表示,因此模型有机会从绝对编码中推断相对位置。

3. 相对位置与 RoPE

另一种思路不是把“位置向量”简单加到输入,而是在 Attention 计算中直接表达位置关系。

标准 Attention 为:

Attention(Q,K,V)=softmax(QKdk)V\operatorname{Attention}(Q,K,V) = \operatorname{softmax} \left( \frac{QK^\top}{\sqrt{d_k}} \right)V

相对位置方法会让注意力分数额外依赖 iji-j,即查询位置 ii 与键位置 jj 的相对距离。

RoPE(Rotary Position Embedding)则对 Query 和 Key 的二维维度对施加与位置相关的旋转。对每个二维分量:

[x2kx2k+1]=[cosθi,ksinθi,ksinθi,kcosθi,k][x2kx2k+1]\begin{bmatrix} x_{2k}' \\ x_{2k+1}' \end{bmatrix} = \begin{bmatrix} \cos\theta_{i,k} & -\sin\theta_{i,k} \\ \sin\theta_{i,k} & \cos\theta_{i,k} \end{bmatrix} \begin{bmatrix} x_{2k} \\ x_{2k+1} \end{bmatrix}

其中 ii 是位置,θi,k\theta_{i,k} 是由位置和频率决定的旋转角。由于两个旋转后的向量做内积时会出现角度差,Attention 分数能够编码相对位置信息。

位置编码的具体实现必须以模型配置为准。不能因为某个模型使用 RoPE,就假设所有 Transformer 都使用 RoPE;也不能把 RoPE 结果当作一个独立的“句子向量”。

4. Padding、位置 ID 与缓存

位置编号通常对应有效序列中的位置,但不同模型在左 Padding、右 Padding、生成式解码和批处理时可能有不同约定。

在自回归生成中,已有前缀的 Key 和 Value 会存入 KV Cache。新 Token 的位置不能重新从零开始,否则新 Token 会被错误地当成序列开头。生成系统需要维护:

  • 当前序列长度;
  • 新 Token 的位置 ID;
  • 因果 Mask;
  • 每层的 Key/Value 缓存;
  • 批次中不同请求的有效长度。

位置处理错误常见的表现包括:

  • 单条输入正常,批量输入异常;
  • 左 Padding 与右 Padding 的结果差异异常;
  • 生成在长上下文中质量明显下降;
  • 使用 KV Cache 的结果与不使用 Cache 不一致。

六、Transformer 如何产生上下文表示

1. Self-Attention 的输入与输出

对于一个位置 ii 的隐藏状态 hih_i,模型通过线性变换得到:

qi=hiWQ,ki=hiWK,vi=hiWVq_i = h_iW_Q,\quad k_i = h_iW_K,\quad v_i = h_iW_V

其中 WQ,WK,WVW_Q, W_K, W_V 是可训练矩阵。

位置 ii 对位置 jj 的注意力分数是:

sij=qikjdks_{ij} = \frac{q_i k_j^\top}{\sqrt{d_k}}

经过 Softmax 后:

αij=exp(sij)r=1nexp(sir)\alpha_{ij} = \frac{\exp(s_{ij})} {\sum_{r=1}^{n}\exp(s_{ir})}

位置 ii 的输出为:

zi=j=1nαijvjz_i = \sum_{j=1}^{n}\alpha_{ij}v_j

这说明一个位置的表示会混合其他位置的信息。对于双向编码器,通常可以关注整个输入;对于因果语言模型,位置 ii 不能访问未来位置 j>ij>i

2. 注意力 Mask 的两个不同含义

工程中经常把两类 Mask 混在一起:

Padding Mask

防止模型关注批次补齐出来的无效位置。

Causal Mask

防止自回归模型访问未来 Token。例如长度为 4 时,允许关系通常是:

位置 1 -> 位置 1
位置 2 -> 位置 1, 2
位置 3 -> 位置 1, 2, 3
位置 4 -> 位置 1, 2, 3, 4

二者可以同时存在。实际注意力分数通常先加上一个极小值或负无穷屏蔽非法位置,再进行 Softmax。

3. 残差与归一化对表示的影响

Transformer 层通常包含 Attention、残差连接、LayerNorm 和前馈网络。抽象写法可以是:

u(l)=LayerNorm(h(l1)+Attention(h(l1)))u^{(l)} = \operatorname{LayerNorm} \left(h^{(l-1)} + \operatorname{Attention}(h^{(l-1)})\right)

h(l)=LayerNorm(u(l)+FFN(u(l)))h^{(l)} = \operatorname{LayerNorm} \left(u^{(l)} + \operatorname{FFN}(u^{(l)})\right)

具体模型可能采用 Pre-LN、Post-LN 或其他变体,不能只凭“Transformer”这个名称断言精确顺序。

对池化而言,重要事实是:不同层的表示承担的功能不同。较底层可能更保留词形和局部语法,较高层可能更受任务目标和上下文影响。最后一层不一定对所有语义相似度任务都最好,因此句向量模型常会使用专门的训练目标或选择特定层。


七、池化:把多个 Token 向量变成一个向量

Transformer 输出通常是一个矩阵:

H=[h1h2hn]Rn×dH = \begin{bmatrix} h_1 \\ h_2 \\ \vdots \\ h_n \end{bmatrix} \in \mathbb{R}^{n\times d}

但分类、聚类和向量检索经常需要固定长度向量:

pRdp \in \mathbb{R}^{d}

池化就是从 HH 构造 pp 的过程。池化不是一个唯一算法,不同方式会保留不同信息。

1. [CLS] 池化

如果模型设计了句首聚合 Token,可以取:

p=hCLSp = h_{\text{CLS}}

这种方式成立的前提是模型训练过程使该位置承担了聚合职责。例如某些编码器在预训练或下游分类中专门使用 [CLS]

反例是:把任意模型第一个 Token 的向量都叫作句向量。对于没有按此方式训练的模型,第一个位置可能只是普通 Token,甚至是 <bos>,其向量不一定代表整句语义。

2. Masked Mean Pooling

平均池化是:

p=imihiimip = \frac{\sum_i m_i h_i}{\sum_i m_i}

实现时必须使用 Mask 排除 Padding,通常也要明确是否排除特殊 Token。

例如有效 Token 的三维向量是:

h1 = [1, 0, 0]
h2 = [0, 2, 0]
h3 = [0, 0, 3]

平均池化得到:

p = [1/3, 2/3, 1]

如果有两个 Padding 位置,却错误地除以总长度 5,则会得到:

[1/5, 2/5, 3/5]

这不仅数值变小,还会改变不同长度文本之间的比较关系。

3. 加权平均池化

可以使用权重 wiw_i

p=imiwihiimiwip = \frac{\sum_i m_iw_i h_i} {\sum_i m_iw_i}

权重可能来自:

  • Attention 权重;
  • Token 重要性模型;
  • TF-IDF;
  • 训练出的池化层;
  • 手工排除标点或特殊 Token 的规则。

但不能直接把任意 Transformer 层中的 Attention 权重解释为“语义重要性”。Attention 权重受头、层、归一化和任务影响,一个头关注标点或语法边界并不意味着标点是句子语义核心。

4. Max Pooling

逐维取最大值:

pk=maxi:mi=1hi,kp_k = \max_{i:m_i=1}h_{i,k}

它可以突出某些强激活特征,但会丢失出现次数、位置和整体分布。对长文本来说,少数异常 Token 可能主导结果。

5. Last-Token Pooling

对因果语言模型,常见做法是取最后一个有效 Token 的隐藏状态:

p=hlastp = h_{\text{last}}

因为最后位置在因果 Mask 下可以聚合前面的上下文。但这不等于所有因果模型最后位置天然适合向量检索。它是否适合作为句向量,取决于预训练目标、模型结构和是否经过对比学习等专门训练。

6. 池化前后是否归一化

得到向量 pp 后,常使用 L2 归一化:

p^=pp2\hat p = \frac{p}{\|p\|_2}

两个向量的余弦相似度为:

cos(p,q)=pqp2q2\cos(p,q) = \frac{p^\top q}{\|p\|_2\|q\|_2}

如果已经将向量归一化,则:

cos(p^,q^)=p^q^\cos(\hat p,\hat q)=\hat p^\top \hat q

这使相似度计算可以用内积实现。但归一化会丢失向量长度信息。如果长度本身编码了置信度、强度或其他任务信号,就不能未经验证地归一化。


八、语义空间:向量相近到底意味着什么

1. 语义空间是任务依赖的几何结构

设一个文本编码器为:

f:textRdf: \text{text} \rightarrow \mathbb{R}^d

对文本 xx 得到:

zx=f(x)z_x=f(x)

当两个文本的向量距离较近时,严格含义应是:

在该编码器的训练目标、数据分布和相似度度量下,它们被表示为相近。

这不保证它们在所有任务中都相似。

例如,一个按主题训练的 Embedding 模型可能认为:

“如何重置数据库密码”
“数据库密码忘记怎么办”

非常接近;但它未必能很好地区分“重置管理员密码”和“重置普通用户密码”这类权限差异。

2. 距离函数

常见度量包括:

欧氏距离

d2(x,y)=xy2d_2(x,y)=\|x-y\|_2

对尺度敏感。向量长度变化可能显著影响距离。

点积

s(x,y)=xys(x,y)=x^\top y

同时考虑方向和长度。某些模型或向量数据库直接以点积为检索分数。

余弦相似度

scos(x,y)=xyx2y2s_{\cos}(x,y) = \frac{x^\top y}{\|x\|_2\|y\|_2}

只关注方向,不关注长度。使用前必须确认查询向量和文档向量采用相同的归一化策略。

“余弦相似度越大越相似”只在模型训练与评测支持这种解释时才有意义。对任意中间层 Hidden State 直接做余弦排序,可能得到看似合理但实际召回很差的结果。

3. 各向异性问题

理想的语义空间希望不同语义方向充分展开。但许多语言模型的句向量会集中在狭窄区域,表现为:

  • 大量不相关句子的余弦相似度都偏高;
  • 前几个主成分解释了过多方差;
  • 相似度排序被句长、频率或格式影响;
  • 不同语言或领域形成明显簇。

这称为表示空间的各向异性。可用随机抽样诊断:

  1. 编码一批不相关文本;
  2. 计算向量范数和两两相似度分布;
  3. 检查相似度是否异常集中;
  4. 用 PCA 查看前几个主成分的解释比例;
  5. 与已标注的相似/不相似样本比较排序质量。

不能用“相似度均值低”作为唯一目标。中心化、白化或去除主成分有时能改善特定数据集,但也可能删除有用信息,必须通过下游评测验证。

4. 静态词向量与上下文 Embedding

静态词向量为每个词提供一个固定向量:

e(bank)e(\text{bank})

上下文模型则为每次出现生成上下文相关向量:

hi=f(x1,,xn,i)h_i = f(x_1,\dots,x_n,i)

因此:

bank account
river bank

中的 bank 可以有不同表示。

但上下文表示也不自动等于高质量句向量。它首先是为模型的预训练或生成任务服务的中间状态。要用于语义检索,通常需要:

  • 合适的池化;
  • 归一化策略;
  • 与查询/文档格式一致;
  • 与检索目标匹配的训练;
  • 在目标数据集上的召回评测。

5. 双编码器与交叉编码器

向量检索常使用双编码器:

zq=fq(q),zd=fd(d)z_q=f_q(q),\quad z_d=f_d(d)

然后计算:

s(q,d)=zqzds(q,d)=z_q^\top z_d

查询和文档可以预先编码,适合 ANN 索引。

交叉编码器则把查询和文档拼接后共同输入模型:

s(q,d)=g([q;d])s(q,d)=g([q;d])

它可以进行更细粒度的 Token 交互,但每个候选文档都需要重新计算,通常用于召回后的重排。

这解释了一个系统边界:Embedding 负责快速产生候选,不一定负责最终判断。检索系统常见的数据流是:

查询
 -> Query Embedding
 -> ANN 粗召回
 -> 元数据过滤或过滤后召回
 -> Cross-Encoder/LLM 重排
 -> 返回结果

如果查询和文档使用不同模型、不同池化或不同归一化方式,向量空间不再对齐,检索分数也失去可比性。


九、一个可运行的 Hugging Face 端到端示例

下面示例演示:

  1. 加载 Tokenizer;
  2. 查看 Token、ID 和 Mask;
  3. 加载 Transformer;
  4. 取最后一层隐藏状态;
  5. 做 Masked Mean Pooling;
  6. 进行 L2 归一化;
  7. 计算文本之间的余弦相似度。

前置条件:

pip install torch transformers

代码:

import torch
import torch.nn.functional as F
from transformers import AutoTokenizer, AutoModel

MODEL_NAME = "sentence-transformers/all-MiniLM-L6-v2"

tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
model = AutoModel.from_pretrained(MODEL_NAME)
model.eval()

texts = [
    "如何重置数据库密码?",
    "数据库密码忘记了怎么办?",
    "今天天气很好。"
]

# padding=True 使同一批次中的序列长度一致
# truncation=True 防止超过模型允许的最大长度
# return_tensors="pt" 返回 PyTorch 张量
batch = tokenizer(
    texts,
    padding=True,
    truncation=True,
    return_tensors="pt",
    return_attention_mask=True
)

print("input_ids shape:", tuple(batch["input_ids"].shape))
print("tokens of first text:", tokenizer.convert_ids_to_tokens(batch["input_ids"][0]))
print("attention_mask:", batch["attention_mask"])

with torch.no_grad():
    outputs = model(**batch)

# last_hidden_state 形状为 [batch_size, sequence_length, hidden_size]
last_hidden_state = outputs.last_hidden_state
mask = batch["attention_mask"].unsqueeze(-1).to(last_hidden_state.dtype)

# 将 Padding 位置置零
masked_hidden = last_hidden_state * mask

# 只除以有效 Token 数,而不是整个补齐后的序列长度
sum_hidden = masked_hidden.sum(dim=1)
valid_count = mask.sum(dim=1).clamp(min=1e-9)
mean_embedding = sum_hidden / valid_count

# 归一化后,向量点积等于余弦相似度
embeddings = F.normalize(mean_embedding, p=2, dim=1)

similarity = embeddings @ embeddings.T

print("embedding shape:", tuple(embeddings.shape))
print("similarity matrix:")
print(similarity)

预期结构如下:

input_ids shape: (3, 某个批次最大长度)
tokens of first text: [...]
attention_mask: tensor([...])
embedding shape: (3, 384)
similarity matrix:
tensor([
    [1.0000, 较高值, 较低值],
    [较高值, 1.0000, 较低值],
    [较低值, 较低值, 1.0000]
])

具体 Token、相似度和最大长度会随模型版本、Tokenizer 配置和运行环境变化,不能把示例中的数值当作规范保证。

这个模型是专门用于句子 Embedding 的模型,因此平均池化是其常见用法。若换成普通生成式语言模型,不能机械地假设同样的池化方式仍然有效。必须查看模型卡、配置或训练说明,并在目标任务上评测。

代码中的关键错误处理

错误一:没有设置 truncation=True

输入过长时,Tokenizer 可能报错,或者在某些调用方式下产生超长输入。截断会丢失信息,因此不能只为消除报错而盲目截断。生产系统应先按 Token 数切块,并记录被截断的文本范围。

错误二:平均时包含 Padding

这会使短文本的向量被大量填充位置稀释。诊断方式是比较:

mean_hidden_correct
mean_hidden_without_mask

如果两者在批量大小或 Padding 比例变化时显著不同,说明池化实现依赖了无效位置。

错误三:查询和文档使用不同处理流程

例如:

  • 查询使用了指令前缀,文档没有;
  • 查询做了归一化,文档没有;
  • 一侧使用了 [CLS],另一侧使用平均池化;
  • 一侧截断前 512 Token,另一侧截断后 512 Token。

这些差异会破坏双编码器的空间对齐。

错误四:训练模式未关闭

推理时应使用:

model.eval()
with torch.no_grad():
    ...

否则 Dropout 等训练行为可能使同一文本多次编码结果不同,并增加不必要的显存占用。


十、从 Token 到向量检索的完整小例子

假设有三个文档:

D1: 如何修改 MySQL 数据库密码
D2: 忘记数据库登录密码的处理方法
D3: 北京今天有小雨

查询为:

Q: 数据库密码忘了怎么改

系统过程如下:

  1. Query Tokenizer 将 Q 转成 ID 序列;
  2. 文档 Tokenizer 将每个文档转成 ID 序列;
  3. 编码器输出各位置的上下文向量;
  4. 对每条文本做相同的池化;
  5. 统一归一化;
  6. 计算 Q 与每个文档的相似度;
  7. 按分数排序;
  8. 用权限、租户、时间等过滤条件限制候选;
  9. 根据标注数据评估 Recall@K、MRR 或 nDCG。

即使 D1 和 D2 的向量非常接近,也不代表用户一定有权读取 D1。语义相似度与权限判断是两个不同维度:

  • Embedding 解决“内容是否可能相关”;
  • ACL 或元数据过滤解决“用户是否允许看到”。

如果先召回、后过滤,可能发生过滤后没有结果;如果过滤条件必须严格满足,则需要支持过滤的索引结构,或扩大候选数后再过滤。系统设计不能把向量空间当作权限系统。

召回评测中的 Tokenization 因素

切块长度以 Token 数而不是字符数为准。切块过短会:

  • 丢失上下文;
  • 增加文档数量;
  • 使答案分散到多个块。

切块过长会:

  • 稀释主题;
  • 增加编码成本;
  • 受上下文窗口限制;
  • 使一个向量混合多个无关主题。

重叠窗口可以减少边界信息损失,但会增加存储、索引和检索成本。重叠不是越大越好,应通过验证集比较不同切块策略的 Recall@K 和延迟。


十一、常见误解与失败表现

1. “ID 相近,Embedding 就相近”

错误。ID 是离散索引,没有序关系。向量相似度应在 Embedding 或上下文向量上计算。

2. “每个词都有一个固定向量”

只有静态词向量近似如此。Transformer 的最终 Token 表示依赖上下文,同一 Token 在不同句子中可以不同。

3. “最后一层每个 Token 的平均值就是高质量句向量”

不一定。平均池化只是一个函数。模型是否适合句向量,取决于训练目标。普通语言模型的最后层可能更偏向下一个 Token 预测,而不是句子级相似度。

4. “[CLS] 一定代表整句话”

只有模型训练让它承担聚合作用时,这个假设才有依据。对于生成式模型的 <bos> 或第一个普通 Token,更不能直接套用。

5. “Attention 权重就是词的重要性”

Attention 权重表示当前头和当前层的加权关系,不能单独当作因果解释或特征重要性证明。需要结合任务、梯度、遮挡实验或其他解释方法。

6. “Unicode 字符数就是 Token 数”

错误。不同语言、字符、空格、代码和 Emoji 的 Token 化长度差异很大。上下文窗口、费用和切块必须以实际 Tokenizer 计数。

7. “换一个池化方式不会影响检索”

会影响。[CLS]、平均池化、最后 Token、最大池化可能产生完全不同的空间结构。池化方式必须和模型训练、向量索引度量、评测数据一起确定。

8. “只要余弦相似度高,结果就正确”

相似度只是排序信号。失败可能来自:

  • 词表无法表示领域术语;
  • 文本被错误截断;
  • 查询和文档模板不一致;
  • 句向量空间各向异性;
  • 多语言或跨领域迁移失败;
  • 语义相关但权限不允许;
  • 相关信息位于切块边界;
  • 需要精确匹配而不是语义匹配。

诊断时应同时记录:

原文
Token 序列
Token 数
是否截断
池化方式
向量范数
相似度分布
过滤前候选
过滤后候选
人工相关性标签

这样可以区分 Tokenization、编码、索引、过滤和排序各阶段的问题。


十二、生产系统中的版本、成本与一致性

1. Tokenizer 与模型必须成对管理

Tokenizer 的以下内容都会影响结果:

  • 词表;
  • 合并规则;
  • 规范化;
  • 特殊 Token;
  • 最大长度;
  • Padding 方向;
  • 截断方向;
  • 字符偏移量实现。

只升级模型权重而保留旧 Tokenizer,或只升级 Tokenizer 而不升级模型,都会造成 ID 与 Embedding 矩阵不匹配,严重时直接越界,轻微时则产生隐蔽的质量下降。

应将以下信息和模型版本一起固定:

model revision
tokenizer revision
tokenizer_config
special_tokens_map
pooling implementation
normalization strategy
distance metric

2. Embedding 维度与索引结构必须一致

若向量维度是 dd,索引必须以 dd 建立。更换模型后,即使新旧模型输出维度相同,语义空间也通常不兼容,不能直接把新向量写入旧索引并混合排序。

更换 Embedding 模型通常需要:

  1. 双写或离线重编码;
  2. 建立新索引;
  3. 在同一评测集上比较;
  4. 验证过滤、召回和延迟;
  5. 完成切换;
  6. 保留旧索引用于回滚。

3. 量化的边界

向量可以使用 float32float16 或更低精度存储。量化能降低内存和计算成本,但会改变距离:

s(x,y)=xys(x,y)=x^\top y

在量化后变成近似值:

s~(x,y)=Q(x)Q(y)\tilde{s}(x,y)=Q(x)^\top Q(y)

如果候选分数相近,微小误差可能改变排序。必须通过 Recall@K、nDCG、延迟和内存占用共同验证,不能只看存储缩减比例。

4. 多语言与领域术语

同一个 Tokenizer 对不同语言的切分效率不同。Token 数偏高可能导致:

  • 相同字符长度占用更多上下文;
  • 领域文本更容易被拆碎;
  • 稀有术语的表示质量下降;
  • 训练和推理成本上升。

对于法律、医疗、代码、金融等领域,应检查真实语料上的:

  • Token 长度分布;
  • 未知或异常 Token 比例;
  • 领域术语的切分结果;
  • 长文本截断率;
  • 检索标注集上的效果。

十三、如何选择表示与池化方式

可以按使用场景区分:

用于语言模型输入

使用模型自带的 Tokenizer 和输入 Embedding。这里的重点是 Token ID、Mask、位置和上下文窗口管理,不是直接拿输入矩阵做语义检索。

用于 Token 级任务

如命名实体识别、槽位填充、代码高亮,通常保留每个 Token 的上下文向量:

HRn×dH \in \mathbb{R}^{n\times d}

需要将 Token 边界映射回字符边界,并处理一个词被拆成多个子词的情况。

用于句子或段落检索

优先使用专门训练的句向量或检索 Embedding 模型,并确认:

  • 查询和文档的编码方式;
  • 推荐池化方式;
  • 是否需要指令前缀;
  • 输出是否需要归一化;
  • 索引使用余弦、内积还是欧氏距离。

用于精确问答或重排

Embedding 适合快速缩小候选集合;对数字、版本号、字段名、权限条件和否定关系等精确信息,通常需要关键词过滤、结构化查询或交叉编码器重排共同参与。


Tokenization 决定模型看到哪些离散单元,Embedding 将这些单元放入可学习的连续空间,位置机制使顺序可被建模,Transformer 将局部向量变成上下文表示,池化再决定如何把序列压缩为固定长度向量。最终的“语义空间”不是某个单独组件,而是 Tokenizer、模型参数、训练目标、池化、归一化和距离度量共同形成的结果。

理解这条链路后,才能正确解释向量检索中的召回失败、上下文长度限制、批处理 Padding、模型迁移和 Embedding 版本切换,而不是把所有问题都归因于“模型不够大”或“相似度算法不够好”。


系列导航与关联阅读

官方资料

本文依据研究论文、标准组织与主流框架官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。