RAG de A à Z : construire un système de question-réponse sur vos documents

Niveau du tutoriel : Expert
🟡 Intermédiaire⏱️ 22 min

📋 Sommaire

  1. Introduction : qu’est-ce que le RAG et pourquoi l’utiliser ?
  2. RAG vs Fine-tuning : comprendre les différences fondamentales
  3. Architecture d’un système RAG
  4. Les embeddings : transformer le texte en vecteurs
  5. Stratégies de chunking : découper intelligemment vos documents
  6. Vector stores : ChromaDB en pratique
  7. Le Retriever : trouver les passages pertinents
  8. Le Generator : produire des réponses contextuelles
  9. Code complet : pipeline RAG de bout en bout
  10. Évaluation d’un système RAG
  11. Optimisations avancées
  12. Conclusion et prochaines étapes

1. Introduction : qu’est-ce que le RAG et pourquoi l’utiliser ?

Le RAG (Retrieval-Augmented Generation) est une architecture qui combine la recherche d’information (retrieval) avec la génération de texte par un LLM. L’idée fondamentale est simple mais puissante : plutôt que de s’appuyer uniquement sur les connaissances “figées” dans les poids d’un modèle, on enrichit chaque requête avec des documents pertinents extraits d’une base de connaissances externe.

Imaginez un expert qui, avant de répondre à chaque question, consulte rapidement sa bibliothèque personnelle pour retrouver les passages les plus pertinents, puis formule sa réponse en s’appuyant sur ces sources. C’est exactement ce que fait un système RAG : il cherche, il lit, puis il répond — avec des sources vérifiables.

Les cas d’usage du RAG sont innombrables et en pleine expansion. Les entreprises l’utilisent pour créer des assistants de support technique qui répondent à partir de la documentation produit, des chatbots RH qui connaissent les conventions collectives, des assistants juridiques capables de citer les textes de loi pertinents, ou encore des outils d’analyse financière qui s’appuient sur les rapports annuels.

Dans ce tutoriel complet, nous allons construire un système RAG de A à Z avec Python, LangChain et ChromaDB. À la fin, vous aurez un pipeline fonctionnel capable de répondre à des questions sur vos propres documents avec des citations précises.

💡 Astuce

Le RAG est particulièrement adapté quand vos données changent fréquemment (documentation technique, FAQ, base légale) ou quand la traçabilité des sources est critique (domaines réglementés). Contrairement au fine-tuning, le RAG ne nécessite aucun entraînement de modèle — vous pouvez mettre à jour votre base de connaissances en temps réel.

2. RAG vs Fine-tuning : comprendre les différences fondamentales

Avant de plonger dans l’implémentation, il est essentiel de comprendre quand utiliser le RAG plutôt que le fine-tuning (et vice versa). Ces deux approches répondent à des problématiques différentes et sont souvent complémentaires.

Le RAG excelle quand :

Le fine-tuning est préférable quand :

En pratique, les architectures les plus robustes en production combinent souvent les deux : un modèle fine-tuné pour le style et le raisonnement de base, augmenté par du RAG pour les connaissances factuelles et dynamiques. Cette approche hybride offre le meilleur des deux mondes.

⚠️ Attention

Un piège courant est de croire que le RAG résout tous les problèmes de connaissance. Si le LLM ne comprend pas fondamentalement votre domaine (vocabulaire, logique de raisonnement), même avec les bons documents en contexte, il peut produire des réponses incorrectes. Dans ce cas, combinez RAG + fine-tuning.

3. Architecture d’un système RAG

Un système RAG se compose de deux phases principales : l’indexation (offline) et l’inférence (online). Comprendre cette architecture est fondamental pour construire un système efficace.

Phase 1 : Indexation (offline)

Cette phase transforme vos documents bruts en une base de données vectorielle interrogeable. Elle se décompose en quatre étapes :

  1. Chargement (Loading) : lecture des documents sources (PDF, Word, HTML, Markdown, CSV, etc.)
  2. Découpage (Chunking) : division des documents en segments de taille appropriée
  3. Embedding : transformation de chaque segment en vecteur numérique via un modèle d’embedding
  4. Stockage (Indexing) : insertion des vecteurs dans une base de données vectorielle (vector store)

Phase 2 : Inférence (online)

À chaque requête utilisateur, le système exécute un pipeline en trois étapes :

  1. Embedding de la requête : la question est transformée en vecteur avec le même modèle d’embedding
  2. Retrieval : recherche des k segments les plus proches dans le vector store (similarité cosinus)
  3. Generation : le LLM génère une réponse en utilisant la question + les segments récupérés comme contexte

Cette architecture est parfois résumée par le schéma : Question → Embed → Search → Context + Question → LLM → Réponse. Chaque composant peut être optimisé indépendamment, ce qui rend le système modulaire et flexible.

