RAG : Retrieval-Augmented Generation#

Les grands modèles de langage possèdent une connaissance paramétrique impressionnante, acquise durant leur pré-entraînement sur des corpus massifs. Cependant, cette connaissance est figée à la date de la dernière mise à jour des données d’entraînement (knowledge cutoff), elle ne couvre pas les documents internes d’une organisation, et elle peut produire des réponses plausibles mais factuellement incorrectes — les hallucinations. Le paradigme RAG (Retrieval-Augmented Generation), introduit par Lewis et al. en 2020, propose une solution élégante : plutôt que de compter uniquement sur la mémoire paramétrique du modèle, on ancre la génération dans des documents externes récupérés dynamiquement au moment de la requête.

Ce chapitre présente le pipeline RAG complet, depuis le découpage des documents en fragments (chunking) jusqu’à la génération augmentée par la récupération, en passant par les embeddings vectoriels et la recherche de similarité avec FAISS. Nous verrons comment chaque composant influence la qualité finale de la réponse et comment évaluer un système RAG de manière rigoureuse.

Le RAG s’inscrit naturellement dans la suite du chapitre 9 sur la mémoire conversationnelle : alors que la mémoire gère le contexte d’une conversation, le RAG permet au modèle d’accéder à une base de connaissances externe potentiellement très vaste. Combinés, ces deux mécanismes donnent au LLM une capacité de raisonnement ancré dans des faits vérifiables.

Pourquoi le RAG ?#

Les LLM, aussi puissants soient-ils, souffrent de trois limitations fondamentales qui motivent l’adoption du RAG.

Définition 51 (Retrieval-Augmented Generation (RAG))

Le Retrieval-Augmented Generation (RAG) est un paradigme d’augmentation des LLM qui combine un module de récupération (retriever) et un module de génération (generator). Étant donné une requête \(q\), le système :

  1. Récupère un ensemble de \(k\) passages pertinents \(\{d_1, \ldots, d_k\}\) depuis une base de connaissances externe \(\mathcal{D}\).

  2. Augmente le prompt en concaténant les passages récupérés au contexte de la requête.

  3. Génère une réponse \(y\) conditionnée par la requête et les passages :

\[p(y \mid q) = \sum_{i=1}^{k} p(y \mid q, d_i) \, p(d_i \mid q)\]

\(p(d_i \mid q)\) est la pertinence estimée du passage \(d_i\) pour la requête \(q\).

Knowledge cutoff. Un LLM entraîné jusqu’en janvier 2024 ne sait rien des événements postérieurs. Toute question portant sur des faits récents — une nouvelle réglementation, un article scientifique publié le mois dernier — recevra une réponse obsolète ou inventée. Le RAG résout ce problème en permettant l’ajout de documents à jour sans ré-entraîner le modèle.

Hallucinations. Les LLM génèrent du texte en maximisant la vraisemblance token par token. Rien ne garantit la véracité factuelle de la sortie. Le RAG réduit les hallucinations en fournissant des passages sources que le modèle peut citer ou paraphraser.

Remarque 62

Le RAG ne supprime pas les hallucinations, mais les réduit significativement. Le modèle peut toujours ignorer le contexte fourni ou en faire une synthèse incorrecte. C’est pourquoi l”évaluation de la fidélité (faithfulness) — la cohérence de la réponse avec les passages récupérés — est une métrique centrale des systèmes RAG. En pratique, des instructions explicites dans le prompt (« Réponds uniquement à partir du contexte fourni ») améliorent la fidélité.

Connaissances spécifiques à un domaine. Une entreprise possède des documents internes — procédures, contrats, documentations techniques — qui n’ont jamais été vus par le LLM durant son entraînement. Le RAG permet d’exploiter ces connaissances privées sans fine-tuning, en les indexant dans une base vectorielle.

Hide code cell source

import numpy as np
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import seaborn as sns
import faiss
from sentence_transformers import SentenceTransformer
from tqdm.notebook import trange, tqdm

sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)
np.random.seed(42)

Hide code cell source

# Illustration : les trois limitations des LLM résolues par le RAG
fig, ax = plt.subplots(figsize=(10, 5))

limitations = ["Knowledge\ncutoff", "Hallucinations", "Connaissances\nspécifiques"]
sans_rag = [0.3, 0.4, 0.1]
avec_rag = [0.85, 0.75, 0.9]

x = np.arange(len(limitations))
width = 0.3
bars1 = ax.bar(x - width / 2, sans_rag, width, label="Sans RAG", color="#E24A33", alpha=0.85)
bars2 = ax.bar(x + width / 2, avec_rag, width, label="Avec RAG", color="#55A868", alpha=0.85)

ax.set_ylabel("Score (schématique)")
ax.set_title("Impact du RAG sur les limitations des LLM")
ax.set_xticks(x)
ax.set_xticklabels(limitations)
ax.set_ylim(0, 1.05)
ax.legend()

for bars in [bars1, bars2]:
    for bar in bars:
        h = bar.get_height()
        ax.text(bar.get_x() + bar.get_width() / 2, h + 0.02, f"{h:.0%}",
                ha='center', va='bottom', fontsize=10, fontweight='bold')
plt.show()
_images/407c0594ef482533f14aae392d1844f465e3d3bfc3035c21887d9e0a07bb3bcc.png

