ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从编号到词向量:Embedding层的原理、查表机制与工程实践

从编号到词向量:Embedding层的原理、查表机制与工程实践

把编号变成词向量这件事,我问过不少转行做LLM的工程师,几乎每个人第一次看到Embedding层的时候都有过类似的困惑:“它凭什么就是一个向量?这向量怎么就知道Apple和iPhone靠得近?”

如果你也有这个疑问,别急着觉得自己基础差。这恰恰说明你在认真琢磨底层机制,而不是停留在调接口的水平。这篇内容就围绕“一个编号如何变成词向量”展开,用直白的方式把这层窗户纸捅破。适合刚入门的算法新人、转行做LLM应用开发的工程师,以及对大模型内部机制感兴趣但不想啃厚书的读者。

1. 问题的起点:一个编号到底能代表什么

1.1 一切都要从one-hot这个尴尬的方案说起

在还没有Embedding的年代,自然语言处理领域第一步做的事情,就是把文字转成机器能计算的数字。最原始的做法就是one-hot编码。假设我们有一个词表,里面只有4个词:“猫”、“喜欢”、“鱼”、“我”。那“猫”就被编码成[1, 0, 0, 0],“喜欢”编码成[0, 1, 0, 0],以此类推。

这个方案用是能用,但很快就暴露了两个致命问题。第一个是维度爆炸。词表大小为5万,每个词就是一个5万维的向量,其中只有一位是1,其余全是0。当词表膨胀到几十万、上百万,这组向量巨大的存储开销直接让人绝望。一个只有数百GB内存的服务器,连词向量都装不下,更别提拿去训练了。

第二个问题更致命:one-hot无法表达词与词之间的相似关系。“猫”和“鱼”都是名词,但它们的one-hot向量不一样,在数学上没有任何关联,汉明距离、余弦相似度算出来都是零。这等于把“猫”和“鱼”视为完全无关的两个符号。可人的认知里,它们明明是相近的范畴,都应该出现在“宠物”“海洋”“动物”这样的语境里。one-hot这种编码方式让模型完全没有办法学习到语义相关性。

1.2 语义不能靠定义写出来,只能靠分布学出来

有个很经典的语言学假说,叫“分布假说”,核心观点就一句话:词的意义由其上下文决定。你从来没有给机器一本大词典让它查“猫是什么意思”,但你给它喂了亿万的语料,让它在“猫在打盹”、“猫在追老鼠”、“猫喜欢吃鱼”这些句子里反复看见“猫”。于是模型慢慢理解:猫是一种会发出“喵”、会抓老鼠、经常出现在居家环境里的东西。

这说明语义不是靠某个公式直接计算出来的,而是靠统计词在大量语料中的共现模式学出来的。Embedding层设计的初衷,就是为这种分布式语义表征提供一个数学容器。词向量里的每一个维度,你不一定非得给它起出具体的人类可解释的名字,但它整体上以一组浮点数的形态,把“猫”和“鱼”编码进了相邻的向量空间区域。

这里我想强调一件事:你不需要把每个维度都强行理解为“是否可爱”或“是否有毛”。词向量本身是一个分布式的语义压缩,它把词的语义和句法特征分散到了所有维度里。收益点在于,向量空间里某个方向可能对应“阳性/阴性”的性别差异,另一个方向可能对应“时态变化”,但你不能指着第127维说“这一维就是大小”。维度之间是互相纠缠的,只有把整个向量当作一个整体来看,语义才浮现得出来。

这其实和你平时看人脸识别系统很像:模型为你生成一个128维的人脸向量,任何单独的某一维都没有直观含义,但不同人的向量在空间中天然拉开距离。词向量也是这个逻辑,只是尺度不同、语义类型更丰富。

2. Embedding层的第一性原理:它就是一张可学习的查表

2.1 模型不知道什么是“魅力”,它只知道编号

很多人一听到Embedding,就觉得里面蕴藏着某种高深的数学魔法。其实从工程实现的角度来看,Embedding层就是一张大表,或者更准确地说,是一个可训练的矩阵。这就是“编号变成向量”的物理真相。

举一个具体的例子,假设我们要为一个小型词表构造词向量,词表里有10000个词,我们决定让每个词用256维向量来表示。那么Embedding矩阵的尺寸就是[10000, 256]。整个矩阵有256万个参数。

