直觉:把"语义"变成坐标

计算机不懂"国王"和"女王"语义相近,它只看到两个不同的字符串。嵌入(embedding)做的事,是把离散对象(词、句子、图片、用户)映射到一个连续的 dd 维向量空间,使得语义相近的对象在空间里距离也近。一旦有了这个空间,“找相似"就退化成"找最近的点”,这正是现代检索、推荐、RAG 的地基。

本文从最经典的 word2vec 讲到工业级的近似最近邻(ANN),把这条链路打通。

机制:word2vec 是怎么学出向量的

word2vec 的核心假设是分布式语义:一个词的含义由它的上下文决定。以 skip-gram 为例,目标是用中心词预测窗口内的上下文词。给定中心词 wcw_c 和上下文词 wow_o,用两套向量 vv(中心词)和 uu(上下文词),softmax 概率为:

P(wowc)=exp(uwovwc)wVexp(uwvwc)P(w_o \mid w_c) = \frac{\exp(u_{w_o}^\top v_{w_c})}{\sum_{w \in V} \exp(u_{w}^\top v_{w_c})}

问题在分母:词表 V|V| 动辄几十万,每步都对全表求和,复杂度 O(V)O(|V|),不可接受。**负采样(negative sampling)**把它改写成二分类——对每个真实 (中心词, 上下文词) 正样本,采样 kk 个噪声词作负样本,目标变成:

logσ(uwovwc)+i=1kEwiPn[logσ(uwivwc)]\log \sigma(u_{w_o}^\top v_{w_c}) + \sum_{i=1}^{k} \mathbb{E}_{w_i \sim P_n}\big[\log \sigma(-u_{w_i}^\top v_{w_c})\big]

其中 σ\sigma 是 sigmoid,Pn(w)f(w)0.75P_n(w) \propto f(w)^{0.75} 是经验上效果好的噪声分布(对高频词降权)。这样每步只算 k+1k+1 个内积,复杂度从 O(V)O(|V|) 降到 O(k)O(k)。训练完,vv(或 v+uv+u)就是词向量。著名的 king - man + woman ≈ queen 类比,反映的是这个空间里存在近似线性的语义方向。

现代句向量/文档向量(如基于 Transformer 的编码器)思路升级了——上下文相关、用对比学习训练——但"用向量近邻表达语义近邻"的内核没变。

相似度度量:别在度量上踩坑

拿到向量后用什么衡量"近"?两个主流:

  • 余弦相似度cos(a,b)=abab\cos(a,b) = \dfrac{a^\top b}{\|a\|\,\|b\|},只看方向不看长度。文本嵌入几乎都用它。
  • 欧氏距离ab2\|a-b\|_2,方向和长度都看。

关键工程技巧:如果把所有向量归一化到单位长度,则 ab22=22ab\|a-b\|_2^2 = 2 - 2\,a^\top b。此时"欧氏距离最小"和"内积最大"和"余弦最大"三者完全等价。所以工业做法常是:先 L2 归一化,再用内积索引。这避免了在线计算除法,也让不同索引后端行为一致。

一个常见踩坑:训练时用余弦、检索时用欧氏(且没归一化),度量不一致会让召回质量莫名其妙下降。

为什么需要 ANN:精确检索扛不住

有了 NN 个向量、一个查询 qq,精确最近邻就是线性扫描全部,复杂度 O(Nd)O(Nd)。当 NN 上亿、dd 是几百到上千维时,单次查询要做上亿次内积——延迟和成本都不可接受。

而且"维度灾难"让事情更糟:在高维空间里,最近点和最远点的距离趋于接近,精确最近邻的意义本身在变弱。于是工业界普遍接受用近似最近邻(ANN):牺牲一点召回率(recall@k),换取数量级的延迟下降。核心权衡就是 recall ↔ latency ↔ 内存 这个三角。

主流 ANN 索引有三类:

  • IVF(倒排 + 量化):先用 k-means 把空间切成若干 cluster,查询时只搜最近的几个 cluster(参数 nprobe)。nprobe 越大召回越高、越慢。常配合 PQ(乘积量化) 压缩向量:把 dd 维切成 mm 段,每段单独量化成 1 字节码本索引,把一个 float 向量从 4d4d 字节压到 mm 字节,显存/内存占用骤降,代价是距离计算变成近似查表。
  • HNSW(分层可导航小世界图):构建多层近邻图,高层稀疏用于"跳远",底层稠密用于"精搜"。查询从顶层贪心下降,平均查询复杂度近似 O(logN)O(\log N),召回-延迟曲线通常最好,但内存占用大(要存图的边),且增量构建成本高。
  • 暴力 + GPUNN 不太大(百万级)时,GPU 上的精确扫描反而简单可靠。

最小检索示例

下面用 numpy 演示归一化 + 内积检索的内核(生产请用 Faiss / ScaNN / hnswlib):

1
2
3
4
5
6
7
8
9
10
11
12
import numpy as np

def normalize(X):
return X / (np.linalg.norm(X, axis=1, keepdims=True) + 1e-12)

db = normalize(np.random.randn(100_000, 256).astype("float32")) # 库
q = normalize(np.random.randn(1, 256).astype("float32")) # 查询

scores = db @ q.T # 归一化后内积 == 余弦
topk = np.argpartition(-scores.ravel(), 10)[:10] # 取 top-10,O(N) 不全排序
topk = topk[np.argsort(-scores.ravel()[topk])] # 仅对 10 个排序
print(topk)

注意 argpartition 而非 argsort:找 top-k 不需要全排序,前者是 O(N)O(N),后者 O(NlogN)O(N\log N)。这是检索里常被忽略的小优化。

工程权衡清单

  • 维度 dd:维度高语义更丰富,但内存、计算、索引时间都线性涨。常用降维(PCA/OPQ)在召回和成本间折中。
  • 归一化:基本无脑做,统一度量、稳定数值。
  • 索引选型:内存吃紧 → IVF-PQ;要极致召回-延迟且内存富裕 → HNSW;库小 → 暴力。
  • 召回评估:上线前一定测 recall@k(用精确检索做 ground truth),别只看延迟。
  • 更新模式:HNSW 增量插入还行但删除麻烦;IVF 重训码本代价高。高频更新场景要提前规划重建策略。

小结

嵌入把离散语义压成连续向量,word2vec 用负采样把 O(V)O(|V|) 的 softmax 降成 O(k)O(k) 的二分类,奠定了"语义近邻=向量近邻"的范式。检索阶段,先做 L2 归一化统一余弦/内积/欧氏度量,再用 ANN(IVF-PQ、HNSW)在 recall、latency、内存三角里取舍——亿级规模下用一点点召回损失换数量级的速度,是这套系统能跑起来的根本原因。