Tu as une montagne de PDF qui dorment dans un dossier : contrats, comptes rendus, notes, rapports. Tu rêves de pouvoir leur poser des questions en langage naturel et d'obtenir des réponses précises, sans tout relire. C'est exactement ce que permet LlamaIndex, une bibliothèque Python qui construit des moteurs de RAG (Retrieval-Augmented Generation, ou génération augmentée par récupération). Dans ce tutoriel, on va construire de bout en bout un moteur de questions-réponses qui ingère tes documents et répond en s'appuyant uniquement sur leur contenu. Pas de hallucination, pas de baratin.
Le principe du RAG est simple : on découpe les documents en petits morceaux, on les transforme en vecteurs (des listes de nombres qui capturent leur sens), on les range dans une base vectorielle, puis, à chaque question, on récupère les morceaux les plus proches sémantiquement et on les injecte dans le prompt d'un grand modèle de langage. Le modèle répond en s'appuyant sur ces extraits, ce qui ancre ses réponses dans tes données réelles.
LlamaIndex orchestre tout ce pipeline pour toi : chargement, découpage, vectorisation, indexation, récupération et synthèse. En moins de cinquante lignes de Python, tu obtiens un assistant qui connaît tes documents sur le bout des doigts.
Ce que tu vas construire
À la fin de ce tutoriel, tu auras un script Python capable de :
- Charger tous les PDF d'un dossier (contrats, rapports, notes de cours...).
- Les découper en morceaux de taille adaptée pour la recherche.
- Créer un index vectoriel interrogeable en langage naturel.
- Répondre à des questions avec citation des sources (les extraits utilisés).
- Sauvegarder l'index pour ne pas le recalculer à chaque lancement.
- Basculer du modèle payant d'OpenAI vers un modèle local Ollama sans réécrire le pipeline.
Prérequis et installation
Il te faut Python 3.9 ou plus récent, un accès internet et, pour le flux principal, une clé API OpenAI. Si tu préfères une solution 100% locale et gratuite, installe Ollama et suis l'encadré de la fin du tutoriel : le code est identique, seul le choix du modèle change.
mkdir mon-rag && cd mon-rag
python3 -m venv .venv
source .venv/bin/activate # Linux / macOS
# .venv\Scripts\activate # Windows
pip install llama-index llama-index-llms-openai llama-index-embeddings-openai
pip install pypdfL'installation est légère : le paquet llama-index est le cœur de la bibliothèque, tandis que llama-index-llms-openai et llama-index-embeddings-openai apportent respectivement l'intégration du LLM et du modèle d'embedding d'OpenAI. Le paquet pypdf sert à extraire le texte des fichiers PDF.
Étape 1 — Configurer les modèles
La classe Settings est la tour de contrôle de LlamaIndex. C'est là que tu déclares, une fois pour toutes, quel modèle de langage génère les réponses et quel modèle d'embedding transforme le texte en vecteurs. Tout le pipeline (indexation et requêtes) hérite ensuite automatiquement de ces réglages.
import os
from llama_index.core import SimpleDirectoryReader, VectorStoreIndex, Settings
from llama_index.llms.openai import OpenAI
from llama_index.embeddings.openai import OpenAIEmbedding
# Ta clé API OpenAI (garde-la en variable d'environnement en production)
os.environ["OPENAI_API_KEY"] = "sk-..."
# On configure une fois pour tout le pipeline : LLM + modèle d'embedding
Settings.llm = OpenAI(model="gpt-4o-mini", temperature=0.1)
Settings.embed_model = OpenAIEmbedding(model="text-embedding-3-small")
print("Configuration OK :", Settings.llm.model, "/", Settings.embed_model.model_name)Le paramètre temperature=0.1 rend le modèle factuel et stable, ce qu'on veut pour de la recherche documentaire : on privilégie la fidélité au texte source plutôt que la créativité. Le modèle text-embedding-3-small est un bon compromis qualité-coût pour vectoriser des documents.
Étape 2 — Charger les PDF
SimpleDirectoryReader est le point d'entrée le plus simple : il parcourt un dossier, détecte les fichiers, et en extrait le contenu. Chaque document devient un objet Document avec son texte et des métadonnées (nom de fichier, chemin).
from llama_index.core import SimpleDirectoryReader
# Charge tous les PDF du dossier (récursivement)
documents = SimpleDirectoryReader(
input_dir="./mes_pdfs",
required_exts=[".pdf"],
recursive=True,
).load_data()
print(f"{len(documents)} documents chargés")
for doc in documents[:5]:
print("-", doc.metadata.get("file_name"), f"({len(doc.text)} caractères)")La limite required_exts=[".pdf"] restreint la lecture aux PDF, mais LlamaIndex sait aussi lire le Markdown, le HTML, le CSV, les fichiers Word et bien d'autres formats, souvent via des lecteurs additionnels. Le paramètre recursive=True descend dans les sous-dossiers.
Étape 3 — Découper en chunks
Un PDF peut faire des dizaines de pages. On ne peut pas l'envoyer d'un bloc au modèle (trop long) ni le vectoriser d'un seul tenant (trop vague). On le découpe donc en morceaux appelés chunks. C'est l'étape la plus sous-estimée du RAG : la qualité de tes réponses dépend directement de la qualité du découpage.
from llama_index.core.node_parser import SentenceSplitter
# On découpe chaque document en morceaux (chunks) de ~512 caractères,
# avec un chevauchement de 64 caractères pour ne pas couper une phrase en deux.
splitter = SentenceSplitter(chunk_size=512, chunk_overlap=64)
nodes = splitter.get_nodes_from_documents(documents)
print(f"{len(nodes)} chunks générés")La règle d'or : un chunk doit contenir une idée complète. chunk_size=512 convient à la plupart des textes ; chunk_overlap=64 évite de tronquer une phrase en plein milieu, ce qui préserve la continuité entre deux morceaux. Pour des textes très techniques, tu peux descendre à 256 caractères avec un chevauchement de 32.
Étape 4 — Créer l'index vectoriel
VectorStoreIndex est l'index par défaut de LlamaIndex. Il vectorise chaque chunk avec le modèle d'embedding, puis construit une structure qui permet de retrouver rapidement les chunks les plus proches d'une question donnée.
from llama_index.core import VectorStoreIndex
# LlamaIndex vectorise chaque chunk, puis construit l'index vectoriel.
# L'index est stocké en mémoire (on verra la persistance plus loin).
index = VectorStoreIndex(nodes)
print("Index vectoriel prêt")En interne, chaque chunk est converti en vecteur (une liste de nombres qui encode son sens), et ces vecteurs sont stockés en mémoire. Deux phrases qui parlent de la même chose produisent des vecteurs proches : c'est ce qui permettra de retrouver le bon passage même si la question n'utilise pas les mots exacts du document.
Étape 5 — Poser des questions
Le query engine est la pièce maîtresse. Il enchaîne trois étapes : récupérer les chunks les plus pertinents (retrieval), les assembler dans un prompt avec ta question, puis demander au LLM de synthétiser une réponse. Le paramètre similarity_top_k=4 limite la recherche aux quatre chunks les plus proches.
# Le query engine orchestre : recherche + synthèse + réponse
query_engine = index.as_query_engine(similarity_top_k=4)
response = query_engine.query(
"Quelle est la date de signature du contrat et qui sont les signataires ?"
)
print(response)La réponse renvoyée est un objet Response : la chaîne affichée est sa forme texte, mais il contient aussi les sources, les métadonnées et le prompt complet envoyé au modèle. On va exploiter tout cela dans les sections suivantes.
Affiner tes réponses
Activer le streaming
Par défaut, query attend la fin de la génération avant de renvoyer quoi que ce soit. Avec streaming=True, tu récupères la réponse mot à mot, ce qui rend l'outil nettement plus agréable à utiliser dans une interface.
# Active le streaming pour afficher la réponse mot à mot
query_engine = index.as_query_engine(streaming=True)
response = query_engine.query("Résume les clauses de résiliation du contrat.")
for token in response.response_gen:
print(token, end="")Citer les sources
Le grand intérêt du RAG, c'est la traçabilité. Chaque réponse s'accompagne des chunks qui ont servi à la construire, avec un score de similarité. C'est ce qui te permet de vérifier qu'une affirmation provient bien de tes documents, et non d'une hallucination du modèle.
response = query_engine.query("Quelles sont les pénalités de retard ?")
# response.source_nodes contient les chunks utilisés pour répondre
for i, node in enumerate(response.source_nodes):
score = round(node.score, 4)
print(f"--- Source {i + 1} (score de similarité : {score}) ---")
print(node.node.get_content()[:300])
print()Personnaliser le prompt de synthèse
Le prompt par défaut est correct mais générique. En le remplaçant, tu imposes un ton, une langue, un format de sortie, ou une consigne métier. C'est là que ton outil gagne en valeur : un assistant juridique ne répond pas comme un assistant scolaire.
from llama_index.core import PromptTemplate
# On personnalise le prompt de synthèse pour un usage juridique
qa_template = PromptTemplate(
"Tu es un assistant juridique rigoureux. Réponds uniquement à partir "
"du contexte fourni, en français, de manière structurée.\n"
"Si la réponse ne figure pas dans le contexte, dis-le explicitement.\n\n"
"Contexte :\n{context_str}\n\n"
"Question : {query_str}\n"
"Réponse :"
)
query_engine.update_prompts(
{"response_synthesizer:text_qa_template": qa_template}
)
print(query_engine.query("Quelle est la durée du préavis ?"))Persister l'index
Construire un index prend du temps (lecture des PDF, vectorisation). Le recalculer à chaque lancement serait du gaspillage. LlamaIndex sait sauvegarder l'index sur le disque, puis le recharger en une fraction de seconde.
# Sauvegarde l'index sur le disque (fichiers JSON + vecteurs)
index.storage_context.persist(persist_dir="./storage")
# --- Dans un autre script, plus tard ---
from llama_index.core import StorageContext, load_index_from_storage
storage_context = StorageContext.from_defaults(persist_dir="./storage")
index = load_index_from_storage(storage_context)
# L'index est rechargé : on peut re-poser des questions sans re-indexer
query_engine = index.as_query_engine()
print(query_engine.query("Qui est le prestataire mentionné dans le contrat ?"))Le dossier ./storage contient l'ensemble de l'état : les vecteurs, les documents, et l'arbre d'index. Tu peux l'archiver, le versionner ou le copier sur un serveur. À condition de réutiliser le même modèle d'embedding, recharger l'index donne exactement les mêmes résultats qu'au moment de la sauvegarde.
Changer de modèle LLM et d'embedding
Le découplage est total : les étapes de chargement et de découpage ne dépendent pas du modèle. Tu peux donc améliorer la qualité de tes réponses en changeant simplement le LLM, sans toucher au reste du pipeline.
from llama_index.core import Settings
from llama_index.llms.openai import OpenAI
from llama_index.embeddings.openai import OpenAIEmbedding
# Passe à un modèle plus puissant pour des questions complexes
Settings.llm = OpenAI(model="gpt-4o", temperature=0)
# Ou, pour un coût réduit sur des questions simples
Settings.embed_model = OpenAIEmbedding(model="text-embedding-3-small")
# Les nouveaux réglages s'appliquent aux prochains query engines créés
index = VectorStoreIndex(nodes)
query_engine = index.as_query_engine()
print(query_engine.query("Résume ce document en trois points."))Ce découplage vaut aussi pour le fournisseur. Voici la version locale, sans clé API et sans coût, qui passe par Ollama :
# Alternative 100% locale : aucun appel API, aucune clé, tout tourne sur ta machine
# pip install llama-index-llms-ollama llama-index-embeddings-ollama
from llama_index.core import Settings
from llama_index.llms.ollama import Ollama
from llama_index.embeddings.ollama import OllamaEmbedding
Settings.llm = Ollama(model="llama3.2", request_timeout=120)
Settings.embed_model = OllamaEmbedding(model_name="nomic-embed-text")
# Le reste du pipeline est identique
index = VectorStoreIndex(nodes)
print(index.as_query_engine().query("Quelle est la date de signature ?"))À condition qu'Ollama soit installé et que les modèles soient téléchargés (ollama pull llama3.2 puis ollama pull nomic-embed-text), ce code fait tourner l'intégralité du RAG sur ta machine. C'est la solution idéale pour des documents confidentiels qui ne doivent pas quitter ton poste.
Aller plus loin : le chat multi-tours
Le query engine est sans mémoire : chaque question est traitée indépendamment. Le chat engine, lui, conserve l'historique de la conversation et reformule chaque question en tenant compte du contexte. C'est indispensable pour des échanges du type « et la clause suivante ? » où la question ne se comprend qu'avec la précédente.
from llama_index.core import VectorStoreIndex
index = VectorStoreIndex(nodes)
chat_engine = index.as_chat_engine(chat_mode="condense_question", verbose=True)
print("Pose tes questions (tape 'quit' pour sortir).")
while True:
question = input("Toi : ")
if question.lower() in ("quit", "exit", "q"):
break
response = chat_engine.chat(question)
print(f"IA : {response}")
Le mode condense_question réécrit la question de l'utilisateur en y condensant l'historique, puis lance une recherche classique. C'est le mode le plus économique et le plus robuste pour commencer. Les modes react et best offrent des comportements plus avancés (raisonnement par étapes, utilisation d'outils) au prix d'une plus grande complexité.
Les erreurs classiques et comment les corriger
- OPENAI_API_KEY manquante : le pipeline plante au premier embedding. Exporte ta clé avec os.environ ou un fichier .env avant de créer l'index.
- Réponses qui semblent inventées : augmente similarity_top_k ou vérifie que tes chunks ne sont pas trop petits (une phrase isolée perd son contexte).
- Index incohérent après rechargement : tu as changé de modèle d'embedding entre la sauvegarde et le rechargement. Garde le même, ou reconstruis l'index.
- Chunks qui coupent les tableaux ou les listes : le découpage par phrases ignore la structure. Passe par un parseur de document dédié (LlamaParse) pour les PDF complexes.
- Mémoire qui explose sur de gros corpus : pour des dizaines de milliers de documents, branche un vrai vector store (Chroma, Qdrant, Pinecone) à la place du stockage en mémoire.
Comprendre ce qui se passe sous le capot
Pour bien maîtriser ton moteur de RAG, il est utile de savoir ce que LlamaIndex fait à chaque question. Cinq opérations s'enchaînent, dans l'ordre :
- La question est transformée en vecteur par le modèle d'embedding, exactement comme les chunks l'ont été au moment de l'indexation.
- LlamaIndex calcule la similarité entre ce vecteur et tous les vecteurs des chunks, généralement via une similarité cosinus.
- Les k chunks les plus proches (k étant le paramètre similarity_top_k) sont sélectionnés : c'est l'étape de récupération.
- Ces chunks sont assemblés dans un prompt avec la question et, le cas échéant, l'historique de conversation.
- Le LLM génère une réponse en s'appuyant sur ce contexte, ce qui ancre sa sortie dans tes documents plutôt que dans sa mémoire.
La qualité du résultat dépend de chaque maillon de la chaîne. Si le découpage est mauvais, les chunks récupérés sont peu pertinents. Si le modèle d'embedding est faible, la similarité sémantique est approximative et des passages utiles passent à la trappe. Si le prompt de synthèse est bâclé, la réponse finale peut ignorer le contexte fourni. C'est précisément pour cela qu'on affine ces trois leviers en priorité : le découpage, l'embedding et le prompt.
Un point mérite d'être souligné : le LLM ne « connaît » pas tes documents. Toute sa connaissance du contenu provient des chunks qu'on lui injecte dans le prompt. C'est ce qui rend le RAG fiable pour des données récentes ou privées, mais c'est aussi ce qui rend la qualité de la récupération cruciale : si le bon passage n'est pas récupéré, le modèle ne peut pas deviner son existence.
Choisir la bonne taille de chunk
Il n'existe pas de taille de chunk universelle : le bon réglage dépend de ton corpus et de tes questions. Quelques repères pour t'orienter :
- Chunks courts (128 à 256 caractères) : précis pour répondre à des questions factuelles ciblées, mais ils perdent le contexte d'une discussion.
- Chunks moyens (512 à 1024 caractères) : le bon compromis par défaut pour la plupart des documents narratifs ou techniques.
- Chunks longs (1500 caractères et plus) : utiles quand une réponse exige plusieurs paragraphes de contexte, au prix d'un bruit plus important.
Le chevauchement (chunk_overlap) compense la perte de contexte aux frontières : 10 à 20% de la taille du chunk est un point de départ raisonnable. Pour les documents très structurés (code, tableaux, contrats), un découpage par section plutôt que par taille de caractères donne souvent de meilleurs résultats. C'est là que des parseurs spécialisés comme LlamaParse prennent tout leur intérêt.
Filtrer les résultats par métadonnées
SimpleDirectoryReader attache à chaque document des métadonnées utiles : le nom du fichier, son chemin, sa taille. Tu peux t'en servir pour restreindre la recherche à un sous-ensemble de documents, par exemple ne chercher que dans les contrats, ou écarter un document obsolète.
from llama_index.core import VectorStoreIndex
# SimpleDirectoryReader attache le nom de fichier à chaque document.
# On filtre AVANT l'indexation pour ne garder que les contrats.
contrats = [
doc for doc in documents
if "contrat" in doc.metadata.get("file_name", "")
]
print(f"{len(contrats)} contrats retenus sur {len(documents)} documents")
index_contrats = VectorStoreIndex.from_documents(contrats)
query_engine = index_contrats.as_query_engine()
print(query_engine.query("Quelle est la date de signature ?"))Cette technique est précieuse quand ton corpus est hétérogène : plutôt que de mélanger contrats, factures et notes de service dans une même recherche, tu crées des index spécialisés ou tu filtres à la requête. Tu peux aussi enrichir les métadonnées toi-même, en ajoutant un champ type, une date ou un identifiant client avant l'indexation, pour affiner encore le filtrage.
Évaluer la qualité de tes réponses
Un moteur de RAG ne se juge pas à l'impression, mais à des critères objectifs. Quand tu ajustes tes paramètres, la taille de chunk, le modèle ou le top_k, mesure l'impact plutôt que de te fier à ton intuition :
- La fidélité : la réponse s'appuie-t-elle réellement sur les chunks récupérés, sans rien inventer ?
- La pertinence : les chunks sélectionnés répondent-ils bien à la question posée ?
- La couverture : la réponse omet-elle des informations importantes pourtant présentes dans le corpus ?
- La concision : la réponse va-t-elle droit au but ou noie-t-elle l'essentiel dans du remplissage ?
Pour automatiser ces mesures, LlamaIndex fournit des évaluateurs dédiés (faithfulness, relevancy, correctness) qui notent chaque réponse à l'aide d'un LLM. En intégrant ces métriques à ton processus d'itération, tu transformes le réglage du RAG d'un art approximatif en une démarche mesurable et reproductible.
Passer à l'échelle : vector stores et reranking
L'index en mémoire convient à quelques centaines de documents. Au-delà, ou dès que tu veux partager l'index entre plusieurs services, tu branches un vector store externe : Chroma pour du local, Qdrant ou Pinecone pour du cloud, PostgreSQL avec pgvector pour réutiliser une base existante. La logique du code reste la même, seul le backend de stockage change.
Autre levier d'amélioration : le reranking. La recherche par similarité est rapide mais parfois grossière. Un modèle de reranking, comme ceux de Cohere ou de la famille BGE, reprend les meilleurs candidats et les reclasse plus finement, ce qui améliore nettement la précision sur les corpus volumineux ou techniques. LlamaIndex intègre ces rerankers en quelques lignes, sans bouleverser le pipeline construit ici.
LlamaIndex vs LangChain : lequel choisir ?
LlamaIndex et LangChain sont souvent cités ensemble, mais ils ne jouent pas le même rôle. LlamaIndex est spécialisé dans l'indexation et la récupération de données (le cœur du RAG), tandis que LangChain est un framework généraliste d'orchestration d'agents et de chaînes. En pratique, les deux ne s'excluent pas : beaucoup de projets utilisent LlamaIndex pour la partie données et LangChain pour l'orchestration.
- LlamaIndex : parsers de documents, découpage, index vectoriels, query engines et chat engines. Le choix naturel pour du RAG sur tes propres données.
- LangChain : chaînes, agents, outils, mémoire. Le choix naturel pour des workflows multi-étapes qui orchestrent plusieurs composants.
- Le point commun : les deux s'appuient sur les mêmes modèles (OpenAI, Ollama...) et les mêmes vector stores. La brique données est interchangeable.
Si ton besoin se résume à « interroger mes documents », LlamaIndex est le chemin le plus court. Si tu envisages une application plus large avec des agents et des outils, tu pourras toujours y intégrer un index LlamaIndex comme composant.
Sécurité et confidentialité des données
Un moteur de RAG manipule souvent des documents sensibles. Deux précautions s'imposent avant de mettre ton outil en production :
- Si tes documents ne doivent pas quitter ta machine, utilise le flux Ollama (modèles locaux) : ni les PDF ni les questions ne transitent par un service tiers.
- Si tu passes par une API cloud, vérifie la politique de conservation des données du fournisseur et évite d'y envoyer des informations réglementées ou confidentielles.
Pense aussi aux clés API : ne les écris jamais en dur dans ton code. Utilise un fichier .env chargé avec python-dotenv, ou les variables d'environnement de ton système. C'est une ligne de défense de plus contre les fuites accidentelles de secrets.
Conclusion
En quelques dizaines de lignes, tu as transformé un dossier de PDF en un assistant interrogeable en langage naturel, avec citation des sources, personnalisation du prompt, persistance et choix libre du modèle. LlamaIndex brille par cette capacité à masquer la complexité du RAG derrière une API cohérente, tout en laissant chaque brique accessible quand tu veux affiner.
La suite logique : brancher un vector store persistant pour les gros volumes, ajouter un ré-ordonnancement (reranking) pour améliorer la précision, ou exposer le tout derrière une petite API web. Les fondations posées ici te serviront pour chacun de ces chantiers.






