Cours Python · Module 11: RAG et systèmes multi-agents

RAG - la mémoire étendue de l'IA

9 min de lecture
Dans cette leçon6

Imagine un guide qui ne connaît la savane que par des livres imprimés il y a trois ans. Tu lui demandes le nouveau sentier vers le point d'eau, et il te décrit avec assurance un chemin qui n'existe plus. C'est exactement ce que fait un modèle de langage sans accès à des connaissances à jour. Bienvenue à l'avant-dernière étape du Python Safari ! Dans ce lieu, on va apprendre au modèle à consulter son carnet de terrain avant de dire quoi que ce soit.

Tout comme les grands mammifères ont développé un cerveau plus gros et une meilleure mémoire, les modèles de langage évoluent grâce au RAG (Retrieval-Augmented Generation). C'est une technique qui donne à l'IA l'accès à des connaissances externes sans réentraîner le modèle.

Ce que tu vas apprendre

  • reconnaître les limites des LLM que le RAG résout,
  • décrire les trois étapes du pipeline : retrieve, augment, generate,
  • construire le RAG le plus simple en Python pur avec OpenAI et NumPy,
  • découper de longs documents en morceaux (chunking),
  • mesurer la qualité de la recherche avec la précision et le rappel.

Le problème des LLM traditionnels

Les modèles de langage ont des limites fondamentales, un peu comme des animaux adaptés à un seul environnement :

Limites des LLM de base :

  1. Knowledge cutoff - connaissances figées au moment de l'entraînement
  2. Hallucinations - génération d'informations fausses
  3. Absence de contexte d'entreprise - ils ne connaissent pas tes documents
  4. Coût élevé du fine-tuning - adapter le modèle coûte cher

La première limite est la plus importante : le modèle ne sait rien de ce qui s'est passé après la fin de son entraînement, et il n'a jamais vu tes documents privés. Le RAG ne modifie pas le modèle lui-même. Il modifie ce que le modèle reçoit à lire avant de répondre.

Qu'est-ce que le RAG ?

Le RAG est une architecture qui combine la recherche d'informations et la génération de texte. Le schéma ci-dessous suit une question depuis l'utilisateur jusqu'à la réponse finale :

1┌─────────────────────────────────────────────────────────┐
2│                    RAG Pipeline                          │
3├─────────────────────────────────────────────────────────┤
4│                                                          │
5│  User question                                           │
6│         │                                                │
7│         ▼                                                │
8│  ┌─────────────────┐                                    │
9│  │   Embedding     │  ← Convert to vector               │
10│  └────────┬────────┘                                    │
11│           │                                              │
12│           ▼                                              │
13│  ┌─────────────────┐     ┌─────────────────┐           │
14│  │ Vector Search   │────▶│ Vector Database │           │
15│  └────────┬────────┘     └─────────────────┘           │
16│           │                                              │
17│           ▼                                              │
18│  ┌─────────────────┐                                    │
19│  │ Retrieved Docs  │  ← Top-K similar documents         │
20│  └────────┬────────┘                                    │
21│           │                                              │
22│           ▼                                              │
23│  ┌─────────────────┐                                    │
24│  │   LLM + Context │  ← Generate with context           │
25│  └────────┬────────┘                                    │
26│           │                                              │
27│           ▼                                              │
28│      Answer                                              │
29└─────────────────────────────────────────────────────────┘

Lis-le de haut en bas. On transforme la question en vecteur de nombres (un embedding), on cherche dans une base vectorielle des passages au sens proche, on garde les meilleurs (Top-K) et on les colle dans le prompt. Seulement ensuite, le LLM génère la réponse. Le modèle ne change pas, seule son "antisèche" change pour cette question.

Implémentation de base du RAG

Construisons tout le pipeline en quelques dizaines de lignes. On commence par le client OpenAI, NumPy et une toute petite base de connaissances, une liste de phrases qui joue le rôle de notre carnet de terrain :

1from openai import OpenAI
2import numpy as np
3
4client = OpenAI()
5
6# Base de connaissances simple
7knowledge_base = [
8    "Python Safari is a Python programming course with 12 modules.",
9    "RAG combines document retrieval with text generation by LLMs.",
10    "Embeddings are vector representations of text in semantic space.",
11    "Vector databases store and search embeddings efficiently.",
12    "LlamaIndex is a framework for building RAG applications.",
13    "CrewAI enables the creation of multi-agent systems."
14]

OpenAI() lit la clé API dans la variable d'environnement OPENAI_API_KEY, donc on ne l'écrit jamais dans le code. La base de connaissances elle-même est une simple liste de chaînes, rien de magique.

