Embedding et recherche sémantique : explication et exemples

Cet article a une courte vidéo :YouTubeTikTok

1. Vue d’ensemble

Lorsque vous posez à un chatbot la question « Mon vol a été annulé, puis-je être remboursé pour l’hôtel ? », un système RAG doit trouver le bon passage de politique qui parle de « perturbation de voyage » — même si la question ne contient aucun mot qui correspond exactement au texte original. Cet article explique le mécanisme des embeddings qui permet de le faire : transformer le texte en vecteur, comparer par distance sémantique, et stocker de manière que le LLM puisse toujours retrouver le texte original. Après cette lecture, vous pourrez écrire vous-même un pipeline de recherche sémantique simple en Java, accompagné d’un test pour vérifier qu’il fonctionne correctement.

2. Qu’est-ce qu’un embedding

Un modèle d’embedding prend un fragment de texte et retourne un vecteur — une suite de nombres réels, généralement de plusieurs centaines à plusieurs milliers de dimensions. Ce vecteur n’est pas une « compression de mots-clés » mais un point dans un espace multidimensionnel, où les passages de texte ayant une signification proche auront des vecteurs proches.

public record EmbeddingVector(float[] values) {
    public int dimension() {
        return values.length;
    }
}

EmbeddingVector est simplement un wrapper pour float[], mais l’avoir aide à éviter de passer des float[] mal mélangés entre différents modèles — une erreur très courante lorsque le système grandit.

2.1. Pourquoi un embedding n’est pas une « carte » clairement étiquetée

Sur une vraie carte, vous avez des axes de longitude, latitude, avec des étiquettes « Hà Nội », « Đà Nẵng ». Avec l’embedding, chaque dimension individuelle n’a pas d’étiquette comme « hôtel », « vol », « remboursement ». Le sens n’émerge que par le motif global de l’ensemble du vecteur. C’est pourquoi vous ne pouvez pas « lire » une dimension pour savoir ce qu’elle représente — vous pouvez seulement comparer le vecteur entier avec un autre vecteur.

C’est important car cela explique pourquoi vous ne devez pas arbitrairement tronquer un vecteur (par exemple ne garder que 100/1536 dimensions pour économiser la mémoire) — cela casse le motif et la précision de recherche diminue fortement.

3. Correspondance par distance sémantique

Deux vecteurs « proches » sont généralement mesurés par similitude cosinus — plus proche de 1, plus semblable en signification.

public final class VectorMath {

    public static double cosineSimilarity(float[] a, float[] b) {
        if (a.length != b.length) {
            throw new IllegalArgumentException(
                "Dimension du vecteur non correspondante : %d vs %d".formatted(a.length, b.length));
        }
        double dot = 0, normA = 0, normB = 0;
        for (int i = 0; i < a.length; i++) {
            dot += a[i] * b[i];
            normA += a[i] * a[i];
            normB += b[i] * b[i];
        }
        return dot / (Math.sqrt(normA) * Math.sqrt(normB));
    }
}
  • La ligne if (a.length != b.length) : arrête immédiatement l’erreur courante — comparer des vecteurs de deux modèles différents (par exemple l’un de 768 dimensions, l’autre de 1536 dimensions) créerait une erreur d’exécution difficile à déboguer si on ne vérifie pas tôt.
  • dot, normA, normB : calculent le produit scalaire et la norme de chaque vecteur selon la formule cosinus standard.

Attention : deux modèles ayant le même nombre de dimensions ne signifie pas qu’ils sont compatibles. Le modèle A et le modèle B produisent tous les deux des vecteurs de 1536 dimensions, mais leurs espaces sémantiques sont différents — les comparer entre eux donnera des résultats sans sens même si le code s’exécute sans erreur.

4. Simulation d’un modèle d’embedding pour pratiquer sans appel API réel

Pour que l’exemple fonctionne immédiatement, j’utilise un EmbeddingClient simulé basé sur la fréquence des mots-clés appartenant à des « catégories » (voyage, mot de passe, facturation). En pratique, vous remplaceriez par OpenAI, Cohere ou un modèle open source, mais l’interface reste la même.

<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-openai-spring-boot-starter</artifactId>
    <version>1.0.0</version>
</dependency>
public interface EmbeddingClient {
    EmbeddingVector embed(String text);
}
public class KeywordEmbeddingClient implements EmbeddingClient {

