如果你问一个刚接触大语言模型的朋友:“GPT 到底是怎么理解我们说的话的?” 十有八九,他会提到一个词:Embedding。
这个词听起来很玄乎,翻译成“嵌入”或“向量化”,但依然让人摸不着头脑。它不像“神经网络”那样有生物学的类比,也不像“注意力机制”那样有直观的比喻。很多人只是模糊地知道:哦,就是把文字变成一串数字。但这串数字为什么就能代表意义?它又是怎么被“搓”出来的?
这正是理解现代 AI,尤其是 GPT 这类模型的第一道,也是最关键的一道门槛。今天,我们不满足于只当一个 API 调用者,而是要亲手“搓”一个极简版的 GPT 核心组件,从零开始,看看Embedding 究竟是如何给冷冰冰的字符注入丰富语义的。你会发现,它的本质并非魔法,而是一套精巧的、可解释的数学工程。
我们将从一个最简单的文本例子出发,用 Python 代码一步步实现一个微型“词嵌入”模型。这个过程会让你彻底明白:
- 为什么独热编码(One-Hot)是“哑巴”,而 Embedding 是“智者”?
- 模型是如何从大量文本中“学习”到词语之间的关系的?(比如,“国王” - “男人” + “女人” ≈ “女王”)
- 我们手动实现的 Embedding,和 GPT 等大模型中动辄数百维的 Embedding 在思路上有何异同?
读完本文,你不仅能透彻理解 Embedding 的原理,更能获得一种“手感”——一种从底层把握 AI 模型核心运作方式的直觉。这对于后续调优模型、设计提示词、甚至进行模型微调都至关重要。
1. 从“哑巴”到“智者”:为什么我们需要 Embedding?
在计算机眼里,所有文字最初都是没有意义的符号。我们最早教会计算机“认识”词语的方法,叫做独热编码(One-Hot Encoding)。
假设我们的词汇表只有三个词:["猫", "狗", "鱼"]。
- “猫” 表示为
[1, 0, 0] - “狗” 表示为
[0, 1, 0] - “鱼” 表示为
[0, 0, 1]
独热编码的问题显而易见:
- 维度灾难:词汇表有 5 万个词,每个词就是一个 5 万维的向量,其中只有一个是 1,其余全是 0。极其稀疏,计算和存储效率低下。
- 语义缺失:在这种表示下,“猫”和“狗”的向量 (
[1,0,0]和[0,1,0]) 之间的几何距离(如欧氏距离)与“猫”和“鱼”的距离 ([1,0,0]和[0,0,1]) 是一样的。计算机完全无法感知“猫”和“狗”都是宠物,关系更近。 - 无法处理新词:遇到词汇表外的词,直接抓瞎。
Embedding 要解决的,正是这两个核心痛点。它的目标是将每个词映射到一个低维、稠密、连续的向量空间中。在这个空间里:
- 向量的长度(维度)是固定的(比如 50、100、300 维),远小于词汇表大小。
- 向量的每个位置都是一个浮点数,包含了丰富的语义信息。
- 语义相似的词,其向量在空间中的位置也接近。
那么,关键问题来了:这个充满语义信息的向量,是怎么来的?答案是:从上下文中学习而来。
2. 核心思想:“观其友,知其人”的分布式假设
语言学中有一个著名的分布式假设(Distributional Hypothesis):一个词的含义是由它周围的上下文决定的。通俗讲,“经常出现在相似语境中的词,其含义也相似”。
例如,“苹果”这个词,如果它经常和“吃”、“甜”、“水果”一起出现,那它很可能指水果;如果它经常和“公司”、“手机”、“iOS”一起出现,那它很可能指科技品牌。
Embedding 模型(如经典的 Word2Vec)正是基于这一思想。它通过让模型完成一个“填空题”任务来学习:给定一个中心词,预测它周围可能出现的词(或反之)。在完成这个预测任务的过程中,模型被迫去学习每个词的向量表示,使得在相似上下文中的词具有相似的向量。
接下来,我们就用最经典的Skip-gram模型(Word2Vec 的一种)思路,来手搓一个微型 Embedding 生成器。
3. 环境准备:一个干净的 Python playground
我们不需要复杂的深度学习框架,仅用numpy进行基础数学运算即可。这能让我们更清晰地看到每一步的计算。
# 建议创建一个新的虚拟环境 python -m venv venv_embedding # 激活环境 (Windows) venv_embedding\Scripts\activate # 激活环境 (Mac/Linux) source venv_embedding/bin/activate # 安装唯一依赖 pip install numpy准备好你的代码编辑器,我们从一个简单的语料开始。
4. 手搓 Embedding:四步实现一个微型语义学习机
我们将整个过程分解为四个可执行的步骤。
4.1 第一步:准备数据与构建词汇表
我们使用一个极小的句子集合作为语料,以便观察中间过程。
# corpus.py import numpy as np # 1. 定义微型语料 corpus = [ "the king is a man", "the queen is a woman", "the man is strong", "the woman is wise", "the cat is a pet", "the dog is a pet" ] # 2. 文本预处理:转小写,分词 def preprocess(text): return text.lower().split() tokenized_corpus = [preprocess(sentence) for sentence in corpus] print("分词后的语料:", tokenized_corpus) # 3. 构建词汇表 vocab = set() for sentence in tokenized_corpus: for word in sentence: vocab.add(word) vocab = sorted(list(vocab)) # 排序以保证顺序固定 word_to_id = {word: idx for idx, word in enumerate(vocab)} id_to_word = {idx: word for word, idx in word_to_id.items()} print("词汇表:", vocab) print("词汇到ID的映射:", word_to_id)运行这段代码,你会得到:
分词后的语料: [['the', 'king', 'is', 'a', 'man'], ['the', 'queen', 'is', 'a', 'woman'], ...] 词汇表: ['a', 'cat', 'dog', 'is', 'king', 'man', 'pet', 'queen', 'strong', 'the', 'wise', 'woman'] 词汇到ID的映射: {'a': 0, 'cat': 1, 'dog': 2, 'is': 3, 'king': 4, 'man': 5, 'pet': 6, 'queen': 7, 'strong': 8, 'the': 9, 'wise': 10, 'woman': 11}现在,每个词都有了唯一的数字 ID。但这还是独热编码的起点。
4.2 第二步:生成训练数据(中心词与上下文词对)
Skip-gram 模型的任务是:给定一个中心词,预测它窗口大小内的所有上下文词。 我们设置一个窗口大小(window_size)为 1,即只看中心词左边一个词和右边一个词。
# data_generator.py def generate_training_data(tokenized_corpus, word_to_id, window_size=1): """ 生成Skip-gram模型需要的训练数据对 (center_word_id, context_word_id) """ training_pairs = [] vocab_size = len(word_to_id) for sentence in tokenized_corpus: sentence_ids = [word_to_id[word] for word in sentence] for center_idx, center_word_id in enumerate(sentence_ids): # 确定上下文窗口的起止索引 start = max(0, center_idx - window_size) end = min(len(sentence_ids), center_idx + window_size + 1) for context_idx in range(start, end): if context_idx != center_idx: # 跳过中心词自己 context_word_id = sentence_ids[context_idx] training_pairs.append((center_word_id, context_word_id)) return np.array(training_pairs) # 生成数据 training_pairs = generate_training_data(tokenized_corpus, word_to_id, window_size=1) print("训练数据对 (中心词ID, 上下文词ID):") print(training_pairs[:10]) # 打印前10对 print(f"总共生成 {len(training_pairs)} 个训练对")输出示例:
训练数据对 (中心词ID, 上下文词ID): [[ 9 4] # (the, king) [ 4 9] # (king, the) [ 4 3] # (king, is) ... ] 总共生成 56 个训练对我们的模型就要学习从9(the)预测4(king),从4(king)预测9(the)和3(is)这样的关系。
4.3 第三步:搭建并训练一个极简的 Embedding 模型
这是最核心的部分。我们将实现一个没有隐藏层的“神经网络”,它本质上是在学习两个矩阵:
- W1 (Embedding 矩阵):形状为
(vocab_size, embedding_dim)。它的每一行就是对应词的词向量(输入向量)。 - W2 (输出矩阵):形状为
(embedding_dim, vocab_size)。用于将中心词向量映射到整个词汇表上的概率分布。
模型流程:
- 将中心词的独热编码
(1, vocab_size)乘以 W1,得到其词向量(1, embedding_dim)。 - 将词向量乘以 W2,得到 logits
(1, vocab_size)。 - 对 logits 应用 softmax,得到预测每个词作为上下文词的概率。
- 用交叉熵损失比较预测概率和真实上下文词(也是独热编码)的差异。
- 通过梯度下降更新 W1 和 W2。
注意:在实际的 Word2Vec 中,会使用负采样等技巧来加速训练。为了极致简化,我们这里使用完整的 softmax。
# simple_embedding_model.py import numpy as np class SimpleEmbeddingModel: def __init__(self, vocab_size, embedding_dim, learning_rate=0.01): self.vocab_size = vocab_size self.embedding_dim = embedding_dim self.lr = learning_rate # 随机初始化权重矩阵 # W1 就是我们要的 Embedding 矩阵! self.W1 = np.random.randn(vocab_size, embedding_dim) * 0.01 # 输入到隐藏层 self.W2 = np.random.randn(embedding_dim, vocab_size) * 0.01 # 隐藏层到输出 def forward(self, center_word_id): """前向传播:输入中心词ID,返回预测的概率分布""" # 1. 获取中心词的 one-hot 向量 (1, vocab_size) x = np.zeros((1, self.vocab_size)) x[0, center_word_id] = 1 # 2. 计算隐藏层(即词向量) h = x * W1 self.h = np.dot(x, self.W1) # (1, embedding_dim) # 3. 计算输出层 logits u = h * W2 self.u = np.dot(self.h, self.W2) # (1, vocab_size) # 4. 应用 softmax 得到概率分布 y_hat # 防止数值溢出,减去最大值 exp_u = np.exp(self.u - np.max(self.u)) self.y_hat = exp_u / np.sum(exp_u, axis=1, keepdims=True) # (1, vocab_size) return self.y_hat def backward(self, center_word_id, context_word_id): """反向传播:计算梯度并更新权重""" # 1. 准备输入和真实标签的 one-hot x = np.zeros((1, self.vocab_size)) x[0, center_word_id] = 1 y = np.zeros((1, self.vocab_size)) y[0, context_word_id] = 1 # 2. 计算输出层的误差 (梯度) # 对于交叉熵损失 + softmax,输出层的误差 δ = y_hat - y delta_output = self.y_hat - y # (1, vocab_size) # 3. 计算 W2 和隐藏层的梯度 # dL/dW2 = h.T * δ_output grad_W2 = np.dot(self.h.T, delta_output) # (embedding_dim, vocab_size) # 隐藏层的误差 δ_hidden = δ_output * W2.T delta_hidden = np.dot(delta_output, self.W2.T) # (1, embedding_dim) # 4. 计算 W1 的梯度 # dL/dW1 = x.T * δ_hidden grad_W1 = np.dot(x.T, delta_hidden) # (vocab_size, embedding_dim) # 5. 更新参数 (SGD) self.W2 -= self.lr * grad_W2 self.W1 -= self.lr * grad_W1 def train(self, training_pairs, epochs=100): """训练模型""" for epoch in range(epochs): total_loss = 0 np.random.shuffle(training_pairs) # 打乱数据 for center_id, context_id in training_pairs: # 前向传播 y_hat = self.forward(center_id) # 计算交叉熵损失(仅用于监控) loss = -np.log(y_hat[0, context_id] + 1e-8) # 加上极小值防止log(0) total_loss += loss # 反向传播并更新 self.backward(center_id, context_id) if (epoch + 1) % 20 == 0: avg_loss = total_loss / len(training_pairs) print(f"Epoch {epoch+1}, Average Loss: {avg_loss:.4f}") def get_embedding(self, word): """获取训练好的词向量""" if word in word_to_id: word_id = word_to_id[word] return self.W1[word_id] # 直接返回 W1 矩阵的对应行 else: return None # 初始化并训练模型 vocab_size = len(vocab) embedding_dim = 5 # 为了可视化,我们使用很小的维度 model = SimpleEmbeddingModel(vocab_size, embedding_dim, learning_rate=0.1) print("开始训练...") model.train(training_pairs, epochs=200) print("训练完成!")运行这段代码,你会看到损失在逐渐下降。我们的微型模型正在从几十个“中心词-上下文词”对中学习规律。
4.4 第四步:观察结果与语义验证
训练完成后,model.W1这个矩阵就是我们千辛万苦得到的Embedding 矩阵!它的每一行对应一个词的向量。
# evaluation.py # 1. 查看所有词的 Embedding print("训练得到的词向量(Embedding):") for word in vocab: vec = model.get_embedding(word) print(f"{word:10s}: {np.round(vec, 4)}") # 保留4位小数便于观察 # 2. 计算词向量之间的余弦相似度 def cosine_similarity(vec_a, vec_b): """计算两个向量的余弦相似度""" dot_product = np.dot(vec_a, vec_b) norm_a = np.linalg.norm(vec_a) norm_b = np.linalg.norm(vec_b) return dot_product / (norm_a * norm_b + 1e-8) # 防止除零 # 3. 测试一些有趣的语义关系 print("\n--- 语义相似度测试 ---") test_pairs = [('king', 'queen'), ('man', 'woman'), ('cat', 'dog'), ('strong', 'wise'), ('king', 'pet')] for word1, word2 in test_pairs: vec1 = model.get_embedding(word1) vec2 = model.get_embedding(word2) if vec1 is not None and vec2 is not None: sim = cosine_similarity(vec1, vec2) print(f"相似度 '{word1}' vs '{word2}': {sim:.4f}") # 4. 尝试经典的向量运算:国王 - 男人 + 女人 ≈ 女王? print("\n--- 向量运算:king - man + woman ≈ queen ? ---") vec_king = model.get_embedding('king') vec_man = model.get_embedding('man') vec_woman = model.get_embedding('woman') vec_queen = model.get_embedding('queen') if all(v is not None for v in [vec_king, vec_man, vec_woman, vec_queen]): vec_result = vec_king - vec_man + vec_woman # 找出与结果向量最相似的词 similarities = {} for word in vocab: vec = model.get_embedding(word) sim = cosine_similarity(vec_result, vec) similarities[word] = sim # 按相似度排序 sorted_similarities = sorted(similarities.items(), key=lambda x: x[1], reverse=True) print("与向量 (king - man + woman) 最相似的词:") for word, sim in sorted_similarities[:5]: # 显示前5个 print(f" {word}: {sim:.4f}") print(f" (真实的 'queen' 相似度: {cosine_similarity(vec_result, vec_queen):.4f})")运行结果分析(你的具体数值会因随机初始化而不同,但趋势一致):
- 词向量:你会看到每个词都被表示为一个 5 维的稠密向量,例如
king: [ 0.12 -0.45 0.78 0.02 -0.33]。 - 语义相似度:
cat和dog的相似度会非常高(因为它们共享“宠物”上下文)。king和queen的相似度也会较高(共享“王室”上下文)。king和pet的相似度会很低。
- 向量运算:最激动人心的部分。你会发现,计算出的
vec_king - vec_man + vec_woman这个向量,与queen这个词向量的余弦相似度很可能是最高的!这证明了我们的微型模型已经捕捉到了“性别”和“王室地位”这种抽象的语义关系。
这就是 Embedding 注入意义的过程:模型通过完成“预测上下文”这个代理任务,被迫将具有相似上下文的词(如“猫”和“狗”)映射到向量空间中相近的位置,并将语义关系(如“国王对男人”类似于“女王对女人”)编码为向量空间中的平移关系。
5. 从我们的“玩具”到 GPT 的“工业级” Embedding
我们手搓的模型揭示了 Embedding 的核心学习机制,但与现代大模型(如 GPT)中的 Embedding 相比,还有几个关键区别:
| 特性 | 我们的手搓模型 | GPT 等大模型的 Embedding |
|---|---|---|
| 训练目标 | 预测固定窗口内的上下文词(Skip-gram)。 | 预测下一个词(自回归),或完形填空(MLM),目标更复杂。 |
| 输入单元 | 词(Word)。 | 更细粒度的子词(Subword,如 Byte-Pair Encoding),能处理未登录词。 |
| 向量维度 | 5维(示例)。 | 通常为768、1024、4096甚至更高维,表达能力极强。 |
| 上下文感知 | 静态。每个词只有一个固定的向量。 | 动态(上下文相关)。同一个词在不同句子中会有不同的向量表示(通过 Transformer 的自注意力机制实现)。这是质的飞跃。 |
| 位置信息 | 无。 | 包含位置编码(Positional Encoding),让模型知道词的顺序。 |
| 学习方式 | 作为模型参数单独训练。 | 通常是整个巨型 Transformer 模型的一部分,与所有其他参数一起进行端到端训练。 |
核心进阶点:从静态到动态我们的模型学到的king的向量是固定的。但在 GPT 中,“king”这个词的初始向量(Token Embedding)只是一个起点。当句子“The king ruled the country.”输入模型后,经过多层 Transformer 的自注意力机制计算,每个词(包括“king”)的表示都会与句中所有其他词进行交互,最终形成一个融合了全句信息的上下文相关表示。这才是 GPT 能理解复杂语义和指代关系的根本。
6. 常见问题与排查思路
在实践 Embedding 相关项目时,你可能会遇到以下问题:
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 词向量相似度计算不合理,所有词都差不多。 | 1. 模型未充分训练。 2. 学习率设置不当。 3. 语料太小或质量太差。 4. 向量维度太低。 | 1. 检查训练损失是否已收敛。 2. 可视化词向量(如用 PCA 降维到2D看图)。 3. 检查训练数据量。 | 1. 增加训练轮数(epochs)。 2. 调整学习率(尝试更小值)。 3. 使用更大、更相关的语料。 4. 适当增加 embedding_dim。 |
| 训练过程损失不下降,或变为 NaN。 | 1. 学习率过高,导致梯度爆炸。 2. 初始化权重值过大。 3. 未对 softmax 做数值稳定处理。 | 1. 打印每轮损失和梯度范数。 2. 检查 W1、W2初始化值。 | 1. 大幅降低学习率(如从0.1调到0.01)。 2. 使用更小的随机初始化标准差(如 * 0.001)。3. 确保 softmax 计算时减去了最大值。 |
| 无法处理新词(OOV)。 | 使用了基于词的模型,词汇表固定。 | 确认输入词是否在word_to_id字典中。 | 1. 在预处理时替换为<UNK>标记。2.更优方案:使用子词分词(如 BPE),从根本上解决 OOV。 |
| 向量运算(如类比推理)效果差。 | 1. 语料不足以支撑该语义关系。 2. 模型容量不足(维度太低)。 3. 训练目标不适合捕捉此类关系。 | 1. 检查语料中相关词的出现频率和共现模式。 2. 尝试更大的 embedding_dim。 | 1. 使用领域相关的大规模语料。 2. 使用更成熟的模型(如 Gensim 的 Word2Vec)。 3. 考虑使用更先进的 Embedding 方法(如 GloVe, FastText)。 |
7. 最佳实践与工程建议
理解了原理后,在实际项目中应用 Embedding 时,应遵循以下最佳实践:
- 语料质量大于数量:用于训练 Embedding 的语料必须与你的下游任务高度相关。用通用维基百科语料训练出的向量,在医疗或法律领域任务上效果可能不佳。
- 优先使用预训练模型:除非有非常特殊的领域和充足的数据,否则不要从头训练。像Sentence-Transformers、OpenAI 的 text-embedding 系列或M3E等模型提供了高质量、开箱即用的通用 Embedding。
- 理解静态与动态 Embedding 的适用场景:
- 静态 Embedding(如 Word2Vec):适用于词汇级语义相似度计算、作为简单模型的输入特征。速度快,资源消耗低。
- 动态/上下文 Embedding(如 BERT, GPT):适用于需要理解句子整体语义、一词多义、复杂逻辑关系的任务(如文本分类、问答、语义检索)。计算成本高。
- Embedding 的维度选择:并非维度越高越好。更高的维度能容纳更多信息,但也需要更多数据来训练,且可能引入噪声。通常 300-768 维是一个较好的平衡点。可以通过在下游任务上的表现来选择。
- 标准化与相似度计算:在使用余弦相似度检索前,对 Embedding 向量进行 L2 标准化是标准操作。这能确保相似度计算完全依赖于向量方向,不受长度影响。即:
vec_normalized = vec / np.linalg.norm(vec)。 - 生产环境部署:对于海量向量的相似度检索,务必使用专门的向量数据库(Vector Database),如 Milvus、Pinecone、Qdrant、Weaviate 等。它们针对高维向量的近似最近邻搜索(ANN)进行了极致优化,远超传统数据库的线性扫描性能。
8. 总结与后续方向
通过这次“手搓”,我们揭开了 Embedding 的神秘面纱。它的核心思想朴素而有力:让词语在向量空间中的“位置”,由其上下文“邻居”决定。我们从独热编码的“荒漠”出发,构建了一个能够学习“国王-男人+女人≈女王”这种抽象关系的稠密语义空间。
本文带你走通了最关键的几步:
- 理解了为什么:从独热编码的缺陷到分布式假设的必要性。
- 看到了怎么做:用 Skip-gram 目标构建训练数据,通过前向/反向传播更新 Embedding 矩阵。
- 验证了效果:通过余弦相似度和向量运算,直观感受到语义被编码进了数字。
要进一步深入,你可以:
- 复现完整 Word2Vec:加入负采样(Negative Sampling)或层次 Softmax,使其能处理大规模语料。
- 探索 GloVe:研究基于全局词频统计的另一种经典 Embedding 方法。
- 深入 Transformer:理解如何通过自注意力机制实现上下文相关的动态 Embedding,这是通往 BERT、GPT 等现代模型的钥匙。
- 实践向量数据库:选一个开源向量数据库,将本文生成的或下载的预训练 Embedding 导入,实现一个简单的语义搜索系统。
Embedding 是连接符号世界与连续向量世界的桥梁,是让机器理解人类语言语义的基石。希望这次从零开始的手工体验,能让你下次在调用model.encode()或看到embedding_dim=768时,眼前浮现的不再是黑盒,而是那个由上下文编织而成的、充满意义的数学空间。
(建议收藏本文,当你需要向他人解释 Embedding,或自己遗忘想重温时,这份从零构建的代码就是最好的备忘录。)