Les LLMs ont besoin de preuves : RAG expliqué en détail avec exemples
1. Vue d’ensemble
Aucun LLM, aussi puissant soit-il, ne connaît naturellement le contenu des documents internes de votre entreprise — il ne sait que ce qui est fourni en entrée. Cet article explique le mécanisme de Retrieval-Augmented Generation (RAG) : pourquoi en avons-nous besoin, comment fonctionne la récupération, et pourquoi la qualité de la réponse dépend presque entièrement de l’étape de recherche avant la génération du texte. À la fin, vous comprendrez clairement le flux de données dans un système RAG et serez capable d’écrire vous-même un pipeline de récupération simple en Java.
2. Le problème : les LLMs n’ont pas de « mémoire » des données privées
Le modèle a été entraîné sur un ensemble de données publiques, figé à un moment donné. Il ne connaît pas la nouvelle politique de remboursement promulguée la semaine dernière par votre entreprise, et encore moins le contenu d’un quelconque contrat interne.
Il y a deux façons d’intégrer des informations privées :
- Injecter directement dans le prompt — ne fonctionne que si le document est petit, compatible avec la fenêtre de contexte.
- Récupération (retrieval) — quand la collection de documents est importante, l’application sélectionne seulement les passages les plus pertinents avant de les injecter dans le prompt.
// Approche 1 : injecter tout le document — fonctionne seulement avec des textes courts
String prompt = """
En vous basant sur la politique suivante, répondez à la question du client.
Politique : %s
Question : %s
""".formatted(fullPolicyText, userQuestion);
Cette approche est simple mais ne scale pas : la fenêtre de contexte a une limite de tokens, et plus vous injectez de texte non pertinent, plus le modèle tend à s’égarer (lost in the middle). Quand la collection compte des milliers de pages, vous êtes obligé d’utiliser la récupération.
3. Qu’est-ce que RAG et son fonctionnement
RAG (Retrieval-Augmented Generation) divise le problème en deux étapes indépendantes :
- Retrieval : trouver les passages de texte pertinents pour la question.
- Generation : le LLM utilise ces passages pour générer une réponse, avec des citations de sources.
Question de l'utilisateur
│
▼
[Modèle d'embedding] ── convertir la question en vecteur
│
▼
[Base de données vectorielle] ── trouver les passages sémantiquement les plus proches
│
▼
[Passages originaux] ── injecter le texte brut dans le prompt
│
▼
[LLM] ── générer une réponse + citations de sources
Le point clé : le LLM ne « consulte » pas directement la base de données vectorielle. Il ne reçoit que du texte brut (plain text) fourni par l’application après la récupération. La base de données vectorielle n’est qu’un outil de recherche, pas un lieu de stockage de « connaissances » que le modèle lit.
3.1. Embedding — convertir la question en nombres
Un modèle d’embedding convertit un passage de texte en un vecteur de nombres réels (par exemple en 1536 dimensions). Deux passages avec une sémantique proche produiront des vecteurs proches dans cet espace.
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai-spring-boot-starter</artifactId>
<version>1.0.0</version>
</dependency>
@Service
public class PolicyEmbeddingService {
private final EmbeddingModel embeddingModel;
public PolicyEmbeddingService(EmbeddingModel embeddingModel) {
this.embeddingModel = embeddingModel;
}
public float[] embed(String text) {
EmbeddingResponse response = embeddingModel.embedForResponse(List.of(text));
return response.getResult().getOutput();
}
}
embed() reçoit un passage de texte (par exemple : « Comment fonctionne la politique d’annulation d’hôtel ? ») et retourne un tableau float[] — un vecteur représentant la sémantique de cette phrase. C’est ce vecteur qui sera comparé avec ceux déjà stockés dans la base de données vectorielle.
3.2. Base de données vectorielle — trouver les passages sémantiquement proches
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-pgvector-store-spring-boot-starter</artifactId>
<version>1.0.0</version>
</dependency>
@Service
public class PolicyRetrievalService {
private final VectorStore vectorStore;
public PolicyRetrievalService(VectorStore vectorStore) {
this.vectorStore = vectorStore;
}
public void indexPolicy(String policyId, String content) {
Document document = new Document(content, Map.of("policyId", policyId));
vectorStore.add(List.of(document));
}
public List<Document> search(String question) {
SearchRequest request = SearchRequest.query(question).withTopK(3);
return vectorStore.similaritySearch(request);
}
}
indexPolicy()stocke un passage de politique dans la base de données vectorielle, accompagné de métadonnées (policyId) pour identifier plus tard le document source de ce passage.search()reçoit la question de l’utilisateur, effectue automatiquement un embedding en interne, puis trouve les 3 passages sémantiquement les plus proches (topK(3)).
Résultat de search(« Comment fonctionne la politique d'annulation d'hôtel ? ») :
1. « Les clients peuvent annuler gratuitement jusqu'à 24 heures avant... »
2. « Cas de réservation non remboursable (non-refundable)... »
3. « Frais d'annulation applicables aux réservations de groupe de 5 personnes ou plus... »
3.3. Generation — le LLM répond en s’appuyant sur les preuves
@Service
public class PolicyAnswerService {
private final ChatClient chatClient;
private final PolicyRetrievalService retrievalService;
public PolicyAnswerService(ChatClient.Builder builder,
PolicyRetrievalService retrievalService) {
this.chatClient = builder.build();
this.retrievalService = retrievalService;
}
public String answer(String question) {
List<Document> passages = retrievalService.search(question);
String context = passages.stream()
.map(Document::getContent)
.collect(Collectors.joining("\n---\n"));
String prompt = """
Basez-vous uniquement sur les passages suivants pour répondre. Si l'information est insuffisante, dites-le clairement.
Passages :
%s
Question : %s
""".formatted(context, question);
return chatClient.prompt(prompt).call().content();
}
}
Notez l’instruction « Basez-vous uniquement sur les passages suivants » — c’est une contrainte importante pour réduire les hallucinations : le modèle est forcé de s’en tenir aux preuves (evidence) plutôt que d’inventer à partir de ses connaissances d’entraînement.
Note : Sans une instruction explicite « basez-vous uniquement sur le contexte », le modèle peut toujours mélanger ses connaissances de base avec le contenu récupéré, conduisant à une réponse qui semble plausible mais qui s’écarte du document réel.
4. Pourquoi une mauvaise récupération entraîne une mauvaise réponse
C’est la partie souvent négligée : le LLM n’est doué que pour interpréter ce qu’il reçoit, il n’a aucun moyen de vérifier lui-même si l’information est correcte ou suffisante.
Deux situations courantes :
Situation 1 — La récupération ramène des documents obsolètes. Si la base de données vectorielle indexe toujours une ancienne version de la politique (non mise à jour ou supprimée), le système retournera un passage obsolète. Le LLM lit ce passage et répond avec une confiance totale — mais c’est une confiance erronée.
Situation 2 — La récupération manque de contexte crucial. Supposons que le document source ait cette structure :
Article 5. Politique d'annulation de chambre
Les clients peuvent annuler gratuitement jusqu'à 24 heures avant l'heure d'arrivée.
Article 5.1. Exception
Pour les réservations pendant les vacances du Têt, l'annulation gratuite ne s'applique pas ;
les frais d'annulation sont fixes à 50% de la valeur de la réservation.
Si l’étape de fractionnement (chunking) sépare « Article 5 » et « Article 5.1 » en deux chunks indépendants, et que la récupération ne ramène que le chunk « Article 5 » (parce que la question ne contient pas le mot « Têt » ou « exception »), le LLM répondra « les clients peuvent annuler gratuitement jusqu’à 24 heures » — cela semble très plausible, mais omet la condition d’exception cruciale.
@Test
void quandChunkExceptionManque_LLMOmetteLaCondition() {
// Simuler une récupération qui retourne seulement 1 chunk, en omettant la partie exception
List<Document> incompletePassages = List.of(
new Document("Les clients peuvent annuler gratuitement jusqu'à 24 heures avant l'arrivée.")
);
String answer = fakeAnswerService.answerFromPassages(
"Je dois annuler ma réservation pendant le Têt, y a-t-il des frais ?",
incompletePassages
);
// La réponse sera incorrecte car il manque des preuves sur l'exception de période de fête
assertThat(answer).doesNotContain("50%");
}
Ce test ne vise pas à piéger le LLM — il illustre que le problème réside dans la récupération, pas dans la capacité de raisonnement du modèle. Si vous fournissez suffisamment de preuves (les deux chunks), la réponse sera correcte même avec un modèle intermédiaire.
5. Chunking : la clé pour une récupération « au bon niveau de détail »
Comme l’exemple précédent le montre, un mauvais chunking a des conséquences graves, c’est donc l’endroit où investir le plus lors de la construction d’un système RAG.
@Service
public class PolicyChunkingService {
public List<String> splitByHeading(String document) {
// Fractionner selon le motif « Article X » ou « Article X.Y » pour conserver le contexte parent-enfant
String[] rawChunks = document.split("(?=Article \\d+)");
return Arrays.stream(rawChunks)
.map(String::trim)
.filter(chunk -> !chunk.isBlank())
.toList();
}
}
Au lieu de fractionner sur un nombre de caractères fixe (par exemple couper tous les 500 caractères — ce qui risque de couper entre une clause et son exception), cette fonction fractionne selon des limites sémantiques (« Article X »). Cela garantit que chaque chunk conserve une unité d’information complète, réduisant le risque de séparer une règle principale de ses exceptions.
@Test
void splitByHeading_DoitIncluireArticleEtException() {
String document = """
Article 5. Politique d'annulation de chambre
Les clients peuvent annuler gratuitement avant 24 heures.
Article 5.1. Exception
Pendant les vacances du Têt, les frais d'annulation sont fixes à 50%.
""";
List<String> chunks = chunkingService.splitByHeading(document);
assertThat(chunks).hasSize(2);
assertThat(chunks.get(1)).contains("Exception", "50%");
}
Note : Le chunking par en-tête n’est efficace que si le document a une structure claire (contrats, règlements, documentation technique). Pour le texte libre (e-mails, journaux de chat), vous avez besoin d’une stratégie différente — par exemple fractionner par paragraphes avec chevauchement, ou utiliser le semantic chunking basé sur la similarité entre phrases consécutives.
6. Quand NE PAS utiliser RAG
RAG n’est pas une solution universelle :
- Collection de documents petite et fixe : si tous les documents ne font que quelques milliers de tokens, les injecter directement dans le prompt est plus simple et moins sujet à erreurs qu’une pipeline entière de récupération.
- Questions nécessitant un raisonnement multi-étapes complexe où la réponse ne réside pas directement dans un seul passage — RAG trouve « le passage ressemblant à la question », pas « le passage contenant la réponse déduite ».
- Données changeant continuellement à chaque seconde (cours boursiers, inventaire en temps réel) : préférez appeler directement une API (function calling) plutôt que de ré-indexer continuellement la base de données vectorielle.
7. Conclusion
Les LLMs ne « connaissent » pas naturellement vos données privées — ils excellent uniquement à interpréter les preuves fournies. RAG résout ce problème en séparant la récupération (trouver les bons passages) de la génération (produire une réponse), avec le modèle d’embedding et la base de données vectorielle comme lien. La qualité finale de la réponse dépend presque entièrement de la qualité de votre indexation et chunking — aucun LLM puissant ne peut sauver une étape de récupération médiocre. Si vous construisez un système RAG, investissez du temps dans la vérification du chunking et du taux de récupération correct/incorrect avant de penser à passer à un modèle plus grand.