Aller au contenu principal

Fine-tuning d'embeddings

Mis à jour le 29 juillet 2026

Objectifs

  • Comprendre quand le fine-tuning d’embeddings est nécessaire
  • Préparer un dataset de paires pour l’entraînement
  • Implémenter le fine-tuning avec des adaptateurs légers

Quand fine-tuner ?

Commençons par la mauvaise nouvelle, qui est en réalité une bonne : dans la majorité des cas, les modèles d’embedding pré-entraînés font parfaitement l’affaire, et le fine-tuning est du travail perdu. Trois situations le justifient vraiment. La première est un domaine au vocabulaire très spécialisé — médecine, droit, industrie — où le modèle générique ignore que « IDM » et « infarctus du myocarde » désignent la même chose. La deuxième est le plateau : vous avez optimisé le chunking, ajouté la recherche hybride et le reranking, et les métriques ne bougent plus. La troisième est simplement d’avoir sous la main des paires de textes annotées, similaires ou dissimilaires, qui dormaient dans vos logs de recherche.

À l’inverse, renoncez si vous avez moins de mille paires annotées : l’entraînement mémorisera vos exemples sans généraliser. Renoncez aussi si les performances sont déjà satisfaisantes — un gain de deux points de recall ne vaut pas un modèle supplémentaire à maintenir. Et vérifiez surtout qu’un meilleur chunking ou un meilleur prompt ne résout pas le problème : c’est presque toujours moins cher et plus rapide.

Approche 1 : adaptateur linéaire (Matryoshka)

L’idée la plus économique ne touche pas au modèle d’embedding. On le laisse produire ses vecteurs, et on entraîne par-dessus une petite couche de projection qui déforme l’espace pour rapprocher ce qui doit l’être dans votre domaine. Le modèle d’OpenAI reste une boîte noire appelée par API ; seuls quelques centaines de milliers de paramètres sont entraînés, sur CPU, en quelques minutes.

L’entraînement s’appuie sur des triplets : une requête, un document qui y répond, et un document qui parle du même sujet sans y répondre. Regardez les exemples médicaux ci-dessous — le négatif de « effets secondaires du paracétamol » n’est pas un texte sur le football, mais un texte sur le paracétamol vendu en pharmacie. C’est ce qu’on appelle un négatif difficile, et c’est lui qui apprend quelque chose au modèle : un négatif trop évident ne corrige rien puisque le modèle générique le classait déjà correctement.

import numpy as np
from openai import OpenAI
from sklearn.model_selection import train_test_split

client = OpenAI()

# Dataset de paires (query, document_positif, document_négatif)
triplets = [
    {
        "query": "effets secondaires du paracétamol",
        "positif": "Le paracétamol peut causer des troubles hépatiques...",
        "negatif": "Le paracétamol est disponible en pharmacie..."
    },
    {
        "query": "posologie aspirine enfant",
        "positif": "Pour les enfants de 6 à 12 ans, la dose recommandée...",
        "negatif": "L'aspirine a été découverte au XIXe siècle..."
    },
    # ... plus de triplets
]

def preparer_embeddings(triplets: list[dict]) -> dict:
    """Génère les embeddings pour les triplets."""
    tous_textes = []
    for t in triplets:
        tous_textes.extend([t["query"], t["positif"], t["negatif"]])

    response = client.embeddings.create(
        input=tous_textes,
        model="text-embedding-3-large"
    )
    embs = np.array(
        [d.embedding for d in sorted(response.data, key=lambda x: x.index)]
    )

    n = len(triplets)
    return {
        "queries": embs[0::3],
        "positifs": embs[1::3],
        "negatifs": embs[2::3],
    }

L’adaptateur lui-même est un réseau de deux couches qui projette les 3 072 dimensions du modèle vers 256, avec une normalisation finale pour que les vecteurs de sortie restent comparables par cosinus. La fonction de coût est un triplet margin loss : elle vaut zéro dès que la requête est plus proche du positif que du négatif d’au moins la marge de 0,2, et pénalise proportionnellement le reste. Autrement dit, on n’apprend au modèle que ce qu’il n’a pas encore compris, et l’entraînement se stabilise naturellement une fois les triplets bien séparés.