Pipeline RAG : vue d’ensemble#

Le pipeline RAG se décompose en deux phases distinctes : une phase d”indexation (offline) et une phase de requête (online).

Phase d’indexation. Les documents sont découpés en fragments (chunks), chaque fragment est transformé en un vecteur dense par un modèle d’embeddings, et ces vecteurs sont stockés dans un index vectoriel (par exemple FAISS).

Phase de requête. La question de l’utilisateur est encodée par le même modèle d’embeddings, les \(k\) fragments les plus similaires sont récupérés par recherche de similarité, ces fragments sont insérés dans le prompt, et le LLM génère la réponse.

Hide code cell source

# Diagramme du pipeline RAG complet
fig, ax = plt.subplots(figsize=(14, 7))
ax.set_xlim(0, 14); ax.set_ylim(0, 7); ax.axis('off')

c_index, c_query, c_gen, c_arrow = "#4C72B0", "#DD8452", "#55A868", "#333333"

ax.text(7, 6.6, "Phase d'indexation (offline)", ha='center', fontsize=13,
        fontweight='bold', color=c_index)

boxes_index = [(1, 5.5, "Documents"), (4, 5.5, "Chunking"),
               (7, 5.5, "Embeddings"), (10.5, 5.5, "Index\nvectoriel")]
for (xp, yp, lab) in boxes_index:
    ax.add_patch(mpatches.FancyBboxPatch((xp - 0.9, yp - 0.4), 1.8, 0.8,
                 boxstyle="round,pad=0.1", facecolor=c_index, edgecolor='white', alpha=0.8))
    ax.text(xp, yp, lab, ha='center', va='center', fontsize=10, fontweight='bold', color='white')
for i in range(len(boxes_index) - 1):
    ax.annotate('', xy=(boxes_index[i+1][0] - 1.0, boxes_index[i+1][1]),
                xytext=(boxes_index[i][0] + 1.0, boxes_index[i][1]),
                arrowprops=dict(arrowstyle='->', color=c_arrow, lw=1.5))

ax.text(7, 3.8, "Phase de requête (online)", ha='center', fontsize=13,
        fontweight='bold', color=c_query)

boxes_query = [(1, 2.5, "Requête"), (4, 2.5, "Embedding\nrequête"),
               (7, 2.5, "Recherche\ntop-k"), (10.5, 2.5, "Prompt\naugmenté"), (13, 2.5, "Réponse")]
for (xp, yp, lab) in boxes_query:
    c = c_gen if lab == "Réponse" else c_query
    ax.add_patch(mpatches.FancyBboxPatch((xp - 0.9, yp - 0.4), 1.8, 0.8,
                 boxstyle="round,pad=0.1", facecolor=c, edgecolor='white', alpha=0.8))
    ax.text(xp, yp, lab, ha='center', va='center', fontsize=10, fontweight='bold', color='white')
for i in range(len(boxes_query) - 1):
    ax.annotate('', xy=(boxes_query[i+1][0] - 1.0, boxes_query[i+1][1]),
                xytext=(boxes_query[i][0] + 1.0, boxes_query[i][1]),
                arrowprops=dict(arrowstyle='->', color=c_arrow, lw=1.5))

ax.annotate('', xy=(7.0, 2.9), xytext=(10.5, 5.1),
            arrowprops=dict(arrowstyle='->', color=c_index, lw=1.5, linestyle='--'))
ax.set_title("Pipeline RAG : indexation et requête", fontsize=14, fontweight='bold', pad=15)
plt.show()
_images/871497dcdfa83953b387de398e4a122fb38a31a2a5dca75e259a1fe08bf70cbe.png

Exemple 40 (Pipeline RAG de bout en bout)

Supposons une base de connaissances contenant 500 articles techniques. Le pipeline RAG procède comme suit :

  1. Chunking : chaque article est découpé en fragments de 512 tokens avec un recouvrement de 64 tokens, produisant environ 3 000 chunks.

  2. Embedding : chaque chunk est encodé par all-MiniLM-L6-v2 en un vecteur de dimension 384.

  3. Indexation : les 3 000 vecteurs sont ajoutés à un index FAISS IndexFlatIP (\(\sim\) 4,5 Mo).

  4. Requête : la question est encodée en un vecteur de dimension 384.

  5. Récupération : les 5 chunks les plus similaires sont récupérés (top-5).

  6. Génération : le LLM reçoit le prompt augmenté et génère une réponse fidèle aux passages récupérés.

Chunking de documents#

Le chunking est l’opération qui découpe un document en fragments de taille gérable pour l’embedding et la récupération. Des chunks trop grands diluent l’information pertinente ; des chunks trop petits perdent le contexte nécessaire à la compréhension.

Définition 52 (Chunking)

Le chunking (fragmentation) est le processus de découpage d’un document textuel \(D = (t_1, t_2, \ldots, t_N)\) de \(N\) tokens en une séquence de fragments \(\{c_1, c_2, \ldots, c_M\}\), où chaque chunk \(c_i\) contient un sous-ensemble contigu de tokens de \(D\). Les hyperparamètres principaux sont :

  • La taille du chunk \(s\) : nombre de tokens (ou caractères) par fragment.

  • Le recouvrement (overlap) \(o\) : nombre de tokens partagés entre deux chunks consécutifs, avec \(0 \leq o < s\).

  • Le nombre de chunks résultant : \(M = \left\lceil \frac{N - o}{s - o} \right\rceil\).

