Blog | WeScale

RAG : l’art de sélectionner ce que voit votre LLM

Rédigé par Axel Chauvin | 03/11/2026

Introduction

Demandez à un LLM combien de jours de télétravail autorise votre entreprise. Il vous répondra avec aplomb, et probablement à côté. Il n’a jamais lu votre politique de télétravail. Un modèle ne connaît que ce qu’il a vu pendant son entraînement, figé à une date donnée, et vos documents internes n’en font pas partie.

La solution la plus évidente consiste à coller vos documents directement dans le prompt. Ça fonctionne, jusqu’à un certain point. Au-delà de quelques pages, ça devient lent, coûteux, et paradoxalement moins fiable.

Le RAG (Retrieval-Augmented Generation, ou génération augmentée par la recherche) repose sur une idée simple : plutôt que de tout donner au modèle, on va chercher les quelques passages utiles à la question posée, et on ne lui transmet que ceux-là. Dans cet article, nous allons construire pas à pas un RAG minimal autour d’un fil rouge : la FAQ interne de Nimbus, une start-up fictive. À la fin, vous aurez un système fonctionnel qui tourne sur votre machine, sans GPU ni clé API.

1. Le principe « select » : choisir ce qui entre dans le contexte

Tout ce qu’un LLM « voit » au moment de répondre tient dans sa fenêtre de contexte : vos instructions, l’historique de la conversation, les documents fournis. Cette fenêtre a une taille limitée, mesurée en tokens, et c’est la seule information dont le modèle dispose en plus de ce qu’il a appris à l’entraînement.

Le context engineering consiste à gérer ce qui entre dans cette fenêtre. Cet article porte sur l’une de ses stratégies, select : ne faire entrer dans le contexte que l’information pertinente pour la tâche en cours.

Pourquoi ne pas tout envoyer, maintenant que les fenêtres de contexte atteignent des centaines de milliers de tokens ? Pour trois raisons.

  • Le coût d’abord : chaque token envoyé se paie, en euros avec une API ou en temps de calcul en local.

  • La latence ensuite : plus le contexte est long, plus la réponse met de temps à arriver.

  • La qualité enfin, et c’est la raison la moins intuitive : un modèle noyé sous l’information l’exploite moins bien. Les passages hors sujet le distraient, et les études montrent qu’une information placée au milieu d’un long contexte est moins bien prise en compte que la même information présentée seule.

Autrement dit, on gagne davantage à bien choisir le contexte qu’à l’allonger. Pensez à un bibliothécaire : face à votre question, il revient avec les trois livres qui vous seront utiles.

Reste une question : comment savoir quels passages sont pertinents ? La recherche par mots-clés montre vite ses limites. Si un salarié de Nimbus demande « Je peux bosser à distance ? », le document qui lui répond parle de « télétravail » et ne contient aucun des mots de la question. Il nous faut un moyen de comparer des textes selon leur sens. Les embeddings servent à ça.

2. Les embeddings : comparer des textes par leur sens

Un embedding est une représentation d’un texte sous forme de vecteur, une liste de quelques centaines de nombres. On l’obtient avec un modèle spécialisé, le modèle d’embedding, distinct du LLM qui rédigera la réponse finale. Pris isolément, ces nombres ne veulent rien dire. Mais le modèle a été entraîné pour que des textes de sens proche produisent des vecteurs proches.

L’intuition la plus simple est géométrique. Chaque texte devient un point sur une carte. « Je peux bosser à distance ? » et « Politique de télétravail » se retrouvent voisins, alors qu’ils n’ont aucun mot en commun. « Procédure de note de frais » atterrit à l’autre bout. La vraie carte compte des centaines de dimensions, mais le principe ne change pas : chercher un texte pertinent revient à chercher les points les plus proches.

Pour mesurer cette proximité, on utilise le plus souvent la similarité cosinus : un score qui se rapproche de 1 quand deux textes vont dans la même direction de sens. Vérifions-le en quelques lignes :

from sentence_transformers import SentenceTransformer, util

model = SentenceTransformer("paraphrase-multilingual-MiniLM-L12-v2")

question = "Je peux bosser à distance ?"
docs = [
"Les salariés peuvent télétravailler jusqu'à trois jours par semaine.",
"Les notes de frais doivent être déposées avant le 5 du mois.",
]

print(util.cos_sim(model.encode(question), model.encode(docs)))