Voici maintenant deux fonctions utilitaires. La première envoie le texte au modèle text-embedding-3-small et renvoie un vecteur, la seconde calcule la similarité cosinus, qui indique à quel point deux vecteurs "pointent" dans la même direction :

1def get_embedding(text: str) -> list[float]:
2    """Génère un embedding pour le texte."""
3    response = client.embeddings.create(
4        model="text-embedding-3-small",
5        input=text
6    )
7    return response.data[0].embedding
8
9def cosine_similarity(vec1: list, vec2: list) -> float:
10    """Calcule la similarité cosinus de deux vecteurs."""
11    vec1, vec2 = np.array(vec1), np.array(vec2)
12    return np.dot(vec1, vec2) / (np.linalg.norm(vec1) * np.linalg.norm(vec2))

La similarité cosinus va de -1 à 1, et plus elle s'approche de 1, plus le sens des textes est proche. On la décortiquera dans la prochaine leçon.

La troisième étape, c'est la fonction de recherche. Pour chaque document, on calcule sa similarité avec la question, on trie par ordre décroissant et on renvoie les top_k meilleurs :

1def retrieve_relevant_docs(query: str, docs: list, top_k: int = 3) -> list[str]:
2    """Récupère les documents les plus similaires."""
3    query_embedding = get_embedding(query)
4
5    similarities = []
6    for doc in docs:
7        doc_embedding = get_embedding(doc)
8        similarity = cosine_similarity(query_embedding, doc_embedding)
9        similarities.append((doc, similarity))
10
11    # Trie par similarité
12    similarities.sort(key=lambda x: x[1], reverse=True)
13
14    return [doc for doc, _ in similarities[:top_k]]

Remarque une faiblesse : cette fonction calcule l'embedding de chaque document à chaque question. Avec six phrases, ça ne pose aucun problème, avec des milliers de documents ce serait lent et coûteux. C'est pour ça que, dans les leçons suivantes, on calcule les embeddings des documents une seule fois et on les garde dans une base vectorielle.

Pour finir, on assemble tout dans rag_query, où tu retrouves les trois lettres de l'acronyme : Retrieve, Augment, Generate :

1def rag_query(question: str) -> str:
2    """Pipeline RAG complet."""
3    # 1. Retrieve - trouve les documents pertinents
4    relevant_docs = retrieve_relevant_docs(question, knowledge_base)
5
6    # 2. Augment - construit le prompt avec le contexte
7    context = "\n".join(relevant_docs)
8
9    prompt = f"""Answer the question using ONLY the context below.
10If you cannot answer based on the context, say "I don't know".
11
12Context:
13{context}
14
15Question: {question}
16"""
17
18    # 3. Generate - produit la réponse
19    response = client.chat.completions.create(
20        model="gpt-4o-mini",
21        messages=[{"role": "user", "content": prompt}]
22    )
23
24    return response.choices[0].message.content
25
26# Test
27answer = rag_query("What is RAG?")
28print(answer)

Le point clé, c'est l'instruction dans le prompt : "using ONLY the context below", avec le droit de répondre "I don't know". C'est ce qui limite les hallucinations. Le modèle gpt-4o-mini est exactement le même qu'il y a un instant, seul le prompt a changé.

Chunking - découper les documents

Les longs documents doivent être découpés en morceaux plus petits (chunks), car l'embedding d'un livre entier dilue le sens et un prompt a une longueur limitée. La méthode la plus simple coupe le texte tous les N caractères, avec un chevauchement (overlap) :

1from typing import Generator
2
3def simple_chunker(text: str, chunk_size: int = 500, overlap: int = 100) -> Generator[str, None, None]:
4    """Fonction simple pour découper le texte en chunks."""
5    start = 0
6    while start < len(text):
7        end = start + chunk_size
8        chunk = text[start:end]
9        yield chunk
10        start = end - overlap  # Chevauchement pour préserver le contexte

Le chevauchement garantit qu'une phrase coupée à la frontière se retrouve entière dans au moins un morceau. Veille seulement à ce que overlap reste plus petit que chunk_size, sinon la boucle ne se termine jamais. Le dernier morceau peut aussi être très court, car c'est juste la fin du texte.

Le découpage par phrases préserve mieux le sens. Une expression régulière coupe le texte après un point, un point d'exclamation ou un point d'interrogation, puis on regroupe quelques phrases :

