RAG de A à Z : récupérer le document d'abord

1. Aperçu

Les LLM ne connaissent que ce qui se trouve dans leurs données d’entraînement. Ils ne connaissent pas la politique de retour de votre magasin ou les processus internes de votre entreprise. Quand on les interroge à ce sujet, ils inventent souvent des réponses. RAG (Retrieval-Augmented Generation) résout ce problème : le système récupère d’abord les documents pertinents, puis laisse le LLM répondre en se basant sur ces documents.

Dans cet article, vous allez construire vous-même un pipeline RAG complet avec Spring Boot 3 et Spring AI. Le pipeline comprend les étapes de chargement des documents, chunking, création d’embeddings, recherche, assemblage de prompts avec sources et tests. À la fin, il y a une section sur les erreurs courantes et les cas où on ne devrait pas utiliser RAG.

2. Comment fonctionne RAG

RAG a deux phases distinctes.

La phase d’ingestion s’exécute une fois ou régulièrement :

  1. Lire les documents (Markdown, PDF, HTML…).
  2. Diviser en chunks de quelques centaines de tokens.
  3. Appeler le modèle d’embedding pour transformer chaque chunk en vecteur numérique.
  4. Stocker le vecteur et le contenu du chunk dans un vector store.

La phase de requête (query) s’exécute à chaque fois qu’un utilisateur pose une question :

  1. Convertir la question en vecteur.
  2. Trouver les chunks dont les vecteurs sont les plus proches (similarity search).
  3. Insérer ces chunks dans le prompt. C’est l’étape augment.
  4. Envoyer le prompt au LLM pour générer une réponse. C’est l’étape generate.

L’idée centrale est très simple. Le LLM n’a pas besoin de « mémoriser » vos documents, il doit juste lire le bon passage directement dans le prompt.

3. Préparation du projet

Vous ajoutez le BOM Spring AI et le starter OpenAI. Ce starter fournit à la fois le modèle de chat et le modèle d’embedding.

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-bom</artifactId>
            <version>1.0.0</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-starter-model-openai</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

Ensuite, la configuration du modèle :

spring.ai.openai.api-key=${OPENAI_API_KEY}
spring.ai.openai.chat.options.model=gpt-4o-mini
spring.ai.openai.chat.options.temperature=0.1
spring.ai.openai.embedding.options.model=text-embedding-3-small

Vous devriez fixer la temperature basse. Avec RAG, l’objectif est de répondre fidèlement aux documents, pas de créer.

Enfin, vous déclarez deux beans : un vector store et un ChatClient.

@Configuration
public class RagConfig {

    @Bean
    VectorStore vectorStore(EmbeddingModel embeddingModel) {
        return SimpleVectorStore.builder(embeddingModel).build();
    }

    @Bean
    ChatClient chatClient(ChatClient.Builder builder) {
        return builder.build();
    }
}

SimpleVectorStore stocke tous les vecteurs en mémoire. Il convient pour l’apprentissage et la démo. EmbeddingModel est auto-configuré par Spring AI à partir du starter OpenAI. Chaque fois que vous ajoutez un document, le store appelle ce modèle pour générer le vecteur.

Remarque : SimpleVectorStore perd toutes ses données au redémarrage de l’application et effectue une recherche linéaire. Pour la production, vous devriez utiliser PGvector, Qdrant, Elasticsearch… Spring AI a un starter pour chacun, et l’interface VectorStore reste la même donc le code ci-dessous n’a pas besoin de changer.

4. Chargement des documents

Supposons que vous ayez un fichier src/main/resources/docs/return-policy.md :

# Politique de retour

Les clients peuvent retourner les produits dans les 30 jours suivant la réception.
Le produit doit conserver son sceau, son étiquette et le reçu.
Le remboursement sera traité sur le compte dans 5–7 jours ouvrables.
Les articles d'hygiene et les produits réduits de plus de 50% ne peuvent pas être retournés.

4.1. Lecture et chunking

@Component
public class DocumentIngestor implements ApplicationRunner {

    private final VectorStore vectorStore;
    private final ResourcePatternResolver resolver;