Remarque 63

Le choix de la taille du chunk est un compromis fondamental :

  • Chunks petits (128–256 tokens) : plus précis pour la récupération (un chunk = une idée), mais risquent de perdre le contexte.

  • Chunks grands (512–1024 tokens) : conservent plus de contexte, mais diluent la pertinence.

En pratique, des chunks de 256 à 512 tokens avec un recouvrement de 10 à 20 % offrent un bon compromis pour la plupart des cas d’usage.

Remarque 64

Le recouvrement (overlap) entre chunks consécutifs assure qu’une phrase coupée à la frontière de deux chunks reste présente dans au moins l’un des deux. Sans recouvrement, une information clé située à la jonction pourrait n’apparaître de manière cohérente dans aucun fragment. Un recouvrement de 10 à 20 % de la taille du chunk est un bon point de départ.

Hide code cell source

def chunk_text_fixed(text, chunk_size=200, overlap=50):
    """Découpe un texte en chunks de taille fixe avec recouvrement."""
    chunks = []
    start = 0
    while start < len(text):
        end = start + chunk_size
        chunks.append(text[start:end])
        start += chunk_size - overlap
    return chunks

# Texte d'exemple
sample_text = (
    "Le Retrieval-Augmented Generation (RAG) est un paradigme qui combine la récupération "
    "d'information avec la génération de texte par un grand modèle de langage. L'idée centrale "
    "est simple : plutôt que de compter uniquement sur la mémoire paramétrique du modèle, on "
    "ancre la génération dans des documents externes récupérés dynamiquement. "
    "Le pipeline RAG se compose de deux phases. La phase d'indexation consiste à découper les "
    "documents en fragments, à les encoder en vecteurs denses, puis à les stocker dans un index "
    "vectoriel. La phase de requête encode la question de l'utilisateur, récupère les fragments "
    "les plus similaires, et les insère dans le prompt du LLM pour générer une réponse fidèle. "
    "FAISS est une bibliothèque développée par Meta AI pour la recherche de similarité efficace "
    "dans des espaces vectoriels de grande dimension. Elle offre plusieurs types d'index adaptés "
    "à différents compromis entre vitesse et précision. L'index le plus simple, IndexFlatL2, "
    "effectue une recherche exacte par force brute en calculant la distance euclidienne entre "
    "le vecteur requête et tous les vecteurs de la base. Pour des bases plus grandes, des index "
    "approximatifs comme IVF ou HNSW offrent des gains de vitesse considérables."
)

chunks = chunk_text_fixed(sample_text, chunk_size=200, overlap=50)
print(f"Texte original : {len(sample_text)} caractères")
print(f"Nombre de chunks : {len(chunks)} (taille 200, recouvrement 50)\n")
for i, chunk in enumerate(chunks):
    print(f"--- Chunk {i} ({len(chunk)} car.) ---")
    print(chunk[:80] + "..." if len(chunk) > 80 else chunk)
Texte original : 1223 caractères
Nombre de chunks : 9 (taille 200, recouvrement 50)

--- Chunk 0 (200 car.) ---
Le Retrieval-Augmented Generation (RAG) est un paradigme qui combine la récupéra...
--- Chunk 1 (200 car.) ---
langage. L'idée centrale est simple : plutôt que de compter uniquement sur la mé...
--- Chunk 2 (200 car.) ---
s externes récupérés dynamiquement. Le pipeline RAG se compose de deux phases. L...
--- Chunk 3 (200 car.) ---
 les encoder en vecteurs denses, puis à les stocker dans un index vectoriel. La ...
--- Chunk 4 (200 car.) ---
gments les plus similaires, et les insère dans le prompt du LLM pour générer une...
--- Chunk 5 (200 car.) ---
r la recherche de similarité efficace dans des espaces vectoriels de grande dime...
--- Chunk 6 (200 car.) ---
is entre vitesse et précision. L'index le plus simple, IndexFlatL2, effectue une...
--- Chunk 7 (173 car.) ---
 entre le vecteur requête et tous les vecteurs de la base. Pour des bases plus g...
--- Chunk 8 (23 car.) ---
 vitesse considérables.

Chunking récursif et sémantique#

Le chunking récursif tente de respecter les frontières naturelles du document : d’abord par paragraphe, puis par phrase, puis par mot, en recourant à des délimiteurs de plus en plus fins si un fragment dépasse la taille cible.

Hide code cell source