import torch
import torch.nn as nn
import torch.optim as optim

class AdaptateurEmbedding(nn.Module):
    """Couche de projection pour adapter les embeddings."""
    def __init__(self, dim_input: int = 3072, dim_output: int = 256):
        super().__init__()
        self.projection = nn.Sequential(
            nn.Linear(dim_input, dim_output),
            nn.ReLU(),
            nn.Linear(dim_output, dim_output),
        )

    def forward(self, x):
        projected = self.projection(x)
        return projected / projected.norm(dim=-1, keepdim=True)

def triplet_loss(anchor, positive, negative, margin=0.2):
    """Triplet margin loss."""
    dist_pos = 1 - torch.cosine_similarity(anchor, positive)
    dist_neg = 1 - torch.cosine_similarity(anchor, negative)
    return torch.relu(dist_pos - dist_neg + margin).mean()

def entrainer_adaptateur(data: dict, epochs: int = 50):
    """Entraîne l'adaptateur sur les triplets."""
    model = AdaptateurEmbedding()
    optimizer = optim.Adam(model.parameters(), lr=1e-3)

    queries = torch.tensor(data["queries"], dtype=torch.float32)
    positifs = torch.tensor(data["positifs"], dtype=torch.float32)
    negatifs = torch.tensor(data["negatifs"], dtype=torch.float32)

    for epoch in range(epochs):
        optimizer.zero_grad()

        q_proj = model(queries)
        p_proj = model(positifs)
        n_proj = model(negatifs)

        loss = triplet_loss(q_proj, p_proj, n_proj)
        loss.backward()
        optimizer.step()

        if (epoch + 1) % 10 == 0:
            print(f"Epoch {epoch + 1}/{epochs}, Loss: {loss.item():.4f}")

    return model

# Entraîner
data = preparer_embeddings(triplets)
adaptateur = entrainer_adaptateur(data)

# Sauvegarder
torch.save(adaptateur.state_dict(), "adaptateur_medical.pt")

Un point d’exploitation à ne pas manquer : le jour où vous déployez cet adaptateur, tous les vecteurs déjà stockés dans votre base doivent repasser par la projection, faute de quoi vous compareriez des vecteurs de deux espaces différents. Prévoyez la réindexation complète du corpus dans le plan de mise en production.

Approche 2 : fine-tuning avec Sentence Transformers

Quand l’adaptateur ne suffit pas, il faut modifier le modèle d’embedding lui-même, ce qui suppose un modèle ouvert que vous hébergez — ici BAAI/bge-large-en-v1.5. Sentence Transformers rend l’opération accessible : on décrit les exemples comme des paires de textes avec un label continu, où 1,0 signifie « ces deux textes doivent être proches » et 0,2 « ils se ressemblent superficiellement, éloignez-les ». La CosineSimilarityLoss pousse alors la similarité cosinus calculée vers le label fourni.

Trois époques et cent pas de warmup sont des valeurs prudentes, et elles le sont pour une raison : sur un dataset modeste, un entraînement trop long détruit les connaissances générales du modèle, qui devient excellent sur vos exemples et médiocre partout ailleurs.

from sentence_transformers import (
    SentenceTransformer, InputExample, losses
)
from torch.utils.data import DataLoader

# Charger un modèle de base
model = SentenceTransformer("BAAI/bge-large-en-v1.5")

# Préparer les exemples d'entraînement
train_examples = [
    InputExample(
        texts=["effets secondaires paracétamol",
               "Le paracétamol peut causer des troubles hépatiques"],
        label=1.0
    ),
    InputExample(
        texts=["effets secondaires paracétamol",
               "Le paracétamol est vendu en pharmacie"],
        label=0.2
    ),
]

train_dataloader = DataLoader(train_examples, shuffle=True, batch_size=16)
train_loss = losses.CosineSimilarityLoss(model)

# Entraîner
model.fit(
    train_objectives=[(train_dataloader, train_loss)],
    epochs=3,
    warmup_steps=100,
    output_path="./modele_medical_finetuned"
)

