Vous avez certainement eu envie de demander à l'IA des informations sur vos documents personnels. Sauf que, par défaut, une IA n'a aucune connaissance de ces documents. Un LLM ne connaît que ce qu'il a vu pendant son entraînement, et à la limite, l'IA est capable d'aller chercher des informations qu'elle ne connaît pas via une recherche web ou un MCP.
Le RAG (Retrieval-Augmented Generation) résout ce problème. Vous l'avez peut-être déjà utilisé sans le savoir avec NotebookLM ou quand vous glissez des documents dans un projet Claude. Il y a de bonnes chances qu'un mécanisme RAG travaille en arrière-plan. Claude l'a d'ailleurs documenté ici.
C'est quoi le RAG ?
RAG signifie Retrieval-Augmented Generation, qu'on pourrait traduire par "génération augmentée par la récupération". Dit comme ça, on s'attend à quelque chose de très complexe, mais vous allez voir que ça ne l'est pas tant que ça.
Imaginez deux façons différentes de passer un examen :
-
Sans vos notes : vous répondez uniquement avec ce que vous avez retenu. Si vous avez mal révisé ou que des informations ne sont plus d'actualité, soit vous improvisez, soit vous dites que vous ne savez pas
-
Avec vos notes : vous avez le droit de les consulter, en cherchant à la bonne page, puis vous rédigez votre réponse
Comme on l'a vu précédemment, un LLM utilisé seul répond avec ce qu'il a appris pendant son entraînement, c'est tout. À nuancer quand même, car maintenant le LLM peut aller faire des recherches sur le web ou utiliser un MCP.
Le principe a été formalisé en 2020 par des chercheurs de Meta AI.
Concrètement, le mécanisme se résume en trois étapes :
-
Retrouver : chercher dans une base de connaissances les passages les plus pertinents par rapport à la question posée
-
Augmenter : insérer ces passages dans le prompt envoyé au modèle
-
Générer : rédiger la réponse en s'appuyant sur ce contexte, plutôt que sur sa seule mémoire
À noter
Le RAG déplace le problème de la mémoire du modèle vers la qualité de vos documents. Les documents doivent être exacts et bien découpés.
Pourquoi le RAG ?
Trois bonnes raisons de mettre en place du RAG plutôt que d'utiliser un LLM nu.
Les hallucinations
Face à une question dont il ne connaît pas la réponse, un LLM a tendance à générer quelque chose de plausible. En lui donnant vos documents, vous réduisez ce risque, car le modèle a la réponse sous les yeux (enfin, façon de parler).
Les données privées ou récentes
Un modèle est entraîné à un instant T sur des données publiques (normalement 😅). Il ne connaît pas vos documents internes, e-mails, etc. Le RAG permet de brancher n'importe quelle IA sur vos données, sans qu'elles aient jamais fait partie de son entraînement.
Le coût
Pourquoi ne pas simplement réentraîner le modèle sur nos documents à chaque mise à jour ? Réentraîner coûte cher en temps et en calcul, demande des compétences spécifiques, et il faudrait souvent recommencer. Avec le RAG, ajouter une information à jour se résume à l'indexer dans la base.
Votre premier RAG en 5 minutes
Si vous n'êtes pas développeur ou que vous voulez voir de quoi il retourne, on va faire un vrai RAG sans rien installer.
Nous allons utiliser un outil simple : NotebookLM, un service gratuit made in Google.
-
Rendez-vous sur https://notebooklm.google/
-
Cliquez sur Essayer NotebookLM, puis Créer un notebook
-
Glissez vos sources, pour ma part des PDF fictifs
Glisser vos sources
- Posez vos questions, vous verrez que les réponses s'appuient sur vos documents, avec de petites citations numérotées cliquables
Discussion
- Vous pouvez même utiliser le studio à droite pour générer des audios, des présentations, des tableaux de données, etc.
Studio
Tableau de données
La porte d'entrée idéale pour comprendre le RAG de manière simple mais efficace ! Il convient de regarder comment ça marche vraiment en arrière-plan.
Les embeddings
Imaginez une carte géante où chaque mot, phrase ou paragraphe reçoit une position. Les textes qui veulent dire la même chose se retrouvent proches sur la carte. Voiture et automobile sont voisins, alors que voiture et brocoli sont éloignés.
Un embedding, c'est la transformation d'un texte en une liste de nombres (un vecteur) qui capture son sens. La taille de la liste dépend du modèle utilisé, mais en moyenne, on parle de 384 à 1024 nombres.
Pourquoi le RAG a-t-il besoin de ça ? Quand on pose une question, on ne va pas chercher les mots exacts, au risque que le mot automobile ne matche pas avec voiture. On transforme la question en vecteur, puis on cherche quel passage de notre base a le vecteur le plus proche. La mesure de cette proximité s'appelle la similarité cosinus.
Le RAG s'appuie sur des modèles dédiés à cette tâche. Pour les exemples qui vont suivre, on utilisera bge-m3 via Ollama. D'ailleurs, n'hésitez pas à aller lire notre article Comment faire tourner une IA en local avec Ollama ?
Le pipeline du RAG
Le pipeline complet se décompose en quatre briques : le découpage, la vectorisation, le stockage, la récupération et la génération.
Le découpage (chunking)
On ne peut pas indexer des documents de plusieurs pages en un seul bloc. On les découpe donc en petits morceaux, généralement de quelques centaines de caractères, avec un léger chevauchement entre chaque morceau pour ne pas couper une idée en plein milieu. Pour sa lecture, l'IA a besoin de ce découpage, notamment pour gagner en précision.
On ne peut pas résumer un livre entier en une seule coordonnée. Le sens global serait dilué.
La vectorisation (embeddings)
Nous en avons parlé juste avant, chaque morceau est transformé en vecteur grâce à l'embedding.
Le stockage (base vectorielle)
Les vecteurs, accompagnés du texte d'origine, sont stockés dans une base créée pour la recherche par similarité.
La récupération et la génération
Quand une question arrive, on la vectorise aussi. On cherche les morceaux les plus proches, puis on les injecte dans le prompt envoyé au LLM.
On construit un RAG : en local et dans le cloud
Passons à la pratique avec un RAG en local puis dans le cloud :
-
En local avec Ollama : vos données ne quittent jamais votre machine, zéro coût par requête
-
Sur le cloud, par exemple avec Mistral : moins de ressources nécessaires, des modèles souvent plus performants, et dans le cas de Mistral, une souveraineté européenne
En local avec Ollama
Il faut dans un premier temps installer Ollama, le lancer et installer les deux modèles (pour l'embedding et la génération).
# Le modèle d'embedding multilingue (transforme le texte en vecteurs) ollama pull bge-m3 # Le modèle de génération (rédige la réponse finale) ollama pull llama3.2:3b
On va maintenant se créer un dossier avec un environnement virtuel, puis installer les librairies nécessaires :
uv init rag # Puis cd rag/ # Ensuite uv add ollama numpy
Vous pouvez utiliser pip si vous le souhaitez, mais si uv vous intéresse, n'hésitez pas à lire notre guide sur uv.
À noter
On utilise llama3.2:3b, un modèle volontairement léger qui tourne sur une machine modeste comme la mienne 😅. Je n'ai que 8 Go de RAM.
Commençons par une version sans base. On reste simple : une liste Python, du calcul de similarité. Par conséquent, ces vecteurs sont recalculés à chaque lancement du script.
import ollama import numpy as np # Notre mini base de connaissances (imaginons la doc interne de Patrick) documents = [ "Pour redémarrer le service, utilisez systemctl restart mon-service sur le VPS.", "Les congés doivent être posés au moins 15 jours à l'avance sur l'outil RH.", "Le mot de passe du Wi-Fi bureau se trouve dans le gestionnaire de secrets de l'équipe.", ] def embed(texte): reponse = ollama.embed(model="bge-m3", input=texte) # ollama.embed renvoie un dictionnaire dont la clé "embeddings" contient # une LISTE de vecteurs (un par texte envoyé). Comme on n'envoie qu'un seul # texte, on récupère le premier (et unique) vecteur avec [0]. return reponse["embeddings"][0] def similarite_cosinus(a, b): # Cette fonction compare deux vecteurs et # renvoie un score entre -1 et 1 (proche de 1 = sens très proche). # Et rassurez-vous : on ne la gardera pas ! Dès la version ChromaDB qui suit, # la base fait ce calcul pour nous. On pourrait aussi passer par une fonction # toute faite comme cosine_similarity de scikit-learn. a, b = np.array(a), np.array(b) return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)) # 1. On vectorise notre base une seule fois embeddings_documents = [embed(doc) for doc in documents] def repondre(question, top_k=1): # 2. On vectorise la question embedding_question = embed(question) # 3. On calcule la similarité avec chaque document scores = [similarite_cosinus(embedding_question, emb) for emb in embeddings_documents] meilleur_index = int(np.argmax(scores)) contexte = documents[meilleur_index] # 4. On augmente le prompt avec le contexte trouvé, puis on génère prompt = f"Contexte : {contexte}\n\nQuestion : {question}\nRéponds uniquement à partir du contexte." reponse = ollama.chat( model="llama3.2:3b", messages=[{"role": "user", "content": prompt}], ) return reponse["message"]["content"] print(repondre("Comment redémarrer le service ?"))
-
ollama.embed()transforme chaque document, puis la question, en vecteurs grâce au modèlebge-m3 -
similarite_cosinuspermet de calculer la similarité entre la question et chaque document vectorisé -
Le document avec le meilleur score devient le
contexte -
On utilise ce contexte dans le prompt, et
ollama.chat()génère la réponse en s'appuyant dessus avecllama3.2:3bdans notre cas
À noter
Il s'agit d'une version très simple pour voir ce qui se passe sous le capot. Il n'y a aucune base vectorielle ici, juste une liste Python stockée en mémoire.
Maintenant, nous allons voir ChromaDB, une base vectorielle simple à prendre en main. Elle permet d'écrire les vecteurs sur le disque et de gérer les calculs de similarité.
Commencez par installer ChromaDB dans vos dépendances :
uv add chromadb
Puis l'exemple de code :
import ollama import chromadb # PersistentClient écrit la base sur le disque (dossier ./ma_base_rag) client = chromadb.PersistentClient(path="./ma_base_rag") # Une collection regroupe des documents et leurs vecteurs collection = client.get_or_create_collection(name="doc_interne_patrick") documents = [ "Pour redémarrer le service, utilisez systemctl restart mon-service sur le VPS.", "Les congés doivent être posés au moins 15 jours à l'avance sur l'outil RH.", "Le mot de passe du Wi-Fi bureau se trouve dans le gestionnaire de secrets de l'équipe.", ] # On n'indexe qu'une seule fois : si la base est déjà remplie, on saute l'étape if collection.count() == 0: for i, doc in enumerate(documents): embedding = ollama.embed(model="bge-m3", input=doc)["embeddings"][0] # Chroma stocke l'embedding ET le texte d'origine collection.add(ids=[str(i)], embeddings=[embedding], documents=[doc]) print("Base indexée.") else: print("Base déjà présente sur le disque : on réutilise les vecteurs.") def repondre(question): embedding_question = ollama.embed(model="bge-m3", input=question)["embeddings"][0] # Chroma se charge de trouver le(s) document(s) le(s) plus proche(s) resultats = collection.query(query_embeddings=[embedding_question], n_results=1) contexte = resultats["documents"][0][0] prompt = f"Contexte : {contexte}\n\nQuestion : {question}\nRéponds uniquement à partir du contexte." reponse = ollama.chat(model="llama3.2:3b", messages=[{"role": "user", "content": prompt}]) return reponse["message"]["content"] print(repondre("Où trouver le mot de passe du Wi-Fi ?"))
-
chromadb.PersistentClient(path=...)ouvre ou crée une base stockée dans le dossier indiqué. C'est ce qui va remplacer notre liste en mémoire pour que les vecteurs persistent -
get_or_create_collection()récupère notre collection si elle existe déjà, et la crée sinon -
collection.add()enregistre pour chaque document son vecteur et son texte. Chroma garde les deux liés -
collection.query()s'occupe du travail de recherche, Chroma calcule les similarités et remonte les documents les plus proches -
resultats["documents"][0][0]récupère le texte du meilleur document. Le double[0]s'explique par la structure de la réponse. Chroma accepte plusieurs questions, donc le premier[0]permet de récupérer les résultats de notre question, et le second[0], le premier document de la liste retournée
Le stockage et le calcul de similarité sont bien délégués à Chroma 😎.
En cloud avec Mistral
ChromaDB se moque de savoir d'où viennent les vecteurs. Nous utiliserons l'API de Mistral pour remplacer nos modèles locaux par leurs propres modèles.
Dans un premier temps, installez la bibliothèque mistralai :
uv add mistralai
Et voici l'exemple complet :
from mistralai.client import Mistral import chromadb # La clé API se récupère sur la console Mistral, et se stocke dans une variable # d'environnement plutôt qu'en dur dans le code ! client_mistral = Mistral(api_key="votre_cle_api_mistral") client = chromadb.PersistentClient(path="./ma_base_mistral") collection = client.get_or_create_collection(name="doc_interne_patrick") documents = [ "Pour redémarrer le service, utilisez systemctl restart mon-service sur le VPS.", "Les congés doivent être posés au moins 15 jours à l'avance sur l'outil RH.", "Le mot de passe du Wi-Fi bureau se trouve dans le gestionnaire de secrets de l'équipe.", ] def embed(textes): # Mistral accepte une liste de textes et renvoie un vecteur par texte reponse = client_mistral.embeddings.create(model="mistral-embed", inputs=textes) return [donnee.embedding for donnee in reponse.data] if collection.count() == 0: # On envoie les documents d'un coup : un seul appel réseau au lieu de trois embeddings = embed(documents) collection.add( ids=[str(i) for i in range(len(documents))], embeddings=embeddings, documents=documents, ) def repondre(question): embedding_question = embed([question])[0] resultats = collection.query(query_embeddings=[embedding_question], n_results=1) contexte = resultats["documents"][0][0] prompt = f"Contexte : {contexte}\n\nQuestion : {question}\nRéponds uniquement à partir du contexte." reponse = client_mistral.chat.complete( model="mistral-small-latest", messages=[{"role": "user", "content": prompt}], ) return reponse.choices[0].message.content print(repondre("Comment redémarrer le service ?"))
Le code est quasiment identique au précédent, nous avons juste modifié le fournisseur des appels embed et chat. Une fois le principe acquis, passer du local au cloud devient un simple changement de brique.
À noter
Nous avons utilisé ChromaDB pour notre exemple, mais il existe bien d'autres bases vectorielles comme FAISS, Milvus, Pinecone, etc.
Ce que l'on vient de coder à la main, des frameworks et des plateformes le proposent avec des composants prêts à l'emploi. On retrouve par exemple LangChain, LlamaIndex, Dify ou encore Onyx.
D'ailleurs, ces outils pourraient faire l'objet de tutoriels... 🤷
Le RAG n'est pas magique : il faut bien penser à la qualité du découpage, à la qualité des documents, et il existe toujours un risque de récupération à côté de la plaque.
La suite logique ? Donner au modèle la capacité non seulement de consulter les documents, mais aussi d'agir, d'appeler des outils et d'itérer. C'est ce que l'on appelle l'Agentic RAG. On en reparle bientôt 🫡.