9. Construire votre premier système RAG
Toutes les applications que nous avons construites jusqu'à présent reposaient uniquement sur les connaissances pré-entraînées du LLM. C'est pourquoi elles ne pouvaient pas répondre à des questions sur des informations que le LLM n'avait jamais apprises — comme les documents internes de votre entreprise ou les manuels de produits.
RAG (Retrieval-Augmented Generation) résout ce problème. Lorsqu'une question d'utilisateur arrive, il récupère d'abord les documents pertinents, puis transmet le contenu récupéré avec la question au LLM afin qu'il puisse répondre en se basant sur ce contenu. Vous combinez la capacité de raisonnement du LLM avec vos connaissances documentaires.
Dans ce chapitre, nous allons construire un pipeline RAG complet depuis la préparation des documents (chargement, découpage, embedding) jusqu'à la génération de réponses basées sur la récupération. Le système terminé récupère les documents pertinents lorsqu'une question arrive, puis les transmet avec la question au LLM afin qu'il réponde en se basant sur ce contenu documentaire. Il répond avec précision lorsque l'information se trouve dans les documents, et dit honnêtement "Je ne sais pas" lorsqu'elle n'y est pas — c'est l'essence d'un RAG fiable.
9.1) Comprendre le RAG
9.1.1) Le problème : les LLM ne connaissent pas vos données
Les LLM sont entraînés sur des données internet comme Wikipédia, des articles de presse et du code public. Ils ne connaissent pas les documents internes de votre entreprise ou le contrat que vous avez reçu hier. Ils ne peuvent donc pas répondre à des questions comme :
- "Quelle est notre politique de congés ?"
- "Résumez le rapport de ventes de ce trimestre"
- "Quelles sont les conditions de remboursement dans le contrat que je viens de recevoir ?"
from langchain_openai import ChatOpenAI
llm = ChatOpenAI(model="gpt-5-mini")
# Poser une question sur un document privé que le LLM n'a jamais vu
response = llm.invoke("Quelle est la politique de remboursement d'Acme Corp ?")
print(response.content)Sortie :
Je n'ai pas d'informations spécifiques sur la politique de remboursement d'Acme Corp. Je vous recommande de consulter leur site web officiel ou de contacter directement leur équipe de support client pour obtenir les informations les plus précises et à jour.Dans cet exemple, le LLM dit honnêtement qu'il ne sait pas. (Ou il pourrait halluciner une réponse plausible.)
Mais que se passerait-il si nous fournissions le document de politique de remboursement avec la question ? Le LLM donnerait une réponse précise basée sur le contenu fourni. C'est l'idée centrale du RAG.
9.1.2) Comment devons-nous fournir le document ?
L'approche la plus simple consiste à copier-coller l'intégralité du document dans le prompt. Cela fonctionne bien pour les documents courts.
from langchain_openai import ChatOpenAI
llm = ChatOpenAI(model="gpt-5-mini")
# En réalité, ce serait beaucoup plus long, mais supposons que ce qui suit est le document complet
document_text = """
Politique de remboursement (En vigueur depuis janvier 2026) :
- Remboursement complet dans les 30 jours suivant l'achat avec le reçu original.
- Après 30 jours, crédit magasin uniquement.
- Les produits numériques ne sont pas remboursables après téléchargement.
- Les articles défectueux peuvent être retournés à tout moment pour un remboursement complet.
"""
response = llm.invoke(
f"""Veuillez répondre en vous basant sur le document suivant :
{document_text}
Question : Quelle est la politique de remboursement pour les produits numériques ?"""
)
print(response.content)Sortie :
Les produits numériques ne sont pas remboursables après téléchargement.
...Cela fonctionne bien pour les documents courts. Mais que se passe-t-il si le document est très volumineux ? Cela entraîne les problèmes graves suivants :
1. Limites de la fenêtre de contexte : Les LLM ont un nombre limité de tokens qu'ils peuvent traiter à la fois. Pour GPT-5-mini, c'est 400K tokens. Cependant, l'ensemble de la documentation de votre entreprise peut facilement dépasser cela. Même si elle tient, les réponses deviennent plus lentes et moins précises à mesure que le contexte s'allonge.
2. Coût : Les API LLM facturent par token. Envoyer l'intégralité du document alors qu'un ou deux paragraphes seulement sont nécessaires fait exploser les coûts.
3. Dégradation de la précision : Lorsque vous incluez l'intégralité du document, l'information dont vous avez réellement besoin se retrouve noyée dans du contenu non pertinent. L'attention du LLM est détournée par des informations sans rapport, dégradant la qualité de la réponse.
Le RAG résout ces trois problèmes en récupérant et en fournissant uniquement les parties pertinentes du document.
9.1.3) Idée centrale : récupérer les parties pertinentes et les fournir avec la question
L'essence du RAG est simple : Avant d'envoyer la question au LLM, trouvez d'abord les parties pertinentes de vos documents et fournissez-les avec la question.
Voici comment cela fonctionne :
- L'utilisateur pose une question.
- Le système récupère (Retrieval) le contenu pertinent depuis le stockage de documents.
- Le contenu récupéré est ajouté (Augmentation) au prompt avec la question.
- Le LLM génère (Generation) une réponse basée sur le contenu récupéré.
Ces trois étapes sont à l'origine du nom RAG (Retrieval-Augmented Generation).
9.1.4) Comment récupérer le contenu pertinent ? (Limites de la correspondance par mots-clés)
L'étape de récupération est cruciale pour le RAG. Vous devez fournir du contenu pertinent pour obtenir des réponses appropriées. Alors comment récupérer du contenu lié à la question ?
La méthode la plus simple est la correspondance par mots-clés : trouver des documents qui contiennent des mots de la question. Par exemple, si quelqu'un demande "Quelle est la politique de remboursement pour les produits numériques ?" vous rechercheriez des documents contenant les mots "remboursement", "numériques" et "produits".
Mais la correspondance par mots-clés a une faiblesse critique : elle ne peut trouver que des correspondances de mots exactes.
Supposons que vous ayez un document de politique de remboursement avec ce contenu :
"Remboursement complet disponible dans les 30 jours suivant l'achat."
Que se passe-t-il lorsqu'un utilisateur demande "Comment récupérer mon argent ?" Ce document ne sera pas récupéré. Le document ne contient pas l'expression "récupérer mon argent". Les humains comprennent que "remboursement" et "récupérer mon argent" ont le même sens, mais la recherche par mots-clés ne correspond qu'aux mots, donc elle échoue à le trouver.
La recherche par mots-clés ne correspond qu'aux mots. Même lorsque le sens est le même, si les mots diffèrent, elle ne le trouvera pas.
La solution est la recherche sémantique. Et ce qui la rend possible, ce sont les embeddings.
9.1.5) Embeddings : convertir le texte en vecteurs numériques
Les embeddings représentent le sens du texte sous forme de liste de nombres (un vecteur). Lorsque vous entrez du texte dans un modèle d'embedding, il le convertit en un vecteur de centaines à milliers de nombres.
from langchain_openai import OpenAIEmbeddings
embeddings_model = OpenAIEmbeddings(model="text-embedding-3-small")
# Embedder une seule phrase
vector = embeddings_model.embed_query("Comment récupérer mon argent ?")
print(f"Dimensions du vecteur : {len(vector)}")
print(f"5 premières valeurs : {vector[:5]}")Sortie :
Dimensions du vecteur : 1536
5 premières valeurs : [0.0123, -0.0456, 0.0789, -0.0234, 0.0567]Dimension est le nombre de valeurs qui composent le vecteur. Le modèle text-embedding-3-small représente tout texte sous forme de 1 536 nombres.
Pourquoi avons-nous besoin d'autant de nombres ? Parce que chaque dimension capture différents aspects du sens :
- Certaines dimensions pourraient distinguer "action/état"
- D'autres pourraient représenter des degrés de "concret/abstrait"
- D'autres encore pourraient indiquer le sentiment "positif/négatif"
- ... (1 536 caractéristiques sémantiques — bien que nous ne puissions pas réellement interpréter ce que chaque dimension représente)
Tout comme les coordonnées 2D (x, y) représentent un point sur un plan, un vecteur à 1 536 dimensions représente un point dans un "espace de sens" à 1 536 dimensions. Plus de dimensions permettent des distinctions plus fines dans le sens.
Des sens similaires sont situés proches les uns des autres dans l'espace de sens. "Méthode de remboursement" et "récupérer mon argent" utilisent des mots différents, mais parce qu'ils ont des sens similaires, ils sont placés proches l'un de l'autre dans l'espace de sens.
9.1.6) Recherche sémantique : sens similaire, distance plus proche
Une fois que vous avez converti à la fois les documents et les requêtes en vecteurs, vous pouvez trouver les documents les plus pertinents en mesurant la similarité entre les vecteurs. C'est ce qu'on appelle la recherche sémantique — rechercher par similarité sémantique plutôt que par correspondance de mots-clés.
La mesure de similarité la plus courante est la similarité cosinus, qui mesure l'angle entre deux vecteurs. Lorsque les vecteurs pointent dans des directions similaires, la similarité est plus élevée. Plus proche de 1,0 signifie un sens très similaire, tandis que plus proche de 0 signifie une faible pertinence.
Calculons cela réellement :
from langchain_openai import OpenAIEmbeddings
import numpy as np
embeddings_model = OpenAIEmbeddings(model="text-embedding-3-small")
# Embedder la requête et deux documents candidats
query_vec = embeddings_model.embed_query("Quelle est la période de remboursement ?")
doc1_vec = embeddings_model.embed_query("Remboursement complet disponible dans les 30 jours suivant l'achat.") # Lié
doc2_vec = embeddings_model.embed_query("Notre bureau est situé dans le centre-ville de Seattle.") # Non lié
def cosine_similarity(a, b):
"""Calcule la similarité cosinus entre deux vecteurs."""
return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))
sim1 = cosine_similarity(query_vec, doc1_vec)
sim2 = cosine_similarity(query_vec, doc2_vec)
print(f"Requête vs doc 'remboursement' : {sim1:.4f}")
print(f"Requête vs doc 'bureau' : {sim2:.4f}")Sortie :
Requête vs doc 'remboursement' : 0.6415
Requête vs doc 'bureau' : 0.1706(Les valeurs réelles peuvent varier selon le modèle)
Le document de remboursement obtient un score beaucoup plus élevé. Le modèle d'embedding comprend que "période de remboursement" et "remboursement complet dans les 30 jours" sont sémantiquement liés. C'est la recherche sémantique, et c'est le mécanisme central du RAG.
9.1.7) Vue d'ensemble du pipeline RAG
La combinaison des concepts que nous avons appris crée le pipeline RAG suivant :
Le pipeline se compose de deux phases :
Ingestion des connaissances (effectuée une fois initialement, ou lorsque les documents changent) :
- Chargement des documents : Extraire les données textuelles de diverses sources (Markdown, PDF, etc.).
- Découpage du texte (Chunking) : Diviser les longs documents en chunks plus petits pour améliorer la précision de récupération et respecter les limites d'entrée du LLM.
- Conversion en vecteurs (Embedding) : Utiliser un modèle d'embedding pour convertir les chunks en vecteurs numériques basés sur le sens.
- Stockage vectoriel : Stocker les vecteurs convertis et le texte original dans une base de données vectorielle (indexation).
Récupération et génération de réponse (effectuée pour chaque question utilisateur) :
- Embedding de la question : Convertir la question de l'utilisateur en vecteur numérique en utilisant le même modèle utilisé lors de l'ingestion.
- Recherche de similarité (Récupération) : Extraire les top-K chunks qui sont sémantiquement les plus proches du vecteur de question depuis la base de données vectorielle.
- Augmentation du prompt : Combiner la question originale avec les chunks récupérés pour augmenter le prompt.
- Génération de réponse : Le LLM se réfère aux chunks fournis pour générer une réponse fondée.
9.2) Chargement et découpage des documents
Cette section couvre les deux premières étapes de la phase d'ingestion des connaissances du pipeline RAG :
- Chargement des documents : Lecture des données textuelles depuis des fichiers
- Découpage du texte (Chunking) : Découpage des données textuelles en petits morceaux recherchables
Dans la section suivante (9.3), nous apprendrons comment convertir ces chunks en vecteurs et les stocker.
9.2.1) Chargement de documents depuis des fichiers
La première étape d'un pipeline RAG consiste à charger des documents dans des objets Python. LangChain fournit des document loaders — des classes qui prennent en charge une variété de formats de fichiers. Les principaux loaders sont :
TextLoader: Fichiers texte brut (.txt) et Markdown (.md)PyPDFLoader: Fichiers PDF (.pdf), chargés page par pageCSVLoader: Fichiers CSV (.csv), avec chaque ligne chargée comme un document séparéUnstructuredMarkdownLoader: Fichiers Markdown (.md), avec conscience de la structure (en-têtes, listes, etc.)
Quel que soit le loader que vous utilisez, le résultat est toujours retourné sous forme de liste d'objets Document. Chaque Document a deux attributs clés :
page_content: Le contenu textuel du documentmetadata: Un dictionnaire contenant des méta-informations telles que le chemin du fichier et le numéro de page
Dans ce tutoriel, nous utiliserons TextLoader pour charger des fichiers Markdown.
Préparation de documents d'exemple
Tout d'abord, créons quelques documents d'exemple avec lesquels travailler. Créez un dossier data/docs/ dans votre projet et ajoutez les fichiers suivants :
mkdir -p data/docsCréez data/docs/refund_policy.md :
# Politique de remboursement
**Date d'entrée en vigueur** : 1er janvier 2026
## Retours standards
Tous les produits physiques peuvent être retournés dans les 30 jours suivant l'achat pour un remboursement complet.
Le reçu original ou l'e-mail de confirmation de commande est requis. Les articles doivent être dans leur
emballage d'origine et en état non utilisé.
Après 30 jours, les retours sont acceptés pour un crédit magasin uniquement. Le crédit magasin n'expire pas.
## Produits numériques
Les produits numériques (licences logicielles, e-books, cours en ligne) ne sont pas remboursables
une fois que le lien de téléchargement ou d'accès a été activé. Si vous rencontrez des problèmes
techniques empêchant l'accès, contactez le support dans les 7 jours pour un remplacement ou un remboursement.
## Articles défectueux
Les articles défectueux peuvent être retournés à tout moment pour un remboursement complet ou un remplacement.
Veuillez inclure une description du défaut. Les frais d'expédition pour les retours d'articles défectueux
sont couverts par l'entreprise.
## Services d'abonnement
Les abonnements mensuels peuvent être annulés à tout moment. Les remboursements sont calculés au prorata en fonction
des jours restants dans le cycle de facturation. Les abonnements annuels peuvent être remboursés intégralement
dans les 14 premiers jours. Après 14 jours, aucun remboursement n'est disponible mais l'accès continue jusqu'à la fin de la période de facturation.Créez data/docs/shipping_info.md :
# Informations d'expédition
## Expédition nationale
Expédition standard (5-7 jours ouvrables) : Gratuite pour les commandes de plus de 50 $, sinon 5,99 $.
Expédition express (2-3 jours ouvrables) : 12,99 $.
Expédition de nuit (jour ouvrable suivant) : 24,99 $.
## Expédition internationale
Les commandes internationales sont expédiées par courrier aérien suivi. Les délais de livraison varient selon
la destination, généralement 10-21 jours ouvrables. Les frais d'expédition internationale sont
calculés à la caisse en fonction du poids et de la destination.
Les droits de douane et les taxes d'importation sont à la charge de l'acheteur et ne sont pas inclus dans les frais d'expédition.
## Suivi de commande
Toutes les commandes incluent un numéro de suivi envoyé par e-mail dans les 24 heures suivant l'expédition.
Suivez votre commande via le lien de suivi dans votre e-mail ou via le site web du transporteur.
## Colis perdus ou endommagés
Si votre colis est perdu ou arrive endommagé, contactez le support dans les 48 heures.
Nous expédierons un remplacement sans frais supplémentaires. Pour les articles endommagés, veuillez
fournir des photos des dommages et de l'emballage.Maintenant, chargeons ces fichiers en utilisant TextLoader :
from pathlib import Path
from langchain_community.document_loaders import TextLoader
# Charger tous les fichiers .md du répertoire data/docs
docs_dir = Path("data/docs")
for md_file in docs_dir.glob("*.md"):
loader = TextLoader(str(md_file), encoding="utf-8")
docs = loader.load()
if docs: # Vérifier que le fichier n'est pas vide
doc = docs[0] # Fichier unique = Document unique
print(f"Fichier : {doc.metadata['source']}")
print(f"Longueur : {len(doc.page_content)} caractères")
print(f"Aperçu : {doc.page_content[:80]}...")
print()Sortie :
Fichier : data/docs/refund_policy.md
Longueur : 1166 caractères
Aperçu : # Politique de remboursement
...
Fichier : data/docs/shipping_info.md
Longueur : 972 caractères
Aperçu : # Informations d'expédition
...Note :
TextLoaderprend un seul chemin de fichier en entrée, mais retourneList[Document]pour une interface cohérente avec les autres loaders. (Par exemple,PDFLoaderretourne plusieurs Documents — un par page.)
9.2.2) Découper les documents en chunks : Chunking
Les deux documents ci-dessus sont intentionnellement courts pour les besoins de ce tutoriel. Dans les applications réelles, vous travaillerez souvent avec des documents de centaines ou de milliers de pages. Si vous embeddez un document entier comme un seul vecteur, des milliers de concepts sont compressés en un seul — rendant impossible la récupération précise de ce dont vous avez réellement besoin.
Le chunking est le processus de division des documents en petits morceaux significatifs. L'objectif est simple : lorsqu'un utilisateur pose une question, seuls les paragraphes spécifiques directement pertinents pour la réponse doivent être récupérés — pas le document entier.
La taille des chunks affecte directement à la fois la récupération et la qualité de la réponse :
- Trop grands : Plusieurs sujets sont mélangés dans un chunk, rendant les embeddings moins précis et la récupération plus difficile. Même lorsque le bon chunk est trouvé, du contenu non pertinent est transmis au LLM, dégradant la qualité de la réponse.
- Trop petits : Le LLM peut ne pas recevoir suffisamment d'informations pour répondre correctement. Par exemple, si seule la phrase "L'expédition standard coûte 5,99 $" est récupérée, le LLM ne peut pas savoir que cela ne s'applique qu'aux commandes de moins de 50 $.
- Juste bien : Chaque chunk couvre un sujet avec suffisamment de contexte, permettant une récupération et des réponses précises.
9.2.3) Contrôler la taille et le chevauchement des chunks
Pour diviser les documents en chunks, vous avez besoin d'un text splitter. Un text splitter est un outil LangChain qui découpe les longs documents en morceaux plus petits. Choisir le bon splitter est important.
RecursiveCharacterTextSplitter: Essaie plusieurs séparateurs dans un ordre hiérarchique pour préserver autant de contexte que possible. Le splitter le plus largement utilisé à des fins générales.CharacterTextSplitter: Découpe sur un seul séparateur (par défaut :\n\n). Adapté aux documents avec une structure simple.MarkdownHeaderTextSplitter: Découpe sur les en-têtes Markdown (#,##). Efficace lorsque vous souhaitez préserver la structure de la table des matières du document.
Pourquoi RecursiveCharacterTextSplitter est-il efficace ?
Ce splitter fonctionne en essayant les séparateurs de la plus grande à la plus petite unité pour trouver le meilleur point de découpe. L'ordre par défaut est le suivant (peut être modifié via le paramètre separators) :
paragraphe (\n\n) → saut de ligne (\n) → mot ( )
Il essaie toujours de découper à la plus grande unité significative en premier. Si un paragraphe dépasse chunk_size, il revient aux sauts de ligne, puis aux mots. Parce qu'il trouve toujours le point de découpe le plus naturel plutôt que de couper arbitrairement au milieu d'un mot, les chunks résultants sont plus susceptibles de contenir des informations sémantiquement complètes.
Paramètres clés
chunk_size: Le nombre maximum de caractères par chunk. Par exemple,chunk_size=400signifie qu'aucun chunk ne dépassera 400 caractères.chunk_overlap: Le nombre de caractères qui se chevauchent entre les chunks adjacents. Par exemple,chunk_overlap=80signifie que les 80 derniers caractères d'un chunk sont répétés au début du suivant.separators: La liste des séparateurs utilisés pour diviser le texte, essayés par ordre de priorité. Si la division sur le séparateur actuel dépasseraitchunk_size, le séparateur suivant est essayé pour éviter de dépasserchunk_size.
Qu'est-ce que le chevauchement et pourquoi est-il nécessaire ?
Le chevauchement signifie que les chunks adjacents partagent du contenu — la fin d'un chunk est incluse au début du suivant.
La raison en est de s'assurer que chaque chunk peut se suffire à lui-même avec un contexte suffisant. Lorsqu'on lit un morceau d'un document sans aucune connaissance de ce qui précède, il peut être difficile de comprendre pourquoi certains contenus sont mentionnés. Le chevauchement maintient la fin d'un chunk qui s'écoule dans le suivant, de sorte que quel que soit le chunk récupéré, le contenu se lit naturellement.
Maintenant, découpons réellement un document :
from langchain_community.document_loaders import TextLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
# Charger le document
loader = TextLoader("data/docs/refund_policy.md", encoding="utf-8")
docs = loader.load()
# Configurer le splitter
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=400,
chunk_overlap=80,
separators=["\n## ", "\n\n", "\n", " "],
)
chunks = text_splitter.split_documents(docs)
print(f"Divisé en {len(chunks)} chunks\n")
for i, chunk in enumerate(chunks):
print(f"--- Chunk {i} (source : {chunk.metadata['source']}) ---")
print(f"Longueur : {len(chunk.page_content)} caractères")
print(chunk.page_content[:120])
print()Sortie :
Divisé en 4 chunks
--- Chunk 0 (source : data/docs/refund_policy.md) ---
Longueur : 374 caractères
# Politique de remboursement
...
--- Chunk 1 (source : data/docs/refund_policy.md) ---
Longueur : 267 caractères
## Produits numériques
...
--- Chunk 2 (source : data/docs/refund_policy.md) ---
Longueur : 204 caractères
## Articles défectueux
...
--- Chunk 3 (source : data/docs/refund_policy.md) ---
Longueur : 315 caractères
## Services d'abonnement
...Note : Dans l'exemple ci-dessus, aucun chevauchement ne s'est produit. C'est parce que chaque paragraphe a été proprement divisé en fonction du premier séparateur (
\n##) tout en restant dans lechunk_size. Le chevauchement ne se produit que lorsqu'un paragraphe spécifique est plus long que lechunk_sizeet doit être divisé en deux morceaux ou plus.
9.3) Stockage vectoriel et récupération avec ChromaDB
9.3.1) Qu'est-ce qu'un vector store ?
Un vector store (également appelé base de données vectorielle) est une base de données optimisée pour stocker et rechercher des données en utilisant des vecteurs d'embedding. Contrairement à une base de données traditionnelle où vous interrogez par valeurs de champs exactes (SELECT * FROM products WHERE category = 'electronics'), un vector store trouve les éléments avec le sens le plus similaire à votre requête.
Dans le RAG, le vector store contient les chunks de documents avec leurs embeddings. Lorsqu'un utilisateur pose une question, la question est convertie en vecteur, et le vector store récupère les chunks avec les vecteurs les plus similaires.
9.3.2) Choisir un vector store et configurer ChromaDB
Les vector stores populaires incluent ChromaDB, Pinecone, Weaviate et pgvector (extension PostgreSQL). Ils diffèrent par le modèle d'hébergement (local vs. cloud), l'échelle et la complexité opérationnelle. Pour ce livre, nous utiliserons ChromaDB — il est open-source, fonctionne entièrement sur votre machine locale sans configuration de serveur, et est utile non seulement pour le développement mais aussi pour les charges de travail de production petites à moyennes.
ChromaDB peut être utilisé de plusieurs façons :
- Mode local (pip) : Installez-le comme une bibliothèque Python et utilisez-le immédiatement. Vous pouvez stocker et charger des données dans un répertoire local sans aucune infrastructure de serveur séparée.
- Serveur autonome (Docker) : Exécutez ChromaDB comme un processus serveur séparé. Utile lorsque plusieurs applications doivent partager le même vector store.
- Service cloud géré (Chroma Cloud) : Utilisez ChromaDB comme un service cloud. Chroma Cloud gère l'hébergement, la mise à l'échelle et la maintenance, vous permettant de fournir un service stable sans charge de gestion d'infrastructure.
Installons ChromaDB en utilisant pip :
pip install chromadb langchain-chromachromadb est la bibliothèque de vector store principale, et langchain-chroma est un package d'intégration qui vous permet d'utiliser ChromaDB directement dans la bibliothèque LangChain.
9.3.3) Choisir le modèle d'embedding
La première chose à décider est quel modèle d'embedding utiliser. Les vecteurs d'embedding ne peuvent être comparés que lorsqu'ils sont générés par le même modèle. Par conséquent, vous devez utiliser le même modèle d'embedding pour stocker les documents et pour interroger.
OpenAI fournit les modèles d'embedding suivants :
| Modèle | Dimensions | Notes |
|---|---|---|
text-embedding-3-small | 1536 | Bon équilibre entre qualité et coût |
text-embedding-3-large | 3072 | Qualité supérieure, coût plus élevé |
Pour ce livre, nous utiliserons le modèle text-embedding-3-small d'OpenAI. Il offre une haute efficacité à faible coût, ce qui en fait un choix pratique pour la recherche générale, le RAG et les projets soucieux des coûts.
from langchain_openai import OpenAIEmbeddings
embedding_model = OpenAIEmbeddings(model="text-embedding-3-small")
# Vérifier que cela fonctionne
test_vector = embedding_model.embed_query("test")
print(f"Dimensions de l'embedding : {len(test_vector)}")Sortie :
Dimensions de l'embedding : 1536Note sur les coûts : Les appels d'API d'embedding sont beaucoup moins chers que les appels LLM, mais ils entraînent des coûts. Lors du stockage de documents dans la base de données (indexation), un appel d'API est requis par chunk, et lorsqu'un utilisateur pose une question (récupération), un appel d'API est requis pour la question. Pour les tarifs actuels, consultez la page de tarification OpenAI.
9.3.4) Stocker les chunks dans ChromaDB
Maintenant, assemblons tout. Nous allons charger des documents, les diviser en chunks, embedder les chunks et les stocker avec leurs vecteurs d'embedding dans ChromaDB.
# ingest.py - Pipeline d'ingestion complet
from langchain_community.document_loaders import DirectoryLoader, TextLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_openai import OpenAIEmbeddings
from langchain_chroma import Chroma
# Étape 1 : Charger les documents
# DirectoryLoader : scanne un répertoire et charge les fichiers correspondants.
# Le chargement réel est délégué au loader spécifié dans loader_cls.
loader = DirectoryLoader(
"data/docs/", glob="**/*.md",
loader_cls=TextLoader, loader_kwargs={"encoding": "utf-8"},
)
documents = loader.load()
print(f"Chargé {len(documents)} documents")
# Étape 2 : Diviser en chunks
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=400,
chunk_overlap=80,
separators=["\n## ", "\n\n", "\n", " ", ""],
)
chunks = text_splitter.split_documents(documents)
print(f"Créé {len(chunks)} chunks")
# Étape 3 : Créer le modèle d'embedding
embedding_model = OpenAIEmbeddings(model="text-embedding-3-small")
# Étape 4 : Créer le vector store et ingérer les chunks
vector_store = Chroma.from_documents(
documents=chunks,
embedding=embedding_model,
persist_directory="data/chroma_db",
collection_name="company_docs",
)
print(f"Stocké {len(chunks)} chunks dans ChromaDB à data/chroma_db/")Sortie :
Chargé 2 documents
Créé 8 chunks
Stocké 8 chunks dans ChromaDB à data/chroma_db/La méthode Chroma.from_documents() effectue deux tâches en un seul appel :
- Passe les chunks fournis via le paramètre
documentsà travers le modèle d'embedding pour obtenir des vecteurs d'embedding. - Stocke chaque chunk avec son vecteur d'embedding dans ChromaDB.
9.3.5) Charger un vector store persisté
Dans la section précédente, nous avons stocké des documents dans le vector store. Cette opération de stockage ne doit être effectuée qu'une seule fois initialement (ou lorsque les documents changent). Après cela, vous pouvez simplement charger le vector store persisté et l'utiliser directement.
from langchain_openai import OpenAIEmbeddings
from langchain_chroma import Chroma
# Charger un vector store persisté — pas besoin de ré-embedder
embedding_model = OpenAIEmbeddings(model="text-embedding-3-small")
vector_store = Chroma(
persist_directory="data/chroma_db",
collection_name="company_docs",
embedding_function=embedding_model,
)
print(f"Chargé le vector store avec {len(vector_store.get()['ids'])} chunks")Sortie :
Chargé le vector store avec 8 chunksMaintenant, vous pouvez commencer à rechercher immédiatement en chargeant simplement le vector store persisté, sans avoir besoin de ré-embedder vos documents.
9.3.6) Recherche de similarité
Avec le vector store chargé, vous pouvez maintenant rechercher des chunks qui sont sémantiquement similaires à une requête. Le paramètre top-K spécifie combien de résultats retourner :
from langchain_openai import OpenAIEmbeddings
from langchain_chroma import Chroma
embedding_model = OpenAIEmbeddings(model="text-embedding-3-small")
vector_store = Chroma(
persist_directory="data/chroma_db",
collection_name="company_docs",
embedding_function=embedding_model,
)
# Rechercher des chunks liés à une question
query = "Puis-je retourner un produit numérique ?"
results = vector_store.similarity_search(query, k=2)
print(f"Requête : {query}")
print(f"Trouvé {len(results)} résultats\n")
for i, doc in enumerate(results):
print(f"--- Résultat {i + 1} (source : {doc.metadata['source']}) ---")
print(doc.page_content[:200])
print()Sortie :
Requête : Puis-je retourner un produit numérique ?
Trouvé 2 résultats
--- Résultat 1 (source : data/docs/refund_policy.md) ---
## Produits numériques
...
--- Résultat 2 (source : data/docs/refund_policy.md) ---
# Politique de remboursement
**Date d'entrée en vigueur** : 1er janvier 2026
## Retours standards
...Pour cette requête, le chunk des produits numériques a été récupéré avec la similarité la plus élevée, suivi du chunk de politique de remboursement.
Vous pouvez également récupérer les résultats avec leurs scores de similarité en utilisant similarity_search_with_score :
results_with_scores = vector_store.similarity_search_with_score(query, k=2)
for doc, score in results_with_scores:
# ChromaDB retourne la distance (plus faible = plus similaire)
print(f"Score : {score:.4f} | Source : {doc.metadata['source']}")
print(f" {doc.page_content[:200]}...")
print()Sortie :
Score : 0.5942 | Source : data/docs/refund_policy.md
## Produits numériques
...
Score : 0.9577 | Source : data/docs/refund_policy.md
# Politique de remboursement
...Notez que ChromaDB utilise des scores de distance (plus faible est plus similaire), pas des scores de similarité (plus élevé est plus similaire). Le chunk des produits numériques a la distance la plus faible de 0.5942, ce qui en fait le résultat le plus pertinent.
9.4) Construire la chaîne RAG complète
Maintenant, nous allons construire un système RAG complet : récupérer d'abord les documents pertinents, puis les transmettre avec la question au LLM pour générer des réponses basées sur les informations fournies.
9.4.1) Concevoir le template de prompt
La partie la plus importante du template de prompt est d'instruire le LLM de répondre uniquement en se basant sur le contexte fourni. Sans cette instruction, le LLM peut ignorer les résultats de recherche et fabriquer des réponses basées sur ses données d'entraînement.
from langchain_core.prompts import ChatPromptTemplate
rag_prompt = ChatPromptTemplate.from_messages([
("system",
"Vous êtes un représentant du service client. "
"Répondez à la question de l'utilisateur en utilisant UNIQUEMENT le contexte fourni. "
"Si le contexte ne contient pas suffisamment d'informations pour répondre, "
"dites \"Je n'ai pas suffisamment d'informations pour répondre à cette question.\"\n\n"
"Contexte :\n{context}"),
("human", "{question}"),
])Le message système force le LLM à répondre en utilisant uniquement le contexte fourni. Crucialement, l'instruction de dire "Je n'ai pas suffisamment d'informations" lorsque le contexte est insuffisant empêche le LLM de fabriquer des réponses plausibles mais non étayées.
9.4.2) Construire la chaîne RAG
Nous avons maintenant tous les composants prêts. Nous devons juste connecter le retriever, le template de prompt et le LLM.
Le système RAG terminé fonctionnera comme suit :
- Recevoir la question de l'utilisateur
- Récupérer les chunks pertinents depuis le vector store
- Transmettre les chunks et la question au template de prompt pour générer le prompt
- Générer une réponse avec le LLM
Connectons la chaîne RAG en utilisant l'opérateur LCEL | du Chapitre 6.
# rag_chain.py - Pipeline RAG complet
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from langchain_chroma import Chroma
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
def format_docs(docs):
"""Joindre les documents récupérés en une seule chaîne de contexte."""
return "\n\n---\n\n".join(doc.page_content for doc in docs)
def build_rag_chain():
"""Construire et retourner la chaîne RAG complète."""
# Charger le vector store
embedding_model = OpenAIEmbeddings(model="text-embedding-3-small")
vector_store = Chroma(
persist_directory="data/chroma_db",
collection_name="company_docs",
embedding_function=embedding_model,
)
# Créer un retriever (k=3 signifie retourner les 3 meilleurs chunks)
retriever = vector_store.as_retriever(search_kwargs={"k": 3})
# Définir le prompt
rag_prompt = ChatPromptTemplate.from_messages([
("system",
"Vous êtes un représentant du service client. "
"Répondez à la question de l'utilisateur en utilisant UNIQUEMENT le contexte fourni. "
"Si le contexte ne contient pas suffisamment d'informations pour répondre, "
"dites \"Je n'ai pas suffisamment d'informations pour répondre à cette question.\"\n\n"
"Contexte :\n{context}"),
("human", "{question}"),
])
# Initialiser le LLM
llm = ChatOpenAI(model="gpt-5-mini")
# Composer la chaîne en utilisant LCEL
rag_chain = (
{"context": retriever | format_docs, "question": lambda x: x}
| rag_prompt
| llm
| StrOutputParser()
)
return rag_chain
if __name__ == "__main__":
chain = build_rag_chain()
answer = chain.invoke("Puis-je retourner un produit numérique ?")
print(answer)Sortie :
Les produits numériques ne sont pas remboursables une fois que le lien de téléchargement ou d'accès a été activé.
Si vous rencontrez des problèmes techniques empêchant l'accès, contactez le support dans les 7 jours pour un remplacement ou un remboursement.Décomposons la composition de la chaîne étape par étape :
rag_chain = (
{"context": retriever | format_docs, "question": lambda x: x}
| rag_prompt
| llm
| StrOutputParser()
)Lorsque vous appelez chain.invoke("Puis-je retourner un produit numérique ?"), voici ce qui se passe :
- Étape du dictionnaire :
retriever | format_docs: Recherche dans le vector store avec la question et combine les chunks en une seule chaînelambda x: x: Transmet la question sans modification- Résultat :
{"context": "chunks récupérés (combinés en une seule chaîne)", "question": "Puis-je retourner un produit numérique ?"}
rag_prompt: Remplit les espaces réservés{context}et{question}dans le template de prompt avec les valeurs du dictionnairellm: Envoie le prompt complété au LLMStrOutputParser(): Extrait uniquement le texte de la réponse du LLM
Pour plus de détails sur le fonctionnement de LCEL, voir le Chapitre 6.
9.4.3) Tester avec des questions auxquelles on peut répondre et auxquelles on ne peut pas répondre
Un système RAG doit gérer à la fois les questions auxquelles il peut répondre (l'information existe dans les documents) et les questions auxquelles il ne peut pas répondre (l'information n'est pas dans les documents). Testons les deux scénarios :
# test_rag.py - Tester la chaîne RAG avec diverses questions
from rag_chain import build_rag_chain
chain = build_rag_chain()
test_questions = [
# Auxquelles on peut répondre — l'information est dans les documents
"Quelle est la politique de remboursement pour les produits physiques ?",
"Combien coûte l'expédition express ?",
"Puis-je retourner un article défectueux après 6 mois ?",
# Auxquelles on ne peut pas répondre — l'information n'est PAS dans les documents
"Quelle est la politique de congés des employés ?",
"Quels langages de programmation sont utilisés ?",
]
for question in test_questions:
print(f"Q : {question}")
answer = chain.invoke(question)
print(f"R : {answer}\n")
print("-" * 60)Sortie :
Q : Quelle est la politique de remboursement pour les produits physiques ?
R : Tous les produits physiques peuvent être retournés dans les 30 jours suivant l'achat pour un remboursement complet. ...
------------------------------------------------------------
Q : Combien coûte l'expédition express ?
R : L'expédition express (2-3 jours ouvrables) coûte 12,99 $.
------------------------------------------------------------
Q : Puis-je retourner un article défectueux après 6 mois ?
R : Oui. Les articles défectueux peuvent être retournés à tout moment pour un remboursement complet ou un remplacement. ...
------------------------------------------------------------
Q : Quelle est la politique de congés des employés ?
R : Je n'ai pas suffisamment d'informations pour répondre à cette question.
------------------------------------------------------------
Q : Quels langages de programmation sont utilisés ?
R : Je n'ai pas suffisamment d'informations pour répondre à cette question.
------------------------------------------------------------Les résultats démontrent exactement le comportement que nous voulons :
- Questions auxquelles on peut répondre : Fournir des réponses précises basées sur les documents récupérés. Le LLM n'ajoute pas d'informations non contenues dans les documents.
- Questions auxquelles on ne peut pas répondre : Répondre avec "Je n'ai pas suffisamment d'informations pour répondre à cette question." Le LLM identifie correctement que le contexte récupéré manque d'informations pertinentes et refuse de fabriquer une réponse.
C'est la puissance du RAG. Votre LLM répond aux questions sur vos données et admet honnêtement quand il ne sait pas. Chaque réponse est soutenue par des documents, rendant le système beaucoup plus fiable qu'un LLM standard.