亲手算一遍、亲手跑一遍,比看十遍图都实在

这篇怎么读

番外 09 给了你直觉——为什么需要 embedding、它能干什么。这一篇给你具体:每一种 embedding 技术怎么算、怎么训、怎么写代码、看起来像什么。

我们顺着历史走:先看 90 年代的 TF-IDF,亲手算几个数字;再看 2013 年的 word2vec,把那个简单的两层网络拆开;再看 2018 年的 BERT 怎么把"上下文"塞进 embedding;最后看 现代 LLM 里 embedding 层到底是什么——剧透:就是一张查找表。最后加载一个真的小 LLM,看一句话被翻译成 embedding 是什么样。

读完你应该能回答:如果让你从零实现一个 embedding 系统,你会写出什么代码。

什么样的 embedding 算"好"

无论用什么技术,一个好的 embedding 至少要满足两件事:

语义性——意思相近的词在向量空间里也要相近。"猫"和"狗"的距离应该比"猫"和"草莓"近。这一点听起来理所当然,但下一节你就会看到 TF-IDF 这种纯统计方法完全做不到

合适的维度——多大才合适?太小(比如 50 维)装不下细致的语义;太大(比如 10000 维)训练时容易过拟合,存储和检索成本也吃不消。作为参考:GPT-2 是 768 维,DeepSeek-V3 / R1 是 7168 维,主流文本检索模型在 768~1536 维之间。

带着这两条标准,看每种技术怎么往这两个目标靠近——以及它们各自卡在哪里。

TF-IDF:从统计开始

90 年代搜索引擎和文本分类的主力。它的想法朴素但有效:一个词对一篇文档有多重要,取决于它在这篇里有多频繁、在所有文档里又有多稀有

具体两个分量:

TF(term frequency):词 t 在文档 d 里的频率。

TF(t, d) = (词 t 在 d 中出现次数) / (d 的总词数)

IDF(inverse document frequency):词 t 在所有文档里有多稀有。

IDF(t) = log( 总文档数 / 包含 t 的文档数 )

最后两者相乘:

TF-IDF(t, d) = TF(t, d) × IDF(t)

直觉是:一个词如果在很多文档里都出现("的"、"是"),IDF 接近 0,整个 TF-IDF 就被压低;一个词在某文档里频繁但在别处罕见(一个专业术语),TF-IDF 就高。

举个具体的:假设你有 10 篇文档,"猫"在其中 2 篇出现过。某一篇文档总共 100 个词,"猫"出现了 5 次。

TF("猫", d)  = 5 / 100  = 0.05
IDF("猫")    = log(10 / 2)  = log(5)  ≈ 1.61
TF-IDF       = 0.05 × 1.61 ≈ 0.08

把这个对每个词都算一遍,每篇文档就有一个长度等于词表大小的 TF-IDF 向量——这就是 TF-IDF embedding。

代码也很短,用 scikit-learn 一行就能跑:

from sklearn.feature_extraction.text import TfidfVectorizer

docs = [
    "猫和狗都是常见的宠物",
    "宠物店里卖各种各样的猫",
    "Python 是一门编程语言",
]

vectorizer = TfidfVectorizer()
X = vectorizer.fit_transform(docs)
print(X.shape)              # (3, 词表大小)
print(vectorizer.get_feature_names_out())

但 TF-IDF 有两个关键的局限,恰好对应上一节的两条标准:

第一,没有语义。 "猫"和"狗"在 TF-IDF 向量空间里不存在直接关联——它们之间的距离仅取决于它们恰好在哪些文档里共同出现,跟它们的词义没关系。这违反了"语义性"。

第二,向量高度稀疏且不可压缩。 词表 10 万的话,向量就是 10 万维,且 99% 都是 0——这本质上是 one-hot 的加权变体。

但 TF-IDF 不是没用——直到今天,做关键词检索(BM25 是它的近亲)、文档分类、做 RAG 的"关键词召回兜底",它依然在线上跑。它的问题不在于无效,而在于只能 capture 字面共现,无法 capture 语义相似

