ITSkillsCenter
Intelligence Artificielle

Recherche sémantique avec les embeddings OpenAI en Python

13 دقائق للقراءة

Votre assistant sait répondre et agir, mais il ignore encore ce que contient votre documentation : les conditions de retour, les délais de livraison, la politique de garantie. Une première idée serait de chercher des mots-clés dans votre FAQ — mais « puis-je renvoyer un article ? » ne contient aucun des mots de la réponse intitulée « Politique de remboursement sous 14 jours ». La recherche par mots-clés échoue sur le sens. Les embeddings résolvent ce problème : ils transforment un texte en un vecteur de nombres qui capture sa signification, de sorte que deux phrases au sens proche se retrouvent proches dans l’espace, même sans aucun mot commun.

Nous ajoutons ici à l’assistant une recherche sémantique sur sa base de connaissances : à partir d’une question, retrouver l’entrée de FAQ la plus pertinente. C’est le socle de ce qu’on appelle le RAG (génération augmentée par récupération), que nous gardons volontairement minimal pour en comprendre le mécanisme de bout en bout.

Guide principal de la série : Développer avec l’API OpenAI : modèles GPT, Responses API et bonnes pratiques.

Cet article utilise les embeddings managés d’OpenAI et une recherche de similarité écrite à la main, pour rester sans infrastructure et bien voir ce qui se passe. Si vous visez un volume important ou une mise en production durable, deux tutoriels complémentaires montrent l’approche avec une base vectorielle dédiée et des embeddings open source : PostgreSQL pgvector pour RAG et construire un RAG avec pgvector et embeddings open source. L’angle est différent : ici, le fournisseur d’embeddings managé et le mécanisme nu ; là-bas, le stockage vectoriel à l’échelle.

🎯 Ce que vous allez apprendre

  • Comprendre ce qu’est un embedding et pourquoi il capture le sens.
  • Calculer l’embedding d’un texte avec l’API OpenAI.
  • Mesurer la proximité entre deux textes par similarité cosinus.
  • Indexer une petite base de FAQ et retrouver l’entrée la plus proche d’une question.
  • Choisir le bon modèle d’embedding et savoir quand passer à une base vectorielle.

🛠️ Ce que vous allez construire

Un module recherche.py qui pré-calcule les embeddings d’une dizaine d’entrées de FAQ, puis, pour toute question d’un client, renvoie l’entrée la plus pertinente avec un score de similarité. Branché sur l’assistant, il lui permet de répondre à partir de vos textes réels au lieu d’inventer.

Prérequis

  • Le premier appel à l’API OpenAI en place.
  • Installer NumPy : pip install numpy.
  • Notion de liste et de boucle en Python ; aucune connaissance mathématique avancée requise.
  • ⏱️ Temps estimé : environ 35 minutes.

Étape 1 — Comprendre l’idée d’embedding

Un embedding est une liste de nombres — un vecteur — qui représente un texte dans un espace à plusieurs centaines ou milliers de dimensions. L’intuition : le modèle a appris à placer les textes de sens voisin au même endroit de cet espace. « Comment renvoyer un produit ? » et « Quelle est votre politique de retour ? » atterrissent tout près l’un de l’autre, alors qu’ils ne partagent presque aucun mot. À l’inverse, « Quels sont vos horaires ? » se situe loin. Mesurer la distance entre deux vecteurs revient donc à mesurer la proximité de sens entre deux textes.

Cette propriété ouvre la recherche sémantique : on transforme à l’avance chaque document en vecteur, on transforme la question en vecteur au moment voulu, et on retourne les documents dont le vecteur est le plus proche. C’est rapide, robuste aux reformulations, et insensible aux synonymes — trois faiblesses classiques de la recherche par mots-clés.

Étape 2 — Calculer un embedding