4. Les embeddings : transformer le texte en vecteurs

Les embeddings sont au cœur du RAG. Un modèle d’embedding transforme un texte (phrase, paragraphe, document) en un vecteur dense de nombres réels, typiquement de dimension 384 à 1536. La propriété clé : des textes sémantiquement proches produisent des vecteurs proches dans l’espace vectoriel.

Le choix du modèle d’embedding impacte directement la qualité du retrieval et donc la qualité globale du système RAG. Voici les principaux modèles disponibles en 2026 :

Python
from sentence_transformers import SentenceTransformer
import numpy as np

# Charger un modèle d'embedding multilingue
model = SentenceTransformer("BAAI/bge-m3")

# Exemples de textes
textes = [
    "Comment configurer un serveur Nginx en reverse proxy ?",
    "La mise en place d'un proxy inverse avec Nginx",
    "Recette de la tarte aux pommes traditionnelle",
    "Les bases de données vectorielles pour l'IA",
]

# Générer les embeddings
embeddings = model.encode(textes, normalize_embeddings=True)

# Calculer la matrice de similarité cosinus
similarity_matrix = np.dot(embeddings, embeddings.T)

print("Matrice de similarité cosinus :")
for i, t1 in enumerate(textes):
    for j, t2 in enumerate(textes):
        if i < j:
            print(f"  [{i}] vs [{j}]: {similarity_matrix[i][j]:.4f}")
            # [0] vs [1]: 0.8234 — très similaires (même sujet)
            # [0] vs [2]: 0.1123 — très différents
            # [0] vs [3]: 0.3456 — légèrement liés (tech)

💡 Astuce

Pour un corpus en français, privilégiez des modèles multilingues comme BGE-M3 ou CamemBERT plutôt que des modèles anglais-only. La différence de qualité en retrieval français est significative (10-20 % de recall en plus). Si votre budget le permet, OpenAI text-embedding-3-large avec le paramètre dimensions=1024 offre un excellent compromis qualité/coût.

5. Stratégies de chunking : découper intelligemment vos documents

Le chunking est l'art de découper vos documents en segments exploitables par le retriever. C'est souvent l'étape la plus sous-estimée et pourtant l'une des plus impactantes sur la qualité finale du système RAG. Un mauvais chunking conduit à des résultats de recherche non pertinents, quel que soit la qualité de votre modèle d'embedding.

Principes fondamentaux du chunking :

Voici les principales stratégies de chunking, de la plus simple à la plus sophistiquée :

Python
from langchain.text_splitter import (
    RecursiveCharacterTextSplitter,
    MarkdownHeaderTextSplitter,
    TokenTextSplitter,
)
from langchain_experimental.text_splitter import SemanticChunker
from langchain_openai import OpenAIEmbeddings


# ============================================================
# Stratégie 1 : Découpage récursif par caractères (recommandé pour commencer)
# ============================================================
recursive_splitter = RecursiveCharacterTextSplitter(
    chunk_size=1000,           # Taille cible en caractères
    chunk_overlap=200,         # Chevauchement entre chunks
    length_function=len,
    separators=["\n\n", "\n", ". ", " ", ""],  # Hiérarchie de séparateurs
    is_separator_regex=False,
)

text = """
La tokenisation est le processus de découpage d'un texte en unités élémentaires 
appelées tokens. Les tokens peuvent être des mots, des sous-mots ou des caractères 
selon l'algorithme utilisé.

Les algorithmes les plus courants sont BPE (Byte-Pair Encoding), WordPiece et 
SentencePiece. BPE est utilisé par GPT, WordPiece par BERT, et SentencePiece 
par LLaMA et T5.

Le choix de la tokenisation impacte directement les performances du modèle. Un 
vocabulaire trop petit force le modèle à découper les mots en nombreux sous-tokens, 
augmentant la longueur des séquences. Un vocabulaire trop grand augmente la taille 
des embeddings et risque de sous-entraîner les tokens rares.
"""

chunks = recursive_splitter.split_text(text)
for i, chunk in enumerate(chunks):
    print(f"Chunk {i} ({len(chunk)} chars): {chunk[:80]}...")


# ============================================================
# Stratégie 2 : Découpage par en-têtes Markdown (structurel)
# ============================================================
headers_to_split = [
    ("#", "Titre principal"),
    ("##", "Section"),
    ("###", "Sous-section"),
]

markdown_splitter = MarkdownHeaderTextSplitter(
    headers_to_split_on=headers_to_split,
    strip_headers=False,
)

markdown_doc = """
# Guide de déploiement

## Prérequis
Il faut installer Docker et docker-compose sur votre serveur.
La version minimale de Docker est 20.10.

## Installation
### Étape 1 : Cloner le repository
Utilisez git clone pour récupérer le code source.

### Étape 2 : Configuration
Copiez le fichier .env.example en .env et modifiez les variables.
"""