如果你在PyTorch里面写nn.Embedding(10000, 256),其实它内部就维护了一个(10000, 256)形状的参数矩阵。当你把词表中某个词的编号(比如第42号)传进去,Embedding层做的事情,就是把矩阵的第42行整个拿出来返回给你。这一行就是一个256维的浮点数向量。这个操作本质上是查表,不是某种复杂的映射函数,更不是“词义自动翻译器”。

我最早看源码的时候,也觉得不可思议:原来所谓“词向量”,就是参数矩阵里的一行。这相当于你去图书馆,报一个编号,管理员就把他手里那张存档卡交给你,卡片上的字迹就是词向量。管理员从来没有“理解了”那张卡片的内容,但经过大量语料训练,卡片的字迹之间确实形成了某种相似的结构。

所以第一个要点:编号到词向量的背后不是“变了”,是“查出来了”。模型并没有为每个词生成一个新的向量,它只是在参数矩阵里把对应行提取出来。这个“查出来”的过程没有任何语义计算,是纯粹的索引操作。

2.2 从“查表”到“向量”:表里的数值到底怎么来的

既然Embedding层只是查表,那查表得到的数值是从哪里来的?有且只有两个来源:一个是随机初始化,另一个是预训练权重。

随机初始化好理解,比如用均值为0、标准差为0.02的正态分布去填充整个矩阵。此时,第42行可能是[0.01, -0.03, 0.05, ...],第128行可能是[-0.02, 0.04, 0.01, ...],两行之间没有任何语义关联。模型在初始化阶段“啥也不懂”。

之后发生的事情,是整个大模型训练里最核心的环节:反向传播。模型把“猫”的编号查出来,拿到初始向量,然后继续向下经过Transformer层、输出层,算出预测结果,与真实文本做损失计算,最后把梯度一路传回来。Embedding层接收到梯度后会更新矩阵里的那一行数值。经过几百万步更新,“猫”所在的那一行向量就被推到了某个位置上——它靠近“鱼”“宠物”“动物”等词的向量,同时远离“跑”“飞”“燃烧”等词的向量。

这个过程所带来的变化,和查字典完全不同。字典是死的,词义是被人为写死的。Embedding里的词向量是活的,它通过海量语料统计出来的分布模式被训练数据不断重塑。每次更新都是一个小修正,但累积起来,词与词之间的几何关系就慢慢浮现了。

现在可以回答一开始那个问题了:一个编号之所以能变成有语义的词向量,并不是因为编号本身蕴含了什么信息,而是因为模型把编号用于查表的操作,然后在训练过程中通过反复迭代,把蕴含词义的浮点数写进了参数矩阵的对应行里。

2.3 口算一个维度:Embedding矩阵到底占用多大空间

很多人在做模型部署时关心显存占用,这里给一个快速估算公式:Embedding参数总量 = 词表大小 × 词向量维度。如果是32位浮点数(4字节),总存储 = 参数量 × 4字节。

举例,LLaMA 7B用32000词表,隐藏维度4096,那Embedding参数量就是32000 × 4096 = 1.31亿。这个数字看起来不太大,大概是7B模型里约1.87%的占比。但如果是词表很大的多语言模型,比如16万词表、隐藏维度5120,那就是8.19亿个参数,在显存里占3.3GB。放到推理场景里,这个开销不容忽视。

训练时还有一个Excel表格操作里容易忽略的点:现代大模型通常把Word Embedding和输出层的权重共享。也就是说,模型最后预测下一个词时,用的线性层权重复用了Embedding矩阵。因为你的词表可能是5万甚至更多,如果单独再存一份和Embedding一样大的输出权重,那等于多了一整份显存开销。共享权重把这个开销砍掉了一半。这个设计细节,很多初读源码的人都会愣一下,但实践上确实很管用。

3. 为什么是“向量”,而不是一个数字这件事

3.1 一个数字放不下所有语义信息,一组数字才放得下

如果你给“猫”分配一个编号5,给“狗”分配编号6,给“鱼”分配编号7,这些数字放在一维数字轴上。你很快会发现:编号大小本身带有误导性。5到6的距离和6到7的距离一样都是1,好像猫狗关系和狗鱼关系一样。可现实里,猫和狗都是宠物和哺乳动物,鱼则是完全不同的族裔。光靠一维的数轴,无论如何都没办法同时表达“猫狗相近”和“猫鱼相近但稍远”这种复杂关系。

