讲透 Embedding 本质:从 one-hot 到表示学习,词嵌入的四个技术前提

摘要: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。

它有两个硬伤:

  1. 维度灾难。词表 100 万时,每个词是 100 万维的稀疏向量,存储和计算都扛不住。
  2. 无语义关系。任意两个 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 集群------分布式向量库的运维成本比想象中高,规模没到就是给自己找事。

坑与个人判断

把这几年的经验浓缩成几条:

  1. Embedding 的语义来自语料,不来自模型。先问语料领域对不对,再谈训练参数。语料错了,调参是白费。
  2. 静态词向量没死。召回层、冷启动、低延迟场景,查表 O(1) 的优势无可替代。BERT 的精度提升值不值 GPU 成本,要算账。
  3. 双塔必须联合训练,query 塔和 doc 塔各训各的,向量空间不对齐,召回就是零。
  4. 版本一致性比模型精度更重要。embedding 升级引起召回结果突变,比向量质量差 5% 更致命。
  5. 先归一化再比内积。这条救过我好几次线上事故。

结语

Embedding 的本质是一个可学习的查表操作,语义是训练任务赋予的副产品------把共现转成预测任务,让预测逼迫向量编码语义,这就是表示学习的全部秘密。词嵌入要成立,语料、词表、训练配置、评估一致性四个前提缺一不可。理解到这一层,你就从"调 API 的"变成了"懂原理的"------向量检索、RAG、推荐召回这些上层应用,不过是这张矩阵 W 的不同用法。


作者 :starzy | AI Data Engineer / 大数据技术实践者 博客blog.starzy.cn | GitHub:starzy1990.github.io 专注 AI Agent · LangGraph · RAG · 大数据架构 · 数据工程实践

相关推荐
BehaviourBlogs1 小时前
ChatGPT Work 推出个人写作风格学习功能:从「通用助手」到「个人化写作代理」
gpt·aigc·ai编程
全栈弄潮儿2 小时前
中级开发者用 AI,最容易掉进的 5 个坑
aigc·openai·ai编程
打呵欠的猫2 小时前
我把 20 个页面的权限控制从"硬编码"改成"配置驱动",AI 帮我生成了 80% 的迁移代码
前端·ai编程
console.log('npc')3 小时前
2026 实测:Grok 4.5 与 Grok 4.6 怎么选?前端开发、教程写作、Figma 还原选型指南
前端·大模型·ai编程·figma·grok
超级架构师3 小时前
连接企业系统,不等于把接口直接交给 Agent:LIMENORA 的集成边界
网络·人工智能·架构·ai编程
虎头金猫4 小时前
Pic Smaller 本地部署:压缩图片、转换格式,再搭建自己的 Web 工具
运维·前端·开源·aigc·ai编程·ai写作
飞哥数智坊5 小时前
周末薅 Token 羊毛,没想到 ZCode 已经把开发和测试打通了
人工智能·ai编程
沧沧凉凉5 小时前
两个小时,AI 把我 2018 年那个 Unity 版保卫萝卜搬进了浏览器
前端·游戏·ai编程