Intelligence Artificielle

RAG avec LangChain : brancher une base de connaissances sur votre agent

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

📍 Article principal de la série : LangChain, LangGraph et CrewAI : le guide des frameworks d’agents IA
Ce tutoriel fait partie de la série « Construire un agent IA avec LangChain ». Pour la vue d’ensemble, lisez d’abord le guide principal.

Au tutoriel précédent, l’Assistant Teranga connaissait un catalogue de deux produits codés en dur. Dans la vraie vie, la coopérative a une fiche par produit, un règlement de livraison, une politique de retour, des horaires d’atelier — des pages et des pages que personne n’a envie de réécrire en Python. Le client, lui, demande « est-ce que vous livrez à Bobo-Dioulasso et en combien de temps ? », et la réponse est noyée quelque part dans ces documents.

La technique pour brancher un agent sur vos documents s’appelle le RAG (Retrieval-Augmented Generation, génération augmentée par récupération). L’idée : avant de répondre, l’agent va chercher les passages pertinents dans votre base, puis répond en s’appuyant dessus. Dans ce tutoriel, on donne à l’Assistant Teranga une mémoire documentaire qu’il interroge tout seul.

🎯 Ce que vous allez apprendre

  • Comprendre le principe du RAG et pourquoi il réduit les réponses inventées.
  • Découper des documents en morceaux avec RecursiveCharacterTextSplitter.
  • Calculer des embeddings et les stocker dans un index vectoriel (InMemoryVectorStore).
  • Faire une recherche sémantique, puis transformer cette recherche en outil que l’agent appelle.

🛠️ Ce que vous allez construire

Une base de connaissances pour l’Assistant Teranga : on charge la FAQ et les conditions de la coopérative, on les indexe, et l’agent répond « oui, nous livrons à Bobo-Dioulasso sous 4 jours, 2 000 FCFA » en citant le bon passage, au lieu de deviner. C’est la brique qui transforme un agent bavard en agent fiable.

Prérequis

  • Avoir suivi le premier tutoriel (installation, init_chat_model, create_agent).
  • Python 3.10+ et le projet déjà installé. On ajoute deux paquets : pip install -U langchain-text-splitters (le découpeur) et un fournisseur d’embeddings.
  • Test express : si vous savez ce que renvoie create_agent(...).invoke(...), vous êtes prêt.
  • ⏱️ Temps estimé : ~45 minutes.

Étape 1 — Comprendre la chaîne du RAG

Avant de coder, fixons le modèle mental, car le RAG paraît magique tant qu’on n’a pas vu les quatre maillons. Premièrement, on découpe les documents en petits morceaux (chunks) : un modèle ne peut pas avaler un PDF de 40 pages d’un coup, et chercher dans de petits passages est plus précis. Deuxièmement, on calcule pour chaque morceau un embedding : un vecteur de nombres qui capture le sens du texte. Deux passages qui parlent de livraison auront des vecteurs proches, même s’ils n’emploient pas les mêmes mots.

Troisièmement, on range tous ces vecteurs dans un index vectoriel. Quatrièmement, à la question du client, on calcule l’embedding de la question, on cherche les morceaux dont le vecteur est le plus proche, et on les donne au modèle comme contexte. Le modèle répond alors à partir de vos textes, pas de ses souvenirs d’entraînement. C’est tout le RAG : retrouver, puis générer.

Point d’étape — vous devez pouvoir expliquer en une phrase la différence entre « le modèle invente » et « le modèle répond à partir de morceaux retrouvés ». Si ce n’est pas clair, relisez les quatre maillons avant de coder.

Étape 2 — Préparer et découper les documents

Partons de quelques textes réels de la coopérative. En production, vous les chargeriez depuis des fichiers ou une base ; ici on les met en clair pour voir ce qui se passe. Chaque document devient un objet Document avec un contenu et des métadonnées (utiles plus tard pour citer la source).

from langchain_core.documents import Document
from langchain_text_splitters import RecursiveCharacterTextSplitter

