Skip to Content
Transformer 工程实战Embedding 的工程用法

Token Embedding vs Sentence Embedding

在 Transformer 模型里,embedding 这个词在两个完全不同的场景下被使用,混淆它们会让很多工程实践走弯路。

Token Embedding 是模型内部的基础构件。输入文本经过分词后变成一串 token ID,embedding 层把每个 ID 映射成一个固定维度的向量。以 BERT-base 为例,词表大小约 30000,embedding 维度 768,所以 embedding 矩阵是一个 30000×768 的参数矩阵。每个 token 在序列中都有独立的向量表示,一句话包含多少个 token,就产生多少个 768 维向量。

这些向量经过后续的 attention 层后,每个位置的表示会融合上下文信息——“苹果”在”我吃了苹果”和”苹果发布了新手机”中,最终的向量表示是不同的。这也是 Transformer 相比静态词向量(Word2Vec)的核心优势。

Sentence Embedding 是把整句话压缩成一个向量。它解决的是不同场景下的工程需求:语义搜索要比较查询和文档的相关性,推荐系统要计算内容相似度,这些场景需要的是”一句话 = 一个向量”,而不是”一句话 = N 个向量”。

从 Token Embedding 到 Sentence Embedding 需要一个聚合步骤。常见做法有两种:取 [CLS] token 的向量(BERT 原始设计),或对所有 token 的向量取均值(mean pooling)。实践中 mean pooling 通常效果更好,因为它用到了序列里所有 token 的信息。

工程上实际用到的大多是 Sentence Embedding。Token Embedding 更多是模型内部机制,只有在做 token 级别任务(命名实体识别、词性标注等)时才会直接使用最后一层的 token 向量。

怎么得到 Sentence Embedding

Mean Pooling vs [CLS] Token

[CLS] token 是 BERT 引入的特殊标记,位于每句话的开头。原始论文的设计意图是让这个 token 在经过 attention 层后汇聚整句的语义信息,用于下游分类任务。但多项研究发现,直接用 [CLS] 向量做句子相似度计算效果并不理想,各向量空间分布不均匀,余弦相似度的区分度差。

Mean pooling 对最后一层所有 token 的隐藏状态取平均,实验证明在语义相似度任务上优于 [CLS]sentence-transformers 库默认使用 mean pooling,这也是目前最常用的方案。

需要注意的是,批量处理时序列会被 padding 到相同长度,padding token 不携带真实语义,均值计算时必须用 attention_mask 排除这些位置——只对 mask 为 1 的 token 取均值,而不是对整段序列(含 padding)直接平均。sentence-transformersencode() 内部已处理这个细节,直接用 pipeline 或 pipeline("feature-extraction") 做简单平均则不会自动排除 padding。

sentence-transformers 的用法

sentence-transformers 是目前工程上最成熟的 sentence embedding 库,封装了模型加载、pooling、归一化等细节:

from sentence_transformers import SentenceTransformer model = SentenceTransformer('all-MiniLM-L6-v2') sentences = [ "The weather is lovely today.", "It's so sunny outside!", "He drove to the stadium.", ] # encode() 内部做了 tokenize → forward → mean pooling → normalize embeddings = model.encode(sentences) print(embeddings.shape) # (3, 384)

all-MiniLM-L6-v2 是一个常用的轻量模型,输出维度 384,模型大小约 22MB,推理速度快,适合本地开发和对延迟敏感的场景。需要更高精度时可以换 all-mpnet-base-v2(维度 768,效果更好但更慢)。

encode() 默认返回 numpy 数组,传入 convert_to_tensor=True 可以得到 PyTorch tensor,便于在 GPU 上做后续计算。

余弦相似度

公式

两个向量 $\mathbf{a}$ 和 $\mathbf{b}$ 的余弦相似度定义为:

$$\cos(\theta) = \frac{\mathbf{a} \cdot \mathbf{b}}{|\mathbf{a}| \cdot |\mathbf{b}|}$$

结果范围是 $[-1, 1]$,1 表示方向完全相同(语义高度相似),0 表示正交(无关),-1 表示方向相反。

为什么不用欧氏距离

欧氏距离衡量的是向量端点之间的绝对距离,对向量长度敏感。两个语义相同但表达长度不同的句子,其 embedding 的模长可能相差较大,导致欧氏距离偏大,即使归一化后也可能引入额外误差。

余弦相似度只关注向量方向,忽略模长,更适合衡量语义的相对距离。实际上,如果向量已经做过 L2 归一化(模长为 1),余弦相似度就等价于点积,可以用矩阵乘法高效计算。sentence-transformersencode() 默认开启归一化(normalize_embeddings=True),所以直接用点积即可。