word2vec:让神经网络学语义

2013 年 Mikolov 一篇论文改变了游戏。核心想法:与其手动统计共现,不如设计一个简单的预测任务,让神经网络在解决任务的过程中自然学出语义

具体做法叫 CBOW(Continuous Bag of Words):训练一个网络,用一个词周围的几个词去预测它。比如句子"今天 天气 很 好 我们 出去 散步",给网络看"今天、天气、很、我们",让它预测"好"。

网络结构出乎意料地简单——只有两层:

[周围词的 one-hot]  →  [embedding 层]  →  [输出层]  →  softmax over 词表
   (vocab,)              (embedding,)        (vocab,)

中间这一层就是 embedding 层:一个 vocab_size × embedding_size 的矩阵。喂进去一个词的 one-hot 向量,由于 one-hot 只有一位是 1,效果等价于直接从矩阵里查出对应那一行——这就是 embedding。

训练步骤:

  1. 选定一个上下文窗口大小(比如 2,意思是用左右各 2 个词)
  2. 从语料里取一个滑动窗口,取窗口中央的词作为目标,周围的词作为输入
  3. 把周围词转成 one-hot,过 embedding 层得到几个向量,求平均(就是 CBOW 的"袋")
  4. 平均向量过输出层 + softmax,输出整个词表上的概率分布
  5. 用交叉熵损失反向传播,更新 embedding 矩阵和输出层权重

整个语料扫几遍后,embedding 矩阵的每一行就是对应词的语义向量。输出层这时可以扔掉——它只是训练时的脚手架,目标从来都是中间那个矩阵。

skip-gram 是反过来:用一个词预测周围的词。两者效果接近,skip-gram 在小数据上略好。

word2vec 还有个优化叫 negative sampling——softmax 要算整个词表(10 万维)的归一化,太慢。换成只算正确答案 + 几个随机负例(比如 5 个)的二分类损失,速度快几十倍,效果几乎没掉。

word2vec 第一次让"语义性"在向量空间里成立——"猫"和"狗"真的会靠近,"国王 - 男 + 女 ≈ 女王"也是这时候被发现的。但它有个老问题,留给下一节解决:一个词只有一个向量。"苹果"无论指水果还是公司,拿到的都是同一个点。

BERT:把上下文塞进 embedding

2018 年 Google 出了 BERT,整个 NLP 领域换天。它的核心贡献是上下文 embedding——同一个词在不同句子里有不同的向量。

BERT 的结构是 4 层:

  1. Tokenizer——把文本切成 token(用 WordPiece,比 word2vec 的"按词切"更细)
  2. Embedding 层——每个 token 查表得到一个静态向量(这一层和 word2vec 的 embedding 矩阵性质完全一样)
  3. Encoder——12 或 24 层 Transformer block,每层都用 self-attention 让 token 之间互相"看"对方
  4. 任务头——下游任务用的分类层、序列标注层等

关键的不是结构,是训练目标。BERT 用两个无监督任务联合训练:

Masked Language Modeling(MLM):随机遮住输入里 15% 的 token,让模型预测被遮的是什么。比如"我[MASK]这本书",模型要预测出"读"。这迫使模型必须同时看左边和右边才能预测——这是"双向"的来源。

Next Sentence Prediction(NSP):给两个句子 A 和 B,预测 B 是不是真的在 A 之后。这让模型学会句子级别的关系。

为什么这就让 embedding "有了上下文"?因为预测被遮的词必须依赖周围的具体词。在"河[MASK]上钓鱼"和"[MASK]行抢劫"两句话里,模型为了准确预测"岸"和"银",就必须让 self-attention 的输出在两个语境里走向不同的方向——即使输入的 [MASK] token 经过第一层(静态 embedding 层)后向量是一样的,过完 12 层 attention 之后两者已经差异显著。