    private static final List<String> TRAVEL_WORDS =
        List.of("vol", "annulé", "hôtel", "voyage", "perturbation");
    private static final List<String> PASSWORD_WORDS =
        List.of("mot de passe", "réinitialisation", "compte", "connexion", "oublié");
    private static final List<String> BILLING_WORDS =
        List.of("remboursement", "paiement", "facture", "frais", "tarif");

    @Override
    public EmbeddingVector embed(String text) {
        String normalized = text.toLowerCase();
        float travel = score(normalized, TRAVEL_WORDS);
        float password = score(normalized, PASSWORD_WORDS);
        float billing = score(normalized, BILLING_WORDS);
        return new EmbeddingVector(new float[] { travel, password, billing });
    }

    private float score(String text, List<String> keywords) {
        long matches = keywords.stream().filter(text::contains).count();
        return matches / (float) keywords.size();
    }
}

KeywordEmbeddingClient utilise seulement 3 dimensions (voyage/mot de passe/facturation) pour faciliter l’observation — un vrai embedding utilise des centaines de dimensions apprises à partir des données, mais l’idée « textes avec une signification proche → vecteurs proches » est la même.

5. Stocker les embeddings avec les métadonnées pour que le LLM retrouve le texte original

Les vecteurs aident seulement à localiser le fragment de texte, tandis que le LLM a toujours besoin du contenu du texte pour répondre. Par conséquent, chaque enregistrement du vector store doit associer l’embedding avec le chunk original et les métadonnées.

public record DocumentChunk(
    String chunkId,
    String documentId,
    String text,
    String section,
    LocalDate effectiveDate,
    int version,
    EmbeddingVector embedding
) {}
  • chunkId et documentId : permettent de récupérer le document entier pour afficher la source ou mettre à jour vers une nouvelle version.
  • section, effectiveDate, version : aident à filtrer avant de comparer par sémantique — par exemple, chercher uniquement dans les politiques actuelles, en ignorant les anciennes versions expirées.
  • text : doit obligatoirement être stocké avec, car le LLM ne peut pas « lire » un vecteur — il a besoin du texte réel pour générer une réponse.
public class InMemoryVectorStore {

    private final List<DocumentChunk> chunks = new ArrayList<>();

    public void add(DocumentChunk chunk) {
        chunks.add(chunk);
    }

    public List<DocumentChunk> findNearest(EmbeddingVector query, int topK) {
        return chunks.stream()
            .sorted(Comparator.comparingDouble(
                (DocumentChunk c) -> -VectorMath.cosineSimilarity(
                    query.values(), c.embedding().values())))
            .limit(topK)
            .toList();
    }
}

findNearest trie les chunks par similitude cosinus décroissante (le signe - pour inverser le Comparator) et récupère les topK résultats les plus proches — c’est précisément l’étape de « retrieval » dans RAG.

6. Exemple complet : une question formulée autrement mais sémantiquement correcte

public class SemanticSearchDemo {

    public static void main(String[] args) {
        EmbeddingClient embedder = new KeywordEmbeddingClient();
        InMemoryVectorStore store = new InMemoryVectorStore();

        store.add(new DocumentChunk(
            "chunk-1", "policy-travel",
            "Politique de remboursement en cas d'annulation de vol ou de perturbation de voyage.",
            "Perturbation de voyage", LocalDate.of(2026, 1, 1), 2,
            embedder.embed("Politique de remboursement en cas d'annulation de vol ou de perturbation de voyage.")
        ));

        store.add(new DocumentChunk(
            "chunk-2", "policy-account",
            "Guide de réinitialisation du mot de passe en cas d'oubli du compte de connexion.",
            "Récupération de compte", LocalDate.of(2025, 6, 1), 1,
            embedder.embed("Guide de réinitialisation du mot de passe en cas d'oubli du compte de connexion.")
        ));

        String question = "Mon vol a été annulé, puis-je être remboursé pour l'hôtel que j'avais réservé ?";
        EmbeddingVector queryVector = embedder.embed(question);

        List<DocumentChunk> results = store.findNearest(queryVector, 1);
        System.out.println(results.get(0).text());
    }
}

Exécuter ce programme affiche :

Politique de remboursement en cas d'annulation de vol ou de perturbation de voyage.

La question ne contient pas les mots « politique » ni « voyage », mais parce que la question et le texte sont proches dans l’espace sémantique (la dimension « voyage » est élevée dans les deux), le système sélectionne le bon chunk — ignore complètement le chunk sur la réinitialisation du mot de passe.

