Aller au contenu principal

Module 4 — Plongements et bases vectorielles

Les passages du module 3 sont du texte. Pour que la machine puisse dire « ces deux passages parlent de la même chose », il faut les projeter dans un espace où la proximité géométrique correspond à la proximité de sens. Ce module explique cette projection — les plongements — et les structures qui les hébergent : les bases vectorielles.

Ce qu'un plongement représente

Un plongement, ou embedding, est un vecteur de dimension fixe — souvent 384, 768 ou 1024 — produit par un modèle spécialisé. Deux textes proches par le sens sont projetés en deux points proches ; deux textes sans rapport sont projetés loin l'un de l'autre. La distance se mesure presque toujours par la similarité cosinus :

sim(u,v)=uvuv\text{sim}(u, v) = \frac{u \cdot v}{\lVert u \rVert \lVert v \rVert}

Une similarité de 1 signifie que les vecteurs pointent dans la même direction, 0 qu'ils sont orthogonaux, 1-1 qu'ils sont opposés. En pratique, sur des passages en français, on lit rarement des valeurs sous 0,2 ou au-dessus de 0,95 ; c'est la relative qui compte, pas la valeur absolue.

Choisir un modèle de plongement

Trois critères pratiques dominent le choix :

CritèreCe qu'il implique
LangueModèle multilingue obligatoire si le corpus mêle français et anglais
Dimension384 : plus rapide et léger ; 1024 : plus précis, quatre fois plus lourd
LicenceOuverte (Apache 2, MIT) pour un déploiement sans coût récurrent

Un bon défaut sur le fil rouge — corpus français, quelques annexes en anglais — est intfloat/multilingual-e5-base ou BAAI/bge-m3. Les modèles propriétaires accessibles par API sont souvent meilleurs marginalement, mais ils facturent chaque appel et transmettent les documents à un tiers : rarement le bon choix pour un intranet.

from sentence_transformers import SentenceTransformer

modele = SentenceTransformer("intfloat/multilingual-e5-base")

def plonger(passages):
# e5 attend un prefixe : "passage:" a l'indexation, "query:" a la requete
textes = [f"passage: {p}" for p in passages]
vecteurs = modele.encode(
textes,
normalize_embeddings=True,
batch_size=32,
show_progress_bar=False,
)
return vecteurs

Le préfixe change selon les modèles et est décrit dans leur fiche : l'ignorer dégrade la qualité de dix à quinze points de rappel sans qu'aucune erreur ne soit levée.

Question et passage n'utilisent pas le même préfixe

Une question courte n'a pas la même distribution qu'un passage. Les modèles de la famille E5 exigent query: d'un côté et passage: de l'autre. Un développeur qui applique le même préfixe partout — souvent parce que le tutoriel en montrait un seul — obtient un rappel de 0,60 là où le bon usage donne 0,80. C'est un piège classique et silencieux.

Un index vectoriel, ce n'est pas une matrice

Avec 300 documents et une taille moyenne de dix passages par document, on obtient trois mille vecteurs. Une recherche exacte — comparer la question à chacun — coûte trois mille produits scalaires. C'est rapide. À 300 000 passages, cela devient une contrainte ; à trois millions, c'est ingérable.

Les index approchés — HNSW est de loin le plus utilisé — organisent les vecteurs dans un graphe en couches où l'on descend de plus en plus près du voisin. Le temps de recherche devient logarithmique, au prix d'une précision légèrement inférieure à la recherche exacte (typiquement 98 % du rappel exact) et d'une phase de construction plus longue.

Deux paramètres pilotent l'HNSW :

  • M : nombre de voisins par nœud (12 à 32 en pratique).
  • ef_construction : profondeur de recherche à la construction (200 par défaut).
  • ef_search : profondeur à la requête, réglable à chaud.

Augmenter ef_search améliore la précision jusqu'à un plateau, puis fait retomber sur la vitesse d'une recherche exacte.

Chroma pour prototyper, pgvector pour livrer

Deux choix couvrent la quasi-totalité des cas.