md_chunks = markdown_splitter.split_text(markdown_doc)
for chunk in md_chunks:
    print(f"Section: {chunk.metadata} → {chunk.page_content[:60]}...")


# ============================================================
# Stratégie 3 : Découpage sémantique (avancé)
# ============================================================
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
semantic_splitter = SemanticChunker(
    embeddings,
    breakpoint_threshold_type="percentile",
    breakpoint_threshold_amount=75,  # Seuil de changement de sujet
)

# Le chunker sémantique détecte automatiquement les changements
# de sujet dans le texte et coupe aux frontières sémantiques
sem_chunks = semantic_splitter.split_text(text)


# ============================================================
# Stratégie 4 : Chunking personnalisé avec métadonnées enrichies
# ============================================================
from dataclasses import dataclass
from typing import List, Dict
import hashlib


@dataclass
class EnrichedChunk:
    content: str
    metadata: Dict
    embedding: List[float] = None
    
    @property
    def id(self) -> str:
        return hashlib.md5(self.content.encode()).hexdigest()


def smart_chunk_document(
    text: str,
    source: str,
    chunk_size: int = 800,
    chunk_overlap: int = 150,
) -> List[EnrichedChunk]:
    """Découpe un document avec métadonnées enrichies."""
    splitter = RecursiveCharacterTextSplitter(
        chunk_size=chunk_size,
        chunk_overlap=chunk_overlap,
    )
    
    raw_chunks = splitter.split_text(text)
    enriched = []
    
    for i, chunk in enumerate(raw_chunks):
        enriched.append(EnrichedChunk(
            content=chunk,
            metadata={
                "source": source,
                "chunk_index": i,
                "total_chunks": len(raw_chunks),
                "char_count": len(chunk),
                "word_count": len(chunk.split()),
                "position": "start" if i == 0 else "end" if i == len(raw_chunks)-1 else "middle",
            }
        ))
    
    return enriched

⚠️ Attention

La taille de chunk idéale dépend de votre cas d'usage et de votre modèle d'embedding. Les modèles comme BGE-M3 gèrent bien des chunks de 512-1024 tokens, tandis que all-MiniLM-L6-v2 perd en qualité au-delà de 256 tokens. Testez toujours plusieurs tailles sur vos données réelles — c'est souvent le paramètre qui a le plus d'impact sur la qualité du retrieval.

6. Vector stores : ChromaDB en pratique

Un vector store (base de données vectorielle) est le cœur du stockage et de la recherche dans un système RAG. Il stocke les embeddings et permet des recherches par similarité vectorielle en temps quasi-réel, même sur des millions de documents.

ChromaDB est un excellent choix pour débuter : open-source, simple d'utilisation, embeddé (pas de serveur séparé pour les petits projets), et avec un mode client-serveur pour la production. Voici comment l'utiliser en détail :

Python
import chromadb
from chromadb.config import Settings
from chromadb.utils import embedding_functions

# ============================================================
# Mode 1 : Client embeddé (développement/prototypage)
# ============================================================
client = chromadb.PersistentClient(path="./chroma_db")

# Configurer la fonction d'embedding
# Option A : Sentence Transformers (local, gratuit)
embedding_fn = embedding_functions.SentenceTransformerEmbeddingFunction(
    model_name="BAAI/bge-m3",
    device="cuda",  # ou "cpu"
)

# Option B : OpenAI (API, payant mais simple)
# embedding_fn = embedding_functions.OpenAIEmbeddingFunction(
#     api_key="sk-...",
#     model_name="text-embedding-3-small",
# )

# Créer une collection
collection = client.get_or_create_collection(
    name="documentation_technique",
    embedding_function=embedding_fn,
    metadata={
        "hnsw:space": "cosine",       # Métrique de distance
        "hnsw:M": 32,                  # Connexions par nœud (qualité ↑)
        "hnsw:construction_ef": 200,   # Effort de construction (qualité ↑)
    }
)

# ============================================================
# Insérer des documents
# ============================================================
documents = [
    "Docker est une plateforme de conteneurisation qui permet d'empaqueter des applications.",
    "Kubernetes orchestre les conteneurs Docker à grande échelle en production.",
    "FastAPI est un framework web Python moderne, rapide et facile à utiliser.",
    "Les embeddings transforment le texte en vecteurs numériques pour la recherche sémantique.",
    "Le monitoring avec Prometheus et Grafana est essentiel pour les applications en production.",
    "CI/CD signifie Continuous Integration / Continuous Deployment.",
    "Les microservices sont une architecture où l'application est divisée en services indépendants.",
    "gRPC est un framework RPC haute performance développé par Google.",
]