Résultat : 0,27 pour la phrase sur le télétravail, 0,08 pour celle sur les notes de frais. Le modèle a bien compris que « bosser à distance » se rapproche de « télétravailler », sans aucun mot en commun. Ne vous laissez pas troubler par ces valeurs faibles : un score de similarité n’est pas un pourcentage de pertinence. Sa valeur absolue varie selon le modèle et la formulation des textes. Seul le classement compte, et c’est lui qui exploite un RAG.

Les embeddings ne sont pas magiques pour autant. Ils retiennent le sens, beaucoup moins la lettre : une référence produit comme « NMB-4021 » ou un nom de serveur exact seront mal retrouvés, là où une bonne vieille recherche par mots-clés excelle. Ils dépendent aussi de la langue et du domaine d’entraînement du modèle, d’où le choix ici d’un modèle multilingue. Combiner les deux approches, ce qu’on appelle la recherche hybride, fera l’objet d’un prochain article.

3. Le pipeline RAG minimal : chunk, embed, retrieve, inject

Un RAG combine le principe « select » et les embeddings en deux phases, qui se déroulent à deux moments différents.

L’indexation se fait une seule fois, en amont, avant que quiconque pose une question. On découpe les documents en petits morceaux (chunk), on calcule l’embedding de chacun (embed), et on range le tout dans un index : un catalogue où chaque morceau de texte est associé à son vecteur.

La requête se fait à chaque question. On calcule l’embedding de la question avec le même modèle, on cherche dans l’index les morceaux les plus proches (retrieve), puis on les transmet au LLM avec la question (inject). Le LLM répond en s’appuyant sur ces passages.

Pourquoi cette séparation ? Parce que vectoriser des milliers de documents prend du temps, alors que vectoriser une question est quasi instantané. En faisant le travail lourd une seule fois, chaque question ne coûte ensuite qu’un embedding et une recherche. Reprenons notre bibliothécaire : l’indexation, c’est lui qui range et étiquette tous les livres le matin. La requête, c’est lui qui retrouve les bons livres en quelques secondes quand un lecteur arrive avec sa question. Sans le rangement du matin, chaque question l’obligerait à feuilleter toute la bibliothèque.

Pour construire ce pipeline, trois briques open source suffisent, toutes utilisables sur un laptop ordinaire, sans GPU ni clé API :

  • sentence-transformers pour calculer les embeddings, avec le modèle multilingue vu plus haut ;

  • NumPy comme index : pour quelques centaines de documents, un tableau en mémoire fait l’affaire ;

  • Ollama pour faire tourner un petit LLM en local, qui rédigera les réponses.

Pour tout installer, dans un environnement virtuel Python :

pip install torch --index-url https://download.pytorch.org/whl/cpu
pip install sentence-transformers numpy ollama
ollama pull llama3.1:8b

La première ligne installe PyTorch en version CPU uniquement, bien plus légère que la version par défaut. Ollama s’installe séparément, depuis ollama.com. Pour le LLM, ne descendez pas trop en taille : avec un modèle d’un milliard de paramètres, nous avons obtenu des réponses correctes mais des citations d’extraits erronées. Un modèle de 7 à 8 milliards de paramètres comme Llama 3.1 8B (environ 5 Go, 8 Go de RAM conseillés) tourne sur un laptop récent et suit bien les consignes.

Côté données, notre fil rouge est la FAQ interne de Nimbus : huit questions-réponses sur le télétravail, les congés, les notes de frais ou le support informatique, dans un fichier faq_nimbus.md reproduit en annexe. Assez pour voir le RAG à l’œuvre, assez court pour tout vérifier à l’œil.

Les extraits de code qui suivent s’enchaînent dans un même script, qui commence par ces imports :

import numpy as np
import ollama
from sentence_transformers import SentenceTransformer

Étape 1 : chunk, découper les documents

Pourquoi découper ? Pourquoi ne pas calculer un embedding par document entier ?

Parce qu’un embedding résume tout le sens d’un texte en un seul point. Pour une phrase ou un paragraphe, ce point est précis. Pour un document de vingt pages qui aborde le télétravail, les congés et la mutuelle, il devient une moyenne floue qui ne ressemble à aucun de ces sujets, et la recherche ne le trouvera pour aucun d’entre eux.

Deuxième raison, plus terre à terre : les modèles d’embedding ont une longueur maximale. Celui que nous utilisons ne lit que les 128 premiers tokens d’un texte, soit environ 80 à 100 mots en français. Au-delà, il tronque sans prévenir. Un chunk trop long, c’est du contenu silencieusement ignoré, qui ne sera jamais retrouvé.