docs = [
    Document(page_content=(
        "Livraison : nous livrons dans tout le Burkina Faso. Ouagadougou sous 2 jours "
        "(1 500 FCFA), Bobo-Dioulasso sous 4 jours (2 000 FCFA). Paiement à la livraison "
        "ou par mobile money."), metadata={"source": "livraison"}),
    Document(page_content=(
        "Retours : un article peut être retourné sous 7 jours s'il n'a pas servi. "
        "Les pièces sur mesure ne sont ni reprises ni échangées."), metadata={"source": "retours"}),
    Document(page_content=(
        "Atelier : ouvert du lundi au samedi, 8 h à 18 h. Les paniers en osier sont "
        "fabriqués à la main par la coopérative de Koudougou."), metadata={"source": "atelier"}),
]

decoupeur = RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=80, add_start_index=True)
morceaux = decoupeur.split_documents(docs)
print(f"{len(docs)} documents -> {len(morceaux)} morceaux")

Le RecursiveCharacterTextSplitter coupe en respectant d’abord les paragraphes, puis les phrases, pour ne pas trancher au milieu d’une idée. Le chunk_overlap fait se chevaucher légèrement les morceaux : ainsi une information à cheval sur deux découpes n’est jamais perdue. Nos textes étant courts, vous verrez sans doute autant de morceaux que de documents ; sur de vrais PDF, le rapport grimpe vite.

Point d’étape — le script affiche un nombre de morceaux. S’il affiche 0, c’est que la liste docs est vide.

Étape 3 — Indexer avec des embeddings

Il faut maintenant transformer chaque morceau en vecteur et le ranger. InMemoryVectorStore garde tout en mémoire vive : parfait pour apprendre et pour de petits volumes. On lui passe un modèle d’embeddings — ici celui d’OpenAI ; pour rester gratuit et hors-ligne, on peut utiliser OllamaEmbeddings.

from langchain_core.vectorstores import InMemoryVectorStore
from langchain_openai import OpenAIEmbeddings
# Variante locale gratuite :
# from langchain_ollama import OllamaEmbeddings
# embeddings = OllamaEmbeddings(model="nomic-embed-text")

embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
index = InMemoryVectorStore(embeddings)
index.add_documents(morceaux)

resultats = index.similarity_search("Livrez-vous à Bobo et à quel prix ?", k=2)
for r in resultats:
    print(r.metadata["source"], "->", r.page_content[:60])

Au moment d’écrire ces lignes, text-embedding-3-small est un modèle d’embeddings économique et largement suffisant pour ce volume. La recherche similarity_search renvoie les k morceaux les plus proches du sens de la question. Vous devriez voir le morceau « livraison » remonter en tête — alors même que le mot « prix » n’y figure pas tel quel : c’est la force de la recherche sémantique face à un simple Ctrl+F.

Point d’étape — la recherche renvoie le passage « livraison » pour une question sur la livraison. Si elle renvoie n’importe quoi, vérifiez que add_documents a bien été appelé avant la recherche.

Étape 4 — Transformer la recherche en outil de l’agent

On pourrait coller manuellement les morceaux dans le prompt. Mais le plus propre, dans la lignée du premier tutoriel, est d’emballer la recherche dans un outil que l’agent décide d’appeler quand la question touche aux documents. L’agent reste maître : pour « bonjour », il ne fouille pas la base ; pour « vous livrez où ? », il le fait.

from langchain.tools import tool
from langchain.agents import create_agent
from langchain.chat_models import init_chat_model

modele = init_chat_model("openai:gpt-4o-mini")

@tool
def chercher_infos(question: str) -> str:
    """Cherche dans la base de connaissances de la coopérative (livraison, retours, atelier)
    les passages utiles pour répondre à la question du client."""
    trouves = index.similarity_search(question, k=2)
    if not trouves:
        return "Aucune information trouvée dans la base."
    return "\n\n".join(f"[{d.metadata['source']}] {d.page_content}" for d in trouves)

assistant = create_agent(
    model=modele,
    tools=[chercher_infos],
    system_prompt=(
        "Tu es l'assistant de la coopérative Teranga. Pour toute question sur la livraison, "
        "les retours ou l'atelier, appelle l'outil chercher_infos et réponds UNIQUEMENT à partir "
        "des passages retournés. Si l'information n'y est pas, dis-le franchement."),
)