7. Vérification par test

class SemanticSearchTest {

    private final EmbeddingClient embedder = new KeywordEmbeddingClient();
    private final InMemoryVectorStore store = new InMemoryVectorStore();

    @BeforeEach
    void setUp() {
        store.add(new DocumentChunk(
            "chunk-1", "policy-travel",
            "Politique de remboursement en cas d'annulation de vol ou de perturbation de voyage.",
            "Perturbation de voyage", LocalDate.of(2026, 1, 1), 2,
            embedder.embed("Politique de remboursement en cas d'annulation de vol ou de perturbation de voyage.")
        ));
        store.add(new DocumentChunk(
            "chunk-2", "policy-account",
            "Guide de réinitialisation du mot de passe en cas d'oubli du compte de connexion.",
            "Récupération de compte", LocalDate.of(2025, 6, 1), 1,
            embedder.embed("Guide de réinitialisation du mot de passe en cas d'oubli du compte de connexion.")
        ));
    }

    @Test
    void shouldMatchTravelPolicyEvenWithoutSharedWords() {
        String question = "Mon vol a été annulé, puis-je être remboursé pour l'hôtel que j'avais réservé ?";
        EmbeddingVector queryVector = embedder.embed(question);

        List<DocumentChunk> results = store.findNearest(queryVector, 1);

        assertThat(results)
            .hasSize(1)
            .first()
            .extracting(DocumentChunk::documentId)
            .isEqualTo("policy-travel");
    }

    @Test
    void shouldRejectComparingDifferentDimensions() {
        float[] a = { 1f, 0f, 0f };
        float[] b = { 1f, 0f };

        assertThatThrownBy(() -> VectorMath.cosineSimilarity(a, b))
            .isInstanceOf(IllegalArgumentException.class)
            .hasMessageContaining("Dimension du vecteur non correspondante");
    }
}

Le premier test affirme le comportement essentiel : une question formulée différemment du texte original trouve toujours le bon chunk. Le deuxième test garantit que vous ne comparez pas accidentellement deux vecteurs incompatibles — cette erreur arrive très facilement quand vous changez de modèle d’embedding mais oubliez de ré-embedder toutes les anciennes données.

8. Erreurs courantes et quand ne pas l’utiliser

  • Changer de modèle d’embedding sans ré-embedder les anciennes données : les vieux vecteurs et les nouveaux vecteurs ne sont pas dans le même espace, la correspondance donnera des résultats incorrects silencieusement — aucune exception n’annonce ce problème.
  • Chunks trop longs ou trop courts : les chunks longs font que le vecteur « mélange » plusieurs idées, réduisant la précision ; les chunks trop courts perdent le contexte. Généralement, 200-500 tokens est un point de départ raisonnable, à affiner selon le domaine.
  • Oublier de stocker les métadonnées : si vous ne stockez que le vecteur sans documentId/version, vous ne pouvez pas supprimer ou mettre à jour le bon document quand la politique change.
  • Utiliser l’embedding pour les données nécessitant une correspondance exacte : si vous devez trouver un code de commande exact comme ORD-2026-00123, la recherche en texte intégral ou l’indexation par clé primaire sont toujours plus rapides et corrects — les embeddings sont forts pour trouver par sémantique, faibles pour trouver par chaîne de caractères exacte.

Attention : avec un petit ensemble de données (quelques centaines de chunks), le InMemoryVectorStore ci-dessus est suffisant. Quand vous montez à des millions de chunks, vous avez besoin d’une base de données vectorielle dédiée (pgvector, Pinecone, Milvus…) supportant l’indexation approximative (ANN) pour éviter de comparer séquentiellement chaque vecteur.

9. Conclusion

L’embedding transforme le texte en vecteur pour que les machines puissent comparer la « sémantique » au lieu de simplement comparer les mots-clés — c’est la base de tout système RAG. Les points clés à retenir : la requête et le document doivent utiliser le même encodeur compatible, chaque vecteur doit être accompagné de métadonnées pour retrouver le texte original, et avoir le même nombre de dimensions ne garantit pas que deux modèles sont compatibles. À partir de l’exemple Java ci-dessus, vous pouvez remplacer KeywordEmbeddingClient par un vrai modèle d’embedding (via Spring AI ou un appel API direct) tandis que tout le reste — similitude cosinus, vector store, métadonnées — reste inchangé.

ragembeddingvector-searchspring-aillm