La règle d’or du découpage : respecter les frontières naturelles du contenu. Pour une FAQ, c’est évident : une question et sa réponse forment une unité de sens, donc un chunk. Notre fichier sépare chaque entrée par une ligne vide, le découpage tient donc en deux lignes :

with open("faq_nimbus.md", encoding="utf-8") as f:

chunks = [c.strip() for c in f.read().split("\n\n") if c.strip()]

Nous voilà avec huit chunks, un par entrée de la FAQ.

Pour des documents longs et peu structurés (un compte rendu, une documentation en un seul bloc), on découpe généralement en morceaux de taille fixe, avec un chevauchement : les dernières phrases d’un chunk sont répétées au début du suivant. Ainsi, une idée coupée en deux par le découpage reste entière dans au moins un des deux morceaux.

C’est l’étape la moins spectaculaire du pipeline, et pourtant la plus déterminante : aucun modèle, aussi bon soit-il, ne retrouvera correctement une information noyée dans un chunk qui mélange trois sujets. Un mauvais découpage ruine tout le reste.

Étape 2 : embed, vectoriser les chunks

Nos chunks sont prêts, il faut maintenant calculer leurs embeddings. Le code est court :

model = SentenceTransformer("paraphrase-multilingual-MiniLM-L12-v2")
chunk_vectors = model.encode(chunks, normalize_embeddings=True)

On obtient un tableau de 8 lignes et 384 colonnes : une ligne par chunk, chaque ligne étant le vecteur qui représente son sens. Ce tableau, c’est notre index.

L’option normalize_embeddings=True ramène chaque vecteur à une longueur de 1. Seule sa direction compte pour mesurer la similarité, et cette normalisation va nous simplifier le calcul à l’étape suivante.

Autre règle, celle-ci absolue : les documents et les questions doivent être vectorisés avec le même modèle. Chaque modèle construit sa propre « carte » du sens. Comparer un vecteur produit par un modèle avec un vecteur produit par un autre revient à comparer des coordonnées GPS avec des coordonnées d’un plan de métro : les chiffres existent, mais ils ne veulent rien dire ensemble. Corollaire : si vous changez de modèle, il faut réindexer tous vos documents.

Notez aussi que notre index vit en mémoire : il est recalculé à chaque lancement du script. Avec huit chunks, cela prend une fraction de seconde. Avec des milliers de documents, on voudra le calculer une fois et le sauvegarder, et c’est là qu’interviennent les bases vectorielles comme Chroma, Qdrant ou pgvector. Elles stockent les vecteurs, les retrouvent rapidement même à très grande échelle, et permettent d’ajouter des documents sans tout recalculer.

Étape 3 : retrieve, retrouver les passages pertinents

L’index est prêt, on bascule dans la phase de requête. Quand une question arrive, il faut trouver les chunks dont le sens est le plus proche :

def retrieve(question, k=3):
q = model.encode(question, normalize_embeddings=True)
scores = chunk_vectors @ q
best = np.argsort(scores)[::-1][:k]
return [chunks[i] for i in best]

Quatre lignes, quatre opérations. On vectorise la question, avec le même modèle et la même normalisation que les chunks. On calcule sa similarité avec chaque chunk : comme tous les vecteurs ont une longueur de 1, la similarité cosinus se réduit à un produit scalaire, que l’opérateur @ de NumPy calcule pour les huit chunks d’un coup. On trie les chunks du plus proche au plus éloigné. Et on garde les k premiers.

Le « retrieval », souvent présenté comme la partie mystérieuse du RAG, se résume donc à un calcul de similarité suivi d’un tri.

Le choix de k est un compromis. Trop petit, et on risque de manquer une information répartie sur plusieurs chunks. Trop grand, et on réinjecte du bruit dans le contexte, ce que le principe « select » cherche à éviter. Pour une petite FAQ, trois passages est un bon point de départ.

Attention à une limite importante de cette version minimale : retrieve renvoie toujours k passages, même quand aucun ne répond à la question. Demandez-lui si l’on peut venir au bureau avec son chien, et il vous renverra consciencieusement les trois chunks les moins éloignés, sans aucun rapport avec le sujet. On ne peut pas non plus se contenter d’un seuil de score, puisqu’on a vu que leur valeur absolue est peu fiable. Ce sera donc au LLM de faire le tri, et c’est tout l’enjeu de l’étape suivante. Des techniques plus robustes existent, comme le reranking, qui réévalue finement les meilleurs candidats avec un second modèle. Nous les verrons dans un prochain article.

Étape 4 : inject, construire le prompt

