Cours Python · Module 11: RAG et systèmes multi-agents
LlamaIndex - framework RAG
Dans cette leçon8
Dans les trois leçons précédentes, on a écrit nous-mêmes le chunking, les embeddings, la recherche et l'assemblage du prompt. C'est une bonne école de pistage, mais lors d'une vraie expédition, personne ne coud sa tente avant de partir. Tu prends un équipement éprouvé et tu te concentres sur l'itinéraire.
LlamaIndex est un framework pour construire des applications RAG. Il simplifie tout le processus - du chargement des documents à la génération des réponses - et fournit des briques toutes prêtes pour chaque étape qu'on a écrite à la main jusqu'ici.
Les bases de LlamaIndex
Tout le pipeline de la première leçon tient en quelques lignes. L'objet Settings définit globalement le modèle de langage et le modèle d'embeddings, et SimpleDirectoryReader lit tous les fichiers d'un dossier :
1from llama_index.core import VectorStoreIndex, SimpleDirectoryReader, Settings
2from llama_index.llms.openai import OpenAI
3from llama_index.embeddings.openai import OpenAIEmbedding
4
5# Configuration globale
6Settings.llm = OpenAI(model="gpt-4o-mini", temperature=0)
7Settings.embed_model = OpenAIEmbedding(model="text-embedding-3-small")
8
9# Charge les documents
10documents = SimpleDirectoryReader("./docs").load_data()
11print(f"Loaded {len(documents)} documents")
12
13# Crée l'index
14index = VectorStoreIndex.from_documents(documents)
15
16# Moteur de requête
17query_engine = index.as_query_engine()
18
19# Requête
20response = query_engine.query("What is RAG?")
21print(response)La chaîne est toujours la même : SimpleDirectoryReader et .load_data() chargent les documents, VectorStoreIndex.from_documents les découpe en morceaux, calcule les embeddings et construit l'index, et .as_query_engine() transforme l'index en moteur de questions. Sous le capot, il se passe exactement ce qu'on a écrit nous-mêmes : retrieve, augment, generate. Seul le niveau auquel tu travailles a changé.
Chargeurs de documents
Les documents arrivent rarement dans un seul format. LlamaIndex a des chargeurs pour le texte, les fichiers et les pages web :
1from llama_index.core import Document
2from llama_index.readers.web import SimpleWebPageReader
3from llama_index.readers.file import PDFReader, DocxReader
4
5# Depuis du texte
6doc = Document(text="This is sample text to be indexed.")
7
8# Depuis des fichiers
9pdf_reader = PDFReader()
10pdf_docs = pdf_reader.load_data(file="document.pdf")
11
12# Depuis une page web
13web_reader = SimpleWebPageReader()
14web_docs = web_reader.load_data(urls=["https://example.com"])
15
16# Depuis plusieurs sources
17from llama_index.core import SimpleDirectoryReader
18
19reader = SimpleDirectoryReader(
20 input_dir="./data",
21 recursive=True,
22 required_exts=[".pdf", ".docx", ".txt", ".md"]
23)
24documents = reader.load_data()Document est l'unité de données de base : du texte plus des métadonnées. PDFReader et SimpleWebPageReader viennent de paquets séparés (llama-index-readers-file, llama-index-readers-web), il faut donc les installer. SimpleDirectoryReader avec recursive=True parcourt aussi les sous-dossiers et ne prend que les extensions listées dans required_exts.
Node Parsing (chunking)
Dans LlamaIndex, un morceau de document s'appelle un node. Tu peux classer les stratégies de découpage de la plus simple à la plus avancée : la coupe par caractères, comme notre simple_chunker de la première leçon, le découpage par phrases, le découpage sémantique et le parser hiérarchique. On commence par les phrases :
1from llama_index.core.node_parser import (
2 SentenceSplitter,
3 SemanticSplitterNodeParser,
4)
5
6# Découpage simple par phrases
7sentence_parser = SentenceSplitter(
8 chunk_size=512,
9 chunk_overlap=50
10)
11nodes = sentence_parser.get_nodes_from_documents(documents)SentenceSplitter coupe le texte en morceaux de 512 tokens maximum en essayant de ne pas casser les phrases, et chunk_overlap est le chevauchement que tu connais déjà.
Le découpage sémantique va plus loin : il compare les embeddings des phrases voisines et coupe là où le sens change nettement :
1# Découpage sémantique
2semantic_parser = SemanticSplitterNodeParser(
3 buffer_size=1,
4 breakpoint_percentile_threshold=95,
5 embed_model=Settings.embed_model
6)
7semantic_nodes = semantic_parser.get_nodes_from_documents(documents)breakpoint_percentile_threshold=95 signifie qu'une coupe n'a lieu qu'aux 5 % des plus grands sauts de sens. C'est plus coûteux, car il faut un embedding pour chaque phrase.
Le parser hiérarchique crée plusieurs niveaux de morceaux à la fois, des grands aux petits :
1# Découpage hiérarchique
2from llama_index.core.node_parser import HierarchicalNodeParser
3
4hierarchical_parser = HierarchicalNodeParser.from_defaults(
5 chunk_sizes=[2048, 512, 128]
6)
7hierarchical_nodes = hierarchical_parser.get_nodes_from_documents(documents)Les petits morceaux (128 tokens) trouvent bien leur cible pendant la recherche, tandis que les grands (2048) donnent au modèle un contexte plus large. Les documents d'entrée ne changent pas, seule leur façon d'être découpés change.
Modes de retriever
Un retriever, c'est l'éclaireur qui rapporte les morceaux correspondants. Tu peux le configurer à la main et ajouter un post-traitement :
1from llama_index.core.retrievers import VectorIndexRetriever
2from llama_index.core.query_engine import RetrieverQueryEngine
3from llama_index.core.postprocessor import SimilarityPostprocessor
4
5# Retriever de base
6retriever = VectorIndexRetriever(
7 index=index,
8 similarity_top_k=5
9)
10
11# Avec post-traitement
12query_engine = RetrieverQueryEngine(
13 retriever=retriever,
14 node_postprocessors=[
15 SimilarityPostprocessor(similarity_cutoff=0.7)
16 ]
17)similarity_top_k=5 récupère cinq morceaux, et SimilarityPostprocessor écarte ceux dont la similarité est inférieure à 0.7, pour que les résultats faibles n'encombrent pas le prompt.
La recherche hybride de la leçon précédente est prête ici aussi. Attention : dans les versions actuelles, BM25Retriever se trouve dans le paquet séparé llama-index-retrievers-bm25, et non dans llama_index.core :
1# Retriever hybride
2from llama_index.retrievers.bm25 import BM25Retriever # pip install llama-index-retrievers-bm25
3from llama_index.core.retrievers import QueryFusionRetriever
4
5vector_retriever = index.as_retriever(similarity_top_k=5)
6bm25_retriever = BM25Retriever.from_defaults(nodes=nodes, similarity_top_k=5)
7
8hybrid_retriever = QueryFusionRetriever(
9 retrievers=[vector_retriever, bm25_retriever],
10 similarity_top_k=5,
11 num_queries=1,
12)QueryFusionRetriever fusionne les résultats des deux éclaireurs en une seule liste. num_queries=1 signifie qu'on utilise seulement la question d'origine, sans variantes supplémentaires générées par le LLM.
Transformations de requêtes
Parfois, le problème vient de la question elle-même. Une question complexe gagne à être découpée en plus petites, et une question courte peut être reformulée pour mieux correspondre aux documents :
1from llama_index.core.query_engine import SubQuestionQueryEngine
2from llama_index.core.tools import QueryEngineTool
3
4# Moteur de sous-questions - décompose les questions en plus petites
5tools = [
6 QueryEngineTool.from_defaults(
7 query_engine=index.as_query_engine(),
8 name="documentation",
9 description="Contains project documentation"
10 )
11]
12
13sub_question_engine = SubQuestionQueryEngine.from_defaults(
14 query_engine_tools=tools
15)
16
17# HyDE - Hypothetical Document Embeddings
18from llama_index.core.indices.query.query_transform import HyDEQueryTransform
19from llama_index.core.query_engine import TransformQueryEngine
20
21hyde = HyDEQueryTransform(include_original=True)
22hyde_query_engine = TransformQueryEngine(
23 index.as_query_engine(),
24 query_transform=hyde
25)SubQuestionQueryEngine découpe une question en sous-questions et envoie chacune vers le bon outil, ici un seul, nommé "documentation". HyDE demande d'abord au modèle d'écrire une réponse hypothétique, puis cherche des documents similaires à celle-ci, car une réponse est souvent plus proche des documents que la question elle-même. include_original=True conserve aussi la question d'origine.
Chat Engine
Un query engine ne se souvient pas des questions précédentes. Pour une conversation, on utilise un chat engine avec mémoire :
1from llama_index.core.chat_engine import CondenseQuestionChatEngine
2from llama_index.core.memory import ChatMemoryBuffer
3
4# Mémoire de chat
5memory = ChatMemoryBuffer.from_defaults(token_limit=3000)
6
7# Moteur de chat avec historique
8chat_engine = index.as_chat_engine(
9 chat_mode="condense_question",
10 memory=memory,
11 verbose=True
12)
13
14# Conversation
15response1 = chat_engine.chat("What is Python?")
16print(response1)
17
18response2 = chat_engine.chat("What are its applications?")
19print(response2)
20
21# Réinitialise la mémoire
22chat_engine.reset()Le mode condense_question réécrit une question de suivi comme "What are its applications?" en question autonome à l'aide de l'historique, et seulement ensuite interroge l'index. reset() vide la mémoire. Dans les versions les plus récentes, ChatMemoryBuffer est marqué deprecated au profit de la classe Memory du même module, mais il fonctionne toujours.
Intégration avec les bases vectorielles
Par défaut, l'index vit en mémoire. Pour qu'il survive à un redémarrage, on branche une base vectorielle de la leçon précédente. Voici à quoi ressemble Chroma :
1# Chroma
2from llama_index.vector_stores.chroma import ChromaVectorStore
3import chromadb
4
5chroma_client = chromadb.PersistentClient(path="./chroma_db")
6chroma_collection = chroma_client.get_or_create_collection("llama_index")
7
8vector_store = ChromaVectorStore(chroma_collection=chroma_collection)
9index = VectorStoreIndex.from_vector_store(vector_store)from_vector_store ouvre un index au-dessus d'une collection qui contient déjà des données. Pour y écrire de nouveaux documents, passe vector_store via un StorageContext à from_documents.
Qdrant et Pinecone se branchent exactement de la même façon, seule la classe de stockage change :
1# Qdrant
2from llama_index.vector_stores.qdrant import QdrantVectorStore
3from qdrant_client import QdrantClient
4
5qdrant_client = QdrantClient(host="localhost", port=6333)
6vector_store = QdrantVectorStore(
7 client=qdrant_client,
8 collection_name="llama_index"
9)
10
11# Pinecone
12from llama_index.vector_stores.pinecone import PineconeVectorStore
13from pinecone import Pinecone
14
15pc = Pinecone(api_key="...")
16pinecone_index = pc.Index("llama-index")
17vector_store = PineconeVectorStore(pinecone_index=pinecone_index)Le reste du code, c'est-à-dire le query engine, les retrievers et le chat engine, reste inchangé, et c'est le plus grand avantage de cette couche d'abstraction.
Évaluation
Pour finir, on vérifie que les réponses sont fiables. LlamaIndex propose des évaluateurs qui utilisent un modèle de langage comme juge :
1from llama_index.core.evaluation import (
2 FaithfulnessEvaluator,
3 RelevancyEvaluator,
4 CorrectnessEvaluator
5)
6
7# Évaluateurs
8faithfulness = FaithfulnessEvaluator()
9relevancy = RelevancyEvaluator()
10
11# Évalue la réponse
12query = "What is RAG?"
13response = query_engine.query(query)
14
15faithfulness_result = faithfulness.evaluate_response(response=response)
16print(f"Faithfulness: {faithfulness_result.passing}")
17
18relevancy_result = relevancy.evaluate_response(query=query, response=response)
19print(f"Relevancy: {relevancy_result.passing}")FaithfulnessEvaluator vérifie si la réponse découle du contexte récupéré, donc il repère les hallucinations. RelevancyEvaluator vérifie si la réponse et le contexte correspondent à la question. Le champ passing vaut True ou False.
Je te conseille de commencer par SentenceSplitter et un query engine simple, et de ne sortir HyDE ou la recherche hybride que lorsque l'évaluation montre un problème concret.
LlamaIndex est un framework complet pour le RAG. Dans la prochaine leçon, tu vas découvrir les systèmes multi-agents - quand un seul agent ne suffit plus !
Retiens : LlamaIndex, c'est un équipement d'expédition tout prêt qui te permet de te concentrer sur l'itinéraire plutôt que de coudre ta tente.
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. À quoi sert LlamaIndex ?
2. Quelle classe LlamaIndex sert à charger des documents depuis un répertoire ?
Tâches pratiques dans le jeu
- Éditeur de code
Implémente un système de questions-réponses basique avec LlamaIndex
- Tri horizontal
Ordonne le pipeline LlamaIndex de base :
- Tri vertical
Trie les stratégies de chunking de la plus simple à la plus avancée :