L’API expose un point d’entrée dédié, distinct de la génération de texte. On lui donne un texte, elle renvoie son vecteur. Le modèle économique par défaut est text-embedding-3-small, qui produit des vecteurs de 1536 dimensions pour un coût très faible.

from openai import OpenAI

client = OpenAI()

def embed(texte):
    reponse = client.embeddings.create(
        model="text-embedding-3-small",
        input=texte,
    )
    return reponse.data[0].embedding

v = embed("Quelle est votre politique de retour ?")
print("Dimensions du vecteur :", len(v))
print("Cinq premieres valeurs :", v[:5])

On lit le vecteur dans reponse.data[0].embedding : c’est une liste de 1536 nombres flottants. Affichez-en la longueur et les premières valeurs pour vous familiariser — il n’y a rien d’« humainement lisible » dans ces nombres, mais leur agencement encode le sens. Chaque appel est facturé selon le nombre de jetons du texte, à un tarif de l’ordre de quelques centimes par million de jetons : indexer une FAQ entière coûte une fraction négligeable.

Point d’étape — Le script affiche « Dimensions du vecteur : 1536 ». Si vous obtenez une erreur d’authentification, revenez au premier tutoriel : la clé n’est pas configurée dans ce terminal.

Étape 3 — Mesurer la proximité par similarité cosinus

Pour comparer deux vecteurs, la mesure standard est la similarité cosinus : elle vaut 1 quand les deux textes pointent exactement dans la même direction (sens identique), et tend vers 0 quand ils n’ont rien à voir. Elle se calcule en quelques lignes avec NumPy, qui gère efficacement les opérations sur les listes de nombres.

import numpy as np

def similarite(a, b):
    a, b = np.array(a), np.array(b)
    return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)))

q1 = embed("Comment renvoyer un produit ?")
q2 = embed("Quelle est votre politique de retour ?")
q3 = embed("Quels sont vos horaires d'ouverture ?")

print("retour vs politique :", round(similarite(q1, q2), 3))
print("retour vs horaires  :", round(similarite(q1, q3), 3))

La fonction calcule le produit scalaire des deux vecteurs divisé par le produit de leurs longueurs : c’est la définition du cosinus de l’angle entre eux. Vous verrez un score nettement plus élevé entre les deux questions de retour qu’entre une question de retour et une question d’horaires. Ce simple écart de score est tout ce dont on a besoin pour trier des documents par pertinence.

Étape 4 — Indexer la FAQ et chercher

On pré-calcule maintenant l’embedding de chaque entrée de FAQ une fois pour toutes — c’est l’indexation — puis on compare la question entrante à chacun pour retourner le meilleur. Pour gagner du temps et des appels, on embede toutes les entrées en une seule requête : le point d’entrée accepte une liste de textes.

FAQ = [
    "Politique de retour : tout article peut etre renvoye sous 14 jours.",
    "Livraison : expedition sous 48h, reception en 3 a 5 jours ouvres.",
    "Horaires : la boutique est ouverte du lundi au samedi, 9h-19h.",
    "Garantie : les appareils sont garantis 12 mois piece et main d'oeuvre.",
]

# Indexation : un seul appel pour toutes les entrees
reponse = client.embeddings.create(model="text-embedding-3-small", input=FAQ)
index = [item.embedding for item in reponse.data]

def chercher(question):
    vq = embed(question)
    scores = [(similarite(vq, vecteur), texte)
              for vecteur, texte in zip(index, FAQ)]
    scores.sort(reverse=True)
    return scores[0]      # (meilleur_score, texte)

score, texte = chercher("Je veux rendre un casque achete la semaine derniere")
print(round(score, 3), "->", texte)

On passe la liste complète des entrées à embeddings.create : la réponse contient un vecteur par entrée, dans le même ordre. La fonction chercher calcule la similarité de la question avec chaque entrée, trie par score décroissant et renvoie la meilleure. Sur l’exemple, la question parle de « rendre un casque » — aucun mot commun avec « politique de retour », et pourtant c’est bien cette entrée qui ressort en tête. La recherche a compris le sens, pas les mots.

