摘要:Embedding 本质是一个可学习的查表操作,语义是训练过程赋予的。本文用 4 张架构图讲透表示学习原理,给出词嵌入成立的四个技术前提,附 Spark/Python/Java 三端可运行代码,帮你从"调 API"升级到"懂原理、能选型、会落地"。
关键词:Embedding、词嵌入、表示学习、word2vec、分布式表示、负采样、向量检索
从一个具体的失败说起
做 RAG 的同事上周找我吐槽:文档库里明明有"iPhone 13 电池更换流程",用户搜"苹果13换电池",Elasticsearch 关键词检索召回 0 条。"苹果"和"iPhone"字面上没有任何交集,倒排索引对此无能为力。
这个问题的本质是:倒排索引匹配的是字符串,不是语义。而语义匹配的前提,是先把"苹果""iPhone 13"变成机器能比较语义距离的东西------向量。这就要说到 Embedding。
我见过太多人把 Embedding 当黑盒:调 OpenAI 的 text-embedding-3-small 接口、给 RAG 文档切块、往 Milvus 里灌向量。问一句"Embedding 到底是什么、向量里的数值是怎么来的",回答多半是"模型生成的"。这篇文章把这层窗户纸捅破,并且给出一线工程判断:什么场景该用静态词向量、什么场景该上 BERT、词嵌入要成立需要哪些前提。
Embedding 的本质:一个可学习的查表操作
one-hot 为什么不行
先看最朴素的词表示------one-hot:词表里 1 万个词,每个词用一个 1 万维的向量表示,词所在位置为 1,其余为 0。
它有两个硬伤:
- 维度灾难。词表 100 万时,每个词是 100 万维的稀疏向量,存储和计算都扛不住。
- 无语义关系。任意两个 one-hot 向量都是正交的(内积恒为 0),"king" 和 "queen"、"苹果" 和 "iPhone" 的距离都是 0------机器完全看不出它们相关。
分布式表示:把语义摊到每个维度上
1986 年 Hinton 提出分布式表示(distributed representation):用一个低维稠密向量表示词,语义分散承载在向量的各个维度上。两个词语义相近,它们的向量在低维空间里距离就近。
这个思想落到工程上,就是一张可训练的 Embedding 矩阵 W,形状是 词表大小 V × 向量维度 d。前向计算就一行:
scss
E(v) = W[v] # 输入词 id,返回第 v 行向量

▲ 图1:Embedding 的本质 ------ one-hot 稀疏向量经过矩阵 W 查表,得到稠密向量。训练的本质就是学习这张矩阵 W
注意图里这个关键点:Embedding 在推理时是查表,不是矩阵乘法 。one-hot × W 数学上等价于取 W 的第 v 行,但工程上没人会真的构造 1×V 的 one-hot 向量去做矩阵乘------直接按行号取数,O(1) 完成。PyTorch 的 nn.Embedding 底层就是这么实现的:
python
import torch
# 词表 10000,向量维度 128
emb = torch.nn.Embedding(num_embeddings=10000, embedding_dim=128)
# 输入是词 id(不是 one-hot),直接查表
ids = torch.tensor([5, 9999, 0]) # 三个词的 id
vectors = emb(ids) # shape: (3, 128)
# 等价手写版本:构造 one-hot 做矩阵乘(仅用于理解原理)
one_hot = torch.nn.functional.one_hot(ids, num_classes=10000).float()
vectors2 = one_hot @ emb.weight # shape: (3, 128),结果与查表一致
print(torch.allclose(vectors, vectors2)) # True
代码注释里为什么要写这个等价版本:很多面试和文档把 Embedding 描述成"矩阵乘法",导致新人以为要构造 one-hot 矩阵。理解查表等价性,是理解 Embedding 本质的第一关 ------它决定了你后续怎么优化(大词表场景用哈希分片、用 nn.EmbeddingBag 做平均池化等)。
语义从哪来?------训练
到这里你可能会问:W 初始是随机的,查出来的向量凭什么有语义?
答案是:Embedding 本身没有语义,语义是训练过程赋予的。这张矩阵 W 是模型的参数,通过反向传播不断更新。训练目标决定了向量空间里"什么算近"------这正是下一节的内容。
表示学习原理:word2vec 怎么把语义学进向量
理论基础:分布式假说
word2vec(Mikolov, 2013)的思想源头是 Firth 1957 年的分布式假说:
You shall know a word by the company it keeps.(词的语义由它经常共现的上下文决定。)
"king" 和 "queen" 经常出现在相似的上下文里("___ ruled the country"),所以它们的向量应该近。"苹果" 和 "iPhone" 经常共现,所以也应该近。
Skip-gram 和 CBOW:把共现变成预测任务
word2vec 提供两种架构,本质都是把共现关系转成一个预测任务:
- Skip-gram:给定中心词,预测窗口内的上下文词。例如中心词 "brown",窗口 2 的上下文是 {the, quick, fox, jumps},训练样本是 (brown, the)、(brown, quick)......
- CBOW:反过来,用上下文词预测中心词。

