model.encode 这一行内部到底发生了什么
把黑盒拆开
前面两篇——05 篇教你用 embedding,番外 09 给你建立直觉。这一篇是技术细节:
vec = model.encode("你好世界", normalize_embeddings=True)
这一行代码内部到底跑了哪几步?训练它的对比损失是什么形状?为什么 OpenAI 的 text-embedding-3-large 可以从 3072 维任意截到 256 维都能用?向量库里的 HNSW 索引到底长什么样?把 1024 维 float32 压成 128 维 int8 损失多少召回?
不掌握这些细节,你做工程时永远停留在"调 API 试试看"。下面一节一节拆。
model.encode 内部到底跑了什么
SentenceTransformer.encode 是一个 wrapper,背后是五个步骤。我们用底层的 transformers 库把它一步步拆出来,验证我们的理解和 sentence-transformers 输出完全一致。
from transformers import AutoTokenizer, AutoModel
from sentence_transformers import SentenceTransformer
import torch
import torch.nn.functional as F
text = "你好世界"
hf_model_id = "BAAI/bge-m3"
# --- 步骤 1:tokenize ---
tokenizer = AutoTokenizer.from_pretrained(hf_model_id)
inputs = tokenizer(
text,
padding=True,
truncation=True,
return_tensors="pt",
max_length=8192, # bge-m3 支持 8K
)
# inputs.input_ids : (1, seq_len),每个值是词表里的 token id
# inputs.attention_mask : (1, seq_len),padding 位置为 0,否则 1
print("token ids:", inputs.input_ids[0].tolist())
# 形如 [0, 6, 250002, 250003, 2],开头/结尾是 <s>/</s>,中间是 BPE 切出来的子词
# --- 步骤 2:forward pass ---
backbone = AutoModel.from_pretrained(hf_model_id)
backbone.eval()
with torch.no_grad():
outputs = backbone(**inputs)
# outputs.last_hidden_state : (1, seq_len, hidden_size=1024)
# 每个 token 一个 1024 维的"上下文向量"
hidden = outputs.last_hidden_state
# --- 步骤 3:pooling(bge-m3 用 CLS pooling)---
sentence_vec = hidden[:, 0] # 取第一个 token([CLS])的向量 -> (1, 1024)
# --- 步骤 4:L2 归一化 ---
sentence_vec = F.normalize(sentence_vec, p=2, dim=1)
# --- 步骤 5:转成 numpy / 取出第一行 ---
manual = sentence_vec[0].numpy()
# --- 验证:和 sentence-transformers 输出完全一致 ---
st_model = SentenceTransformer(hf_model_id)
auto = st_model.encode(text, normalize_embeddings=True)
import numpy as np
print("max abs diff:", np.abs(manual - auto).max()) # 应该 < 1e-6
跑下来 max abs diff 接近 0,说明这五步就是 encode 的全部。每一步都值得看一眼细节:
Tokenize 把字符串切成 token id。bge-m3 用的是 XLM-RoBERTa 的 BPE tokenizer,词表 25 万,覆盖 100 多种语言。一个汉字常常对应一个 token,但生僻字、英文长词会被拆成多个子词。truncation 是个静默的坑——超过 max_length 的部分被直接砍掉,没有任何报错。生产环境一定要先确认你的切片长度都在模型上下文之内。
Forward pass 是整个过程里最贵的一步——一段长度 512 token 的输入要过 24 层 Transformer,CPU 上几十毫秒,GPU 上几毫秒。返回的 last_hidden_state 是一个 (batch, seq_len, hidden) 的张量,每个 token 都拿到了一个被上下文调制过的向量——这正是番外 09 里说的"上下文 embedding"。
Pooling 是把 (seq_len, hidden) 变成 (hidden,) 的关键一步。下一节专门讲。
Normalize 把向量长度归一化为 1,让后续余弦相似度可以直接用点积算。
池化策略:为什么不同模型选不同的方式
把多个 token 的向量"汇总"成一个句子向量,常见做法有三种:
CLS pooling:取第一个特殊 token([CLS] 或 <s>)的向量。BERT 时代就是这么做的,因为预训练时 [CLS] 这个位置专门负责"句子级"任务(比如下一句预测)。bge-m3、bge-large-zh 走这一路。
Mean pooling:把所有非 padding token 的向量按位求平均。直觉是"句子的语义是每个词语义的平均"。E5、jina-embeddings、mxbai-embed 走这一路。注意求平均时必须用 attention mask 过滤掉 padding,否则越长的句子向量被 padding 拉得越平:
def mean_pool(hidden_states, attention_mask):
mask = attention_mask.unsqueeze(-1).float() # (b, s, 1)
summed = (hidden_states * mask).sum(dim=1)
counts = mask.sum(dim=1).clamp(min=1e-9)
return summed / counts
Last-token pooling:取最后一个非 padding token 的向量。这是为基于因果语言模型(LLaMA、Qwen)做的 embedding 模型用的——比如 gte-Qwen2-7B-instruct、linq-embed-mistral。原因是因果模型的最后一个 token "看到了"前面所有 token,包含了整段话的累积信息;而 [CLS] 在因果掩码下根本不知道后文。
选哪种是模型的训练时决定,推理时必须沿用——你不能拿一个 CLS pooling 训出来的模型在推理时改用 mean pooling,会得到一团乱码般的相似度。如果你看 sentence-transformers 模型库下的 1_Pooling/config.json,里面的 pooling_mode_* 字段就指定了这件事。
训练目标:对比学习的数学
embedding 模型的灵魂在于它怎么被训出来。当代主流 sentence embedding 都用 InfoNCE 损失(也叫对比损失),公式长这样:
$$ \mathcal{L} = -\frac{1}{N} \sum_{i=1}^{N} \log \frac{\exp(\text{sim}(q_i, p_i^+) / \tau)}{\sum_{j=1}^{N} \exp(\text{sim}(q_i, p_j) / \tau)} $$
吓人的话不用一个一个看。这个公式做的事就一句话:让 query qᵢ 和它的正例文档 pᵢ⁺ 之间的相似度,比和 batch 里所有其他文档 pⱼ 的相似度都大。
具体拆开看:
- sim(q, p) 通常是归一化后的点积,也就是余弦相似度
- τ(温度) 是一个 0.01~0.1 的小数,作用是放大相似度差距——温度越小,模型越被推着把正负例拉得越开
- 分母 是 batch 内所有 (qᵢ, pⱼ) 的相似度求和,分子只挑出真正的正例对
这个损失暗含两个关键的工程事实:
事实一:Batch 里其他文档自动充当负例。 这就是 in-batch negatives——你不需要专门标注"什么是负例",只要 batch 里有 N 个样本,每个 query 就自动有 N-1 个负例。这也是为什么 embedding 模型的训练 batch size 都很大——bge 训练时 batch size 上万,OpenAI 训练 text-embedding-3 据传 batch 数万。Batch 越大,每个 query 见到的"对比对象"越多,学到的相似度边界越锐利。这条路在数学上等价于"用更大的 softmax 分母"。
事实二:随机抽的负例太容易,效果有限,需要 hard negatives。 一个 batch 里随机抽的 1000 个文档,其中 999 个和你的 query 八竿子打不着,模型很容易就把它们排到正例后面,损失很小,但什么都没学到。Hard negative mining 是专门挑那种"看起来像但其实不是"的负例——比如对"如何重置密码"这个 query,hard negative 是另一篇讲"如何修改用户名"的文档(话题相近但不解决你的问题)。挖掘 hard negative 通常是用一个老版本的 embedding 模型先做一轮检索,把 top-k 但不是真正答案的文档作为新一轮训练的 hard negative。这是一个迭代过程:每一代模型给下一代提供更难的负例,整体能力螺旋上升。
读懂这两件事,你就能理解为什么领域适配如此重要——你的领域里"什么是相似的"和"什么是 hard negative",通用模型完全不知道。同一个 base model,在你自己的领域语料上做一轮对比微调,效果常常比换一个更大的通用模型还好。
Matryoshka:一次训练,多种维度
OpenAI 的 text-embedding-3-large 默认输出 3072 维,但你可以在 API 调用时传一个 dimensions=256,模型会直接返回前 256 维,且这 256 维仍然是一个有效的 embedding。这怎么做到的?
直觉上你会想:截掉后面的维度,前面的维度的语义会不会被破坏?答案是:如果训练时不做特殊处理,会被严重破坏。但如果训练时用 Matryoshka Representation Learning(MRL) 做了特殊处理,就不会。
MRL 的做法是修改对比损失,在多个前缀长度上同时算损失再求和:
$$ \mathcal{L}{\text{MRL}} = \sum{d \in D} w_d \cdot \mathcal{L}_{\text{InfoNCE}}(\text{prefix}_d(q), \text{prefix}_d(p)) $$
其中 D = {64, 128, 256, 512, 1024, ...} 是一组预设的"截断长度",prefix_d(·) 表示把向量截到前 d 维。
这一改的效果是:模型被强迫在前 64 维就把粗粒度的语义编码完,前 128 维加一层细节,前 256 维再加一层,依此类推。最终得到的 3072 维向量像俄罗斯套娃——任意一个前缀都是一个独立可用的 embedding,只是粒度不同。
这个 trick 的工程价值很大:
- 同一个模型库可以服务"高精度但贵"和"低成本但够用"两种业务,无需训练多个模型
- 检索时可以做两阶段:先用前 256 维快速召回 top 1000,再用完整 3072 维对这 1000 条精排,速度和精度兼得
- 向量库的存储可以按需选择,从 3072 维降到 256 维存储成本就只有 1/12
bge-m3、text-embedding-3 都用了 MRL,sentence-transformers 也支持训练 MRL 模型。
检索这一端:从 O(N) 到 O(log N) 的代价
你有 1000 万条向量,每条 1024 维 float32。一次 query 要找 top-10 最相似的:
- 暴力(flat 索引):和每条向量都算一次点积,1024 次乘加 × 1000 万 = 100 亿次浮点运算。CPU 上要几秒,根本不能服务在线请求
- 近似最近邻(ANN)算法:用某种"提前组织好"的数据结构,把搜索复杂度降到次线性
最常用的 ANN 算法是 HNSW(Hierarchical Navigable Small World)。它的核心思想可以用一句话概括:建一个分层的图,每层都是上一层的稀疏采样,查询时从顶层粗略定位、逐层下沉精修。
具体怎么建:
- 每个向量是图的一个节点,节点之间有边连接到它的近邻
- 节点按一个递减的概率分布到 0~L 层(顶层节点最少,越往底节点越多)
- 每个节点在每层最多有 M 条出边(典型值 16~64)
查询时:
- 从顶层的入口节点开始,沿着边贪心走,每次走到比当前更接近 query 的邻居
- 走到这一层的局部最优后,下沉到下一层继续走
- 最底层做一次更细的搜索(搜索宽度由参数
ef(expansion factor)控制),返回 top-k
直觉上像是从空中俯瞰大致定位、然后落地走巷子。理论上 HNSW 的时间复杂度是 O(log N),1000 万条向量每次查询只需要算几百次点积,比 flat 快 4~5 个数量级。
参数怎么调:
- M 越大,图越密,召回越高、内存越大、构建越慢。生产环境 M = 32 左右
- 查询时的 ef 越大,搜索宽度越大、召回越高、延迟越大。常见 ef 在 50~200 之间根据延迟预算调
- 建库时的
ef_construction越大,索引质量越好但建库越慢,一般给 200~400
实战经验:HNSW 的内存占用约等于 (向量大小 + M × 4 字节) × N。1000 万条 1024 维 float32 + M=32,需要约 41GB 内存(其中 40GB 是向量本身、1.3GB 是图结构)。这就引出下一节——量化。
其他常见 ANN 算法:
- IVF(倒排文件):把向量先 k-means 聚成几千簇,查询时只搜最近的几个簇。简单、内存友好,但召回不如 HNSW
- PQ(乘积量化):把每个向量分成几段,每段用 256 个码字编码(每段 1 字节),把 1024 维 float32(4KB)压到几十字节。常和 IVF 组合成 IVF-PQ,是 Milvus、Faiss 在亿级规模的标配
量化:把向量从 4KB 压到 128 字节
向量库的内存和存储成本几乎全花在向量本身上。压缩向量是个性价比极高的方向。常见三档:
float32 → float16:4 字节 → 2 字节,精度几乎无损(embedding 本来就在 [-1, 1] 量级),存储立减一半。属于"无脑该开"的优化。
float32 → int8:4 字节 → 1 字节,4 倍压缩。做法是先在一批向量上估计每一维的 min/max,把 float32 线性映射到 [-128, 127]。检索时用整数点积(SIMD 加速比 float 还快)。在主流 embedding 模型上召回掉点通常 < 1%——text-embedding-3-large 官方就支持 int8 输出。
float32 → 1 bit(binary embedding):4 字节 → 0.125 字节,32 倍压缩。每一维只存"正"或"负"。相似度从点积变成 1 减 Hamming 距离(异或后数 1 的个数,硬件级 popcount 指令一条搞定,比 float 点积快两个数量级)。
直觉上这么粗暴的压缩肯定要崩,但实测召回掉点通常在 5~10%——前提是模型在训练时就考虑了二值化(mxbai-embed-large-v1、Cohere 的 binary embeddings 都是这么训的)。结合两阶段检索(binary 快速召回 top 1000、再用 float32 精排到 top 10),最终精度几乎和纯 float32 持平,但内存只用 1/32。
实战取舍:
| 场景 | 推荐方案 |
|---|---|
| < 100 万条,单机内存够 | 纯 float32 + HNSW |
| 100 万 ~ 1 亿条 | int8 + HNSW,或 IVF-PQ |
| > 1 亿条,存储敏感 | binary + 两阶段(binary 召回 + float 精排) |
一段实战代码:把所有细节捏在一起
最后用一段端到端的代码示意——它做了 tokenize → 池化 → 归一化 → HNSW 索引 → 检索的完整链路,每一步都是上面拆过的细节:
import numpy as np
import hnswlib
from transformers import AutoTokenizer, AutoModel
import torch
import torch.nn.functional as F
device = "cuda" if torch.cuda.is_available() else "cpu"
tokenizer = AutoTokenizer.from_pretrained("BAAI/bge-m3")
model = AutoModel.from_pretrained("BAAI/bge-m3").to(device).eval()
def encode(texts: list[str], batch_size: int = 32) -> np.ndarray:
"""逐批做 tokenize + forward + CLS pooling + L2 归一化。"""
out = []
for i in range(0, len(texts), batch_size):
chunk = texts[i:i + batch_size]
inputs = tokenizer(
chunk, padding=True, truncation=True,
return_tensors="pt", max_length=512,
).to(device)
with torch.no_grad():
hidden = model(**inputs).last_hidden_state
cls = hidden[:, 0] # CLS pooling
cls = F.normalize(cls, p=2, dim=1) # L2 归一化
out.append(cls.cpu().numpy())
return np.vstack(out)
# 1. 编码语料
docs = [
"Python 异步编程依赖 asyncio",
"如何在 macOS 上安装 Homebrew",
"FastAPI 是一个现代 Python Web 框架",
"煮意大利面要在水里加盐",
# ...假设这里有 100 万条
]
doc_vecs = encode(docs) # (N, 1024),已归一化
# 2. 建 HNSW 索引
index = hnswlib.Index(space="ip", dim=1024) # ip = inner product,归一化后等于余弦
index.init_index(max_elements=len(docs), ef_construction=200, M=32)
index.add_items(doc_vecs, ids=np.arange(len(docs)))
index.set_ef(64) # 查询时搜索宽度
# 3. 检索
query_vec = encode(["Python 怎么写异步代码"])
labels, distances = index.knn_query(query_vec, k=3)
# distances 是 1 - inner_product,越小越相似
for label, dist in zip(labels[0], distances[0]):
print(f"sim={1 - dist:.3f} doc={docs[label]}")
读完前面所有节再看这段代码,每一行都不再是"调一下",而是"这里是因为 X 所以这么写"——这就是技术细节带给你的差别。
收尾
把这一篇和前两篇放在一起:
- 05 篇教你跑:API、本地、demo
- 番外 09 教你想清楚:one-hot 的问题、几何直觉、训练范式、它的盲区
- 这一篇教你拆开看:tokenize、池化、对比损失、Matryoshka、HNSW、量化
到这里你应该能从 model.encode("...") 这一行代码出发,向上指出它输出的向量在 RAG 系统里的位置、向下指出它内部经过了哪些张量变换、向旁边指出训练它的损失函数为什么长那样、再向下游指出向量库会怎么把它索引和压缩。
这种全栈视角是工程师调系统时真正吃饭的本钱——出问题时你能马上定位到层级,性能不够时你知道每个层级有什么取舍,需求变化时你知道该改哪一层。embedding 看起来是一个简单的"文本进、向量出"的接口,但接口背后这一整条链路的细节,都是你在生产环境踩过坑之后才会真正记住的东西。下一篇我们就开始踩坑——RAG 实战,用上面所有的零件搭一个能跑的知识库。
参考资料
- Sentence-BERT 原论文 — 现代 sentence embedding 训练范式的起点
- InfoNCE 损失原始论文:Representation Learning with Contrastive Predictive Coding
- Matryoshka Representation Learning
- HNSW 原始论文:Efficient and robust approximate nearest neighbor search
- Faiss: Library for efficient similarity search — Meta 出的 ANN 工具集,HNSW / IVF / PQ 都有
- hnswlib — 纯 HNSW 的轻量实现,本文示例代码用的就是它
- Cohere Binary Embeddings 博客 — 二值化 embedding 的工程实践
版权声明: 如无特别声明,本文版权归 sshipanoo 所有,转载请注明本文链接。
(采用 CC BY-NC-SA 4.0 许可协议进行授权)
本文标题:番外 10:Embedding 的技术细节
本文链接:https://www.sshipanoo.com/blog/ai/ai-for-python/番外10-Embedding技术细节/
本文最后一次更新为 天前,文章中的某些内容可能已过时!