Point d’étape — La meilleure correspondance est l’entrée « Politique de retour » avec un score nettement supérieur aux autres. Si tous les scores sont quasi identiques, vérifiez que vous comparez bien la question à chaque entrée et non un texte à lui-même.

Étape 5 — Brancher la recherche sur l’assistant

Dernière étape : on injecte l’entrée trouvée dans la consigne du modèle, pour qu’il rédige une réponse fondée sur votre texte plutôt que sur ses connaissances générales. C’est le principe du RAG : récupérer le bon passage, puis le donner au modèle comme contexte.

def repondre_avec_contexte(question):
    score, passage = chercher(question)
    consigne = ("Tu es l'assistant de Baobab Informatique. Reponds en francais "
                "en t'appuyant uniquement sur ce passage de la FAQ : " + passage)
    r = client.responses.create(
        model="gpt-5.4",
        input=[
            {"role": "system", "content": consigne},
            {"role": "user", "content": question},
        ],
    )
    return r.output_text

print(repondre_avec_contexte("Mon casque ne me plait pas, je peux le rapporter ?"))

On récupère le passage le plus pertinent et on l’insère dans le message système avec une consigne claire : s’appuyer uniquement sur ce passage. Le modèle reformule alors la politique de retour en une réponse adaptée à la question précise du client. En limitant explicitement la source, on réduit fortement le risque que le modèle invente un détail absent de votre documentation — un enjeu central pour un assistant fiable.

Choisir le modèle et passer à l’échelle

Deux modèles d’embedding sont proposés. text-embedding-3-small (1536 dimensions) est le choix par défaut : très économique, il suffit à la grande majorité des cas. text-embedding-3-large (3072 dimensions) offre une précision supérieure pour un coût plus élevé, utile sur des corpus volumineux ou des distinctions fines. Les deux acceptent un paramètre dimensions permettant de raccourcir le vecteur pour économiser de l’espace de stockage, au prix d’un peu de précision. Une règle simple : commencez avec small, et ne passez à large que si vos mesures montrent un manque de pertinence.

Notre recherche compare la question à toutes les entrées : c’est parfait pour quelques dizaines de documents, mais inadapté à des milliers. Au-delà, on stocke les vecteurs dans une base spécialisée qui sait retrouver les plus proches sans tout parcourir, via un index dédié. C’est précisément le rôle de pgvector, détaillé dans le tutoriel PostgreSQL pgvector pour RAG. Le principe reste identique — embedder, puis chercher par similarité — seul le moteur de recherche change d’échelle.

Découper les documents longs avant de les embedder

Notre FAQ tenait en phrases courtes, mais la réalité est souvent faite de pages entières : conditions générales, manuels, articles. Embedder un document de plusieurs milliers de mots d’un seul bloc est une mauvaise idée, pour deux raisons. D’abord, un vecteur unique pour un long texte « moyenne » trop de sujets : il devient flou et ne ressort fortement sur aucune question précise. Ensuite, même quand la recherche le trouve, vous injecteriez un pavé entier dans la consigne du modèle, ce qui coûte des jetons et noie l’information utile. La solution consiste à découper le document en morceaux — le chunking — avant l’indexation.

Un bon découpage suit la structure du texte plutôt que de couper au milieu d’une phrase : on segmente par paragraphe, par section, ou par fenêtre de quelques centaines de jetons. Chaque morceau est embeddé séparément et stocké avec une référence à son document d’origine. Au moment de la recherche, c’est le morceau pertinent qui ressort, pas le document entier — vous injectez alors juste ce passage dans la consigne. Une astuce répandue consiste à faire légèrement chevaucher les morceaux (quelques phrases communes entre deux segments voisins) pour éviter qu’une information à cheval sur une coupure ne soit perdue.