metadatas = [
    {"source": "docker_guide.md", "category": "infrastructure"},
    {"source": "k8s_intro.md", "category": "infrastructure"},
    {"source": "fastapi_docs.md", "category": "backend"},
    {"source": "ml_basics.md", "category": "machine_learning"},
    {"source": "monitoring.md", "category": "devops"},
    {"source": "cicd_guide.md", "category": "devops"},
    {"source": "architecture.md", "category": "backend"},
    {"source": "grpc_guide.md", "category": "backend"},
]

ids = [f"doc_{i}" for i in range(len(documents))]

# Insertion par batch
collection.add(
    documents=documents,
    metadatas=metadatas,
    ids=ids,
)
print(f"✅ {collection.count()} documents insérés")

# ============================================================
# Recherche par similarité
# ============================================================
results = collection.query(
    query_texts=["comment déployer une application Python ?"],
    n_results=3,
    include=["documents", "metadatas", "distances"],
)

print("\n🔍 Résultats de recherche :")
for i in range(len(results["ids"][0])):
    print(f"  [{i+1}] Score: {1 - results['distances'][0][i]:.4f}")
    print(f"      Source: {results['metadatas'][0][i]['source']}")
    print(f"      Texte: {results['documents'][0][i][:80]}...")

# ============================================================
# Recherche avec filtres sur les métadonnées
# ============================================================
filtered_results = collection.query(
    query_texts=["monitoring et observabilité"],
    n_results=3,
    where={"category": "devops"},  # Filtrer par catégorie
    include=["documents", "metadatas", "distances"],
)

# ============================================================
# Mode 2 : Client-serveur (production)
# ============================================================
# Démarrer le serveur : chroma run --host 0.0.0.0 --port 8000
# client_prod = chromadb.HttpClient(host="localhost", port=8000)

Au-delà de ChromaDB, d'autres vector stores méritent votre attention selon vos besoins de production :

7. Le Retriever : trouver les passages pertinents

Le retriever est le composant qui, étant donné une question, retrouve les k chunks les plus pertinents dans le vector store. La qualité du retriever détermine directement la qualité des réponses : si le retriever ne trouve pas les bons passages, le LLM n'aura pas les informations nécessaires pour répondre correctement.

Au-delà de la recherche vectorielle simple (nearest neighbor search), plusieurs techniques avancées améliorent significativement la qualité du retrieval :

1. Recherche hybride (dense + sparse). Combine la recherche vectorielle (sémantique) avec la recherche lexicale classique (BM25). La recherche vectorielle excelle pour capturer le sens, tandis que BM25 est imbattable pour les termes exacts, les noms propres et les acronymes. La combinaison des deux avec un score pondéré (Reciprocal Rank Fusion) donne les meilleurs résultats.

2. Re-ranking. Après une première recherche (retrieval) qui retourne les top-k résultats, un modèle de re-ranking (cross-encoder) réévalue chaque paire (question, passage) avec une attention croisée. Le re-ranking est plus lent mais beaucoup plus précis que la similarité cosinus. C'est le secret des meilleurs systèmes RAG en production.

3. Multi-query retrieval. Le LLM génère plusieurs reformulations de la question originale, effectue une recherche pour chacune, puis fusionne les résultats. Cela augmente la couverture et réduit le risque de manquer un passage pertinent à cause d'une formulation malheureuse de la question.

Python
from langchain.retrievers import (
    ContextualCompressionRetriever,
    MultiQueryRetriever,
    EnsembleRetriever,
)
from langchain.retrievers.document_compressors import CrossEncoderReranker
from langchain_community.cross_encoders import HuggingFaceCrossEncoder
from langchain_community.retrievers import BM25Retriever
from langchain_community.vectorstores import Chroma
from langchain_openai import ChatOpenAI, OpenAIEmbeddings


# ============================================================
# Retriever hybride (Dense + BM25) avec Re-ranking
# ============================================================

# 1. Retriever dense (vectoriel)
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
vectorstore = Chroma(
    persist_directory="./chroma_db",
    embedding_function=embeddings,
    collection_name="documentation_technique",
)
dense_retriever = vectorstore.as_retriever(search_kwargs={"k": 10})

# 2. Retriever sparse (BM25)
# Charger tous les documents pour BM25
all_docs = vectorstore.get()
bm25_retriever = BM25Retriever.from_texts(
    texts=all_docs["documents"],
    metadatas=all_docs["metadatas"],
)
bm25_retriever.k = 10

# 3. Ensemble retriever (fusion des résultats)
ensemble_retriever = EnsembleRetriever(
    retrievers=[dense_retriever, bm25_retriever],
    weights=[0.6, 0.4],  # Pondération dense/sparse
)