代码实现

import numpy as np from sentence_transformers import SentenceTransformer model = SentenceTransformer('all-MiniLM-L6-v2') sentences = ["今天天气很好", "阳光明媚", "他去了体育馆"] embeddings = model.encode(sentences, normalize_embeddings=True) # 已归一化,点积 == 余弦相似度 similarity_matrix = np.dot(embeddings, embeddings.T) print(similarity_matrix)

sentence-transformers 也内置了工具函数 util.cos_sim(),接受 tensor 或 numpy 数组,返回相似度矩阵,处理批量计算更方便。

向量数据库

为什么需要向量数据库

假设有 100 万条文档,每条文档的 embedding 维度是 768。暴力搜索(brute force)需要计算查询向量与全部 100 万条的余弦相似度,即 100 万次向量点积,延迟在毫秒级别对于实时应用不可接受,且随数据量线性增长。

向量数据库的核心是 ANN(Approximate Nearest Neighbor,近似最近邻) 索引,用少量精度损失换取数量级的速度提升。

ANN 索引原理简介

主流的 ANN 算法包括:

HNSW(Hierarchical Navigable Small World):构建多层图结构,高层是稀疏的跳表式连接,低层是密集的近邻连接。查询时从高层开始,快速定位大致区域,再在低层精细搜索。查询复杂度约 $O(\log n)$,精度高,是目前工程上最常用的算法,Qdrant 和 Chroma 都默认使用 HNSW。

IVF(Inverted File Index):先用 k-means 把向量空间划分成若干 cluster,查询时只在最近的几个 cluster 里搜索。适合超大规模数据集,内存占用比 HNSW 小,但精度稍低。Faiss 常用这种方式。

LSH(Locality Sensitive Hashing):通过哈希函数把相似向量映射到相同的桶里,速度快但精度相对较低,目前在主流向量数据库中使用较少。

Qdrant vs Chroma 选型对比

对比维度QdrantChroma
定位生产级向量数据库本地开发/原型
部署方式Docker / 云服务本地嵌入式或服务端
持久化默认持久化到磁盘默认内存,可持久化
过滤查询支持复杂 payload 过滤支持基础 metadata 过滤
性能高并发场景表现好适合小规模数据
Python API官方 client原生 Python
适用场景生产部署、百万级以上数据本地测试、RAG 原型开发

选型建议:原型阶段用 Chroma,开箱即用,零配置。上生产或数据量超过 10 万条,迁移到 Qdrant,稳定性和性能更有保障。两者的 Python API 设计类似,迁移成本不高。

工程实践要点

Embedding 维度的权衡

维度越高,表达能力越强,但存储和计算开销也更大。100 万条向量,维度 384 需要约 1.5GB 存储(float32),维度 1536(OpenAI text-embedding-3-large)则需要 6GB。

在实际项目中,先用轻量模型(384 维)跑通流程,评估效果,确实需要更高精度再升级模型。不要一开始就上最大维度。

模型选择

几个常用的开源模型:

  • all-MiniLM-L6-v2:384 维,22MB,推理快,适合资源受限场景
  • all-mpnet-base-v2:768 维,420MB,精度更高,SBERT 基准上表现优秀
  • paraphrase-multilingual-MiniLM-L12-v2:384 维,支持 50+ 语言,中文场景首选
  • BAAI/bge-large-zh-v1.5:1024 维,专为中文优化,MTEB 中文排行榜靠前

中文业务场景推荐 bge-large-zh-v1.5paraphrase-multilingual-MiniLM-L12-v2,不要用英文模型处理中文文本。

归一化

入库前对所有向量做 L2 归一化,好处有两点:

  1. 余弦相似度可以直接用点积计算,速度更快
  2. 向量数据库的距离度量设置为 dot_product(点积),比 cosine 计算路径更短

sentence-transformersencode(normalize_embeddings=True) 会自动处理,不需要手动归一化。

批量推理性能

单条逐个调用 encode() 效率很低,批量传入列表可以充分利用 GPU 并行:

# 低效:逐条推理 for sentence in sentences: emb = model.encode(sentence) # 高效:批量推理 embeddings = model.encode(sentences, batch_size=64, show_progress_bar=True)

batch_size 的合理取值取决于显存和模型大小。all-MiniLM-L6-v2 在普通 GPU 上 batch_size=256 通常没问题。CPU 推理建议 batch_size=32,平衡内存和速度。

增量更新文档索引时,用 collection.upsert() 而不是 collection.add()——upsert 会自动跳过已存在的文档,只为新增或修改的文档重新计算 embedding 并更新索引,不需要全量重建。

Last updated on