Le bon calibrage du morceau dépend de votre contenu : trop petit, il perd le contexte nécessaire pour être compris seul ; trop grand, il redevient flou et coûteux. Pour de la documentation technique, un découpage par section ou par paragraphe donne en général d’excellents résultats. Cette étape de préparation, souvent négligée, a plus d’impact sur la qualité finale d’un assistant que le choix du modèle d’embedding lui-même : un découpage soigné fait ressortir les bons passages, là où un découpage grossier renvoie des réponses approximatives quelle que soit la puissance du modèle.

🐞 Pièges fréquents

Symptôme / erreur Cause probable Correctif
Tous les scores sont très proches Comparaison incorrecte ou textes trop similaires Vérifier le zip(index, FAQ) et varier les entrées de test
Erreur de dimensions lors de la similarité Vecteurs issus de deux modèles différents Indexer et interroger avec le même modèle d’embedding
Recherche très lente Recalcul des embeddings de la FAQ à chaque requête Indexer une seule fois au démarrage, réutiliser l’index
Réponse hors sujet du modèle Passage récupéré non pertinent injecté tel quel Ajouter un seuil de score minimal avant d’utiliser le passage
Coût d’indexation élevé Ré-indexation complète à chaque démarrage Stocker les vecteurs sur disque ou en base et ne ré-embedder que les nouveautés

✅ Récapitulatif

Vous savez désormais transformer un texte en vecteur, mesurer la proximité de sens par similarité cosinus, indexer une base de FAQ et retrouver l’entrée la plus pertinente pour une question — même sans mots communs. En injectant ce passage dans la consigne, l’assistant répond à partir de vos textes réels. Vous connaissez aussi le choix entre les deux modèles d’embedding et le moment de passer à une base vectorielle pour absorber le volume.

🧾 Aide-mémoire

Élément Rôle
embeddings.create(model, input) Calculer un ou plusieurs vecteurs
data[i].embedding Lire le i-ème vecteur
text-embedding-3-small Modèle économique par défaut (1536 d)
text-embedding-3-large Modèle précis (3072 d)
similarité cosinus Mesurer la proximité de sens
indexation unique Pré-calculer les vecteurs une fois

💪 À vous de jouer

Ajoutez un seuil : si le meilleur score est inférieur à 0,3, l’assistant doit répondre « Je n’ai pas l’information, je transmets votre question au service client » plutôt que d’utiliser un passage non pertinent. Testez avec une question hors sujet (« Faites-vous de la location de voiture ? »).

Voir une piste
def repondre_sur(question, seuil=0.3):
    score, passage = chercher(question)
    if score < seuil:
        return "Je n'ai pas l'information, je transmets au service client."
    return repondre_avec_contexte(question)

Tutoriels associés

FAQ

Pourquoi ne pas utiliser une recherche par mots-clés ?
Parce qu'elle échoue dès que la question et la réponse n'emploient pas les mêmes mots. Les embeddings comparent le sens, ce qui les rend robustes aux reformulations et aux synonymes.

Dois-je ré-embedder ma FAQ à chaque requête ?
Non. On indexe une seule fois au démarrage et on réutilise les vecteurs. Seules les nouvelles entrées doivent être embeddées.

Embeddings OpenAI ou open source ?
Les embeddings OpenAI sont managés : aucun serveur à maintenir, on paie à l'usage. Les modèles open source s'auto-hébergent et conviennent quand on veut garder les données en interne — voir les tutoriels pgvector liés plus haut.

Combien de dimensions choisir ?
Gardez la valeur par défaut du modèle, sauf contrainte de stockage. Le paramètre dimensions permet de réduire la taille, mais commencez sans l'utiliser.

مشاركة
Service ITSkillsCenter

Application mobile Android et iOS

Création d'application mobile Android et iOS. À partir de 350 000 FCFA.

Démarrer mon projet
Publicité