构建嵌入模型
#2026/01/01 #ai
如果说前面的章节是教你如何“使用”别人造好的轮子,那么这一节就是教你如何从零开始(或基于基础模型)“造”一个自己的嵌入模型。
我们的目标是训练一个模型,让它能把含义相近的句子变成距离相近的向量。
核心思想是对比学习(Contrastive Learning):通过给模型看“相似”和“不相似”的样本对,教它学会语义的距离。
目录
第一步:准备“教材”
要训练模型,首先得有数据。我们需要告诉模型:“这两句话意思一样(正例)”或者“这两句话意思相反(负例)”。
书中使用了 NLI(自然语言推理)数据集,比如 GLUE 基准中的 MNLI。这个数据集里的数据结构非常适合做对比学习,它把句子对分为三类关系:
- 蕴含 (Entailment):
- 句子 A 能推导出句子 B -> 正例(意思相近)。
- 矛盾 (Contradiction):
- 句子 A 和句子 B 冲突 -> 负例(意思相反)。
- 中性 (Neutral):
- 两者没啥关系。
程序员视角的代码逻辑: 我们加载 MNLI 数据集,筛选出 50,000 条数据作为演示,避免训练太久。
from datasets import load_dataset
# 加载数据,split="train"
train_dataset = load_dataset("glue", "mnli", split="train").select(range(50_000))
比如一条数据是:
- 前提:某人将详细执行你的指令。
- 假设:我的团队成员将精确执行你的命令。
- 标签:蕴含(这就是我们要教给模型的“相似”样本)。
第二步:Hello World —— 初次训练
有了数据,我们开始搭建训练流水线。这里使用 sentence-transformers 框架,它封装了复杂的底层逻辑,让训练像写脚本一样简单。
1. 选择基座模型
我们不需要从零初始化随机权重,而是站在巨人的肩膀上,使用一个预训练好的 BERT 模型(bert-base-uncased)作为起点。
2. 定义损失函数 (Loss Function) 这
是告诉模型“你错哪儿了”的关键。起初,书中为了演示,使用了 SoftmaxLoss。
3. 定义评估器 (Evaluator)
为了知道模型练得怎么样,我们使用 STSB (语义文本相似度基准) 数据集。它会给句子对打分(0~1),我们看模型算出的相似度和人工打分是否一致(皮尔逊相关系数)。
训练代码核心逻辑:
from sentence_transformers import SentenceTransformer, losses, trainer
# 1. 加载基座模型
embedding_model = SentenceTransformer('bert-base-uncased')
# 2. 定义损失函数 (Softmax)
train_loss = losses.SoftmaxLoss(model=embedding_model, ...)
# 3. 开始训练
trainer = SentenceTransformerTrainer(
model=embedding_model,
train_dataset=train_dataset,
loss=train_loss,
...
)
trainer.train()
初始成绩单:
使用 Softmax 损失函数,模型在 STSB 上的得分(pearson_cosine)大约是 0.59。成绩一般,我们需要优化。
第三步:进阶优化 —— 换个更好的 Loss
Softmax 并不是训练嵌入模型的最佳选择。书中介绍了两种更强的损失函数,能显著提升效果。
优化方案 A:余弦相似度损失 (Cosine Similarity Loss)
原理: 简单直观。如果两个句子相似(比如“蕴含”),我们就让它们的向量余弦相似度趋近于 1;如果不相似(比如“矛盾”或“中性”),就趋近于 0。
数据处理: 我们需要把 NLI 的标签(0, 1, 2)转换成浮点数分数(1.0 或 0.0)。
- 蕴含 -> 1.0 (相似)
- 中性/矛盾 -> 0.0 (不相似)
效果: 替换 Loss 函数后重新训练,得分从 0.59 提升到了 0.72!这是一个巨大的飞跃。
优化方案 B:多负例排序损失 (Multiple Negatives Ranking Loss, MNR)
原理: 这是目前最流行的方法(也叫 InfoNCE)。它的核心思想非常巧妙: 在一个 Batch(批次)的数据中,假设有 $A_1, B_1$ 是一对, $A_2, B_2$ 是一对。
- 对于 $A_1$ 来说,只有 $B_1$ 是它的“真命天子”(正例)。
- 这个 Batch 里其他的 $B_2, B_3, … B_n$ 全都可以被当作 $A_1$ 的负例(不相关的句子)。
这样,我们只需要正例对数据(只取“蕴含”关系的句子),就能自动生成大量的负例,训练效率极高。
代码实现逻辑:
- 只筛选出标签为“蕴含”的数据。
- 使用
losses.MultipleNegativesRankingLoss。
# 只保留正例
mnli = mnli.filter(lambda x: True if x["label"] == 0 else False)
# ...构建数据集...
# 换用 MNR Loss
train_loss = losses.MultipleNegativesRankingLoss(model=embedding_model)
效果: 再次训练,得分飙升到了 0.80。这说明 MNR 损失函数非常强大。
第四步:追求卓越 —— 难负例 (Hard Negatives)
使用 MNR 损失函数时,我们用的负例是“随机”的(Batch 里其他句子的配对),这些叫简单负例 (Simple Negatives)。比如问“阿姆斯特丹有多少人?”,简单负例可能是“我正在等公交车”。模型很容易就能分辨出它们不相关。
但为了让模型更聪明,我们需要给它出难题,这就是难负例 (Hard Negatives)。
例子:
- 问题:阿姆斯特丹有多少人?
- 正例(答案):阿姆斯特丹有近一百万人。
- 难负例:乌得勒支有超过一百万人,比阿姆斯特丹还多。
为什么要用难负例? “难负例”虽然包含关键词(如“阿姆斯特丹”、“一百万”),甚至语义高度相关,但它是错误的答案。通过强迫模型区分这种细微差别,模型能学到更精准的语义表示。
总结
这一节教会了我们构建嵌入模型的完整路径:
- 数据:利用 NLI 数据集中的蕴含/矛盾关系。
- 起步:基于 BERT,用简单的 Loss 跑通流程(得分 0.59)。
- 迭代:改用 余弦相似度 Loss,明确相似与不相似的距离(得分 0.72)。
- 进阶:改用 MNR Loss,利用批次内其他样本作为负例,效率和效果双丰收(得分 0.80)。
- 精进:概念上引入 难负例,进一步打磨模型的辨别能力。
这就好比教一个学生(模型)做题:一开始只告诉他对错(Softmax),后来告诉他离正确答案有多远(Cosine),最后把他放到一群干扰项里让他挑出唯一的正确答案(MNR),甚至故意用易混淆的错误答案来干扰他(Hard Negatives),从而把他训练成专家。