C’est l’étape la plus sous-estimée du pipeline. Le LLM, lui, ne voit qu’un texte : le prompt, dans lequel on a placé les passages retrouvés. La qualité de la réponse dépend donc directement de la façon dont ce prompt est construit.

def build_prompt(question, passages):
context = "\n\n".join(f"[{i + 1}] {p}" for i, p in enumerate(passages))
return f"""Réponds à la question en t'appuyant uniquement sur les extraits ci-dessous.
Indique entre crochets les numéros des extraits utilisés.
Si la réponse ne figure pas dans les extraits, dis simplement que tu ne sais pas.

Extraits :
{context}

Question : {question}"""

Les passages sont numérotés, puis insérés dans un modèle de prompt avec la question. Chacune des trois consignes a un rôle précis.

« Uniquement sur les extraits » empêche le modèle de compléter avec ses connaissances générales. Sans cette consigne, il pourrait mélanger la politique de télétravail de Nimbus avec ce qu’il a lu sur celle d’autres entreprises.

« Indique les numéros des extraits » rend la réponse vérifiable. C’est grâce à la numérotation des passages qu’on peut demander ces citations, et elles permettent à l’utilisateur de remonter à la source en cas de doute.

« Dis que tu ne sais pas » est la consigne la plus importante. On l’a vu, notre retrieve renvoie toujours trois passages, pertinents ou non. Un LLM à qui l’on n’offre pas cette porte de sortie fera ce qu’il sait faire de mieux : produire une réponse plausible. Autoriser explicitement le « je ne sais pas » est la meilleure protection contre les hallucinations.

Dernier détail : la question est placée à la fin, après les extraits. Le modèle lit ainsi le contexte avant de savoir ce qu’on lui demande, puis répond avec la question fraîchement en tête. C’est une pratique courante qui améliore généralement la qualité des réponses.

Et enfin : générer la réponse

Il ne reste plus qu’à envoyer ce prompt au LLM. Avec Ollama, un appel de fonction suffit :

def answer(question):
prompt = build_prompt(question, retrieve(question))
response = ollama.chat(model="llama3.1:8b", messages=[{"role": "user", "content": prompt}])
return response["message"]["content"]

print(answer("Je dois rendre mes notes de frais quand ?"))
print(answer("Est-ce que je peux venir au bureau avec mon chien ?"))

Sur notre machine, le modèle répond à la première question : « Avant le 5 du mois suivant la dépense [1]. » Une réponse exacte, avec sa source. À la seconde : « Je ne sais pas. Les extraits ne parlent pas de l’accès au bureau avec un animal. » La porte de sortie a fonctionné : les trois passages renvoyés par retrieve n’avaient rien à voir avec les chiens, et le modèle ne s’est pas laissé piéger.

Les réponses d’un LLM varient légèrement d’un lancement à l’autre : vous n’obtiendrez pas forcément ces phrases au mot près, mais le comportement doit être le même.

4. La sélection d’outils : le même principe, ailleurs

Jusqu’ici, nous avons sélectionné des passages de documents. Mais le principe « select » s’applique à tout ce qui entre dans le contexte. Les outils en sont un bon exemple.

Pour qu’un agent puisse agir (ouvrir un ticket, poser un congé, interroger une base), on lui fournit des outils, et on place dans son contexte la description de chacun : son nom, son rôle, ses paramètres. C’est ce que font les serveurs MCP. Avec trois outils, aucun problème. Mais brancher quelques serveurs MCP suffit à en exposer des dizaines, et chaque description consomme des tokens à chaque requête. On retrouve alors les symptômes de la partie 1 : un contexte encombré, plus coûteux, et un modèle qui choisit moins bien, en particulier quand plusieurs outils se ressemblent.

On traite donc les descriptions d’outils comme des documents. Imaginons que l’assistant de Nimbus dispose de nombreux outils :

tools = {
"creer_ticket_it": "Ouvre un ticket au support informatique pour signaler une panne ou un problème matériel.",
"poser_conges": "Pose une demande de congés ou de RTT dans le SIRH.",
"deposer_note_frais": "Dépose une note de frais avec ses justificatifs dans Expensia.",
"reserver_salle": "Réserve une salle de réunion pour une date et un créneau donnés.",
# ... et des dizaines d'autres
}

tool_vectors = model.encode(list(tools.values()), normalize_embeddings=True)

On indexe les descriptions une fois pour toutes. À chaque demande, on retrouve les outils les plus proches avec la même logique que notre fonction retrieve, et on n’injecte que ceux-là dans le contexte. Un salarié écrit « mon écran ne s’allume plus » ? L’agent ne voit que creer_ticket_it et deux ou trois voisins, au lieu de la liste complète.