    public DocumentIngestor(VectorStore vectorStore, ResourcePatternResolver resolver) {
        this.vectorStore = vectorStore;
        this.resolver = resolver;
    }

    @Override
    public void run(ApplicationArguments args) throws IOException {
        // chunkSize=300 tokens, minChunkSizeChars=100, minChunkLengthToEmbed=5,
        // maxNumChunks=10000, keepSeparator=true
        var splitter = new TokenTextSplitter(300, 100, 5, 10_000, true);

        for (Resource file : resolver.getResources("classpath:docs/*.md")) {
            var reader = new TextReader(file);
            reader.getCustomMetadata().put("source", file.getFilename());

            List<Document> chunks = splitter.apply(reader.get());
            vectorStore.add(chunks);
            System.out.printf("Chargé %s : %d chunks%n", file.getFilename(), chunks.size());
        }
    }
}

Le code ci-dessus effectue les opérations suivantes :

  • ApplicationRunner s’exécute une fois après le démarrage du contexte, de sorte que les documents sont toujours chargés avant la première requête.
  • TextReader lit le fichier en un Document. Nous attachons la métadonnée source comme nom du fichier pour l’utiliser lors de la citation de la source à l’étape suivante.
  • TokenTextSplitter divise le texte en chunks d’environ 300 tokens. Les métadonnées sont copiées dans chaque chunk.
  • vectorStore.add(...) appelle le modèle d’embedding pour chaque chunk puis stocke le vecteur.

Au démarrage, vous verrez :

Chargé return-policy.md : 1 chunks
Chargé shipping.md : 3 chunks

4.2. Choix de la taille des chunks

La taille du chunk affecte directement la qualité de la réponse :

  • Chunks trop grands : un chunk contenant plusieurs sujets, le vecteur est « dilué » donc difficile à faire correspondre précisément. Le prompt consomme aussi plus de tokens.
  • Chunks trop petits : la phrase perd le contexte en coupant. Par exemple « dans les 30 jours » se trouve dans un chunk, tandis que « retour » se trouve dans un autre.

Environ 200–500 tokens est un point de départ raisonnable pour les documents en prose. Pour les documents bien structurés (FAQ, index), vous devriez découper selon les en-têtes de sorte que chaque chunk soit une idée complète.

5. Récupération des documents pertinents

L’étape de récupération ne nécessite qu’un seul appel :

List<Document> docs = vectorStore.similaritySearch(
        SearchRequest.builder()
                .query("Pendant combien de temps puis-je retourner?")
                .topK(4)
                .similarityThreshold(0.5)
                .build());
  • query : la question. Le vector store embedded automatiquement la question en utilisant le même modèle utilisé lors du chargement.
  • topK(4) : récupère au maximum 4 chunks les plus proches.
  • similarityThreshold(0.5) : exclut les chunks avec une similarité cosinus inférieure à 0,5. Sans seuil, le store retourne toujours suffisamment de résultats topK, même s’ils ne sont pas pertinents pour la question.

Il n’y a pas de seuil optimal pour tous les cas. Vous devriez enregistrer les scores avec quelques dizaines de questions réelles, puis choisir un seuil qui sépare les bons résultats du bruit.

6. Assemblage du prompt et génération de réponse

6.1. Constructeur de prompt

Séparer l’assemblage du prompt en une fonction pure facilitera les tests :

public final class PromptBuilder {

    private static final String TEMPLATE = """
            Vous êtes un assistant du service client.
            Répondez uniquement en vous basant sur le CONTEXTE ci-dessous.
            Si le contexte ne contient pas d'informations, répondez exactement :
            "Je ne trouve pas cette information dans la documentation."
            À la fin de votre réponse, citez la source entre crochets, par exemple [return-policy.md].

            CONTEXTE :
            {context}

            QUESTION : {question}
            """;

    private PromptBuilder() {}

    public static String build(String question, List<Document> docs) {
        String context = docs.stream()
                .map(d -> "[" + d.getMetadata().getOrDefault("source", "unknown") + "]\n" + d.getText())
                .collect(Collectors.joining("\n---\n"));
        return TEMPLATE.replace("{context}", context)
                       .replace("{question}", question);
    }
}