Chroma est une base embarquée qui tient dans un dossier. Idéale pour un prototype de fil rouge sur un portable.

import chromadb

client = chromadb.PersistentClient(path="./index")
collection = client.get_or_create_collection(
name="procedures",
metadata={"hnsw:space": "cosine"},
)

collection.add(
ids=[p["id"] for p in passages],
embeddings=[list(v) for v in vecteurs],
documents=[p["texte"] for p in passages],
metadatas=[{"source": p["source"], "page": p["page"],
"confidentialite": p["confidentialite"]} for p in passages],
)

pgvector est une extension de PostgreSQL. On garde le SQL, les transactions, les sauvegardes et les droits, et l'on ajoute une colonne vector. C'est le bon choix quand l'assistant vit dans un système d'information qui utilise déjà PostgreSQL — ce qui est le cas dans la grande majorité des entreprises.

CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE passages (
id text PRIMARY KEY,
document_id text NOT NULL,
texte text NOT NULL,
metadonnees jsonb,
plongement vector(768)
);

CREATE INDEX ON passages
USING hnsw (plongement vector_cosine_ops)
WITH (m = 16, ef_construction = 200);

Filtrer par métadonnées

La requête vectorielle seule ne comprend pas la phrase « les seuls passages concernant les stagiaires postérieurs à 2025 ». C'est un filtre par métadonnées, appliqué avant ou après la recherche vectorielle.

resultats = collection.query(
query_embeddings=[list(question_vec)],
n_results=10,
where={"confidentialite": {"$in": ["public", "interne"]}},
)

Deux stratégies existent : filtrer d'abord, chercher ensuite (précis mais coûteux si le filtre est très sélectif car il faut passer par une recherche exacte), ou chercher d'abord et filtrer parmi les résultats (rapide mais peut ne rien renvoyer si aucun des 10 premiers ne passe le filtre). Un bon système vectoriel gère les deux et bascule automatiquement selon la sélectivité estimée.

Le filtre de métadonnées oublié coûte cher

Un stagiaire qui pose une question sur les salaires et à qui l'assistant remonte la grille indicative confidentielle est un incident. Le filtre par confidentialité doit être appliqué avant la génération, jamais après en demandant au modèle de « ne pas répondre s'il n'a pas le droit ». Confier une règle de sécurité à la fluidité d'un modèle de langage est une erreur d'architecture — c'est l'un des sept thèmes de la banque d'examen.

Mise à jour et suppression

Un corpus vit. Une note de service est remplacée, un document confidentiel doit disparaître. Deux règles suffisent :

  1. Chaque passage a un identifiant stable dérivé de (chemin_fichier, position). Une réindexation du fichier remplace les mêmes lignes.
  2. La suppression d'un document se traduit par une suppression par document_id, jamais par identifiant de passage : cela protège contre les décalages liés au redécoupage.

Un mécanisme de journal — quels fichiers ont été indexés à quelle date et avec quel modèle de plongement — évite deux ans plus tard de mélanger des vecteurs de générations différentes qui ne se comparent plus correctement.

Ne jamais changer de modèle sans réindexer entièrement

Les vecteurs d'un modèle A ne sont pas comparables à ceux d'un modèle B. Migrer d'un modèle 384 à un modèle 1024 exige de réindexer l'intégralité du corpus. Prévoir dès le départ que la migration arrivera et automatiser la réindexation à partir du dossier source évite un chantier de plusieurs semaines le jour venu.

En résumé

  • Un plongement projette un texte dans un espace où la similarité cosinus mesure la proximité de sens ; ce sont les distances relatives qui comptent.
  • On choisit un modèle multilingue à licence ouverte pour un intranet, avec des préfixes distincts pour la requête et pour le passage.
  • Un index HNSW rend la recherche logarithmique en taille de corpus, avec quelques paramètres réglables à la requête.
  • Les métadonnées — source, date, confidentialité — filtrent la recherche avant la génération ; un filtre oublié devient un incident de sécurité.

Module suivant : la recherche seule, avec ses limites, et pourquoi il faut la combiner à une recherche lexicale.