BGE embedding 模型 MIT 许可证可商用,四行代码即可本地运行;关键细节是 encode 时必须设置 normalize_embeddings=True,否则对比学习效果无法保证,余弦相似度计算会失准。
BGE 是北京人工智能研究院(BAAI)推出的 Embedding 模型系列,采用 MIT 协议,可免费商用。取出向量只需四行代码。但要让向量表现出模型训练时所期望的行为,需要知道 API 不会告诉你的两件事。
pip install sentence-transformers
python - <<'PY'
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("BAAI/bge-base-en-v1.5")
vecs = model.encode(
["the cat sat on the mat", "a feline rested on a rug"],
normalize_embeddings=True,
)
print(vecs.shape) # (2, 768)
print(vecs @ vecs.T)
PY
normalize_embeddings=True 不是做样子的。BGE 在训练时使用了归一化向量的对比目标,因此余弦相似度才是预期的度量方式;在 encode 时做一次归一化,之后在下游就可以直接用点积,这正是所有向量数据库最高效的计算方式。如果省略这一步,得到的就是内积,其大小会随着文本长度变化。
首次调用会下载到 Hugging Face 缓存目录。对于断网机器,先在其他地方下载一次,复制缓存目录,然后设置 HF_HUB_OFFLINE=1,这样网络请求会直接报错失败,而不是慢慢超时。
BAAI 发布了三个英文 v1.5 模型,模型卡直接给出了维度、序列长度:
model dim max seq params fp32 weights
bge-small-en-v1.5 384 512 33.4M 134 MB
bge-base-en-v1.5 768 512 110M 440 MB
bge-large-en-v1.5 1024 512 335M 1.34 GB
参数数量是官方公布的;权重列是计算出来的,fp32 每个参数 4 字节。512 token 的最大序列长度这一点最容易让人踩坑:它继承自 BERT 主干,是硬截断而非报错。传入一篇 3000 token 的文档,它会静默地只 Embed 前 512 token。分块在这里不是优化项,而是正确性要求。
维度的选择更多是存储决策而非质量决策。从 base 到 large,索引大小和查询成本都乘以 1.33,检索提升却很有限,在小机器上这纯粹是 RAM 说了算。
BAAI 还发布了中文模型(bge-*-zh-v1.5)和多语言模型(bge-m3),它们是独立的模型而非上述模型的配置模式。这意味着你不能把它们混在一个索引里:bge-base-en 和 bge-base-zh 的向量虽然都是 768 维、都来自同一个实验室,但处于不相关的向量空间。如果你的语料是混合的,需要一个覆盖所有语言的模型,这是另一个不同的决策,有它自己的成本计算。
关于 v1.5 后缀还有一点:v1.5 模型并不是简单地在 v1 基础上训练得更好;发布时有意改变了相似度分布,这就是倒数第二节要讲的内容。因此固定在 v1 的检查点和 v1.5 的检查点即使规格相同也需要不同的阈值,所以记录索引时除了模型名还要记录具体版本号。
Transformer 每生成一个 token 对应一个向量。把这些变成每段文本一个向量是 pooling,不同模型系列有不同的选择:BGE 用的是第一个 Token 的最后一个隐藏状态,即 [CLS] 位置。E5 和 Nomic 用的是 Masked Mean。如果用原始 Transformer 重现编码逻辑(有人为了避免 sentence-transformers 依赖就这么做),习惯性地用了 Mean Pooling,得到向量的形状完全正常、维度没问题,但检索效果会很差。
验证方法是手动复现 sentence-transformers 的结果并对比:
import torch, torch.nn.functional as F
from transformers import AutoTokenizer, AutoModel
tok = AutoTokenizer.from_pretrained("BAAI/bge-base-en-v1.5")
enc = AutoModel.from_pretrained("BAAI/bge-base-en-v1.5").eval()
batch = tok(["the cat sat on the mat"], padding=True, return_tensors="pt")
with torch.no_grad():
out = enc(**batch).last_hidden_state
cls = F.normalize(out[:, 0], dim=-1)
mean = F.normalize(out.mean(dim=1), dim=-1)
print(float(cls @ torch.tensor(vecs[0:1]).T)) # ~1.0
print(float(mean @ torch.tensor(vecs[0:1]).T)) # noticeably below 1.0
CLS 向量与 sentence-transformers 给出的结果一致;Mean 向量则不一致。第二个数字就是这个错误的大小,它大到足以毁掉检索效果,又小到在抽查时一切看起来都正常。
BGE 是不对称的:它的训练目标是让短查询和回答它的长段落靠得近,这与让两个相似句子靠得近是不同的目标。要获得这种不对称行为,查询——仅限查询——需要加一个前缀指令。模型卡上原样给出了这条指令:"Represent this sentence for searching relevant passages: "
INSTR = "Represent this sentence for searching relevant passages: "
doc_vecs = model.encode(documents, normalize_embeddings=True)
query_vecs = model.encode([INSTR + q for q in queries],
normalize_embeddings=True)
BAAI 在 v1.5 模型卡上注明,指令可以省略,只会有轻微 degradation,在短查询对长文档的场景下帮助最大。BAAI 发布了指令并给出了使用指导。但有一件事是强制的:一致性。索引不加前缀、查询加前缀,这个版本的召回率会悄悄减半。把前缀策略和索引存在一起,而不是放在应用代码里。
用 BGE v1.5 对两个无关句子做 Embedding,余弦相似度大约是 0.6 或 0.7。这让带着"0.5 意味着一半相似"直觉来的人感到意外,但这是写在文档里的特性而非 bug:模型用温度 0.01 做了微调,把有用分数范围压缩到了大约 0.6 到 1.0。
BAAI 在模型卡上直接说明了后果——相似度分数大于 0.5 并不代表两个句子相似。随之而来的实践规则:
永远不要从其他模型硬搬阈值。在另一个模型族上合理的 0.75 截断值在这里几乎会让所有结果通过。
尽量 Ranking 而不是 Threshold。模型训练的是排序的正确性,绝对值只是温度的人为产物。
如果需要阈值,自己校准。嵌入几百对你自己标注过的句子对,画出两个分布,然后选取截断值。BAAI 建议以 0.8、0.85 和 0.9 作为起点,但那只是起点而非答案。
pip install sentence-transformers 并用 normalize_embeddings=True 做 encode;确认 base 模型的 shape 是 (n, 768)。~1.0;确认 Mean Pooling 不匹配。Running E5 Embedding Models Locally
Quantizing an Embedding Model: What Quality You Actually Lose