# 4. Re-ranker (cross-encoder)
reranker_model = HuggingFaceCrossEncoder(
    model_name="cross-encoder/ms-marco-MiniLM-L-12-v2"
)
reranker = CrossEncoderReranker(
    model=reranker_model,
    top_n=5,  # Garder les 5 meilleurs après re-ranking
)

# 5. Pipeline complet : recherche + re-ranking
retriever = ContextualCompressionRetriever(
    base_compressor=reranker,
    base_retriever=ensemble_retriever,
)

# Utilisation
query = "Comment configurer un healthcheck Docker ?"
results = retriever.invoke(query)

for i, doc in enumerate(results):
    print(f"[{i+1}] {doc.page_content[:100]}...")
    print(f"    Source: {doc.metadata.get('source', 'N/A')}\n")


# ============================================================
# Multi-query retriever (reformulation automatique)
# ============================================================
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0.3)

multi_retriever = MultiQueryRetriever.from_llm(
    retriever=vectorstore.as_retriever(search_kwargs={"k": 5}),
    llm=llm,
)

# Le LLM génère automatiquement des reformulations :
# "Comment configurer un healthcheck Docker ?"
# → "Docker healthcheck configuration"
# → "Vérification de santé conteneur Docker"
# → "Docker HEALTHCHECK instruction Dockerfile"
multi_results = multi_retriever.invoke(query)

8. Le Generator : produire des réponses contextuelles

Le generator est le LLM qui produit la réponse finale en combinant la question de l'utilisateur avec les passages récupérés par le retriever. La qualité du prompt système et du template de génération sont déterminants pour obtenir des réponses fiables et bien structurées.

Voici les bonnes pratiques pour le prompt de génération RAG :

Python
from langchain_openai import ChatOpenAI
from langchain.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnablePassthrough


# Template de prompt RAG optimisé
RAG_PROMPT_TEMPLATE = """Tu es un assistant technique expert. Réponds à la question 
en te basant UNIQUEMENT sur le contexte fourni ci-dessous.

Règles strictes :
1. Si l'information n'est pas dans le contexte, dis-le explicitement
2. Cite tes sources en mentionnant le document d'origine entre [crochets]
3. Structure ta réponse avec des titres et des listes si approprié
4. Sois précis et concis — pas de blabla
5. Si plusieurs sources se contredisent, mentionne les différentes perspectives

Contexte :
{context}

Question : {question}

Réponse structurée :"""

prompt = ChatPromptTemplate.from_template(RAG_PROMPT_TEMPLATE)

# Modèle de génération
llm = ChatOpenAI(
    model="gpt-4o",
    temperature=0.1,    # Basse température pour la précision factuelle
    max_tokens=1500,
)

# Fonction de formatage du contexte
def format_docs(docs):
    formatted = []
    for i, doc in enumerate(docs):
        source = doc.metadata.get("source", "inconnu")
        formatted.append(f"[Source {i+1}: {source}]\n{doc.page_content}")
    return "\n\n---\n\n".join(formatted)

# Chain RAG complète avec LCEL
rag_chain = (
    {
        "context": retriever | format_docs,
        "question": RunnablePassthrough(),
    }
    | prompt
    | llm
    | StrOutputParser()
)

# Utilisation
response = rag_chain.invoke("Comment monitorer une application FastAPI en production ?")
print(response)

💡 Astuce

Utilisez une température basse (0.0 à 0.2) pour les réponses factuelles en RAG. Une température élevée risque de faire "halluciner" le modèle en inventant des informations non présentes dans le contexte. Pour les cas où vous voulez des réponses plus créatives ou des reformulations, montez à 0.3-0.5 maximum.

9. Code complet : pipeline RAG de bout en bout

Voici un pipeline RAG complet, modulaire et prêt pour la production. Ce code intègre toutes les bonnes pratiques vues précédemment : chargement multi-format, chunking intelligent, recherche hybride, re-ranking, et génération avec citations.

Python
"""
Pipeline RAG complet — Prêt pour la production
Auteur: Sophie Laurent | Février 2026
"""

import os
import logging
from pathlib import Path
from typing import List, Optional, Dict, Any
from dataclasses import dataclass

from langchain_community.document_loaders import (
    PyPDFLoader,
    TextLoader,
    UnstructuredMarkdownLoader,
    DirectoryLoader,
    CSVLoader,
)
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_community.vectorstores import Chroma
from langchain_community.retrievers import BM25Retriever
from langchain.retrievers import EnsembleRetriever, ContextualCompressionRetriever
from langchain.retrievers.document_compressors import CrossEncoderReranker
from langchain_community.cross_encoders import HuggingFaceCrossEncoder
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from langchain.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnablePassthrough, RunnableLambda
from langchain_core.documents import Document


logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)


@dataclass
class RAGConfig:
    """Configuration centralisée du pipeline RAG."""
    # Chemins
    documents_dir: str = "./documents"
    chroma_dir: str = "./chroma_db"
    collection_name: str = "rag_collection"
    
    # Chunking
    chunk_size: int = 800
    chunk_overlap: int = 150
    
    # Retrieval
    dense_k: int = 10
    bm25_k: int = 10
    dense_weight: float = 0.6
    rerank_top_n: int = 5
    
    # Embedding
    embedding_model: str = "text-embedding-3-small"
    
    # Generation
    llm_model: str = "gpt-4o"
    llm_temperature: float = 0.1
    max_tokens: int = 2000
    
    # Re-ranking
    reranker_model: str = "cross-encoder/ms-marco-MiniLM-L-12-v2"


class RAGPipeline:
    """Pipeline RAG complet avec recherche hybride et re-ranking."""
    
    def __init__(self, config: RAGConfig = None):
        self.config = config or RAGConfig()
        self.embeddings = OpenAIEmbeddings(model=self.config.embedding_model)
        self.llm = ChatOpenAI(
            model=self.config.llm_model,
            temperature=self.config.llm_temperature,
            max_tokens=self.config.max_tokens,
        )
        self.vectorstore = None
        self.retriever = None
        self._all_docs = []
    
    # ========================================================
    # Phase 1 : Indexation
    # ========================================================
    
    def load_documents(self, directory: str = None) -> List[Document]:
        """Charge les documents depuis un répertoire (multi-format)."""
        doc_dir = directory or self.config.documents_dir
        logger.info(f"📂 Chargement des documents depuis {doc_dir}")
        
        loaders = {
            "**/*.pdf": PyPDFLoader,
            "**/*.txt": TextLoader,
            "**/*.md": UnstructuredMarkdownLoader,
            "**/*.csv": CSVLoader,
        }
        
        all_docs = []
        for glob_pattern, loader_cls in loaders.items():
            try:
                loader = DirectoryLoader(
                    doc_dir,
                    glob=glob_pattern,
                    loader_cls=loader_cls,
                    show_progress=True,
                    use_multithreading=True,
                )
                docs = loader.load()
                all_docs.extend(docs)
                logger.info(f"  ✅ {len(docs)} documents {glob_pattern}")
            except Exception as e:
                logger.warning(f"  ⚠️ Erreur {glob_pattern}: {e}")
        
        logger.info(f"📊 Total: {len(all_docs)} documents chargés")
        return all_docs
    
    def chunk_documents(self, documents: List[Document]) -> List[Document]:
        """Découpe les documents en chunks."""
        splitter = RecursiveCharacterTextSplitter(
            chunk_size=self.config.chunk_size,
            chunk_overlap=self.config.chunk_overlap,
            separators=["\n\n", "\n", ". ", ", ", " ", ""],
            length_function=len,
        )
        
        chunks = splitter.split_documents(documents)
        
        # Enrichir les métadonnées
        for i, chunk in enumerate(chunks):
            chunk.metadata["chunk_id"] = i
            chunk.metadata["char_count"] = len(chunk.page_content)
        
        logger.info(f"✂️ {len(documents)} documents → {len(chunks)} chunks")
        return chunks
    
    def index_documents(self, chunks: List[Document]):
        """Indexe les chunks dans ChromaDB."""
        logger.info("🔄 Indexation dans ChromaDB...")
        
        self.vectorstore = Chroma.from_documents(
            documents=chunks,
            embedding=self.embeddings,
            persist_directory=self.config.chroma_dir,
            collection_name=self.config.collection_name,
        )
        
        self._all_docs = chunks
        logger.info(f"✅ {len(chunks)} chunks indexés")
    
    def build_index(self, directory: str = None):
        """Pipeline d'indexation complet."""
        docs = self.load_documents(directory)
        chunks = self.chunk_documents(docs)
        self.index_documents(chunks)
        self._setup_retriever()
        return len(chunks)
    
    # ========================================================
    # Phase 2 : Retrieval
    # ========================================================
    
    def _setup_retriever(self):
        """Configure le retriever hybride avec re-ranking."""
        # Dense retriever
        dense_retriever = self.vectorstore.as_retriever(
            search_kwargs={"k": self.config.dense_k}
        )
        
        # BM25 retriever
        bm25_retriever = BM25Retriever.from_documents(self._all_docs)
        bm25_retriever.k = self.config.bm25_k
        
        # Ensemble (hybride)
        ensemble = EnsembleRetriever(
            retrievers=[dense_retriever, bm25_retriever],
            weights=[self.config.dense_weight, 1 - self.config.dense_weight],
        )
        
        # Re-ranker
        reranker = CrossEncoderReranker(
            model=HuggingFaceCrossEncoder(model_name=self.config.reranker_model),
            top_n=self.config.rerank_top_n,
        )
        
        self.retriever = ContextualCompressionRetriever(
            base_compressor=reranker,
            base_retriever=ensemble,
        )
        
        logger.info("🔍 Retriever hybride + re-ranking configuré")
    
    def load_existing_index(self):
        """Charge un index existant."""
        self.vectorstore = Chroma(
            persist_directory=self.config.chroma_dir,
            embedding_function=self.embeddings,
            collection_name=self.config.collection_name,
        )
        
        # Récupérer les docs pour BM25
        data = self.vectorstore.get(include=["documents", "metadatas"])
        self._all_docs = [
            Document(page_content=doc, metadata=meta)
            for doc, meta in zip(data["documents"], data["metadatas"])
        ]
        
        self._setup_retriever()
        logger.info(f"✅ Index chargé: {len(self._all_docs)} chunks")
    
    # ========================================================
    # Phase 3 : Generation
    # ========================================================
    
    def _format_context(self, docs: List[Document]) -> str:
        """Formate les documents récupérés en contexte."""
        formatted = []
        for i, doc in enumerate(docs):
            source = doc.metadata.get("source", "inconnu")
            source_name = Path(source).name if source != "inconnu" else source
            formatted.append(
                f"[Source {i+1}: {source_name}]\n{doc.page_content}"
            )
        return "\n\n---\n\n".join(formatted)
    
    def query(self, question: str) -> Dict[str, Any]:
        """Exécute une requête RAG complète."""
        if not self.retriever:
            raise RuntimeError("Index non chargé. Appelez build_index() ou load_existing_index().")
        
        # Récupérer les documents pertinents
        docs = self.retriever.invoke(question)
        context = self._format_context(docs)
        
        # Prompt de génération
        prompt = ChatPromptTemplate.from_template(
            """Tu es un assistant technique expert. Réponds à la question 
en te basant UNIQUEMENT sur le contexte fourni.

Règles :
1. Base ta réponse exclusivement sur le contexte
2. Cite tes sources avec [Source X]
3. Si l'info n'est pas dans le contexte, dis-le
4. Sois précis et structuré

Contexte :
{context}

Question : {question}

Réponse :"""
        )
        
        # Générer la réponse
        chain = prompt | self.llm | StrOutputParser()
        answer = chain.invoke({"context": context, "question": question})
        
        return {
            "question": question,
            "answer": answer,
            "sources": [
                {
                    "content": doc.page_content[:200],
                    "source": doc.metadata.get("source", "inconnu"),
                    "chunk_id": doc.metadata.get("chunk_id"),
                }
                for doc in docs
            ],
            "num_sources": len(docs),
        }
    
    def query_stream(self, question: str):
        """Exécute une requête RAG avec streaming."""
        docs = self.retriever.invoke(question)
        context = self._format_context(docs)
        
        prompt = ChatPromptTemplate.from_template(
            """Contexte : {context}\n\nQuestion : {question}\n\nRéponse :"""
        )
        
        chain = prompt | self.llm | StrOutputParser()
        
        for chunk in chain.stream({"context": context, "question": question}):
            yield chunk