def chunk_text_recursive(text, chunk_size=200, separators=None):
    """Découpe récursive : essaie les séparateurs dans l'ordre."""
    if separators is None:
        separators = ["\n\n", "\n", ". ", " "]
    if len(text) <= chunk_size:
        return [text]
    for sep in separators:
        if sep in text:
            parts, chunks, current = text.split(sep), [], ""
            for part in parts:
                candidate = current + sep + part if current else part
                if len(candidate) <= chunk_size:
                    current = candidate
                else:
                    if current:
                        chunks.append(current)
                    current = part
            if current:
                chunks.append(current)
            result = []
            remaining_seps = separators[separators.index(sep) + 1:]
            for c in chunks:
                if len(c) > chunk_size:
                    result.extend(chunk_text_recursive(c, chunk_size, remaining_seps))
                else:
                    result.append(c)
            return result
    return chunk_text_fixed(text, chunk_size, chunk_size // 4)

chunks_rec = chunk_text_recursive(sample_text, chunk_size=200)
print(f"Chunking récursif : {len(chunks_rec)} chunks")
for i, chunk in enumerate(chunks_rec):
    print(f"  [{i}] ({len(chunk)} car.) {chunk[:60]}...")
Chunking récursif : 8 chunks
  [0] (157 car.) Le Retrieval-Augmented Generation (RAG) est un paradigme qui...
  [1] (175 car.) L'idée centrale est simple : plutôt que de compter uniquemen...
  [2] (189 car.) Le pipeline RAG se compose de deux phases. La phase d'indexa...
  [3] (168 car.) La phase de requête encode la question de l'utilisateur, réc...
  [4] (138 car.) FAISS est une bibliothèque développée par Meta AI pour la re...
  [5] (92 car.) Elle offre plusieurs types d'index adaptés à différents comp...
  [6] (176 car.) L'index le plus simple, IndexFlatL2, effectue une recherche ...
  [7] (114 car.) Pour des bases plus grandes, des index approximatifs comme I...

Exemple 41 (Comparaison des stratégies de chunking)

Stratégie

Taille cible

Recouvrement

Nombre de chunks

Préserve la structure

Fixe

200

50

7

Non

Récursive

200

5–6

Oui (paragraphes)

Sémantique

variable

4–8

Oui (thématique)

Le chunking sémantique regroupe les phrases par similarité d’embeddings : on calcule l’embedding de chaque phrase, puis on fusionne les phrases consécutives tant que la similarité cosinus entre elles dépasse un seuil \(\tau\). Cette approche produit des chunks de taille variable mais thématiquement cohérents.

Embeddings et indexation vectorielle#

Une fois les documents découpés en chunks, chaque fragment doit être transformé en un vecteur dense qui capture son sens sémantique. Ces vecteurs sont ensuite stockés dans un index vectoriel permettant la recherche rapide des passages les plus pertinents.

Définition 53 (Base de vecteurs (vector store))

Une base de vecteurs (vector store) est une structure de données qui stocke des vecteurs denses \(\mathbf{v}_1, \ldots, \mathbf{v}_N \in \mathbb{R}^d\) et permet la recherche efficace des \(k\) plus proches voisins d’un vecteur requête \(\mathbf{q} \in \mathbb{R}^d\) selon une métrique de distance :

  • Distance euclidienne (\(L_2\)) : \(d(\mathbf{q}, \mathbf{v}_i) = \|\mathbf{q} - \mathbf{v}_i\|_2\)

  • Produit scalaire (inner product) : \(\text{IP}(\mathbf{q}, \mathbf{v}_i) = \mathbf{q}^\top \mathbf{v}_i\)

  • Similarité cosinus : \(\cos(\mathbf{q}, \mathbf{v}_i) = \frac{\mathbf{q}^\top \mathbf{v}_i}{\|\mathbf{q}\| \, \|\mathbf{v}_i\|}\)

Pour des vecteurs normalisés (\(\|\mathbf{v}\| = 1\)), le produit scalaire est équivalent à la similarité cosinus.

Remarque 65

Le choix du modèle d’embeddings est crucial pour la qualité du RAG :

  • all-MiniLM-L6-v2 : léger (80 Mo), dimension 384, bon pour le prototypage.

  • all-mpnet-base-v2 : plus lourd (420 Mo), dimension 768, meilleur sur les benchmarks STS.

  • Modèles multilingues (e.g., multilingual-e5-large) : nécessaires pour des corpus non anglophones.

  • Modèles spécialisés : un fine-tuning du modèle d’embeddings sur des données du domaine améliore significativement la récupération.

# Chargement du modèle d'embeddings (~80 Mo)
model = SentenceTransformer('all-MiniLM-L6-v2')
print(f"Modèle : all-MiniLM-L6-v2")
print(f"Dimension des embeddings : {model.get_sentence_embedding_dimension()}")
BertModel LOAD REPORT from: sentence-transformers/all-MiniLM-L6-v2
Key                     | Status     |  | 
------------------------+------------+--+-
embeddings.position_ids | UNEXPECTED |  | 

Notes:
- UNEXPECTED	:can be ignored when loading from different task/architecture; not ok if you expect identical arch.
Modèle : all-MiniLM-L6-v2
Dimension des embeddings : 384

Hide code cell source

# Préparation de documents thématiques pour la démonstration
documents = {
    "RAG": [
        "Le RAG combine récupération d'information et génération de texte par un LLM.",
        "Le pipeline RAG comprend une phase d'indexation et une phase de requête.",
        "La récupération de passages pertinents réduit les hallucinations du modèle.",
        "Le RAG permet d'utiliser des connaissances privées sans fine-tuning.",
    ],
    "FAISS": [
        "FAISS est une bibliothèque de recherche de similarité développée par Meta AI.",
        "L'index IndexFlatL2 effectue une recherche exacte par distance euclidienne.",
        "Les index IVF partitionnent l'espace en cellules de Voronoï pour accélérer la recherche.",
        "HNSW construit un graphe navigable pour la recherche approximative rapide.",
    ],
    "Embeddings": [
        "Les sentence embeddings transforment un texte en un vecteur de dimension fixe.",
        "La similarité cosinus mesure la proximité sémantique entre deux vecteurs.",
        "MiniLM est un modèle compact qui produit des embeddings de dimension 384.",
        "Le fine-tuning contrastif améliore la qualité des embeddings pour un domaine donné.",
    ],
    "LLM": [
        "Les grands modèles de langage génèrent du texte de manière autorégressive.",
        "Le knowledge cutoff limite la connaissance du modèle à sa date d'entraînement.",
        "Les hallucinations sont des réponses plausibles mais factuellement incorrectes.",
        "Le prompt engineering permet de guider le comportement du LLM sans fine-tuning.",
    ],
    "Évaluation": [
        "La fidélité mesure la cohérence de la réponse avec les passages récupérés.",
        "La pertinence évalue si la réponse correspond bien à la question posée.",
        "Le rappel contextuel vérifie que tous les faits nécessaires ont été récupérés.",
        "RAGAS est un framework d'évaluation automatique pour les systèmes RAG.",
    ],
}

all_chunks, chunk_labels = [], []
for source, texts in documents.items():
    for t in texts:
        all_chunks.append(t)
        chunk_labels.append(source)

print(f"Nombre total de chunks : {len(all_chunks)}")
print(f"Sources : {list(documents.keys())}")
Nombre total de chunks : 20
Sources : ['RAG', 'FAISS', 'Embeddings', 'LLM', 'Évaluation']

Hide code cell source

# Encodage des chunks en vecteurs
embeddings = model.encode(all_chunks, normalize_embeddings=True, show_progress_bar=False)
print(f"Matrice d'embeddings : {embeddings.shape}")
print(f"Norme du premier vecteur : {np.linalg.norm(embeddings[0]):.4f} (normalisé)")
Matrice d'embeddings : (20, 384)
Norme du premier vecteur : 1.0000 (normalisé)

FAISS : recherche de similarité efficace#

FAISS (Facebook AI Similarity Search) est une bibliothèque développée par Meta AI Research pour la recherche de similarité efficace dans des espaces vectoriels de grande dimension.

Définition 54 (FAISS)

FAISS est une bibliothèque de recherche de similarité vectorielle qui implémente plusieurs structures d’index :

  • IndexFlatL2 : recherche exacte par distance euclidienne (force brute). Complexité \(O(Nd)\) par requête.

  • IndexFlatIP : recherche exacte par produit scalaire. Pour des vecteurs normalisés, équivalent à la similarité cosinus.

  • IndexIVFFlat : recherche approximative par partitionnement de Voronoï. L’espace est divisé en \(n_{\text{cells}}\) cellules ; seules les \(n_{\text{probe}}\) cellules les plus proches sont explorées.

  • IndexHNSWFlat : recherche approximative par graphe Hierarchical Navigable Small World. Excellent compromis vitesse/précision pour les grandes bases.

Les index sont construits sur des vecteurs de dimension \(d\) en float32, soit \(4d\) octets par vecteur.

Hide code cell source

# Création de l'index FAISS (produit scalaire sur vecteurs normalisés)
d = embeddings.shape[1]
index = faiss.IndexFlatIP(d)
index.add(embeddings.astype(np.float32))
print(f"Index : IndexFlatIP, dim {d}, {index.ntotal} vecteurs")
print(f"Mémoire estimée : {index.ntotal * d * 4 / 1024:.1f} Ko")
Index : IndexFlatIP, dim 384, 20 vecteurs
Mémoire estimée : 30.0 Ko

Exemple 42 (Création et interrogation d’un index FAISS)

La création et l’interrogation d’un index FAISS se font en trois lignes :

  1. Créer l’index : index = faiss.IndexFlatIP(d) pour un index exact par produit scalaire en dimension \(d\).

  2. Ajouter les vecteurs : index.add(vectors)vectors est un tableau NumPy float32 de forme \((N, d)\).

  3. Chercher : D, I = index.search(query, k) retourne les distances \(D\) et indices \(I\) des \(k\) plus proches voisins.

Pour \(N = 10\,000\) vecteurs en dimension \(d = 384\), l’index occupe environ 15 Mo et une recherche exacte prend quelques millisecondes.

Hide code cell source

# Recherche de similarité : embed query → retrieve top-5
query = "Comment fonctionne la recherche de similarité dans un système RAG ?"
query_embedding = model.encode([query], normalize_embeddings=True).astype(np.float32)

k = 5
scores, indices = index.search(query_embedding, k)

print(f"Requête : \"{query}\"\n")
print(f"Top-{k} résultats :")
print("-" * 80)
for rank, (idx, score) in enumerate(zip(indices[0], scores[0])):
    print(f"  [{rank + 1}] (score: {score:.4f}) [{chunk_labels[idx]}] {all_chunks[idx]}")
Requête : "Comment fonctionne la recherche de similarité dans un système RAG ?"

Top-5 résultats :
--------------------------------------------------------------------------------
  [1] (score: 0.6475) [Évaluation] RAGAS est un framework d'évaluation automatique pour les systèmes RAG.
  [2] (score: 0.6025) [RAG] Le RAG permet d'utiliser des connaissances privées sans fine-tuning.
  [3] (score: 0.5673) [Embeddings] La similarité cosinus mesure la proximité sémantique entre deux vecteurs.
  [4] (score: 0.5426) [RAG] Le RAG combine récupération d'information et génération de texte par un LLM.
  [5] (score: 0.5342) [RAG] Le pipeline RAG comprend une phase d'indexation et une phase de requête.

Propriété 14 (Complexité des index FAISS)

Pour \(N\) vecteurs en dimension \(d\) et une requête top-\(k\) :

Index

Complexité temporelle

Complexité mémoire

Exactitude

IndexFlatL2 / IndexFlatIP

\(O(Nd)\)

\(O(Nd)\)

Exacte

IndexIVFFlat (\(n_c\) cellules, \(n_p\) probes)

\(O\!\left(\frac{n_p}{n_c} \cdot Nd\right)\)

\(O(Nd + n_c d)\)

Approximative

IndexHNSWFlat (\(M\) voisins, \(ef\) candidats)

\(O(ef \cdot \log N \cdot d)\)

\(O(Nd + NM)\)

Approximative

Pour de petites bases (\(N < 50\,000\)), IndexFlatIP est suffisant. Au-delà, les index approximatifs offrent des gains de vitesse de 10 à 100x avec une perte de rappel inférieure à 5 %.

Remarque 66

En pratique, les systèmes RAG combinent la recherche vectorielle avec un filtrage par métadonnées : date du document, source, catégorie, langue. Ce filtrage peut être appliqué avant la recherche (pre-filtering) ou après (post-filtering). Les bases de vecteurs dédiées (Pinecone, Weaviate, Qdrant, ChromaDB) offrent ce filtrage nativement, tandis qu’avec FAISS il faut le gérer manuellement.

Hide code cell source

# Visualisation PCA des embeddings colorés par source
from sklearn.decomposition import PCA

pca = PCA(n_components=2, random_state=42)
embeddings_2d = pca.fit_transform(embeddings)

fig, ax = plt.subplots(figsize=(10, 7))
sources_unique = list(documents.keys())
colors = sns.color_palette("Set2", len(sources_unique))

for i, source in enumerate(sources_unique):
    mask = np.array([s == source for s in chunk_labels])
    ax.scatter(embeddings_2d[mask, 0], embeddings_2d[mask, 1],
               c=[colors[i]], label=source, s=100, alpha=0.8,
               edgecolors='white', linewidth=0.8)

query_2d = pca.transform(query_embedding)
ax.scatter(query_2d[0, 0], query_2d[0, 1], c='red', marker='*', s=300,
           zorder=5, label="Requête", edgecolors='black', linewidth=1)
for idx in indices[0]:
    ax.plot([query_2d[0, 0], embeddings_2d[idx, 0]],
            [query_2d[0, 1], embeddings_2d[idx, 1]], 'r--', alpha=0.3, linewidth=1)

ax.set_xlabel(f"PC1 ({pca.explained_variance_ratio_[0]:.1%} de variance)")
ax.set_ylabel(f"PC2 ({pca.explained_variance_ratio_[1]:.1%} de variance)")
ax.set_title("Projection PCA des embeddings de chunks (colorés par source)")
ax.legend(loc='best', framealpha=0.9)
plt.show()
_images/0aa01382ec68029416e244af7d0112ec366a82def5d4dc00546fa6991d2db466.png

Hide code cell source

# Distribution des scores de similarité
sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)
all_scores = (query_embedding @ embeddings.T).flatten()