# Utiliser le modèle fine-tuné
embeddings = model.encode([
    "douleurs abdominales après prise de médicament",
    "effets indésirables médicamenteux"
])

Préparer un dataset de qualité

Reste l’obstacle réel : personne n’a mille paires annotées sous la main. La parade consiste à les fabriquer à partir de ce que vous avez déjà, c’est-à-dire vos documents. On soumet chaque document au LLM en lui demandant les questions auxquelles il répond précisément, et l’on obtient des paires question-document sans aucune annotation manuelle. La température de 0,7 assure que les trois questions générées pour un même document ne soient pas trois variantes de la même phrase, et le lstrip nettoie la numérotation que le modèle ajoute spontanément.

def generer_paires_entrainement(
    documents: list[str],
    n_par_doc: int = 3
) -> list[dict]:
    """Génère des paires query-document pour l'entraînement."""
    paires = []

    for doc in documents:
        response = client.chat.completions.create(
            model="gpt-5.6-terra",
            messages=[{
                "role": "user",
                "content": (
                    f"Voici un document :\n\n{doc[:500]}\n\n"
                    f"Génère {n_par_doc} questions auxquelles ce document "
                    f"répond précisément. Une question par ligne."
                )
            }],
            temperature=0.7
        )
        questions = response.choices[0].message.content.strip().split("\n")

        for q in questions[:n_par_doc]:
            q = q.strip().lstrip("0123456789.-) ")
            if q:
                paires.append({"query": q, "document": doc})

    return paires

Évaluer le fine-tuning

Un entraînement dont la perte diminue n’a rien prouvé : la seule question qui compte est de savoir si la recherche s’est améliorée. La fonction ci-dessous mesure le recall@5 sur le même jeu de requêtes, avec et sans adaptateur, en passant simplement adaptateur=None pour obtenir la référence. Le corpus comme les requêtes traversent la projection lorsqu’elle est fournie, ce qui est la condition pour que les deux vecteurs vivent dans le même espace.

Exécutez toujours cette comparaison avant de déployer. Il arrive régulièrement qu’un adaptateur entraîné sur trop peu de triplets dégrade le recall, et sans mesure vous auriez livré une régression en croyant livrer une amélioration.

def evaluer_avant_apres(
    queries: list[str],
    docs_pertinents: list[list[str]],
    corpus: list[str],
    adaptateur=None
):
    """Compare les performances avant et après fine-tuning."""
    resp = client.embeddings.create(
        input=corpus, model="text-embedding-3-large"
    )
    corpus_embs = np.array(
        [d.embedding for d in sorted(resp.data, key=lambda x: x.index)]
    )

    if adaptateur:
        corpus_embs_adapted = adaptateur(
            torch.tensor(corpus_embs, dtype=torch.float32)
        ).detach().numpy()
    else:
        corpus_embs_adapted = corpus_embs

    recall_scores = []
    for query, pertinents in zip(queries, docs_pertinents):
        q_resp = client.embeddings.create(
            input=query, model="text-embedding-3-large"
        )
        q_emb = np.array(q_resp.data[0].embedding)

        if adaptateur:
            q_emb = adaptateur(
                torch.tensor(q_emb, dtype=torch.float32).unsqueeze(0)
            ).detach().numpy()[0]

        scores = corpus_embs_adapted @ q_emb
        top_5 = set(np.argsort(scores)[-5:])
        pertinent_ids = set(
            i for i, d in enumerate(corpus) if d in pertinents
        )
        recall = len(top_5 & pertinent_ids) / len(pertinent_ids)
        recall_scores.append(recall)

    return np.mean(recall_scores)

Résumé

  • Le fine-tuning d’embeddings améliore les performances sur des domaines spécialisés
  • Un adaptateur linéaire est la méthode la plus simple et souvent suffisante
  • Sentence Transformers permet un fine-tuning plus poussé du modèle complet
  • Un minimum de 1 000 paires annotées est recommandé
  • Évaluez toujours avant et après pour valider le gain