La consigne est le cœur d’un bon RAG : on interdit au modèle de répondre de mémoire et on l’oblige à s’appuyer sur les passages retrouvés. C’est cette discipline qui fait la différence entre un assistant qui rassure à tort et un assistant en qui le gérant peut avoir confiance.

Remarquez que l’outil renvoie aussi la source de chaque passage, tirée des métadonnées ([livraison], [retours]…). Ce détail n’est pas cosmétique : il permet à l’agent de dire « d’après nos conditions de livraison, … » et, surtout, il vous donne un moyen de vérifier d’où vient chaque réponse quand un client conteste. En production, on pousse l’idée plus loin en stockant dans les métadonnées le titre du document, sa date ou un identifiant de fiche produit ; l’agent peut alors citer une référence précise plutôt qu’une affirmation anonyme. Une réponse traçable vaut toujours mieux qu’une réponse confiante.

Point d’étape — l’agent est créé avec l’outil chercher_infos. Vérifiez qu’aucune erreur d’import n’apparaît avant de l’interroger.

Étape 5 — Interroger et vérifier

Posons deux questions : une dont la réponse est dans la base, une autre absente. Un RAG bien réglé doit briller sur la première et rester honnête sur la seconde.

for q in ["Vous livrez à Bobo-Dioulasso ? En combien de temps et à quel prix ?",
          "Acceptez-vous le paiement en plusieurs fois ?"]:
    rep = assistant.invoke({"messages": [{"role": "user", "content": q}]})
    print("Q:", q)
    print("R:", rep["messages"][-1].content, "\n")

Pour la première question, l’agent appelle chercher_infos, récupère le passage « livraison », et répond « Bobo-Dioulasso sous 4 jours, 2 000 FCFA ». Pour la seconde, aucun passage ne mentionne le paiement échelonné : l’agent répond qu’il n’a pas cette information plutôt que d’inventer une facilité de paiement qui n’existe pas. Cette honnêteté est exactement ce qui protège la réputation de la coopérative.

De l’index en mémoire à la vraie base

InMemoryVectorStore a un défaut majeur : tout disparaît à l’arrêt du programme. Pour un assistant qui tourne en continu, on remplace l’index en mémoire par une base vectorielle persistante — Chroma en local, ou un service comme pgvector si vous utilisez déjà PostgreSQL. Le code change à peine : on échange la classe de l’index, le reste (découpage, embeddings, outil) reste identique. Pour creuser ce choix, voyez notre comparatif des bases vectorielles auto-hébergées et l’exemple concret du RAG avec pgvector.

RAG ou tout mettre dans le prompt ?

Une question revient toujours : « les modèles acceptent désormais d’énormes contextes, pourquoi ne pas coller tous mes documents dans le prompt à chaque fois ? ». Techniquement, on peut. En pratique, c’est une mauvaise idée pour trois raisons concrètes.

D’abord le coût : vous payez chaque mot envoyé, à chaque question. Coller 40 pages à chaque message de client, c’est multiplier la facture par cent pour une information qui tient en deux lignes. Ensuite la précision : noyé sous 40 pages, le modèle se disperse et rate parfois le détail pertinent — le RAG, lui, lui sert sur un plateau les deux ou trois passages utiles. Enfin la fraîcheur : avec un index, mettre à jour un tarif revient à réindexer un document ; avec un prompt géant codé en dur, il faut retoucher le programme. Le RAG n’est donc pas un contournement d’une limite technique : c’est la façon économique et précise de donner des connaissances à un agent, et elle le restera même quand les contextes grandiront encore.

🐞 Pièges fréquents

Symptôme / erreur Cause probable Correctif
La recherche renvoie des passages hors sujet Morceaux trop gros (un chunk mélange plusieurs thèmes) Réduisez chunk_size (par ex. 300–500) et gardez un chunk_overlap
L’agent répond de mémoire au lieu de citer la base Consigne system trop permissive Imposez « réponds UNIQUEMENT à partir des passages retournés »
Embeddings très lents ou facture qui grimpe Réindexation à chaque démarrage Persistez l’index (Chroma/pgvector) au lieu de tout recalculer
Dimension mismatch entre index et requête Changement de modèle d’embeddings après indexation Réindexez tout avec le même modèle d’embeddings