fig, ax = plt.subplots(figsize=(10, 5))
ax.hist(all_scores, bins=20, color="#4C72B0", alpha=0.7, edgecolor='white', linewidth=0.8)
ax.axvline(x=scores[0][-1], color='#E24A33', linestyle='--', linewidth=2,
           label=f"Seuil top-{k} ({scores[0][-1]:.3f})")
for score in scores[0]:
    ax.axvline(x=score, color='#55A868', alpha=0.5, linewidth=1)

ax.set_xlabel("Score de similarité (produit scalaire)")
ax.set_ylabel("Nombre de chunks")
ax.set_title("Distribution des scores de similarité avec la requête")
ax.legend()
plt.show()
_images/0078e5844ded911274a41aa21f120d52522f4a25477ae1aef493246242960df5.png

Génération augmentée par la récupération#

La dernière étape du pipeline RAG consiste à injecter les chunks récupérés dans le prompt du LLM pour générer une réponse fidèle au contexte fourni. La formulation du prompt est déterminante pour la qualité et la fidélité de la réponse.

Exemple 43 (Template de prompt RAG)

Un template de prompt RAG typique :

Tu es un assistant qui répond aux questions en te basant
uniquement sur le contexte fourni. Si l'information n'est
pas dans le contexte, dis-le explicitement.