这就引出 BERT 的两个 embedding 概念,容易混淆所以一定要分清

  • token embedding:第一层查找表的输出,是静态的——同一个 token 永远一样
  • contextual embedding(上下文表示):经过 N 层 Transformer 后的中间隐藏状态,是动态的——依赖整句话

很多文档管这两个都叫 "embedding",看到时要根据上下文判断指的是哪一种。RAG 里用的"句子向量"通常是 contextual embedding 经过池化(CLS pooling 或 mean pooling)得到的。

现代 LLM:embedding 就是一张查找表

LLM(GPT、Llama、Qwen、DeepSeek)里的 embedding 层和 BERT 的第一层是同一种东西——一张 vocab_size × hidden_size 的查找表。在 PyTorch 里就是 torch.nn.Embedding

import torch
import torch.nn as nn

vocab_size = 152064   # Qwen2.5 的词表大小
hidden_size = 896     # Qwen2.5-0.5B 的隐藏维度

emb = nn.Embedding(vocab_size, hidden_size)
print(emb.weight.shape)  # torch.Size([152064, 896])

# 给它一个 token id 序列,返回对应的向量序列
token_ids = torch.tensor([[5835, 20329, 388]])
vectors = emb(token_ids)
print(vectors.shape)     # torch.Size([1, 3, 896])

nn.Embedding 的实现就是一句 weight[input]——以 token id 为下标在权重矩阵里取行。它和一个"接受 one-hot 输入的全连接层"等价,但避免了 one-hot 的稀疏开销。

LLM 和 word2vec 在 embedding 这一层有两个关键区别:

第一,不再需要单独的预训练目标。LLM 用同一个目标(预测下一个 token)联合训练 embedding 层和上面所有 Transformer 层,embedding 层的参数也在反向传播里更新。这就是为什么 LLM 不导入 word2vec 权重——整个网络在自己的任务上端到端训出来的 embedding,比换一个外部预训练 embedding 效果好。

第二,输入 embedding 和输出 embedding 经常是同一个矩阵(叫 weight tying)。LLM 最后一层要把隐藏状态投影回词表大小做 softmax,那个投影矩阵的形状和 embedding 矩阵互为转置,很多模型让它们共享参数——这能省一份巨大的参数(GPT-2 small 1.5 亿参数里 embedding 占 4000 万,共享后就只算一份)。

动手实战:拆开 Qwen 看看

讲了一圈,落到实际代码上——加载一个真实的 LLM,把一句话过一遍 embedding 层,看看到底长什么样。

from transformers import AutoTokenizer, AutoModel
import torch

model_id = "Qwen/Qwen2.5-0.5B"
tokenizer = AutoTokenizer.from_pretrained(model_id)
model = AutoModel.from_pretrained(model_id).eval()

# 1. 取出 embedding 层(就是一个 nn.Embedding)
embed_layer = model.get_input_embeddings()
print(type(embed_layer))            # <class 'torch.nn.modules.sparse.Embedding'>
print(embed_layer.weight.shape)     # torch.Size([151936, 896])

# 2. tokenize 一句话
sentence = "猫和狗都是常见的宠物"
tokens = tokenizer(sentence, return_tensors="pt")
print(tokens.input_ids)
# tensor([[101426, 33108, 50404, 99213, 30709, 105207, 105168]])

# 3. 在 embedding 层里查表
token_ids = tokens.input_ids[0]
vectors = embed_layer(token_ids)    # (seq_len, 896)
print(vectors.shape)                # torch.Size([7, 896])

# 4. 看每个 token 和它对应的向量前几位
for tok_id, vec in zip(token_ids, vectors):
    text = tokenizer.decode([tok_id])
    print(f"[{tok_id.item():>6}] {text!r:>10}  →  [{vec[0]:.4f}, {vec[1]:.4f}, ..., {vec[-1]:.4f}]")

输出大致是:

[101426]      '猫'  →  [-0.0123,  0.0456, ..., -0.0089]
[ 33108]      '和'  →  [ 0.0234, -0.0078, ...,  0.0156]
[ 50404]      '狗'  →  [-0.0098,  0.0512, ..., -0.0067]
...

每个 token 在 896 维空间里被映射到一个具体的点。注意:这个向量是静态的——不管这句话还是另一句话,"猫"这个 token 的 embedding 永远一样。要拿到上下文 embedding,得让向量序列再过一遍完整的 Transformer:

# 完整 forward 拿上下文表示
with torch.no_grad():
    outputs = model(**tokens)
    contextual = outputs.last_hidden_state    # (1, 7, 896)
print(contextual.shape)

contextual[0, 0] 才是"猫"在这句话里的上下文向量,它的值会受整句话其他 token 影响。

最后做一个有意思的实验——找出 embedding 空间里和"猫"最近的几个 token

import torch.nn.functional as F

cat_id = tokenizer.encode("猫", add_special_tokens=False)[0]
cat_vec = embed_layer.weight[cat_id]                     # (896,)

# 算"猫"和词表里所有 token 的余弦相似度
all_vecs = embed_layer.weight                            # (151936, 896)
sims = F.cosine_similarity(cat_vec.unsqueeze(0), all_vecs)

# 取 top 10
top_k = sims.topk(10)
for score, idx in zip(top_k.values, top_k.indices):
    print(f"sim={score:.4f}  token={tokenizer.decode([idx]):>8}")

跑出来的 top 通常是"猫咪"、"小猫"、"喵"、"狗"、"宠物"这些——证明这个 embedding 空间里几何上的近邻就是语义上的近邻。同样的代码把"猫"换成"king"或"国王",会看到"queen"、"royal"、"prince"之类的 token——番外 09 讲的几何直觉,到这里你能亲眼看到。

整个 LLM 处理一段输入的开头几步就是这套——tokenize、查 embedding 表、得到向量序列,然后送进 Transformer。embedding 层之于 LLM,就像字典之于一个学语言的人:它本身不"懂"语言,但是后续所有的理解都建立在这张字典上。

一图回顾整个演化

把上面四种技术放在一张表里看:

技术时代核心思想语义?上下文?维度
TF-IDF1990s词频 × 逆文档频率~10 万维稀疏
word2vec2013上下文预测的副产物100~300
BERT2018双向预训练 + Transformer768~1024
LLM emb 层2020+端到端学习的查找表输入静态 / 后续动态768~7168

每一代都解决了上一代的一个具体问题:TF-IDF 能算但学不到语义,word2vec 学到了语义但每个词只有一个向量,BERT 让向量随上下文变化,LLM 把它端到端集成进自己的训练目标。

收尾

回到开头那个问题:如果让你从零实现一个 embedding 系统,你会怎么写?

如果是经典文本检索,TF-IDF + sklearn 几行代码就能跑,至今仍在生产环境里使用。如果想要语义匹配,加载一个 word2vec 或者更现代的 sentence-transformer。如果是为了喂给一个 LLM,你只需要 nn.Embedding(vocab_size, hidden_size),再加上整个网络的端到端训练。

技术细节看似多,主线就一条:让一个固定大小的实数向量,尽可能多地装进语义信息。从纯统计到神经网络,从单向量到上下文动态向量,从外挂的预训练到端到端集成——四十年的演化都围绕这一句话。

下一篇 RAG 实战会用到这一篇里的所有零件:tokenize、查 embedding、计算相似度、检索 top-k——把这一切串起来变成一个能回答问题的系统。

参考资料

版权声明: 如无特别声明,本文版权归 sshipanoo 所有,转载请注明本文链接。

(采用 CC BY-NC-SA 4.0 许可协议进行授权)

本文标题:番外 10:Embedding 演化实战,从 TF-IDF 到 LLM 的 token 表

本文链接:https://www.sshipanoo.com/blog/ai/ai-for-python/番外10-Embedding技术细节/

本文最后一次更新为 天前,文章中的某些内容可能已过时!