1def sentence_chunker(text: str, sentences_per_chunk: int = 5) -> list[str]:
2    """Découpe le texte en chunks par phrases."""
3    import re
4
5    # Segmentation simple en phrases
6    sentences = re.split(r'(?<=[.!?])\s+', text)
7
8    chunks = []
9    for i in range(0, len(sentences), sentences_per_chunk):
10        chunk = ' '.join(sentences[i:i + sentences_per_chunk])
11        chunks.append(chunk)
12
13    return chunks
14
15# Exemple d'utilisation
16document = """
17Python is a high-level programming language. It was created by
18Guido van Rossum. It is very popular in Data Science and Machine Learning.
19RAG is a technique combining retrieval with generation. It allows LLMs
20to use external knowledge sources. It is crucial for enterprise AI.
21"""
22
23chunks = sentence_chunker(document, sentences_per_chunk=2)
24for i, chunk in enumerate(chunks):
25    print(f"Chunk {i}: {chunk[:50]}...")

Chaque morceau affiché contient maintenant des phrases entières, et le contenu du document n'a pas changé, seul son emballage a changé. Je te conseille de commencer par le découpage par phrases, car des morceaux coupés au milieu d'une idée donnent des embeddings plus faibles.

Métriques de qualité du RAG

Comment vérifier que le RAG fonctionne bien ? On évalue deux étapes séparément : la recherche a-t-elle trouvé les bons passages, et la réponse colle-t-elle au contexte. Voici un squelette de classe avec trois métriques typiques :

1from dataclasses import dataclass
2
3@dataclass
4class RAGMetrics:
5    """Métriques pour évaluer un système RAG."""
6
7    def relevance_score(self, query: str, retrieved_doc: str) -> float:
8        """Le document est-il pertinent pour la question ?"""
9        # En pratique, on utilise un LLM pour l'évaluation
10        pass
11
12    def faithfulness_score(self, answer: str, context: str) -> float:
13        """La réponse est-elle ancrée dans le contexte ?"""
14        # Vérifie les hallucinations
15        pass
16
17    def answer_correctness(self, answer: str, ground_truth: str) -> float:
18        """La réponse est-elle correcte ?"""
19        # Comparaison avec la vérité terrain
20        pass

Les méthodes ne contiennent que pass, car en pratique la pertinence (relevance), la fidélité au contexte (faithfulness) et l'exactitude de la réponse sont généralement jugées par un second modèle qui joue l'arbitre. C'est un squelette à compléter, pas un outil fini.

La recherche, elle, se mesure avec une simple arithmétique d'ensembles. La précision indique quelle part des documents trouvés est pertinente, le rappel indique quelle part des documents pertinents on a trouvée :

1# Exemple d'évaluation simple
2def evaluate_retrieval(query: str, retrieved: list[str], relevant: list[str]) -> dict:
3    """Calcule la précision et le rappel du retrieval."""
4    retrieved_set = set(retrieved)
5    relevant_set = set(relevant)
6
7    true_positives = len(retrieved_set & relevant_set)
8
9    precision = true_positives / len(retrieved_set) if retrieved_set else 0
10    recall = true_positives / len(relevant_set) if relevant_set else 0
11
12    f1 = 2 * precision * recall / (precision + recall) if (precision + recall) > 0 else 0
13
14    return {
15        "precision": precision,
16        "recall": recall,
17        "f1": f1
18    }

Exemple : si le système a renvoyé ["a", "b", "c"] et que les documents pertinents étaient ["a", "d"], la précision vaut 1/3, le rappel 1/2, et le F1, la moyenne harmonique des deux, 0.4.

Le RAG est la base des applications d'IA modernes en entreprise. Dans la prochaine leçon, tu vas découvrir les embeddings - le cœur de tout le système de recherche - et comprendre pourquoi des sens proches se retrouvent proches les uns des autres.

Retiens de cette expédition : le RAG, c'est un guide qui consulte son carnet de terrain avant chaque réponse.

Tu as repéré une erreur dans cette leçon ?

Teste tes connaissances

Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.

  1. 1. Que signifie l'acronyme RAG dans le contexte de l'IA ?

  2. 2. Quelle limite des LLM traditionnels le RAG aide-t-il à résoudre ?

Tâches pratiques dans le jeu

  • Éditeur de code

    Implémente un pipeline RAG de base

  • Tri vertical

    Ordonne les étapes dans l'ordre :

  • Clique dans l'ordre

    Ordonne les étapes du pipeline RAG :

  • Tri vertical

    Ordonne les étapes du pipeline de requête RAG dans le bon ordre :

Articles utiles