Contexte :
[1] {chunk_1}
[2] {chunk_2}
...
[k] {chunk_k}

Question : {query}

Réponds de manière concise en citant les sources pertinentes
entre crochets (ex: [1], [3]).

Ce template impose trois contraintes cruciales :

  1. Fidélité : « uniquement sur le contexte fourni ».

  2. Transparence : « Si l’information n’est pas dans le contexte, dis-le ».

  3. Traçabilité : « en citant les sources pertinentes entre crochets ».

Hide code cell source

def build_rag_prompt(query, retrieved_chunks):
    """Construit un prompt RAG avec les chunks récupérés."""
    context = "\n".join(f"[{i+1}] {c}" for i, c in enumerate(retrieved_chunks))
    return (
        "Tu es un assistant qui répond aux questions en te basant "
        "uniquement sur le contexte fourni. Si l'information n'est "
        "pas dans le contexte, dis-le explicitement.\n\n"
        f"Contexte :\n{context}\n\n"
        f"Question : {query}\n\n"
        "Réponds de manière concise en citant les sources entre crochets."
    )

retrieved = [all_chunks[idx] for idx in indices[0]]
prompt = build_rag_prompt(query, retrieved)
print("=== Prompt RAG ===\n")
print(prompt)
=== Prompt RAG ===