▲ 图2:表示学习原理 ------ 分布式假说把共现变成预测任务,训练目标就是让真实上下文的预测概率最大,Embedding 矩阵 W 是训练的副产品
网络结构只有三层:输入层(one-hot 或上下文词)→ 隐藏层(就是查 W)→ 输出层(softmax 预测目标词)。训练完成后,隐藏层的 W 就是 Embedding 矩阵,输出层的 W' 通常丢弃。
为什么说这是"表示学习"而不是"学一个分类器"?因为我们的目的不是预测本身,而是让预测任务逼迫 W 编码语义信息------要预测 "fox" 出现在 "brown" 附近,W"brown" 就必须和 W"fox" 接近。表示是任务驱动的副产品,这就是表示学习(representation learning)的核心思想。
负采样:全词表 softmax 太贵
一个没做过的人容易忽略的工程细节:输出层 softmax 要对整个词表(V 可能 10 万以上)做归一化,每个训练样本都要算一遍,训练会慢到不可接受。
word2vec 的解法是负采样(negative sampling):把"从 V 个词里预测一个"的多分类,改成"判断这个词是不是真上下文"的二分类:
- 正样本:真实的上下文词 (brown, fox),标签 1
- 负样本:按词频加权随机采 K 个词(经验值 K=5~20),标签 0
负样本采样概率 P(w) ∝ f(w)^0.75(f 是词频),指数 0.75 是经验值------压低高频词被反复采到的概率(高频词如"的""了"作为负样本太容易区分,没信息量)。
可运行的训练代码(gensim)
python
import jieba
from gensim.models import Word2Vec
# 1. 准备语料:先分词(中文必须分词,英文按空格即可)
corpus = [
"iPhone 13 电池更换流程 拆机 工具",
"苹果手机 电池 老化 更换 教程",
"小米 手机 屏幕 更换 教程",
# ... 实际场景至少几十万条句子
]
tokenized = [list(jieba.cut(s)) for s in corpus]
# 2. 训练:关键超参数注释
model = Word2Vec(
sentences=tokenized,
vector_size=128, # 向量维度:静态词向量 100~300 常见
window=5, # 上下文窗口:太小抓不到远距离共现
min_count=5, # 词频 < 5 的词丢弃:低频词向量不可靠
sg=1, # 1=Skip-gram(罕见词友好),0=CBOW(训练快)
negative=5, # 负采样个数 K
workers=8, # 多线程,比调大 vector_size 更实在
epochs=5, # 语料小时可加到 10,语料大 3~5 就够
)
# 3. 使用
vec = model.wv["iPhone"] # 取向量
sim = model.wv.similarity("苹果", "iPhone") # 语义相似度
print(model.wv.most_similar("电池", topn=5))
这段代码有个容易踩的坑:min_count 太低时,低频词的向量几乎等于噪声------它只出现三五次,共现信息不足以支撑一个稳定的向量。很多"训练出来效果差"的问题不是模型问题,是词频分布问题,后面讲技术前提时会细说。
词嵌入成立的四个技术前提
这节是本文的重点。Embedding 不是"扔进语料就能用",它成立依赖四个前提,缺一个效果就打折甚至崩掉。
前提一:语料规模、质量与领域匹配
规模:word2vec 类模型没有"收敛"的概念,语料越大越好。百万级句子是最低门槛,千万级以上才谈得上质量。语料太小,共现统计全是噪声。
质量 :清洗决定下限。去 HTML 标签、去重复行、去机器噪声(日志里的时间戳堆砌)------噪声会让高频噪声词污染共现统计。另一个常被忽略的:不要整句去重,但要去"模板噪声"(如每行都带相同签名)。
领域匹配(最大的坑) :用通用中文维基语料训练的向量,拿到电商场景直接崩------"苹果"在通用语料里偏向水果,在电商语料里偏向品牌。Embedding 的语义是语料赋予的,语料领域不对,语义就不对。这是比模型选择更前置的问题:先问"我的语料覆盖目标语义空间吗",再谈训练。
前提二:词表、分词与 OOV
中文必须分词:中文没有天然空格边界,jieba/盘古分词等先切词再训练。分词错误会直接污染共现("苹果手机"被切错成"苹果/手机"还好,切错成"苹/果手/机"就废了)。领域词表(自定义词典)要提前灌给分词器。
词表大小要控制 :词表不是越大越好。min_count=5 过滤后,词表一般在几十万量级。词表越大,矩阵 W 越大(V×d×4 字节),训练和存储都贵。
OOV(未登录词)处理:静态词向量对词表外的新词没有向量,这是硬伤。三个解法按成本排序:
- fastText 的子词(subword)方案:把 "embedding" 拆成字符 n-gram 再求和,新词也能合成向量(虽然质量一般)
- 映射到
<UNK>占位向量(最省事,效果差) - 上 BERT 类模型(见下文,彻底解决)
前提三:训练配置(超参数没有银弹,但有经验区间)
| 超参数 | 经验区间 | 坑 |
|---|---|---|
| vector_size | 100~300(静态) | 太小欠拟合语义,太大引入噪声且存储翻倍 |
| window | 3~10 | 太小只抓局部搭配,太大语义被稀释 |
| negative | 5~20 | 太小负样本不足,太大训练变慢收益递减 |
| min_count | 3~10 | 太低低频噪声,太高损失长尾词 |
| epochs | 3~10 | 小语料多迭代,大语料过拟合(向量空间退化) |
一个反直觉的点:维度不是越大越好。我见过有人把维度拉到 1024 想"多存点语义",结果相似度区分度反而下降------维度超过语料能支撑的信息量后,多出来的维度就是在拟合噪声。静态词向量 128 维打底,语料大到亿级再考虑 256~300。
前提四:评估与使用一致性
离线评估 :训练完先跑类比任务(king - man + woman ≈ queen)和相似度评测(词对打分层级),再谈上线。评估不过关的向量上线就是事故。
向量使用前必须归一化 :这是工程上最常见的低级错误。余弦相似度 = 归一化后的内积;很多人直接拿原始向量算内积当相似度,量级差异会把结果带偏。写入向量库前 L2 归一化,检索时用内积(比余弦少一次除法,性能更好)。
版本一致性 :向量由(模型版本 + 词典版本 + 语料版本)共同决定。上线后如果只更新词典不重新训练,新旧向量空间不对齐,检索结果会莫名变差。训练按版本发布、索引双写、灰度切换是标配。
从静态到上下文相关:为什么还需要 BERT
静态词向量有个解决不了的问题:一词多义。"bank" 在"河岸"和"银行"语境下只能有一个向量,语义被平均掉了------两个意思都沾边,两个都不准。