🌍 Adaptation au contexte ouest-africain

Les embeddings sont bien moins chers que la génération de texte, mais ils restent facturés à l’indexation. Bonne nouvelle : on n’indexe qu’une fois, puis on persiste — la facture est donc ponctuelle, pas par requête. Pour rester totalement gratuit, OllamaEmbeddings avec un modèle comme nomic-embed-text calcule les vecteurs en local, sans connexion. Côté stockage, Chroma écrit dans un simple dossier sur disque : aucun serveur à louer tant que votre base tient sur quelques milliers de documents, ce qui couvre largement une PME ou une coopérative. Rédigez vos documents sources en français clair : la qualité des réponses dépend d’abord de la qualité de ce que vous indexez.

✅ Récapitulatif

Votre agent a maintenant une mémoire documentaire. Vous savez découper des documents, les transformer en embeddings, les indexer, faire une recherche sémantique, et — surtout — emballer cette recherche dans un outil pour que l’agent l’utilise seul et réponde à partir de faits, pas de suppositions. C’est la brique qui rend l’Assistant Teranga digne de confiance. Gardez en tête la règle d’or : un agent RAG ne vaut jamais mieux que les documents qu’on lui donne à indexer — soignez vos sources, et la qualité des réponses suivra naturellement.

🧾 Aide-mémoire

Élément Rôle
RecursiveCharacterTextSplitter Découper les documents en morceaux cohérents
OpenAIEmbeddings / OllamaEmbeddings Transformer le texte en vecteurs de sens
InMemoryVectorStore.add_documents() Indexer les morceaux
index.similarity_search(q, k=2) Retrouver les k morceaux les plus proches
@tool autour de la recherche Laisser l’agent décider quand fouiller la base

💪 À vous de jouer

Ajoutez un document sur les moyens de paiement acceptés (mobile money : Orange Money, Moov Money ; espèces à la livraison), réindexez, et reposez la question « Acceptez-vous le mobile money ? ». L’agent doit maintenant répondre précisément.

Voir une solution
docs.append(Document(page_content=(
    "Paiement : nous acceptons Orange Money, Moov Money et les espèces à la livraison. "
    "Aucun paiement en plusieurs fois."), metadata={"source": "paiement"}))

morceaux = decoupeur.split_documents(docs)
index = InMemoryVectorStore(embeddings)
index.add_documents(morceaux)   # réindexation complète

print(assistant.invoke({"messages": [
    {"role": "user", "content": "Acceptez-vous le mobile money ?"}
]})["messages"][-1].content)

Comme l’agent référence l’objet index via l’outil, il suffit de réindexer pour qu’il connaisse le nouveau document — sans toucher au reste du code.

Tutoriels frères

Pour aller plus loin

FAQ

Q : C’est quoi un embedding, en une phrase ?
R : Une liste de nombres qui représente le sens d’un texte, de sorte que deux textes proches par le sens aient des listes proches — ce qui permet de chercher « par idée » et non par mot exact.

Q : Le RAG empêche-t-il complètement les réponses inventées ?
R : Il les réduit fortement quand la consigne impose de répondre à partir des passages retrouvés. Le risque résiduel vient d’une base incomplète ou de morceaux mal découpés — d’où l’importance de soigner vos documents et votre découpage.

Q : Dois-je réindexer à chaque ajout de document ?
R : Avec une vraie base vectorielle, vous ajoutez seulement les nouveaux morceaux (add_documents) sans tout recalculer. Avec InMemoryVectorStore, qui repart de zéro à chaque exécution, la réindexation complète est la norme.

Q : Quelle taille de morceaux choisir ?
R : Il n’y a pas de chiffre magique. On commence souvent autour de 300–800 caractères avec un chevauchement de 10–20 %, puis on ajuste selon que les réponses sont trop vagues (morceaux trop gros) ou tronquées (trop petits).

مشاركة