Tu es un assistant qui répond aux questions en te basant uniquement sur le contexte fourni. Si l'information n'est pas dans le contexte, dis-le explicitement.

Contexte :
[1] RAGAS est un framework d'évaluation automatique pour les systèmes RAG.
[2] Le RAG permet d'utiliser des connaissances privées sans fine-tuning.
[3] La similarité cosinus mesure la proximité sémantique entre deux vecteurs.
[4] Le RAG combine récupération d'information et génération de texte par un LLM.
[5] Le pipeline RAG comprend une phase d'indexation et une phase de requête.

Question : Comment fonctionne la recherche de similarité dans un système RAG ?

Réponds de manière concise en citant les sources entre crochets.

Classe RAGPipeline complète#

Combinons l’ensemble des composants — chunking, embedding, indexation et récupération — dans une classe unifiée.

Hide code cell source

class RAGPipeline:
    """Pipeline RAG complet : chunking, embedding, indexation, récupération."""

    def __init__(self, model_name='all-MiniLM-L6-v2',
                 chunk_size=200, overlap=50, top_k=5):
        self.model = SentenceTransformer(model_name)
        self.chunk_size = chunk_size
        self.overlap = overlap
        self.top_k = top_k
        self.index = None
        self.chunks = []

    def _chunk_text(self, text, source=""):
        chunks, start = [], 0
        while start < len(text):
            end = start + self.chunk_size
            chunks.append({"text": text[start:end], "source": source,
                           "start": start, "end": min(end, len(text))})
            start += self.chunk_size - self.overlap
        return chunks

    def index_documents(self, documents):
        """Indexe une liste de documents [{'text': ..., 'source': ...}]."""
        self.chunks = []
        for doc in documents:
            self.chunks.extend(self._chunk_text(doc["text"], doc.get("source", "")))
        texts = [c["text"] for c in self.chunks]
        embs = self.model.encode(texts, normalize_embeddings=True, show_progress_bar=False)
        d = embs.shape[1]
        self.index = faiss.IndexFlatIP(d)
        self.index.add(embs.astype(np.float32))
        print(f"Indexés : {len(documents)} docs → {len(self.chunks)} chunks "
              f"→ index FAISS ({self.index.ntotal} vecteurs, dim {d})")

    def retrieve(self, query):
        """Récupère les top-k chunks les plus pertinents."""
        q_emb = self.model.encode([query], normalize_embeddings=True).astype(np.float32)
        scores, idxs = self.index.search(q_emb, self.top_k)
        return [{**self.chunks[i], "score": float(s), "chunk_id": int(i)}
                for i, s in zip(idxs[0], scores[0])]

    def query(self, question):
        """Pipeline complet : récupération + construction du prompt."""
        results = self.retrieve(question)
        context = "\n".join(f"[{i+1}] ({r['source']}) {r['text']}"
                            for i, r in enumerate(results))
        prompt = (f"Contexte :\n{context}\n\nQuestion : {question}\n\n"
                  "Réponds en citant les sources entre crochets.")
        return {"question": question, "results": results, "prompt": prompt}


# Démonstration
rag = RAGPipeline(chunk_size=200, overlap=50, top_k=3)
rag.index_documents([{"text": sample_text, "source": "cours_rag"}])

output = rag.query("Quels types d'index propose FAISS ?")
print(f"\nQuestion : {output['question']}\n")
for i, r in enumerate(output['results']):
    print(f"  [{i+1}] (score: {r['score']:.4f}) {r['text'][:80]}...")
BertModel LOAD REPORT from: sentence-transformers/all-MiniLM-L6-v2
Key                     | Status     |  | 
------------------------+------------+--+-
embeddings.position_ids | UNEXPECTED |  | 

Notes:
- UNEXPECTED	:can be ignored when loading from different task/architecture; not ok if you expect identical arch.
Indexés : 1 docs → 9 chunks → index FAISS (9 vecteurs, dim 384)

Question : Quels types d'index propose FAISS ?

  [1] (score: 0.4858) r la recherche de similarité efficace dans des espaces vectoriels de grande dime...
  [2] (score: 0.4793) is entre vitesse et précision. L'index le plus simple, IndexFlatL2, effectue une...
  [3] (score: 0.4409)  entre le vecteur requête et tous les vecteurs de la base. Pour des bases plus g...

Évaluation du RAG#

L’évaluation d’un système RAG doit mesurer la qualité à la fois de la récupération et de la génération. Le framework RAGAS (Retrieval-Augmented Generation Assessment) propose un ensemble de métriques standardisées.

Définition 56 (Évaluation RAG)