Il y a trois points importants dans le template :

  1. « Répondez uniquement en vous basant sur le CONTEXTE » limite le LLM aux documents, réduisant les hallucinations.
  2. Une phrase de refus fixe donne au LLM une sortie valide quand la documentation n’a pas l’information.
  3. Chaque chunk a un nom de source associé, ce qui permet au LLM de citer la source et à l’utilisateur de vérifier.

Ici, nous utilisons replace au lieu de String.formatted. Raison : le contenu du document peut contenir le caractère % (par exemple « réduction de 50% »), et formatted lèverait UnknownFormatConversionException en le rencontrant.

6.2. RagService

@Service
public class RagService {

    static final String NOT_FOUND = "Je ne trouve pas cette information dans la documentation.";

    private final VectorStore vectorStore;
    private final ChatClient chatClient;

    public RagService(VectorStore vectorStore, ChatClient chatClient) {
        this.vectorStore = vectorStore;
        this.chatClient = chatClient;
    }

    public String answer(String question) {
        List<Document> docs = vectorStore.similaritySearch(
                SearchRequest.builder()
                        .query(question)
                        .topK(4)
                        .similarityThreshold(0.5)
                        .build());

        if (docs == null || docs.isEmpty()) {
            return NOT_FOUND;
        }

        return chatClient.prompt()
                .user(PromptBuilder.build(question, docs))
                .call()
                .content();
    }
}

Quand aucun chunk n’est trouvé, le service rejette immédiatement la requête sans appeler le LLM. Cela économise des coûts tout en éliminant la possibilité que le LLM hallucine à partir de connaissances générales.

Enfin, le contrôleur :

@RestController
public class AskController {

    private final RagService ragService;

    public AskController(RagService ragService) {
        this.ragService = ragService;
    }

    @GetMapping("/ask")
    public String ask(@RequestParam String q) {
        return ragService.answer(q);
    }
}

Maintenant, essayez d’appeler l’API :

curl -G "http://localhost:8080/ask" --data-urlencode "q=Pendant combien de temps puis-je retourner?"
curl -G "http://localhost:8080/ask" --data-urlencode "q=Vendez-vous des ordinateurs portables?"
Vous pouvez retourner dans les 30 jours suivant la réception, à condition que le produit conserve son sceau, son étiquette et son reçu. [return-policy.md]
Je ne trouve pas cette information dans la documentation.

La première réponse adhère étroitement au document et cite la source. La deuxième réponse n’a aucun document correspondant, le système refuse donc plutôt que de halluciner.

7. Écriture de tests

Vous ne devriez pas appeler le LLM réel dans un test unitaire car c’est lent, coûteux et instable. À la place, testez les deux parties déterministes : le constructeur de prompt et la branche de refus quand il n’y a pas de document.

class RagServiceTest {

    @Test
    void promptContainsContextSourceAndQuestion() {
        var doc = new Document("Retour dans les 30 jours.", Map.of("source", "return-policy.md"));

        String prompt = PromptBuilder.build("Retour pendant combien de temps?", List.of(doc));

        assertThat(prompt)
                .contains("[return-policy.md]")
                .contains("Retour dans les 30 jours.")
                .contains("QUESTION: Retour pendant combien de temps?")
                .doesNotContain("{context}", "{question}");
    }

    @Test
    void percentSignInDocumentDoesNotBreakPrompt() {
        var doc = new Document("Réduction de 50% ou plus non remboursable.", Map.of("source", "return-policy.md"));

        assertThat(PromptBuilder.build("Articles en solde?", List.of(doc)))
                .contains("50% ou plus");
    }

    @Test
    void returnsNotFoundWithoutCallingLlmWhenNoDocuments() {
        VectorStore vectorStore = mock(VectorStore.class);
        ChatClient chatClient = mock(ChatClient.class);
        when(vectorStore.similaritySearch(any(SearchRequest.class))).thenReturn(List.of());

        String answer = new RagService(vectorStore, chatClient).answer("Vendez-vous des ordinateurs portables?");

        assertThat(answer).isEqualTo(RagService.NOT_FOUND);
        verifyNoInteractions(chatClient);
    }
}