# ============================================================
# Utilisation
# ============================================================
if __name__ == "__main__":
    # Configuration
    config = RAGConfig(
        documents_dir="./mes_documents",
        chunk_size=800,
        chunk_overlap=150,
        llm_model="gpt-4o",
        llm_temperature=0.1,
    )
    
    # Initialiser le pipeline
    rag = RAGPipeline(config)
    
    # Indexer les documents (première fois)
    rag.build_index()
    
    # Ou charger un index existant
    # rag.load_existing_index()
    
    # Poser une question
    result = rag.query("Comment configurer le monitoring Prometheus ?")
    
    print(f"\n📝 Question: {result['question']}")
    print(f"\n💬 Réponse:\n{result['answer']}")
    print(f"\n📚 Sources ({result['num_sources']}):")
    for s in result['sources']:
        print(f"  - {s['source']}: {s['content'][:80]}...")

10. Évaluation d'un système RAG

Évaluer un système RAG est plus complexe que d'évaluer un simple modèle de génération, car il faut mesurer la qualité à chaque étape du pipeline : retrieval, contexte, et génération. Le framework RAGAS (Retrieval Augmented Generation Assessment) est devenu la référence pour cette évaluation.

RAGAS propose quatre métriques complémentaires :

Python
from ragas import evaluate
from ragas.metrics import (
    faithfulness,
    answer_relevancy,
    context_precision,
    context_recall,
)
from datasets import Dataset


