Module 5 — Plongements contextuels et modèles préentraînés
Le module 4 a donné un vecteur par mot. Le mot « avocat » n'en a qu'un, alors qu'il désigne un fruit ou un métier selon la phrase. Les plongements contextuels lèvent cette limite : le vecteur d'un mot dépend de la phrase entière, et deux occurrences du même mot dans des contextes différents ont deux vecteurs différents.
De la polysémie ignorée à la polysémie modélisée
Prenons deux avis du corpus :
- « La chambre donnait sur une avenue passante, avec beaucoup de bruit. »
- « Un bruit étrange sortait de la climatisation. »
Le mot « bruit » désigne dans le premier cas un environnement sonore, dans le second un phénomène localisé, presque un défaut. Un plongement Word2Vec leur donne le même vecteur. Un plongement contextuel leur en donne deux, et un modèle de classification en aval pourra distinguer un problème d'isolation phonique d'un défaut d'équipement.
Le premier modèle à populariser cette idée fut ELMo (Peters et coll., 2018), un LSTM bidirectionnel. Ses vecteurs surpassaient tous les plongements statiques sur toutes les tâches. Un an plus tard, BERT (Devlin et coll., 2019) a fait sauter le verrou architectural avec un Transformer entraîné à deux tâches :
- Masked language modeling. On masque 15 % des jetons d'une phrase, le modèle apprend à les reconstituer à partir du reste. C'est ce qui force la représentation d'un mot à intégrer son contexte gauche et droit.
- Next sentence prediction. Deux phrases sont concaténées ; le modèle décide si la seconde suit vraiment la première. Cette tâche a depuis été abandonnée dans la plupart des variantes (RoBERTa, CamemBERT), car son apport était marginal.
Récupérer un vecteur par mot ou par phrase
Un modèle BERT expose plusieurs niveaux de représentation, et le choix dépend de l'usage.
from transformers import AutoTokenizer, AutoModel
import torch
tokeniseur = AutoTokenizer.from_pretrained("camembert-base")
modele = AutoModel.from_pretrained("camembert-base")
texte = "La chambre donnait sur une avenue passante."
entree = tokeniseur(texte, return_tensors="pt")
with torch.no_grad():
sortie = modele(**entree)
# Un vecteur par jeton, du dernier bloc
vecteurs_jetons = sortie.last_hidden_state # forme (1, n_jetons, 768)
# Un vecteur pour la phrase entière (méthode CLS, à éviter en général)
vecteur_cls = sortie.last_hidden_state[:, 0, :] # forme (1, 768)
Le vecteur du jeton spécial [CLS] (ou <s> chez RoBERTa et CamemBERT) est souvent présenté comme le « vecteur de la phrase ». C'est vrai pour un modèle affiné pour la classification, faux pour un modèle brut. Sur un modèle non affiné, la moyenne des vecteurs de jetons donne des similarités entre phrases très proches de zéro pour tout couple, ce qui rend la comparaison inutile.
Sentence-BERT : le bon outil pour la recherche sémantique
Sentence-BERT (Reimers et Gurevych, 2019) résout précisément ce problème. Le modèle prend deux phrases, produit deux vecteurs par moyenne pondérée des jetons, et est affiné pour que le cosinus entre ces vecteurs corresponde à la similarité sémantique jugée par des humains.
Le cosinus vaut 1 pour deux vecteurs parfaitement alignés (sens très proche), 0 pour deux vecteurs orthogonaux (sens sans rapport), et jusqu'à -1 pour deux vecteurs opposés (rare avec des plongements réels). Sur du texte français, la bibliothèque sentence-transformers expose des modèles comme sentence-camembert-base ou dangvantuan/sentence-camembert-large qui donnent des vecteurs comparables directement.
from sentence_transformers import SentenceTransformer, util
modele = SentenceTransformer("dangvantuan/sentence-camembert-base")
requete = "chambre bruyante à cause de la rue"
avis = [
"La circulation nocturne rendait le sommeil impossible",
"Les draps étaient propres et le lit confortable",
"Impossible de dormir tellement l'avenue était bruyante",
"Le petit-déjeuner était copieux et varié",
]
v_requete = modele.encode(requete, normalize_embeddings=True)
v_avis = modele.encode(avis, normalize_embeddings=True)
scores = util.cos_sim(v_requete, v_avis)[0]
for score, texte in sorted(zip(scores, avis), reverse=True):
print(f"{score.item():+.3f} {texte}")
Sur le corpus d'avis, on obtient l'avis 3 en tête (0,82), l'avis 1 juste derrière (0,74), l'avis 2 et l'avis 4 loin (0,10). La recherche a compris que « bruyante », « rue », « circulation » et « dormir » forment un même champ sémantique, sans que le mot « bruit » ne soit dans l'avis 1.
normalize_embeddings=True divise chaque vecteur par sa norme, ce qui ramène le cosinus à un simple produit scalaire. Sans cela, un vecteur plus long domine artificiellement les scores. C'est la source classique de résultats aberrants où un même document dominait toutes les requêtes.
Un vecteur d'avis, une base vectorielle
En pratique, on encode l'ensemble du corpus une fois, on stocke les vecteurs dans une base vectorielle (FAISS, Milvus, Qdrant), et chaque requête n'exige plus qu'une recherche approximative des plus proches voisins.
import faiss
import numpy as np
vecteurs = modele.encode(corpus_avis, normalize_embeddings=True)
index = faiss.IndexFlatIP(vecteurs.shape[1]) # produit scalaire = cosinus normalisé
index.add(vecteurs.astype(np.float32))
D, I = index.search(v_requete.reshape(1, -1).astype(np.float32), k=5)
Pour dix mille avis, l'index tient en soixante Mo. Pour dix millions, on passe à un index approximatif (HNSW ou IVFPQ), la recherche reste en dessous de dix millisecondes. C'est le socle des systèmes de recherche sémantique et de génération augmentée par récupération (RAG) que le cours 22 traitera en détail.
Le coût par rapport au module 3
Comparons sur le même corpus d'avis et la même tâche de recherche du plus proche voisin.
| Méthode | Encodage 10 000 avis | Recherche | Mémoire | Qualité |
|---|---|---|---|---|
| TF-IDF (module 3) | 2 s | 5 ms | 300 Mo (creux) | correcte pour mots identiques |
| Sentence-BERT | 90 s (CPU) ou 8 s (GPU) | 5 ms | 60 Mo | comprend les paraphrases |
L'encodage est cinquante fois plus lent sur CPU, mais il ne se fait qu'une fois. La recherche coûte le même prix. Et surtout, le score qualitatif est incomparable dès que la requête ne partage pas exactement le vocabulaire des documents.
Deux phrases proches en cosinus peuvent dire des choses opposées — « J'ai adoré cet hôtel » et « J'ai détesté cet hôtel » diffèrent d'un mot et restent en tête de similarité. Pour la classification de sentiment, un plongement seul ne suffit pas ; il faut le combiner à un classificateur qui exploite précisément la différence, comme au module 6.
En résumé
- Un plongement contextuel donne un vecteur différent au même mot selon la phrase, ce qui résout la polysémie que Word2Vec ignorait.
- BERT et ses variantes (RoBERTa, CamemBERT pour le français) sont entraînés à reconstruire des jetons masqués, ce qui force la représentation à intégrer les contextes gauche et droit.
- Sentence-BERT est le bon outil pour la recherche sémantique : ses vecteurs sont conçus pour que le cosinus corresponde à la similarité sémantique, ce qu'un modèle brut ne garantit pas.
- Une base vectorielle (FAISS, Qdrant) rend la recherche sur des millions de documents opérationnelle en quelques millisecondes ; le vrai gain est qualitatif dès que requête et documents ne partagent pas les mêmes mots.
Module suivant : au lieu de comparer des vecteurs, on va affiner un encodeur pour classer directement les avis en cinq étoiles, et le comparer à la référence TF-IDF.