Skip to content

Embedding 微调与领域适配

提出问题

通用 Embedding 模型(如 text-embedding-3-small、bge-large-en-v1.5)在大规模通用语料上训练,能覆盖大部分常见语义。但在垂直领域——法务、医疗、金融、代码补全、企业内部知识库——经常出现「召不回来」的问题:搜法律条款时「股权回购」和「对赌协议」的语义距离拉不开,搜 API 文档时「request body」和「payload」的向量不近。这就是语义空间漂移:通用 Embedding 的分布,跟你的业务空间不重合。很多人遇到召回率不行,第一反应是换更大的模型或加 Rerank,但往往忽略了微调 Embedding 本身这条更根本的路径。

真实案例: 某金融科技公司用 bge-large-zh-v1.5 做内部知识库检索,800 万文档。用户搜「对公转账限额」,通用模型 Recall@10 只有 37%。微调后 Recall@10 升到 82%,而加 Rerank(cross-encoder)只提到 53%。微调的直接 ROI 比 Rerank 高 2 倍,而且推理延迟从 300ms 降到 50ms(因为 Embedding 还是 1024 维向量,不需要额外模型)。

分析问题

对比学习:教模型区分「像」和「不像」

Embedding 微调的核心范式是 对比学习(Contrastive Learning)。给定一个 query q,一个正例 p(相关文档),以及一组负例 n1, n2, …(不相关文档),目标是让 q 与 p 的向量距离小于 q 与任意 ni 的距离。

损失函数最常见的是 InfoNCE(NT-Xent)

python
import torch
import torch.nn.functional as F

def contrastive_loss(query_emb, pos_emb, neg_embs, temperature=0.05):
    """
    query_emb: (1, d)
    pos_emb:   (1, d)
    neg_embs:  (N, d)
    """
    pos_sim = F.cosine_similarity(query_emb, pos_emb) / temperature  # scalar
    neg_sim = F.cosine_similarity(query_emb.unsqueeze(1), neg_embs.unsqueeze(0), dim=2) / temperature  # (1, N)
    logits = torch.cat([pos_sim.unsqueeze(0), neg_sim.squeeze(0)], dim=0)  # (N+1)
    labels = torch.zeros(1, dtype=torch.long, device=query_emb.device)
    return F.cross_entropy(logits.unsqueeze(0), labels)

trick: temperature 参数很关键。设 0.05 是 Sentence-BERT 的默认值,但领域数据如果正例和负例差异很大(比如法条 vs 合同),可以调高到 0.1-0.2 让梯度更平缓;如果难负样本本来就很难区分(比如同类别不同接口的文档),调低到 0.02 拉大梯度。

难负样本挖掘(Hard Negative Mining) 是决定微调效果的第一要素。随便采一批负样本出来的效果非常有限——模型很快就能区分「股权回购」和「天气预报」,但区分不了「股权回购」和「股权质押」。必须构造难负样本:

# 难负样本构造策略(按优先级排序)
1. 同类别下的不同文档(同一法条下的不同解释)
2. 同一接口的不同参数说明(CreateUser vs CreateOrder 的 API 文档)
3. 同一主题的不同子话题(「消息队列选型」vs「消息队列运维」)
4. BM25 检索出的 top-k 但实际不相关的文档
5. 随机负样本(只做兜底,batch 内 in-batch negatives 足够)

In-batch Negatives(IB neg):同一 batch 内其他 query 的正例文档,相对于当前 query 就是负样本。这是性价比最高的负样本来源,大 batch 训练时(batch_size ≥ 64) IB neg 就够用,不需要额外挖负样本。

完整微调流程:从数据到上线

# 微调全流程(以 Sentence-Transformers 为例)
# 依赖:pip install sentence-transformers transformers datasets

from sentence_transformers import SentenceTransformer, losses, InputExample
from torch.utils.data import DataLoader, Dataset
import random

# 1. 加载预训练模型
# 用 bge-base 还是 bge-large?经验:base 微调后效果经常超过 frozen large
model = SentenceTransformer('BAAI/bge-base-zh-v1.5')

# 2. 构造数据
# 每个样本是一个 (query, doc, label) 三元组,label=1 为正例
# Sentence-Transformers 的 MultipleNegativesRankingLoss 自动处理 in-batch negatives
train_samples = []
for query, pos_doc in query_doc_pairs:  # 你的业务数据
    train_samples.append(InputExample(texts=[query, pos_doc], label=1))

# 也可以加难负样本
# train_samples.append(InputExample(texts=[query, hard_neg], label=0))

train_dataloader = DataLoader(train_samples, shuffle=True, batch_size=64)

# 3. 配置损失函数
# MultipleNegativesRankingLoss 是最常用的,支持 in-batch negatives
# 如果想显式传负样本,用 ContrastiveLoss 或 CoSentLoss
train_loss = losses.MultipleNegativesRankingLoss(model)

# 4. 微调参数
# 踩坑:learning rate 不要设太大,Embedding 微调 2e-5 就够
# epoch 不要太多,2-3 轮就收敛,超过 5 轮必过拟合
model.fit(
    train_objectives=[(train_dataloader, train_loss)],
    epochs=3,
    warmup_steps=100,
    optimizer_params={'lr': 2e-5},
    output_path='./finetuned-embedding',
    save_best_model=True,
    evaluation_steps=500,
    evaluator=None,  # 可以传 EmbeddingSimilarityEvaluator
)

# 5. 保存并测试
model.save('./finetuned-embedding')
emb = model.encode('对公转账限额')