一维空间不够,那就提高维度。词向量把一维编号变成了几百上千维的浮点数向量。在这个高维空间里,你不需要再操心编号的绝对大小,“猫”的向量不是比“鱼”的小或大,它只是位于空间中某一个坐标。相似度用余弦值或点积来算。这样,“猫”和“狗”的余弦相似度很高,“猫”和“鱼”的稍低,“猫”和“燃烧”的相似度趋近于0,甚至可能因为语料统计的原因出现微弱的负相似度。

实际上这种做法的本质是降维检索和语义拓扑的统一。你给每个词开了几十万维的one-hot空间(完全稀疏),现在压缩到几百上千维的稠密空间。在这个空间里,维度之间不再是“你死我活”的关系,而是相互作用、相互纠缠。每个词分布在所有维度上有不同的权重模式。词与词之间的相似,就表现为这些模式的重合度。

3.2 经典的词向量运算:King - Man + Woman ≈ Queen

讲Embedding的作用,绕不开那个经典例子:vector(King) - vector(Man) + vector(Woman) ≈ vector(Queen)。我第一次亲眼验证这个结果的时候,确实有一种“底层规律竟然真的可以被数字捕捉到”的震撼感。

这背后的原理其实也很朴素:在真实语料里,King和Queen出现的上下文环境相似度极高(宫廷、王室、统治),Man和Woman的上下文里大量出现“男性/女性”相关的语义信号。如果语料足够大,统计模式足够稳定,那King - Man发差,就大约相当于把“王室统治”里属性和男性相关的分量剥离开来,剩下的就是“女性王室统治者”,正好落在Queen的位置附近。

在翻译、对话、检索的场景里,这一特性直接促成了所谓的语义计算。比如做一个小型概念搜索系统,直接对词向量做加减法,可以把查询从“手机”延伸向“苹果”,从“苹果”延伸向“乔布斯”的核心语义群,无需条目级的显式链接就能召回相关内容。

3.3 不只是单词,句子、图片、语音都能Embedding

词向量只是Embedding最原始的形态,这个思路早已拓展到了几乎所有模态。你想一下,大模型里处理的根本单位不一定是纯文本的词。把一段话切成token后每个token都被映射成向量;把一个图像切成patch后,每个patch也被映射成向量;一段语音切片也同样要被映射成向量。

多模态大模型之所以能“看图说话”“听声作答”,靠的就是Embedding把不同模态的东西都拉到同一个向量空间塔基。这是空间维度的统一大前提。图像Embedding和文本Embedding在训练过程中被拉近,使得“一张图”和“描述它的文字”在向量空间里距离很近。你提问时,文本向量和图像向量就可以在这一空间里做匹配、对齐、交叉计算。

做这些项目的时候你会发现,Embedding的本质早就不是“查词表”那点事了。它是一个将离散符号稠密化的通用机制。任何东西只要你能给它一个编号或者一组离散ID,就有可能被Embedding成向量,从而进入神经网络可计算的领域。

4. 实操中的常见细节:维度怎么选、OOV怎么处理、微调要不要动Embedding

4.1 维度选多少合适:不是越大越好,也不是越小越省

Embedding维度是整个模型中一个重要的超级参数,选错了轻则浪费显存,重则影响效果。一般经验法则如下:

  • 如果词表在1万以下,维度建议64到128。此时用512纯属浪费资源,效果反而可能因为过拟合而不稳定。
  • 如果词表在1万到10万之间,这也是绝大多数NLP模型所在的区间,你通常会看到256、512、768这种常见配置。BERT base采用768维度,GPT-2采用768,这是训练任务和数据规模平衡后的结果。
  • 如果词表很大(多语言、代码加自然语言混合),维度可能要上1024或更高,比如LLaMA 2是4096。大词表模型如果维度太低,每个词分到的表达自由度不足,词与词之间的区分度会下降。

还有一个经验公式可以在手头没有参考时帮忙估算:embedding_dim = int((词表大小) ** 0.25)或者int((词表大小) ** 0.5),再乘一个经验系数。比如词表5万,开平方后约223,你可以取256。词表50万,平方根是707,可以取768。这个估算不太严谨,但优势是能快速给出一个可启动的值,然后根据训练曲线去看模型是否有欠拟合或过拟合的迹象,再微调维度。

