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-instructlinq-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)

查询时:

  1. 从顶层的入口节点开始,沿着边贪心走,每次走到比当前更接近 query 的邻居
  2. 走到这一层的局部最优后,下沉到下一层继续走
  3. 最底层做一次更细的搜索(搜索宽度由参数 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 实战,用上面所有的零件搭一个能跑的知识库。

参考资料

版权声明: 如无特别声明,本文版权归 sshipanoo 所有,转载请注明本文链接。

(采用 CC BY-NC-SA 4.0 许可协议进行授权)

本文标题:番外 10:Embedding 的技术细节

本文链接:https://www.sshipanoo.com/blog/ai/ai-for-python/番外10-Embedding技术细节/

本文最后一次更新为 天前,文章中的某些内容可能已过时!