L”évaluation d’un système RAG porte sur quatre dimensions complémentaires :

  1. Fidélité (faithfulness) : la réponse est-elle cohérente avec les passages récupérés ? Proportion d’affirmations soutenues par le contexte.

  2. Pertinence de la réponse (answer relevance) : la réponse répond-elle effectivement à la question posée ?

  3. Rappel contextuel (context recall) : les passages récupérés contiennent-ils toutes les informations nécessaires ? \(\text{recall} = \frac{|\text{faits pertinents récupérés}|}{|\text{faits pertinents totaux}|}\).

  4. Précision contextuelle (context precision) : les passages récupérés sont-ils tous pertinents ? \(\text{precision} = \frac{|\text{passages pertinents}|}{|\text{passages récupérés}|}\).

Hide code cell source

# Visualisation des métriques d'évaluation RAG
fig, axes = plt.subplots(2, 1, figsize=(9, 9))

# Gauche : comparaison de trois systèmes RAG
systems = ["RAG\nbasique", "RAG +\nreranking", "RAG +\nreranking\n+ fine-tuning"]
metrics = {
    "Fidélité":             [0.72, 0.81, 0.89],
    "Pertinence":           [0.68, 0.78, 0.85],
    "Rappel contextuel":    [0.65, 0.82, 0.88],
    "Précision contextuelle": [0.70, 0.85, 0.91],
}

x = np.arange(len(systems))
width = 0.18
colors_metrics = sns.color_palette("Set2", len(metrics))
for i, (metric, values) in enumerate(metrics.items()):
    axes[0].bar(x + (i - 1.5) * width, values, width, label=metric,
                color=colors_metrics[i], alpha=0.85)
axes[0].set_ylabel("Score")
axes[0].set_title("Comparaison de systèmes RAG (RAGAS)")
axes[0].set_xticks(x)
axes[0].set_xticklabels(systems, fontsize=9)
axes[0].set_ylim(0, 1.05)
axes[0].legend(fontsize=8, loc='upper left')

# Droite : impact du nombre de chunks récupérés
k_values = [1, 3, 5, 10, 15, 20]
axes[1].plot(k_values, [0.92, 0.88, 0.85, 0.78, 0.72, 0.65], 'o-',
             label="Fidélité", linewidth=2, markersize=6)
axes[1].plot(k_values, [0.35, 0.60, 0.75, 0.88, 0.92, 0.95], 's-',
             label="Rappel contextuel", linewidth=2, markersize=6)
axes[1].plot(k_values, [0.90, 0.85, 0.78, 0.65, 0.55, 0.45], '^-',
             label="Précision contextuelle", linewidth=2, markersize=6)
axes[1].set_xlabel("Nombre de chunks récupérés ($k$)")
axes[1].set_ylabel("Score")
axes[1].set_title("Impact du top-$k$ sur les métriques RAG")
axes[1].legend()
axes[1].set_ylim(0, 1.05)
plt.show()
_images/a7b5eabaf9077cc8827892e86158c60eb8490123abc3715d2002bcf4f662f1f2.png

Le graphique de droite illustre un compromis fondamental : augmenter \(k\) améliore le rappel (plus de chances de récupérer tous les faits pertinents), mais dégrade la précision (davantage de bruit) et la fidélité (le modèle peut être distrait par des passages non pertinents).

Résumé#

Ce chapitre a présenté le paradigme RAG (Retrieval-Augmented Generation), qui ancre la génération des LLM dans des connaissances externes pour surmonter les limites de la mémoire paramétrique.

  1. Le RAG résout trois problèmes fondamentaux des LLM : le knowledge cutoff (connaissances figées), les hallucinations (réponses factuellement incorrectes) et l’absence de connaissances spécifiques à un domaine.

  2. Le pipeline RAG se compose de deux phases : l’indexation (offline : chunking, embedding, stockage vectoriel) et la requête (online : embedding de la question, récupération top-\(k\), génération augmentée).

  3. Le chunking découpe les documents en fragments exploitables. Le choix de la taille et du recouvrement est un compromis entre précision de la récupération et préservation du contexte. Les chunks de 256 à 512 tokens avec 10–20 % de recouvrement constituent un bon point de départ.

  4. Les sentence embeddings (MiniLM, MPNet) transforment les chunks en vecteurs denses capturant la sémantique. La normalisation des vecteurs permet d’utiliser le produit scalaire comme mesure de similarité cosinus.

  5. FAISS offre une recherche de similarité efficace avec différents types d’index : exact (IndexFlatIP/L2) pour les petites bases, approximatif (IVF, HNSW) pour les grandes bases. La complexité passe de \(O(Nd)\) en exact à \(O(\log N \cdot d)\) avec HNSW.

  6. Le prompt RAG structure la génération en imposant la fidélité au contexte récupéré, la transparence sur les limites de la connaissance disponible et la traçabilité par citation des sources.

  7. L”évaluation RAG (framework RAGAS) mesure quatre dimensions : fidélité, pertinence de la réponse, rappel contextuel et précision contextuelle. Le choix du nombre de chunks \(k\) est un compromis entre rappel et précision.

Le chapitre suivant étendra ces concepts au RAG avancé : re-ranking, chunking sémantique, RAG hybride (dense + sparse), et l’intégration de graphes de connaissances pour un raisonnement structuré.