我实际测试过的经验是:在很多中小规模任务里,把Embedding维度从128提到256,效果提升非常明显;但从256提到512,提升就没那么大了,显存的线性增长却看得见。所以别迷信大维度,要看数据量和任务复杂度。数据只有几十万条,硬上1024维,唯一的结果就是训练慢、显存高,而且容易过拟合。

4.2 遇到没见过的词:OOV问题的几种处理策略

现实应用中一定会遇到“这个词表里没有”的词,专业术语叫OOV(Out-Of-Vocabulary)。处理策略一般有这几种:

  • 集外词统一映射成<unk>特殊token。这种做法简便直接,模型遇到没见过的词时就用<unk>的行去查,相当于告诉模型“这是一个我不认识的符号”。
  • 用子词切分。这也是现在几乎所有大模型采用的办法。中文按字或按词notnecessarily,英文通过BPE或Byte-level BPE把词拆成子词组合,遇到新词时至少它的前缀或主干可能已经被覆盖。比如“超导材料”被切成“超导”“材料”,即使“超导”是新词,词表里往往还有“超导”相关的子串。
  • 使用Char-level Embedding拼字词Embedding。对于形态丰富的语言,可以给每个字符单独做Embedding,然后通过CNN或简单的融合把词表示出来。这样彻底解决了OOV,但计算结构会更复杂。

实际工程里,第一种方案适合小任务快速上线,第三种方案适合处理中文分词困难或医疗、法律等专有名词很多的情况。大模型基本都走第二种方案路线,因为效果和效率平衡最好。

4.3 微调模型时,Embedding层是不是必须冻结

做LLM微调时经常遇到一个争论:要不要冻结Embedding。我的经验是,视数据量而定,分场景处理。

如果你的任务是“领域风格迁移”或者“特定术语学习”,比如你要让模型学会一套内部产品黑话和缩写,此时Embedding层确实值得更新。因为新的映射关系要嵌入到词的向量表达里,如果不更新Embedding,那些新术语永远只能用<unk>兜底,或者只有靠Transformer层去凑合理解,效果通常会打折扣。

如果只有几千到几万条高质量的指令数据,那我的建议是:先冻结Embedding只训练注意力层和FFN层。因为少量数据不足以为几百万甚至上亿的Embedding参数提供足够的梯度信号,强行更新容易把原本良构的语义空间带偏,造成灾难性遗忘。等模型在局部任务上适应了,再解冻Embedding做低成本微调,节奏更稳。

实操上,可以用HuggingFace的~Trainer的requires_grad控制,或者直接用PyTorch把embedding层参数的requires_grad设为False。这都是一行代码的事情,但决策逻辑要有数。

4.4 PyTorch里跑一个最小可观察的Embedding样例

讲太多理论,不如给你一个亲手能看到“编号变成向量”的最小项目。先看代码:

import torch import torch.nn as nn import torch.nn.functional as F torch.manual_seed(42) # 词表大小6,映射为8维词向量 embedding_layer = nn.Embedding(num_embeddings=6, embedding_dim=8) # 模拟两个句子,每个句子的token id分别是: # "我 爱 猫" -> [0, 1, 2] # "我 爱 鱼" -> [0, 1, 3] input_ids = torch.tensor([[0, 1, 2], [0, 1, 3]]) vectors = embedding_layer(input_ids) print("形状:", vectors.shape) # torch.Size([2, 3, 8]) # 观察第0个句子第2个token的词向量,对应“狗”的编号 dog_vec = vectors[0, 2] cat_vec = embedding_layer(torch.tensor([4]))[0] # 假设“猫”编号是4 fish_vec = embedding_layer(torch.tensor([5]))[0] # 假设“鱼”编号是5 def cos_sim(a, b): return F.cosine_similarity(a.unsqueeze(0), b.unsqueeze(0)).item() print("狗-猫相似度:", cos_sim(dog_vec, cat_vec)) print("狗-鱼相似度:", cos_sim(dog_vec, fish_vec))

刚初始化时,这些相似度看起来就是随机数,有正有负,毫无规律。真正有意义的变化来自训练过程。你可以构造一个小任务:固定上下文[“我”, “爱”, X],要求模型预测X到底是“猫”还是“鱼”。用几百条假语料训练几个epoch后,你会发现“猫”和“狗”这两行的向量慢慢靠近,“鱼”则被推到另一个方向。这就是Embedding参数矩阵在训练过程中被重塑的直观证据。