踩坑记录:

  • 过拟合是最大的坑。 Embedding 模型参数全量微调,2 个 epoch 就开始收敛,5 个 epoch 后测试集 Recall 反而下降。解决方案:加 LayerNorm 冻结(model[0].auto_model.encoder.layer.0 等),或只微调最后 2 层 + 加学习率衰减。
  • Batch size 越大越好,但显存有限。 大 batch 意味着更多 in-batch negatives,1024 维向量训练 64 batch 只需要 12GB 显存(BGE base),128 batch 需要 24GB。如果显存不够,用 gradient accumulation 模拟大 batch。
  • 不要用完整的 Embedding 模型所有层更新。 冻结底层 70% 的 Transformer 层,只微调最后几层 + pooler,效果几乎没有损失,但训练速度提升 3 倍。
  • Cosine Similarity 用 Matryoshka 维度? BGE 的 Matryoshka 模式可以截断维度(比如 1024→256),微调时用全维度,部署时截断,能在 30% 精度损失下换 4 倍存储节省。

对比表:微调前后的典型效果

评估维度通用模型(bge-base-zh)微调后提升
Recall@10(金融领域)37%82%+45%
Recall@10(法务领域)41%79%+38%
MRR(金融领域)0.310.72+0.41
搜索延迟(单 query)48ms48ms不变
索引存储2.8GB(100万文档)2.8GB不变

注意:微调后通用场景的 Recall 会下降 5-10%。如果业务既有领域搜索又有通用搜索,建议保留两个模型副本,用路由策略分发 query。

领域数据构造:三种实战方案

  1. Query-Doc 标注对(最理想,成本最高):从业务日志中提取真实搜索 query,人工标注对应文档。法务场景下「股权回购—公司法第142条」就是完美的正例对。每领域 1k-3k 对就能看到明显提升。

  2. LLM 生成合成数据(推荐冷启动,成本低):拿领域文档段,让大模型生成对应的 query。给一段医保政策文本,让 GPT 生成「异地就医报销需要什么材料」这类 query。注意控制噪声,生成后人工抽检 10% 确认。

    python
    # 伪代码:LLM 生成 query
    def generate_queries(doc_chunk, model="gpt-4o-mini"):
        prompt = f"""根据以下文档,生成 3 个用户可能搜索的 query。
        要求:query 是口语化搜索,不是精确的重述。
        文档:{doc_chunk}
        输出格式:每行一个 query"""
        return call_llm(prompt, model)
  3. 自监督循环(迭代优化,适合长期维护):用现有关键词 + 摘要做粗对齐,微调一轮后上线,用线上日志拾取「用户搜 A 点了 B」作为隐式正例,再微调下一轮。阿里巴巴的 Contriever 团队报告过,三轮迭代后 Recall@20 能从 62% 升到 85%。

评估与何时该微调

微调前必须建评测集。用 检索命中率(Recall@K)MTEB 子集(如分类阈值、检索 MRR)做对比。一个简单的领域评测流程:

领域文档 → 人工标注 100-200 query 及答案 → 分别用通用模型和微调模型召回 → 对比 Recall@5 / Recall@10

评估代码片段:

python
def evaluate_recall(model, queries, docs, relevant, k=10):
    """queries: list[str], docs: list[str], relevant: dict[query_idx->set(doc_idx)]"""
    q_embs = model.encode(queries, normalize_embeddings=True)
    d_embs = model.encode(docs, normalize_embeddings=True)
    hits = 0
    total = 0
    for qi, q_emb in enumerate(q_embs):
        scores = d_embs @ q_emb  # dot product
        top_k = scores.argsort(descending=True)[:k].tolist()
        if any(idx in relevant[qi] for idx in top_k):
            hits += 1
        total += 1
    return hits / total

什么时候该微调 Embedding?

场景推荐方案预期效果
通用搜索,召回率 80%+别动,直接用通用模型
领域搜索,召回率低且文档量 < 10 万先试 Rerank,成本更低一般能提 10-15%
领域搜索,召回率低且文档量大微调 Embedding + Rerank 组合最优整体提 30-50%
领域搜索,专业术语多(法务/医疗)必须微调,通用模型语义空间差太远提 40%+
搜索量级 < 500 对训练数据换更大的通用模型,别微调微调必过拟合

什么时候不该微调? 如果你的业务数据量 < 500 对、或领域术语不重(普通电商商品),微调容易过拟合,不如换更大的通用模型或加 Rerank。另外,如果你的场景是纯英文且本身就用 OpenAI text-embedding-3-large,微调空间很小——它已经 3072 维且覆盖了几乎所有领域,微调性价比低。

总结

Embedding 微调是领域检索的终局方案,但前提是 数据质量 > 模型大小。核心路线:对比学习(InfoNCE/MultipleNegativesRankingLoss)+ 难负样本挖掘(优先 IB neg 和同类别难负样本)+ 领域数据构造(LLM 合成+真实日志)。不要一上来就微调——先跑通用模型 baseline,用 Recall@K 判断是否需要动手。如果通用模型把「股权回购」和「对赌协议」的向量拉到 0.95 相似度,那就是该微调的信号。

面试高频题: "Embedding 微调为什么比 Rerank 更适合大规模场景?" → 因为微调后向量维度不变,索引结构不变,检索延迟不变,但 Rerank 需要在每个 top-k 上跑 cross-encoder,O(k * d) vs O(d) 的差距在 100 万文档级别就是 100ms vs 5s 的差距。

踩坑总结: 过拟合 > 数据质量 > 负样本构造 > 参数选择。按这个优先级排,别在调参上花太多时间——数据对才是天花板。

参考

参考:SimCSE 论文(Gao et al., 2021)、Sentence-Transformers 官方文档、E5 / BGE 微调代码、MTEB Benchmark、Alibaba Contriever 实践报告

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。