"嵌入(embedding)"是把一个词、一句话、一张图压成一串数字——一个高维向量。但这些向量在空间里怎么排布,本身就编码了语义。今天不讲某个具体编码模型(那是 Day 24),而讲贯穿所有表示学习的几何规律:为什么"国王−男人+女人≈王后"成立、现代 embedding 靠什么训练目标、为什么 BERT 的句向量"挤成一团"、以及怎么用一个向量同时服务快查询和精排。看懂几何,你才知道一个 embedding 为什么好或为什么坑。
词向量之间的差值(diff)本身是有意义的——就像 git 里两个 commit 之间的 patch:你可以把"性别 diff"从一个词上抽出来,再 apply 到另一个词上。国王 − 男人 = 一段"王权但去掉男性"的位移向量,把它加到"女人"身上,就落在了"王后"附近。语义关系被编码成了可复用的、方向一致的平移操作。
2013 年前,词的表示是 one-hot:每个词一个孤立维度,"猫"和"狗"的相似度严格为 0,机器完全不知道它们都是动物。Word2Vec(Mikolov 等, 2013)和 GloVe(Pennington 等, 2014)的突破:让"意思相近的词,向量也相近",而且关系被编码成方向。
机制核心是一句老话——"一个词由它的上下文定义"。Word2Vec 用一个浅层网络做"完形填空":给中心词预测周围词(Skip-gram)。要把这个预测做好,模型被迫把常出现在相似上下文里的词放到相邻位置。训练完,"语义"就沉淀成了几何:同类词聚成簇,而"单复数""国家→首都""动词时态"这类系统性关系表现为平行且等长的位移向量。
注意"≈":这不是精确等式,而是"加完之后最近邻恰好是王后"。这种线性结构只在高频、规整的关系上稳定,生僻词和复杂关系常常失灵——别把它当成可靠的推理引擎。
import gensim.downloader as api # 加载预训练 GloVe 词向量(首次会下载约 65MB) wv = api.load("glove-wiki-gigaword-100") # 100 维 # 经典类比:king - man + woman ≈ ? 用向量加减后找最近邻 result = wv.most_similar(positive=["king", "woman"], negative=["man"], topn=3) print(result) # → [('queen', 0.78), ('throne', ...), ...] # 几何验证:相似词余弦相似度高,无关词低 print(wv.similarity("cat", "dog")) # ~0.6,都是宠物 print(wv.similarity("cat", "democracy"))# ~0.1,几乎无关
对比学习 = 训练一个"语义指纹函数":同一份内容的不同表面形式(改写、翻译、加噪)要哈希到相邻位置(正样本拉近),不相关内容要哈希到远处(负样本推开)。表示坍缩就是这个哈希函数退化成"永远返回同一个值"——所有 key 撞进一个桶,相似度全是 1,等于没学。负样本就是防坍缩的那条约束。
Word2Vec 给词,但我们更想要整句的好向量(用于检索、聚类)。问题:没有现成标签说"这两句意思一样"。对比学习的巧思——自己造正样本:对同一句话做两次轻微扰动(如两次不同 dropout),它俩天然是一对正样本;一个 batch 里的其他句子就当负样本。这正是 SimCSE(Gao 等, 2021)的做法。
训练目标是 InfoNCE 损失(van den Oord 等, 2018)。直觉:它是一道"N 选 1 的选择题"——给定锚点 query,在"1 个正样本 + 多个负样本"里,让模型给正样本最高相似度。公式:
L = −log [ exp(sim(q,k⁺)/τ) / Σⱼ exp(sim(q,kⱼ)/τ) ]
为什么需要负样本?只拉正样本不推负样本,模型会发现一个偷懒解:把所有句子都映射到同一个点——正样本距离瞬间归零,损失却没真正学到区分。这就是表示坍缩。负样本在分母里制造"互斥张力",逼着表示铺满空间(均匀性),才不会塌缩。
import torch, torch.nn.functional as F def info_nce(z1, z2, tau=0.05): # z1[i] 与 z2[i] 是一对正样本;同 batch 其余皆为负样本 z1, z2 = F.normalize(z1, dim=1), F.normalize(z2, dim=1) sim = z1 @ z2.T / tau # [B,B] 相似度矩阵 / 温度 labels = torch.arange(z1.size(0), device=z1.device) # 对角线 = 正样本,等价于"每行选对角"的分类题 return F.cross_entropy(sim, labels) # 同一 batch 过两次带 dropout 的编码器 → 得到两套表示(SimCSE 思路) z1 = encoder(batch) # dropout 随机性 → 天然正样本对 z2 = encoder(batch) loss = info_nce(z1, z2) # 拉近 (z1[i],z2[i]),推开其余
各向异性(anisotropy)= 数据倾斜(data skew)的几何版。一个糟糕的哈希函数会把所有 key 挤进少数几个桶——余弦相似度因此被系统性抬高,所有句向量看起来"都挺像"。白化(whitening)就是给这堆挤成锥形的向量做"再均衡":线性变换一下,让它们重新均匀铺满空间,相似度才重新可信。
直接拿 BERT 最后一层做句向量,效果意外地差。原因被诊断为各向异性:向量不是均匀分布在球面上,而是全部挤在一个狭窄的圆锥里(都指向相近方向)。后果:任取两个不相关句子,余弦相似度也有 0.7+,因为它们方向本来就接近——相似度失去了区分力。
白化(Su 等, 2021,即 BERT-whitening)是个纯线性的后处理,不用重训。两步:
本质就是统计里的 PCA 白化:对协方差矩阵 Σ 求变换 W 使 WᵀΣW = I。变换后,余弦相似度重新有意义,STS 语义相似度任务上往往直接涨点;还能顺手做降维(只保留方差大的方向)。
import numpy as np def whitening_fit(embs): # embs: [N, d] 一批句向量 mu = embs.mean(axis=0, keepdims=True) # ① 去均值 cov = np.cov((embs - mu).T) # 协方差矩阵 [d,d] u, s, _ = np.linalg.svd(cov) # 特征分解 # ② 变换矩阵 W:把协方差白化成单位阵 W = u @ np.diag(1.0 / np.sqrt(s + 1e-8)) return mu, W mu, W = whitening_fit(train_embs) # 应用:任何新向量都先减均值再乘 W,之后再算余弦相似度 def transform(x): return (x - mu) @ W print(np.linalg.norm(transform(query))) # 白化后再 normalize 检索
像渐进式 JPEG:先传前一小段就能看到一张低清整图,数据越多越清晰。Matryoshka 训练出的向量,前缀的前 k 维就是一个能独立用的低清版本。也像复合索引——只读前几列就能做粗筛。存一次 1536 维,粗排截前 256 维(快),精排用全维(准),不必为不同精度各训一套。
痛点:高维向量(1536 维)又准又贵——存储、内存、检索都随维度涨。但低维向量(快、省)又不够准。传统做法是为不同场景各训各的,或事后硬截断高维向量——而普通向量被截断后信息散落各维,前缀根本不能用,精度崩。
Matryoshka 表示学习(MRL,Kusupati 等, 2022)的巧思:训练时就逼着信息按重要性"由粗到细"地排进前缀。机制只是改了损失——不再只对完整维度算一次损失,而是对一组嵌套前缀(如 [64,128,256,512,1024,1536])各算一次损失再求和:
L = Σ_{k∈{64,128,...,1536}} Loss( embedding[:k] )
直觉:既然每个前缀都被要求独立完成任务,梯度就会把最关键的信息往最前面挤(因为前 64 维要独自顶用),后面的维度只做精细化补充。训练完,一个向量天然分层:截到哪一维都是该精度下"尽量好"的表示,而非随机碎片。开销几乎为零——只是多算几次损失,推理时一个前向。
from openai import OpenAI import numpy as np client = OpenAI() # 需要 OPENAI_API_KEY # text-embedding-3 系列原生支持 Matryoshka:用 dimensions 截维 def embed(text, dim): r = client.embeddings.create(model="text-embedding-3-large", input=text, dimensions=dim) v = np.array(r.data[0].embedding) return v / np.linalg.norm(v) # 截断后务必重新归一化 full = embed("分布式系统的最终一致性", 3072) # 全精度,精排 small = embed("分布式系统的最终一致性", 256) # 低维,粗排快查 print(full.shape, small.shape) # (3072,) (256,) — 同模型,一截即得