5. 我对Embedding的一些心得和几个容易踩的坑

5.1 不要迷信可视化“词向量聚类图”的完美效果

很多教程会在最后放一张词向量的PCA降维图,词被画成一个个点,然后相同类别的点亲密地聚在一起。这张图确实好看,但它背后有相当多的“选择效应”。PCA降到2维,只保留了两个主方向,原空间中大量的语义信息被压缩掉了。换个词表、换组种子、换不同的降维算法,点云的布局就完全不同。

所以看可视化图的时候,更应该关注的是:不同的词是否在局部区域能够区分出明显的聚簇趋势。如果看到同类词散落得到处都是,那才是需要警惕的现象。词向量要做的是在整体空间里保持语义关系,而不是在二维投影上做到完美分类。

5.2 Embedding不是“查词典”,不要以为模型“知道”词义

这是一个很重要的认知校准。模型把一个词映射成向量,不等于它理解了词义,就像你不能说Excel打开一个表格后就“理解”了表里的数据。词向量只是把词义以统计分布的方式编码到了向量空间的几何结构里。模型预测下一个词时,实际上是沿用了词向量之间的共现概率关系。

这种差别在部署AI产品时非常重要。你写一个客服机器人,它在用Embedding帮你衡量“退款”和“退货”的相似性,但它不会知道“退款”背后还可能有一个暴躁的客户在等待人工介入。语义关系的统计性,决定了它是一个好用的代数工具,而不是一个拥有价值观的实体。理解这一点,你就不会在产品上线后对模型产生不合理的预期。

5.3 做检索增强应用时,Embedding的选择直接影响召回质量

现在做RAG(检索增强生成)项目,很多人第一个想到的就是“我要选一个效果最好的Embedding模型”。但效果这个词在不同场景下标准完全不同。做语义检索,你要关注的是相似度排序是否合理,而不是绝对相似度分数的波动范围;做聚类,你更关心的是类别间的可分性;做多语言匹配,你要看跨语言对齐效果。

还有一个很常见但容易忽视的坑:用同一个Embedding模型同时做文档切片的向量化和用户问题的向量化时,如果文本长度差异很大,长文档和短问题在向量空间里的尺度可能不同,这会严重影响检索效果。很多检索评测里,把文档和查询用不同模型或经过不同pooling策略编码,能得到远好于统一处理的queries效果。所以不要觉得“我调用了同样的Embedding API就万事大吉”,切分策略、归一化处理、是否加前置拼接提示词,这些细节决定项目成败。

5.4 关于“词向量”你还需要知道的几件事

如果你做微调,预训练模型的Embedding矩阵已经是一个经过亿级语料打磨的“语义地图”。微调时若更新幅度过大,可能直接把这张地图画花。所以要么用较低的学习率,要么在微调初期冻结Embedding,后期再小范围解冻。这是很多实践项目里调出来的共同经验。

如果你做的是轻量级任务,不要一股脑去加载几GB的预训练Embedding。先用一个随机初始化的Embedding加上训练脚本,看看基准性能;如果有明确提升,再考虑换预训练权重。很多时候,任务本身的偏置比预训练语义的增益更重要。换句话讲,你是在完成任务,不是在做词向量效果大赛。

至于“Embedding是不是必须在Transformer层之前做一次线性变换”这种问题,看具体架构。有些模型加了,有些没有。这些细节本身没有优劣之分,只要整体在验证集上表现好,局部加不加线性层都不必过度纠结。真正值得留意的,是你在调试时能不能看清每个层在向量空间里到底做了什么变换——这才是工程能力的体现。

我自己在带项目时,面对“Embedding到底做了什么”这个问题,最常说的一句话是:“它像图书馆的索引卡片,被打磨得越来越像书的内容;但它永远不是书本身。”编号到词向量,本质上就是在一个可训练的矩阵里查行,然后在反复训练中,让行与行之间的关系逐步逼近真实语料的分布规律。理解了这一层,你去看Transformer的输入处理、去看多模态对齐、去看RAG召回,你会发现它们背后都是一套逻辑:把世界上所有离散的东西,都映射到同一个可以计算、可以比较的向量空间里。这也是所有现代大模型能够工作的基石。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进