▲ 图3:技术演进 ------ word2vec/GloVe/fastText 是静态向量,ELMo/BERT 让同一词在不同上下文得到不同向量,应用生态围绕它们展开
2018 年之后的两条路线:
- ELMo(Peters, 2018):双向 LSTM 语言模型,词向量取各层隐状态的加权和。
- BERT (Devlin, 2018):Transformer 双向编码器,Masked LM 预训练,token 级向量取最后一层,句向量取
[CLS]。
它们的共同点:同一词在不同上下文得到不同向量------"苹果"后面跟"13"和跟"汁"时向量不同。代价是推理要跑整个 Transformer,需要 GPU 或量化。
工程上的典型结构是双塔 :query 塔和 doc 塔各自过 BERT 取 [CLS],在线算余弦相似度。注意一个坑:双塔的 query 塔和 doc 塔必须共享训练(同一次微调),不能一个用预训练 BERT 一个用微调 BERT,否则两个向量空间不对齐,召回直接废。
工程落地:从训练到检索的全链路

▲ 图4:应用全链路 ------ 离线训练(Spark/gensim/BERT)产出向量文件,向量库建索引(HNSW/IVF-PQ),在线服务编码→ANN 检索→精排
大数据场景:Spark MLlib Word2Vec(Scala)
大数据工程师手里最顺手的工具是 Spark MLlib 自带的 Word2Vec,分布式训练,直接吃 HDFS 上的语料:
scala
import org.apache.spark.ml.feature.Word2Vec
import org.apache.spark.sql.SparkSession
val spark = SparkSession.builder()
.appName("word2vec-training")
.master("yarn")
.getOrCreate()
// 语料必须是分词后的句子数组:DataFrame 一列,每行是 Array[String]
val corpus = spark.read.text("hdfs:///data/corpus/*.txt")
.as[String]
.map(line => line.split("\\s+").filter(_.nonEmpty)) // 中文场景:先 jieba 分词再存成空格分隔
.toDF("words")
val w2v = new Word2Vec()
.setInputCol("words")
.setOutputCol("result")
.setVectorSize(128)
.setWindowSize(5)
.setMinCount(5)
.setNumPartitions(64) // 分区数影响训练并行度,集群大可以加大
val model = w2v.fit(corpus)
val vec = model.getVectors // DataFrame: word, vector
model.findSynonyms("苹果", 10).show() // 语义近邻,上线前先拿这个看效果
Spark 实现的坑:setNumPartitions 和语料规模要匹配 。分区太少并行度上不去,分区太多每分区样本不足,训练质量下降。另外 MLlib 的 Word2Vec 只有 Skip-gram 架构(sg 不可配),小语料上想用 CBOW 就去用 gensim。
Java 场景:加载词向量做相似度
Java 工程师拿到训练好的向量文件(word2vec.txt:每行 词 数值 数值 ...),读取和检索逻辑其实很简单:
java
// 用 DJL(Deep Java Library)加载词向量 + 计算余弦相似度
import ai.djl.ndarray.NDArray;
import ai.djl.ndarray.NDManager;
import ai.djl.ndarray.types.DataType;
import java.io.BufferedReader;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.HashMap;
import java.util.Map;
public class WordVectorService {
private final Map<String, float[]> vectors = new HashMap<>();
private int dim;
public void load(String path) throws Exception {
try (BufferedReader br = Files.newBufferedReader(Paths.get(path))) {
String line;
while ((line = br.readLine()) != null) {
String[] parts = line.split(" ");
if (parts.length < 2) continue;
dim = parts.length - 1;
float[] v = new float[dim];
for (int i = 0; i < dim; i++) v[i] = Float.parseFloat(parts[i + 1]);
// 加载时顺手 L2 归一化:之后内积 == 余弦相似度
float norm = 0f;
for (float x : v) norm += x * x;
norm = (float) Math.sqrt(norm);
for (int i = 0; i < dim; i++) v[i] /= norm;
vectors.put(parts[0], v);
}
}
}
public float similarity(String a, String b) {
float[] va = vectors.get(a), vb = vectors.get(b);
if (va == null || vb == null) return Float.NaN; // OOV 词返回 NaN,调用方要处理
float dot = 0f;
for (int i = 0; i < dim; i++) dot += va[i] * vb[i]; // 归一化后内积即余弦
return dot;
}
}
注释里两个工程点:加载时归一化 (别在检索热路径上每次算余弦)和 OOV 返回 NaN (Java 里返回 0 会污染排序结果,必须显式处理)。ES 场景可以直接用 dense_vector 字段 + script_score,把向量灌进 ES 免自建检索服务。
在线检索:ANN 与向量库
亿级向量线性扫描不现实,必须用 ANN(近似最近邻)索引:
- HNSW:图结构,召回率高,内存占用大,千万级以下首选
- IVF-PQ:倒排 + 乘积量化,省内存,适合亿级以上,召回率略降
- ES dense_vector:Java 生态顺滑,数据量百万级够用
选型判断:千万级以内直接用 ES 的 dense_vector 或 FAISS 单机版,别一上来就上 Milvus 集群------分布式向量库的运维成本比想象中高,规模没到就是给自己找事。
坑与个人判断
把这几年的经验浓缩成几条:
- Embedding 的语义来自语料,不来自模型。先问语料领域对不对,再谈训练参数。语料错了,调参是白费。
- 静态词向量没死。召回层、冷启动、低延迟场景,查表 O(1) 的优势无可替代。BERT 的精度提升值不值 GPU 成本,要算账。
- 双塔必须联合训练,query 塔和 doc 塔各训各的,向量空间不对齐,召回就是零。
- 版本一致性比模型精度更重要。embedding 升级引起召回结果突变,比向量质量差 5% 更致命。
- 先归一化再比内积。这条救过我好几次线上事故。
结语
Embedding 的本质是一个可学习的查表操作,语义是训练任务赋予的副产品------把共现转成预测任务,让预测逼迫向量编码语义,这就是表示学习的全部秘密。词嵌入要成立,语料、词表、训练配置、评估一致性四个前提缺一不可。理解到这一层,你就从"调 API 的"变成了"懂原理的"------向量检索、RAG、推荐召回这些上层应用,不过是这张矩阵 W 的不同用法。
作者 :starzy | AI Data Engineer / 大数据技术实践者 博客 :blog.starzy.cn | GitHub:starzy1990.github.io 专注 AI Agent · LangGraph · RAG · 大数据架构 · 数据工程实践