# Préparer le dataset d'évaluation
eval_data = {
    "question": [
        "Comment configurer un healthcheck Docker ?",
        "Quelle est la différence entre CMD et ENTRYPOINT ?",
        "Comment optimiser une image Docker ?",
    ],
    "answer": [
        rag.query("Comment configurer un healthcheck Docker ?")["answer"],
        rag.query("Quelle est la différence entre CMD et ENTRYPOINT ?")["answer"],
        rag.query("Comment optimiser une image Docker ?")["answer"],
    ],
    "contexts": [
        [doc.page_content for doc in retriever.invoke("Comment configurer un healthcheck Docker ?")],
        [doc.page_content for doc in retriever.invoke("CMD vs ENTRYPOINT Docker")],
        [doc.page_content for doc in retriever.invoke("optimiser image Docker")],
    ],
    "ground_truth": [
        "Le healthcheck Docker se configure avec l'instruction HEALTHCHECK dans le Dockerfile...",
        "CMD définit la commande par défaut, ENTRYPOINT définit l'exécutable...",
        "Utiliser des images de base légères, multi-stage builds, minimiser les layers...",
    ],
}

dataset = Dataset.from_dict(eval_data)

# Évaluer
results = evaluate(
    dataset,
    metrics=[faithfulness, answer_relevancy, context_precision, context_recall],
)

print(results)
# {'faithfulness': 0.92, 'answer_relevancy': 0.88, 
#  'context_precision': 0.85, 'context_recall': 0.78}

💡 Astuce

Visez un score de faithfulness > 0.9 en priorité — c'est la métrique la plus critique car elle mesure les hallucinations. Un score bas signifie que votre LLM invente des informations non présentes dans le contexte. Si la context_recall est faible, améliorez votre chunking et votre retriever avant de toucher au prompt de génération.

11. Optimisations avancées

Une fois votre pipeline RAG fonctionnel, plusieurs optimisations peuvent significativement améliorer ses performances.

1. Parent Document Retriever. Indexez des petits chunks pour le retrieval (précision), mais retournez le document parent complet (ou une section plus large) au LLM (contexte). Cette approche combine la précision du retrieval sur des petits segments avec le contexte riche d'un passage plus long.

2. Hypothetical Document Embeddings (HyDE). Au lieu d'embedder directement la question, demandez au LLM de générer une réponse hypothétique, puis embeddez cette réponse pour la recherche. L'intuition : un document-réponse est sémantiquement plus proche des vrais documents que la question elle-même. Cette technique améliore le recall de 10-20 % dans certains cas.

3. Self-Query Retriever. Le LLM analyse la question et extrait automatiquement des filtres de métadonnées. Par exemple, "Quels articles de 2024 parlent de Docker ?" → filtre year=2024 + recherche vectorielle "Docker". C'est particulièrement utile pour les corpus avec des métadonnées riches (date, auteur, catégorie, version).

4. Mise en cache intelligente. Cachez les embeddings des requêtes fréquentes et les résultats de retrieval. Un cache LRU (Least Recently Used) avec Redis ou un simple dictionnaire en mémoire peut réduire la latence de 50-80 % pour les requêtes récurrentes.

5. Feedback loop. Collectez le feedback utilisateur (pouce haut/bas, corrections) et utilisez-le pour améliorer itérativement le système. Les questions sans réponse satisfaisante identifient les lacunes de votre corpus. Les corrections identifient les problèmes de retrieval ou de génération.

⚠️ Attention

Ne sur-optimisez pas prématurément. Commencez par un pipeline simple, mesurez les performances avec RAGAS, identifiez le goulot d'étranglement (retrieval vs génération), puis optimisez ciblé. Un retriever parfait avec un prompt médiocre donnera de mauvais résultats, et inversement. L'approche itérative est toujours la meilleure.

12. Conclusion et prochaines étapes

Le RAG est devenu l'architecture de référence pour construire des systèmes d'IA qui s'appuient sur des connaissances spécifiques et vérifiables. En combinant la puissance de recherche des vector stores avec les capacités de raisonnement des LLMs, le RAG offre une solution pragmatique, flexible et déployable rapidement.

Les points clés à retenir de ce tutoriel :

Pour aller plus loin, explorez les architectures RAG avancées : Corrective RAG (auto-correction des résultats), Adaptive RAG (choix dynamique de la stratégie de retrieval), et GraphRAG (utilisation de knowledge graphs pour enrichir le contexte). Le domaine évolue très rapidement et de nouvelles techniques apparaissent chaque mois.

"Le RAG n'est pas une solution miracle, c'est un framework. Sa valeur réside dans la qualité de chaque composant et dans leur orchestration. Un bon RAG, c'est 30 % de technologie et 70 % d'ingénierie des données."

— Dr. Antoine Dupont, CTO chez VectorMind AI

📚 Sources

Retour à l'accueil