1. 嵌入层到底在干什么:从一个直觉问题讲起
做 NLP 或者推荐系统的人,迟早会撞上 embedding 这个词。我刚入行那年第一次接触它,是在 Word2Vec 刚火起来的时候。当时导师甩给我一句“把词变成向量”,我对着代码看了半天也没想明白:这玩意儿不就是个查表操作吗?凭什么它能承载语义信息?
后来自己动手训练、调参、做可视化、上线推荐系统,才慢慢把这条线捋清楚。embedding 层的本质,确实是一个查表操作——但它查的这张表,是通过海量数据的学习,把“符号”映射成“语义坐标”的关键。今天这篇内容,我把对 embedding 层的理解从头到尾拆一遍,包括它解决了什么问题、底层怎么实现、有哪些坑、以及和 LoRA 微调、Graph Embedding、多模态模型之间的关系。这篇文章适合刚入门 NLP 的读者,也适合已经在做推荐系统或者大模型应用、想把自己对 embedding 的理解再夯实一遍的工程师。
embedding 要解决的最原始的问题,是符号无法直接参与数学计算。你把“苹果”这个词扔给神经网络,它是字符串,没法做加减乘除。传统的做法是 one-hot 编码:给每个词一个唯一的位置,对应的维度置 1,其他全是 0。听起来没问题,但假设词表有 10 万个词,你的向量就是 10 万维。这还不是最致命的,最致命的是它表达不了两个词之间的任何关系。“苹果”和“香蕉”的 one-hot 向量点积是 0,欧氏距离是根号 2,完全看不出它们是同一类东西。
Embedding 的核心目标,就是把这个稀疏、高维、无语义的 one-hot 向量,压缩成一个稠密、低维(通常是 128 到 1024 维)、有语义结构的连续向量。在这个空间里,“苹果”和“香蕉”的向量距离就很近,“苹果”和“操作系统”反而离得远。Word2Vec 当年那个经典的例子——king 减 man 加 woman 约等于 queen——本质就是 embedding 空间里方向向量在做平移。你可以把 embedding 理解成把每个词“安置”在高维空间里的一个坐标,而语义相近的词,坐标天然聚在一起。
这个思想后来被广泛复制:词的 embedding、句子的 embedding、图片的 embedding、用户的 embedding、物品的 embedding、知识图谱实体和关系的 embedding。不管对象是什么,套路都是一样的:把一个离散的符号变成一个连续的稠密向量,让下游模型能直接吃进去。
2. Embedding 层的数学直觉和实现细节
2.1 从 one-hot 到 Embedding 矩阵:一次查表怎么就变成了“语义学习”
实际工程中,我们不会真的先把输入转成 one-hot 再去做矩阵乘法,那样太浪费算力了。更常见的做法是维护一个可训练的 Embedding 矩阵,形状是vocab_size × embedding_dim。输入是词的索引(一个整数),输出是对应索引的那一行向量。这个操作在 PyTorch 里就是nn.Embedding,看起来就是一个查表,但训练的时候,梯度会通过查表这条路回传到矩阵的对应行上,逐步更新这个向量。
为什么矩阵里的每一行最终能承载语义信息?关键在于训练任务的设计。拿 Word2Vec 的 Skip-gram 来说,任务是给定中心词,预测上下文词。为了把预测做对,模型必须让语义相近的词在某个子空间里靠在一起。比如“猫咪”和“小猫”经常出现在相似的上下文里,模型为了让预测更准,就会把它们的向量调得越来越接近。
从信息论的角度看,embedding 层本质上是在做一个降维和特征学习:它把高维稀疏的符号空间压缩到低维稠密的向量空间,同时尽量保留符号之间的共现信息、语法信息甚至一定的逻辑关系。这个过程没有人工设计任何特征,全是数据驱动学出来的。
用 PyTorch 写一个最基础的 embedding 层非常简单:
import torch import torch.nn as nn vocab_size = 50000 embedding_dim = 256 embedding_layer = nn.Embedding(vocab_size, embedding_dim) # 假设输入是一批 token 索引,形状是 [batch_size, seq_len] input_ids = torch.randint(0, vocab_size, (2, 10)) embedded = embedding_layer(input_ids) # 输出形状 [2, 10, 256]这里有一个新手容易搞混的点:nn.Embedding的输入是整数索引,不是 one-hot 向量。你不需要先做 one-hot 再输入,框架内部已经帮你做了“等效于 one-hot 乘以矩阵”的操作,但实际上只取对应行,节省了巨大的内存和算力。
2.2 Embedding 层的参数和训练细节
embedding 层有几个关键参数需要认真对待。
第一个是embedding_dim。维度太小,空间表达能力不够,词之间的差异容易被挤压在一起;维度太大,训练数据不够的话容易过拟合,而且模型体积剧增。词表 5 万词、维度 768,光 embedding 参数就是 3840 万,已经不小了。一个实用的经验法则是:维度取词表大小的 4 次方根附近,比如 5 万词表大约 15 的 4 次方根,也就是 256 左右。不过这只是一个起手式,实际要根据任务调整。
第二个是初始化方式。PyTorch 的nn.Embedding默认用正态分布初始化(均值 0,方差 1)。但实践中更推荐使用较小的方差,比如N(0, 0.1)或者N(0, 1/sqrt(embedding_dim))。如果初始方差太大,早期训练时 embedding 向量之间的相似度会普遍虚高,导致模型收敛慢。我之前在训练一个文本分类模型时,把 embedding 初始化方差从 1 改成 0.1,收敛速度明显变快,最终准确率也高了 0.8 个百分点。
第三个是词表治理。embedding 层参数量和词表大小成正比,而真实语料的词表如果直接取全部词汇,动辄上百万。主流做法是做词频截断:按词频排序,保留前 N 个词(比如 3 万或 5 万),低频词统一映射到一个特殊 token[UNK]。同时,词表里要预留一些特殊 token,比如[PAD](padding)、[CLS](分类标记)、[SEP](句子分隔)等,它们在后续任务的序列处理中会发挥重要作用。
训练 embedding 层要特别关注学习率。embedding 层本质上是高维稀疏参数的查表,每次更新只影响当前 batch 命中的那几行。如果全局学习率太大,某些高频词的向量会被反复大幅更新,而低频词几乎不动,最终导致向量空间分布不均匀。实践中常见做法是给 embedding 层单独设置一个较小的学习率,或者在训练后期对 embedding 层做 warmup 和衰减。在 BERT 等预训练模型的微调阶段,很多框架默认对 embedding 层使用较低的学习率,因为预训练得到的向量空间已经足够好,微调只需要小幅调整而不是推倒重来。
3. Embedding 在真实项目里的几种形态
3.1 冷启动场景下的 Embedding:新词、新用户怎么处理
实际工程里永远存在一个三角难题:词表固定了,但线上数据总会出现新词。拿搜索和推荐场景来说,用户会持续产生新的搜索词,商品会持续上架,这时候如果走离线训练好的 embedding 表,新词直接就是[UNK],信息全丢了。
处理新词的常用方案有以下几种。
第一种是字符级或子词级切分。BPE(Byte Pair Encoding)和 WordPiece 就是干这个的:把词拆成更细粒度的子词单元,虽然“新词”整体没见过,但组成它的子词单元可能在训练集中出现过,可以拼出一个近似的向量。BERT 使用的就是 WordPiece,这也是它能处理 OOV(Out of Vocabulary)问题的重要原因。
第二种是基于哈希的特征映射。推荐系统里经常会用 hash trick:把原始 ID 通过哈希函数映射到固定维度的空间,虽然会有碰撞,但能保证训练和预测时一致性,同时避免词表无限膨胀。
第三种是动态扩展词表。在实时训练系统中,遇到新词时先在 embedding 矩阵里新加一行,用某个初始化策略(比如随机初始化,或者用相近词的向量平均)塞进去。这种做法在推荐系统里很常见,但要注意控制词表膨胀速度,否则内存会爆。
第四种是利用 embedding 的迁移能力。如果你有一个通用大模型或者成熟的预训练模型,可以直接用它对新词所在的句子做编码,取某个位置的隐藏状态作为新词的临时向量,再慢慢用下游数据微调。
3.2 从词的 Embedding 到句子、用户的 Embedding:池化和聚合
词 embedding 好理解,但真实任务往往需要更高层的表示:句子向量、文档向量、用户向量、商品向量。如果你只是把词嵌入直接丢给分类器,向量长度会随句子长度变化,没法统一处理。于是就有了池化操作,常见的有以下三种:
- 平均池化:把句子所有词的 embedding 逐元素求平均,得到一个固定维度的向量。简单稳定,但会稀释重要词的信号。
- 最大池化:逐元素取最大值,保留每个维度上最显著的特征。缺点是完全忽略词的顺序和频次信息。
- 加权池化:按 TF-IDF 权重或者注意力机制给每个词加权,再求和或求平均。效果比前两种好,但多了一个权重计算的步骤。
在具体业务中,平均池化虽然“不够高级”,但在很多基线上表现并不差。比如短文本分类、小样本意图识别,平均池化加一个线性分类器就能打到一个不错的水平。我见过不少项目一上来就上 attention、上 Transformer,结果数据量不够,效果反而不如简单的平均池化。模型不是越复杂越好,关键看数据的量级和信噪比。
用户 embedding 的构建也类似。推荐系统里常用用户的行为序列(点击过的 item ID 序列)做加权池化,得到用户向量;再用用户向量和候选商品向量做内积或余弦相似度,排序后产出推荐结果。这其实就是双塔模型的基本思路:用户塔和物品塔各自把 ID 序列编码成一个稠密向量,线上用向量检索工具做 ANN(Approximate Nearest Neighbor)搜索,效率极高。
4. 动手实现一个可用的 Embedding 层:完整思路和代码解读
4.1 构建词表和处理 OOV
我不喜欢讲太多空洞的理论,直接上一套可以跑通的思路。假设我们要做一个基于 embedding 的文本分类器,目标是判断一条用户评论是正面还是负面。
第一步是构建词表。把训练语料中的所有词按词频排序,保留出现次数不少于 3 的词,加上特殊 token,形成最终的词表。
from collections import Counter import torch import torch.nn as nn from torch.utils.data import Dataset, DataLoader # 模拟语料 corpus = [ "这部电影太棒了 演员演技在线 剧情紧凑", "剧情拖沓 浪费了两个小时 不推荐", # ... 更多数据 ] token_freq = Counter() for text in corpus: for token in text.split(): token_freq[token] += 1 min_count = 3 vocab = [token for token, freq in token_freq.items() if freq >= min_count] vocab = ["[PAD]", "[UNK]", "[CLS]", "[SEP]"] + vocab token2id = {token: idx for idx, token in enumerate(vocab)} unk_id = token2id["[UNK]"]第二步是定义 embedding 模型。用一个最简单的架构:embedding 层 + 平均池化 + 线性分类器。
class SimpleTextClassifier(nn.Module): def __init__(self, vocab_size, embedding_dim, num_classes): super().__init__() self.embedding = nn.Embedding(vocab_size, embedding_dim, padding_idx=0) self.classifier = nn.Linear(embedding_dim, num_classes) def forward(self, input_ids, mask): # input_ids: [batch, seq_len] embedded = self.embedding(input_ids) # [batch, seq_len, embed_dim] # 对非 padding 位置做平均池化 mask = mask.unsqueeze(-1).float() sum_emb = (embedded * mask).sum(dim=1) count = mask.sum(dim=1).clamp(min=1e-9) pooled = sum_emb / count logits = self.classifier(pooled) return logits这里有个细节:nn.Embedding指定了padding_idx=0,意味着索引 0 对应的向量在训练中永远保持为全零,不做梯度更新。这个设计是为了避免 padding token 的向量在训练中被无意义地更新,影响池化结果。
4.2 训练时的注意事项:负样本、学习率和正则化
embedding 模型的训练,有三个问题值得特别留意。
第一是负样本。如果做的是检索或匹配类任务,不能用简单的交叉熵了事,必须构造负样本。常见策略是 batch 内负采样:一个 batch 里有 B 个正样本对,把同 batch 其他样本当作负样本,计算 InfoNCE 或 NCE loss。这样能极大提升训练效率。Batch size 的大小会直接影响负样本多样性和训练效果,一般建议 batch size 不低于 256。
第二是向量空间的正则化。embedding 向量如果不加约束,训练中可能越来越稀疏,出现维度退化现象——所有向量集中在某个低维子空间。常见做法是给 embedding 参数加 L2 正则化,或者在训练中做 embedding 向量的裁剪。还有一种思路是 Post-Training Quantization,把 embedding 向量做均值归一化,保证每个向量的模长一致,这样在线上计算余弦相似度时就不需要额外归一化了。
第三是学习率。embedding 层的高频词更新速度远快于低频词,这会导致低频词的向量始终“没学好”。一个技巧是对 embedding 层的梯度做按频率缩放:高频词梯度乘以一个较小系数,低频词梯度乘以一个较大系数。这在 word2vec 里叫负采样频率惩罚,在深度学习框架里可以手动实现,也可以直接给 embedding 层设一个较低的学习率,经验值在全局学习率的 0.1 到 0.5 倍之间。
4.3 Embedding 相似度的计算和可视化验证
训练完 embedding 之后,不要急着上线上效果,先做个最直观的验证:相似度检索。
import torch.nn.functional as F def find_similar_words(model, token2id, word, top_k=10): if word not in token2id: print("词不在词表中") return token_id = token2id[word] query_vec = model.embedding(torch.tensor(token_id)) all_vecs = model.embedding.weight.detach() # [vocab_size, embed_dim] sims = F.cosine_similarity(query_vec.unsqueeze(0), all_vecs, dim=-1) top_ids = sims.topk(top_k).indices.tolist() id2token = {idx: token for token, idx in token2id.items()} return [(id2token[i], round(sims[i].item(), 4)) for i in top_ids] # 训练完成后调用 print(find_similar_words(model, token2id, "剧情"))输出结果里,如果和“剧情”最接近的是“故事”“情节”这类词,说明 embedding 学得还不错。你还可以用 TSNE 把 embedding 向量降到二维,然后画出来,颜色按人工标注的类别打点,观察同类词是否聚在一起。这一步做出来,比任何指标都直观。
5. 站在更高的视角:Graph Embedding 与 SDNE 原理拆解
5.1 图数据为什么也需要 Embedding:节点、边和子图
词、句子、用户、物品这些都是“表格型”数据,有明确的特征。但现实世界中还有很多关系型数据,比如社交网络里人与人之间的关注关系、知识图谱里实体之间的语义关系、交通网络里站点之间的连接关系。这些数据天然是图结构的,由节点和边组成,没法直接丢进神经网络。
Graph Embedding 的任务,就是把图中的每个节点映射成一个低维稠密向量,使得向量之间的空间关系能反映图上的结构关系。比如社交网络里两个互相关注且有很多共同好友的人,他们的节点向量应该很近;两个毫无交集的社区里的人,向量距离应该很远。
Graph Embedding 的早期代表作是 DeepWalk 和 node2vec。它们的核心思路出奇简单:把图中节点当成“词”,把随机游走产生的节点序列当成“句子”,然后直接套用 Word2Vec 的 Skip-gram 来训练。也就是说,图上的邻居关系和文本里的上下文关系,在数学上是等价的。这个思路我给不少人讲过,每次听完都觉得很妙——把一个图结构问题,通过随机游走转化成序列问题,再转化成语义向量问题。
5.2 SDNE 的原理:用自编码器保留一阶和二阶相似度
node2vec 这类方法的问题是只看重局部邻居关系,对图结构的全局信息利用不够充分。SDNE(Structural Deep Network Embedding)用了一个更优雅的方式:结构化的深度自编码器。
SDNE 的核心思想是同时保留两种相似度:
- 一阶相似度:直接相连的两个节点,向量应该相近。这对应的是图的局部结构。
- 二阶相似度:有共同邻居的两个节点(即使本身不相连),向量也应该相近。这对应的是图的全局结构。
具体做法是这样的:把每个节点的邻接向量(即“它和哪些节点相连”的 0/1 向量)输入一个自编码器。自编码器要做的,是把高维邻接向量压缩成低维向量(中间层),再从这个低维向量重构出原始的邻接向量。如果两个节点的邻接模式高度重合,它们的压缩向量也会接近,这就在损失函数里自动体现了二阶相似度。与此同时,如果两个节点直接相连,就在目标函数里加一个惩罚项,强制它们的低维向量靠近,这就体现了一阶相似度。
用数学表达会更清楚。设自编码器中间层输出的向量为 (y_i) 和 (y_j),一阶相似度的损失是:
[ L_{1st} = \sum_{(i,j) \in E} a_{ij} | y_i - y_j |^2 ]
其中 (a_{ij}) 表示边 ((i,j)) 的权重。二阶相似度的损失则是自编码器的重构误差,但由于邻接矩阵非常稀疏(大多数元素是 0),SDNE 对非 0 元素的重构误差给予更高的惩罚权重,否则模型会偷懒把全部输出预测为 0,重构误差也很小。
SDNE 的优势在于它能处理大规模稀疏图,并且同时保留局部和全局结构,实际在链路预测、节点分类、可视化任务上表现都相当不错。缺点是训练开销较大,因为每个节点的邻接向量维度等于节点总数,图一大内存就吃紧。工程上通常配合稀疏矩阵运算和负采样来缓解。
6. Embedding 模型的选型与向量检索实战
6.1 主流的 Embedding 模型盘点:从 BERT 到专用向量模型
很多人会把“使用 embedding 层”和“使用 embedding 模型”混为一谈。在深度学习框架里嵌入层是模型的一部分,是一个可训练的组件;但在实际应用中,我们更多直接调用一个预训练好的 embedding 模型,把整句话编码成一个向量。这两者并不冲突,前者是模型结构里的基础模块,后者往往是完整的编码器。
目前业界使用较多的 embedding 模型,主要有三类:
第一类是通用预训练语言模型,比如 BERT、RoBERTa。使用方法是取[CLS]位置的输出或者对所有 token 向量做池化,作为句子的 embedding。优点是模型成熟、社区资源多;缺点是没有专门针对句子相似度做过优化,直接算余弦相似度效果一般。如果要用,建议在目标任务上做微调。
第二类是专门为语义相似度优化的模型,比如 Sentence-BERT、bge(BAAI General Embedding)系列、m3e 系列、E5 系列。它们专门用对比学习训练过,对句子做向量化以后直接算相似度,效果通常明显优于直接用 BERT 池化的结果。特别是 bge-large-zh 和 m3e-base,在中文语义相似度任务上表现突出,而且开源权重可以免费商用,社区使用很广泛。
第三类是支持多模态输入的统一 embedding 模型。比如 OpenAI 的 text-embedding-3 系列,支持文本和图像输入映射到同一个向量空间;CLIP 则是典型的双塔结构,文本塔和图像塔分别编码,把图文映射到共享空间。最近炙手可热的 Qwen3-VL 也做了视觉和文本的统一 embedding,可以直接用它的视觉编码器把图片编码成向量,再和文本向量做跨模态检索。这类模型在做以图搜图、图文混合检索、多模态相似度排序时非常有用。
6.2 如何评估一个 Embedding 模型好不好用
选型时不能只看榜单位置,要结合自己的数据。业界通用的评估基准是 MTEB(Massive Text Embedding Benchmark),涵盖检索、聚类、排序、相似度、分类等多项任务。但 MTEB 的高分不代表在你的业务场景里表现好,尤其是垂直领域(法律、医疗、代码)的术语分布差距很大。
我在实际项目中的做法是三步走:
第一步,拿一个小的标注集(两三百条样本就够),把候选模型的向量算出来,看相似度排序是否符合直觉。这一步能快速淘汰明显不合适的模型。
第二步,在真正的下游任务上跑一个简单的检索实验,计算 Recall@K 或 NDCG@K。比如做一个 FAQ 客服问答,把历史优秀回答当作候选池,把用户问题当作 query,看检索返回的正确答案排第几。
第三步才是看显存占用和推理速度。embedding 模型在 CPU 上跑还是比较慢的,如果线上 QPS 高,可能得用 GPU 或者做蒸馏、量化。bge-large-zh 是 326M 参数,FP16 下占显存约 0.7GB,加上 KV cache 和 batch 显存开销,一张 16GB 的 T4 显卡也能扛住中等规模流量。
6.3 向量检索的两种姿势:暴力全量扫描和 ANN
有了 embedding 向量之后,怎么快速找到最相似的向量是另一个工程重点。如果候选集只有几千条,暴力扫描足够,Python 里直接算矩阵乘法就行。
import numpy as np def brute_force_search(query_vec, item_vecs, top_k=5): # query_vec: [d], item_vecs: [N, d] scores = item_vecs @ query_vec top_indices = np.argsort(scores)[::-1][:top_k] return top_indices, scores[top_indices]但当候选规模到百万甚至千万级,暴力扫描就扛不住了。这时候需要用 ANN 算法,常见的有基于图的 HNSW、基于乘积量化的 PQ、以及基于倒排的 IVF。工程上,faiss 和 milvus 是两个绕不开的工具。faiss 偏底层,适合你把向量存在自己的存储里,只把计算交给它;milvus 偏平台化,自带索引管理和分布式部署能力,适合构建独立向量数据库。
选 HNSW 还是 IVF-PQ,取决于你对“召回率”和“内存”的取舍。HNSW 召回率高、查询快,但索引内存大;PQ 类方法内存压缩厉害,但召回率有损,需要调超参数。我一般先跑一个基准测试,把 N、d、M(HNSW 的邻居数)、efSearch 等参数扫一遍,选一个召回率在 95% 以上但延迟可接受的配置,而不是一味追求最高召回率。
7. 用 LoRA 微调 Embedding 模型到底在调什么
7.1 LoRA 是一种训练方式,不是一种模型结构
LoRA(Low-Rank Adaptation)是这两年大模型微调中最常出现的词。很多人会把“LoRA 微调 embedding 模型”理解成“再训练一个 embedding 层”,这种理解不准确。LoRA 是一种参数高效的微调方法,它的核心思想是:冻结预训练模型的大部分参数,只额外训练一些低秩的增量矩阵,加到原始权重上。
对于一个线性层 (W),正常微调时 (W) 会整体更新。LoRA 的做法是保持 (W) 不变,另外训练两个低秩矩阵 (A \in \mathbb{R}^{d \times r}) 和 (B \in \mathbb{R}^{r \times d}),最终的权重变成:
[ W' = W + \alpha \cdot B A ]
其中 (r) 是远小于 (d) 的秩,(\alpha) 是缩放系数。因为 (A)、(B) 的参数总量远比 (W) 少,训练开销大幅下降。
那 LoRA 和 embedding 层有什么关系呢?如果你用大模型做检索或相似度任务,微调时实际上有两个 embedding 空间需要对齐:一个是输入侧的大模型编码器内部 embedding,另一个是你最终输出的句子向量。用 LoRA 微调时,embedding 层本身通常完全冻结不动,LoRA 只加在 Transformer 中间的线性投影层上。也就是说,LoRA 不是去重训 embedding 表,而是通过调整整个编码器的前向传播路径,让最终产出的句子向量更适合目标任务。
7.2 微调 Embedding 模型的真实项目笔记
我在语义检索项目里用 LoRA 微调过 bge 模型。数据是标注好的 query 和正例、难负例三元组。难负例很重要,比如用户搜“苹果手机”,正例是“iPhone 15 参数”,难负例是“苹果(水果)的价格”。如果负例全是随机采样,模型学不到细粒度区分能力,召回率会虚高但用户体验差。
训练流程大致是:
第一步,加载预训练模型和 tokenizer,把 embedding 输出层冻结。第二步,在 Transformer 的 q_proj 和 v_proj 上加 LoRA,秩设为 16。这里没动 embedding 表。第三步,用对比学习损失,batch 内采样正负例,训练几个 epoch。第四步,测试在验证集上的 Recall@10 和 NDCG。第五步,如果指标没提升,优先检查训练数据质量,尤其是负例的难度。
下面是用 HuggingFace PEFT 库做 LoRA 微调的简化代码:
from transformers import AutoModel, AutoTokenizer from peft import LoraConfig, get_peft_model model = AutoModel.from_pretrained("BAAI/bge-large-zh-v1.5") tokenizer = AutoTokenizer.from_pretrained("BAAI/bge-large-zh-v1.5") # 冻结全部参数 for param in model.parameters(): param.requires_grad = False lora_config = LoraConfig( r=16, lora_alpha=32, target_modules=["q_proj", "v_proj"], lora_dropout=0.05, bias="none", ) model = get_peft_model(model, lora_config) model.print_trainable_parameters() # 可训练参数只有百分之几这里有个很容易踩的坑:很多人在get_peft_model之后,不知道哪些参数在训练,就用model.parameters()直接传给优化器。理论上 PEFT 已经自动处理了可训练性,但你要确认零初始化矩阵 (B) 的设置没有出问题。LoRA 的 (B) 矩阵初始为零,是为了保证训练开始时增量矩阵不会影响原始输出,如果初始化不对,微调一开始就会破坏预训练语义,导致训练震荡。
7.3 微调之后怎么部署:合并权重还是动态加载
LoRA 微调完的产物是几个小文件(adapter 权重),大小通常只有几十兆。部署有两种方式:
一种是在推理时动态加载 LoRA adapter。优点是原始模型保持干净,可以同时加载多个 LoRA adapter 做多任务切换。缺点是推理时多一步矩阵加法,延迟略有上升。另一种是先把 LoRA 权重合并进原始模型,导出成完整的模型文件再部署。合并后推理速度最快,但模型体积变大,而且多任务切换不方便。
如果线上对延迟要求严苛,我建议用合并方式;如果是一个模型服务多个场景,动态加载更灵活。还有一个细节:合并前一定要用验证集对比合并前后模型输出向量的一致性问题,有的框架在某些算子下精度会有细微差别,可能导致向量相差一点,进而影响检索效果。
8. 多模态 Embedding:以 Qwen3-VL 为代表的图文统一表示
多模态 embedding 是最近热度很高的方向。Qwen3-VL 这类模型的卖点之一是“图文统一 embedding”,意思是给一张图片和一个句子,即使它们的输入形态完全不同,也能映射到同一个向量空间,直接算相似度。
这个能力是怎么实现的呢?说起来不算神秘:Qwen3-VL 的结构包含视觉编码器(比如 ViT)和文本编码器,两个编码器的输出经过一个对齐层投影到同一个维度空间。训练时用图文配对数据做对比学习:图片和对应的描述文本是正样本,随机组合是负样本,模型学会把匹配的图文拉近、不匹配的推远。和双塔模型在推荐系统里的套路如出一辙。
多模态 embedding 的实际应用场景很多。比如做电商搜索,用户搜“红色碎花连衣裙”,理想结果是返回视觉上匹配的商品图,而不是只看文本描述。传统文本 embedding 只能匹配标题里的“红色、碎花、连衣裙”等关键词,但如果商品标题写得不够完整或不够规范,就检索不到。图片 embedding 可以弥补这个缺陷:即使标题信息不完整,图片本身的视觉特征已经能表达“红色碎花”这个语义。
我在一个商品检索项目里做过实验:只用文本 embedding 时,Recall@10 约 0.71;换成图文混合 embedding(文本向量和图片向量各取一半做拼接,然后降维)后,Recall@10 提升到 0.79,涨幅相当可观。不过代价是索引量翻倍,而且抽取图片 embedding 需要额外跑一个视觉模型,系统复杂度明显增加。
用 Qwen3-VL 提取图片 embedding 的代码概念上大概是:
from transformers import Qwen2VLForConditionalGeneration, AutoProcessor model = Qwen2VLForConditionalGeneration.from_pretrained("Qwen/Qwen2-VL-7B-Instruct") processor = AutoProcessor.from_pretrained("Qwen/Qwen2-VL-7B-Instruct") def encode_image(image_path, text="描述这个图片的视觉特征"): messages = [ {"role": "user", "content": [ {"type": "image", "image": image_path}, {"type": "text", "text": text}, ]} ] text_input = processor.apply_chat_template(messages, tokenize=False) inputs = processor(text=[text_input], images=[image_path], return_tensors="pt") outputs = model(**inputs) # 取视觉 token 的向量做池化,得到图片 embedding image_embeds = outputs.image_embeds pooled = image_embeds.mean(dim=1) return pooled不过,多模态模型参数量普遍较大。Qwen3-VL 各个规格从 2B 到 72B 不等,部署时通常要做量化(比如 AWQ 或 GPTQ 4bit),并在 GPU 上跑。如果线上是 CPU 推理,还是建议用轻量的纯视觉模型(如 CLIP-ViT-B/32)抽图片特征,再和文本 embedding 做后融合,成本会低一个数量级。
9. 实操中常见的问题和排查思路
9.1 训练不收敛或收敛很慢
embedding 相关模型最常见的坑是学习率设不对。embedding 层的梯度更新天然稀疏,如果全局学习率太大,高活跃词会被反复“打过头”,导致损失震荡。排查思路是把训练日志里的梯度 norm 打出来,看是否有异常大的梯度出现。如果是,考虑梯度裁剪,配合为 embedding 层单独设置较低学习率。
还有一个隐蔽的问题:padding 位置没处理。如果你在 forward 里对 sequence 做了平均池化,但没有用 mask 把 padding token 排除在外,那么 padding 位置的向量(即使是全零)会拉低整个句子的表示,导致模型效果莫名其妙地差。这个问题在所有“序列数据 + 池化”的场景里都会出现。
9.2 向量相似度结果毫无语义
如果训练完后,相似度检索返回的 Top K 结果驴唇不对马嘴,优先检查以下三点:
一是词表构建是否有误。是不是把[PAD]这种特殊 token 也塞进了训练语料?有没有把标点当词保留?词频阈值设得是否太低,导致大量噪声词进入词表?
二是训练任务是否合理。如果你的数据只是从爬虫里抽取的无标注文本,直接拿去做分类训练,embedding 学不到语义关系。Embedding 的语义能力来自“上下文共现”的约束,如果你没有设置自监督任务(比如 mask language modeling 或 word2vec 训练),那 embedding 矩阵只能被随机初始化牵着走。
三是相似度计算方式。用余弦相似度还是欧氏距离?两者在高维空间表现不同。余弦更关注方向一致性,欧氏距离更容易受向量模长影响。如果向量没有归一化,直接算欧氏距离,结果可能被模长大的向量主导。
9.3 显存爆炸和推理延迟高
embedding 模型的显存开销主要由两部分构成:模型参数和中间激活。如果词表特别大(比如百万级),nn.Embedding矩阵本身就会吃几个 GB 内存,这时就要考虑 Hash Embedding、Adaptive Embedding 或者对词表做截断。
推理延迟方面,可以用 ONNX Runtime 或者 TensorRT 做加速。embedding 层在 ONNX 导出时通常会变成一个 Gather 操作,这个操作在 CPU 和 GPU 上都非常快,真正的瓶颈在 Transformer 编码器。如果对延迟极致敏感,可以把 embedding 向量离线全部算好,存进向量数据库做近似检索,线上不跑模型,只做一次向量相似度查询。
9.4 不同语言、不同长度文本的向量对齐问题
做多语言检索时会发现,中英文向量之间有系统性偏移。通常的解法是做一个线性映射层,把中文向量空间映射到英文向量空间。这其实就是“跨语言 embedding 对齐”问题。如果用的是 bge-m3 这类原生支持多语言的模型,会好很多,因为它在预训练时已经把多语言语义对齐了。但如果在垂直领域微调,建议训练数据里特意混入一部分跨语言样本。
长度问题也一样。短文本和长文本的 embedding 分布差距很大,在做检索时可以直接限定候选文本长度范围,或者用分级索引策略:先按长度分桶,再桶内做相似度检索。
10. 这些年在 Embedding 上真正让我“啊”了的三个体会
踩过的坑多了,慢慢就有一些不太写进文档、但确实影响项目走向的经验。
第一个体会是:Embedding 不是越“先进”越好,而是越匹配任务越好。有一次做短文本聚类,我用 bge-large 的向量做 K-Means,效果反而不如一个训练在业务数据上的小型 Word2Vec。原因是 bge 的预训练分布偏向开放域语义,而我们的业务数据是高度垂直的术语体系,通用模型反而把内部差异“抹平”了。从那以后,我养成了一个习惯:不管用什么 embedding,先在目标数据的小样本上做人工评估,再决定要不要继续深挖。
第二个体会是:数据质量对 embedding 的影响,远超模型结构。对比学习里难负例的构造,几乎决定了微调的上限。用随机负样本训练出来的模型,线上召回率虚高,但用户稍一转义词就召回失败。后来我把负例构造改成“同一类目下不同商品”“同一意图不同表达”的组合,模型效果立刻上一个台阶。数据是 embedding 的粮,粮不好,模型再先进也白搭。
第三个体会是:Vector 只是中间产物,不要被它绑架。embedding 出来的向量,最终要用在具体的业务指标上——检索的 Recall@K、分类的准确率、推荐的 CTR。向量本身不是一个可以独立评估的“产品”。我见过团队花了几周时间调 embedding 模型,最后业务指标纹丝不动,因为瓶颈根本不在召回,而在粗排或者排序。做技术方案时,先画出完整的链路,再用消融实验确认 embedding 是真正的瓶颈,再去优化它。这个顺序不能反过来。
Embedding 这个概念从 Word2Vec 走到今天,底层思想其实没有变过:把离散符号变成连续向量,让机器能算、能比、能理解。理解了这个本质,不管以后出现什么新的模型架构,你都能一眼看穿它到底在哪个环节用了 embedding、为什么要这样用。剩下的,就是多动手、多踩坑、多复盘了。