Le troisième test vérifie deux choses. Premièrement, la réponse est bien le refus. Deuxièmement, ChatClient n’est pas du tout appelé. Si quelqu’un supprime accidentellement la branche isEmpty(), le test échouera immédiatement.

Pour évaluer la qualité de la récupération, vous pouvez écrire un test d’intégration séparé. Préparez un ensemble de questions avec le nom de fichier source attendu, puis vérifiez si ce fichier se trouve dans les résultats du topK. Ce test devrait s’exécuter régulièrement, pas à chaque build.

8. Simplification avec QuestionAnswerAdvisor

Une fois que vous comprenez chaque étape, vous pouvez utiliser l’advisor fourni par Spring AI. Cet advisor encapsule la récupération et l’assemblage du prompt. Vous devez ajouter la dépendance :

<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-advisors-vector-store</artifactId>
</dependency>
String answer = chatClient.prompt()
        .advisors(QuestionAnswerAdvisor.builder(vectorStore)
                .searchRequest(SearchRequest.builder().topK(4).similarityThreshold(0.5).build())
                .build())
        .user("Pendant combien de temps puis-je retourner?")
        .call()
        .content();

L’advisor embedded automatiquement la question, trouve les chunks et les insère dans le prompt selon le template par défaut. C’est pratique, mais difficile à contrôler le format de la source et la branche de refus précoce. Par conséquent, l’approche d’écriture personnalisée à la section 6 reste pertinente quand vous en avez besoin.

9. Erreurs courantes et quand ne pas utiliser RAG

Changer le modèle d’embedding sans recharger les données. Les vecteurs de différents modèles ne sont pas comparables. Chaque fois que vous changez de modèle, vous devez réindexer tous les documents.

Ne pas définir similarityThreshold. Le vector store retourne toujours suffisamment de résultats topK. Le LLM reçoit alors un contexte erroné et répond de manière incorrecte avec confiance.

Recharger les documents à chaque démarrage avec un store persistant. Avec PGvector, le code ApplicationRunner ci-dessus créera des doublons après chaque déploiement. Vous devriez assigner un ID fixe à chaque chunk, par exemple un hash du contenu et du nom de fichier, ou supprimer par source avant de recharger.

Surcharger le prompt avec trop de chunks. Augmenter topK à 20 n’améliore pas la réponse. Le LLM omet facilement les informations au milieu d’un contexte long, et le coût augmente avec le nombre de tokens.

Faire confiance aveuglément au contenu des documents. Si les documents proviennent d’utilisateurs, ils pourraient contenir des commandes comme « ignorer toutes les instructions précédentes » (prompt injection). Vous devez contrôler la source des données autorisées à être chargées dans le store.

Remarque : RAG ne convient pas à tous les problèmes. Les questions qui nécessitent une synthèse de toutes les données, comme « combien de commandes ont été retournées en septembre », devraient utiliser une requête SQL. La recherche de similarité ne retourne que quelques chunks, impossible de compter ou de résumer. Si tous vos documents ne font que quelques pages, les mettre directement dans le prompt est généralement plus simple et plus précis qu’un pipeline RAG complet.

10. Conclusion

RAG sépare les connaissances du modèle. Les documents se trouvent dans le vector store, et le LLM ne lit que la partie pertinente pour chaque question. Dans cet article, vous avez construit les deux phases complètes : l’ingestion de documents (lecture, chunking, embedding, stockage) et les requêtes (recherche avec seuil, assemblage du prompt avec sources, appel du LLM). Vous avez aussi écrit des tests pour le constructeur de prompt et la branche de refus.

À partir de cette base, vous pouvez poursuivre dans ces directions : remplacer SimpleVectorStore par PGvector, découper les chunks selon les en-têtes, filtrer par métadonnées (par exemple ne chercher que dans les documents d’un département), ou ajouter une étape de reranking pour réordonner les résultats avant de les insérer dans le prompt.

ragspring-aillmvector-storespring-boot