Deux points d’attention. D’abord, la qualité des descriptions devient critique : elles sont désormais des documents à retrouver par similarité. Une description vague comme « gère les demandes IT » sera mal classée, une description précise qui reprend le vocabulaire des utilisateurs le sera bien. Ensuite, certains outils doivent rester toujours disponibles, quelle que soit la demande : un outil de recherche générale ou d’escalade vers un humain, par exemple. On les ajoute systématiquement à la sélection.

Chunk, embed, retrieve, inject : le pipeline reste le même, seul l’objet de la sélection change.

Conclusion

En une trentaine de lignes, nous avons construit un RAG complet : découper les documents, les vectoriser, retrouver les passages proches d’une question et les transmettre à un LLM avec des consignes claires. Mis bout à bout, les extraits de code de cet article forment un script prêt à lancer avec la FAQ reproduite en annexe.

Le principe qui sous-tend ce code s’appelle select : ne faire entrer dans le contexte que ce qui est utile à la tâche. Le RAG documentaire en est l’application la plus connue, la sélection d’outils en est une autre, et vous en croiserez bien d’autres.

Le RAG n’est pas pour autant une réponse universelle. Quand vos données sont déjà structurées et indexées, brancher le modèle sur un moteur de recherche existant peut faire mieux, pour moins d’effort. C’est ce que nous expliquions dans notre article sur le serveur MCP de documentation : commencez par le problème, pas par la techno.

Notre pipeline est volontairement minimal. Il ne sait pas combiner sens et mots-clés, ne vérifie pas la pertinence de ce qu’il retrouve, et nous n’avons aucun moyen de mesurer sa qualité. Recherche hybride, reranking, évaluation : ce seront les sujets des prochains articles de cette série.

Take Away

Plus de contexte n’est pas un meilleur contexte. Un modèle noyé sous l’information l’exploite moins bien. Sélectionnez.

Un embedding mesure le sens, pas les mots. C’est sa force pour comprendre « bosser à distance », sa faiblesse pour retrouver « NMB-4021 ».

Le score compte moins que le classement. Une similarité de 0,27 peut désigner le meilleur passage. Méfiez-vous des seuils fixés au doigt mouillé.

Le découpage fait ou défait un RAG. Respectez les frontières naturelles de vos contenus, et surveillez la limite de longueur de votre modèle.

Le prompt est votre garde-fou. Autoriser le « je ne sais pas » est la meilleure protection contre les hallucinations.

Select s’applique à tout ce qui entre dans le contexte. Les documents comme les outils.

Annexe : la FAQ de Nimbus

Copiez ce contenu dans un fichier faq_nimbus.md, placé dans le même dossier que votre script :

## Combien de jours de télétravail sont autorisés ?
Les salariés de Nimbus peuvent télétravailler jusqu'à trois jours par semaine, après accord de leur manager. Le mardi est un jour de présence obligatoire au bureau pour toute l'équipe.

## Comment poser des congés ou des RTT ?
Les demandes de congés et de RTT se font dans le SIRH, au moins deux semaines à l'avance pour une absence de plus de trois jours. Le manager valide la demande sous 48 heures.

## Comment me faire rembourser une note de frais ?
Les notes de frais se déposent dans Expensia avec les justificatifs scannés, avant le 5 du mois suivant la dépense. Le remboursement est versé avec le salaire du mois.

## Que faire si mon ordinateur tombe en panne ?
Ouvrez un ticket sur le portail du support IT ou écrivez dans le canal Slack #it-help. Un ordinateur de prêt est fourni sous 24 heures si la réparation prend plus d'une journée.

## Comment obtenir un accès au VPN ?
La demande se fait sur le portail du support IT et doit être validée par votre manager. Une fois l'accès accordé, vous recevez un fichier de configuration WireGuard par e-mail.

## Comment fonctionne la mutuelle ?
L'adhésion à la mutuelle d'entreprise est obligatoire et Nimbus en prend en charge 60 %. Une dispense est possible si vous êtes déjà couvert par la mutuelle de votre conjoint.

## Ai-je droit à des formations ?
Chaque salarié dispose d'un budget formation de 1 500 € par an. La demande se fait auprès de votre manager, qui la transmet à l'équipe RH.

## Comment réserver une salle de réunion ?
Les salles se réservent depuis l'agenda partagé, en ajoutant la salle comme invitée à la réunion. Une réservation non utilisée dans les 15 minutes est automatiquement libérée.