RAG avec LangChain de zéro : chargeurs, blocs, embeddings, évaluation
Construire un système de questions-réponses pour documents étape par étape : chargement des fichiers, segmentation en blocs, embeddings, les phases d’indexation et de récupération, ainsi que la manière d’évaluer les réponses obtenues.
Sources: Building a RAG System with LangChain
Un système qui lit vos documents et répond aux questions à leur sujet est l’une des choses les plus utiles que vous puissiez créer avec un LLM aujourd’hui. Ce modèle est appelé RAG, c’est-à-dire génération augmentée par la récupération d’informations. Voici tous les éléments qui le composent, leur fonction, ainsi que comment déterminer si votre création est de qualité.
Qu’est-ce que LangChain ?
LangChain n’est ni un modèle ni une base de données. C’est un framework, proche d’un adaptateur universel. Il existe des dizaines de fournisseurs d’LLM avec leurs propres API, des dizaines de bases de données vectorielles et des dizaines de chargeurs de documents. Sans une couche intermédiaire, utiliser GPT-4 aujourd’hui et Claude demain signifie réécrire la moitié de votre code. LangChain offre une interface cohérente pour tous ces éléments.
Le nom provient de sa deuxième fonction. Une véritable application LLM est rarement le résultat d’une seule appel à un modèle. Il peut être nécessaire de charger un document, de le résumer, d’appliquer une autre requête sur ce résumé et de formater le résultat. Une chaîne relie ces étapes en un pipeline. Dans le cas de RAG, la chaîne commence par la question de l’utilisateur ; celle-ci est intégrée dans un vecteur d’embedding, ce dernier déclenche une recherche dans une base de données, et les résultats principaux sont insérés dans un modèle de prompt. L’LLM est alors sollicité avec ce prompt, sa réponse est analysée, et la réponse ainsi obtenue est renvoyée à l’utilisateur. LangChain fournit les éléments de base pour chacune de ces étapes, ce qui évite de devoir tout assembler manuellement.
Chargement des documents
Tout d’abord, les documents doivent être importés, et ce non pas sous forme de octets bruts. Que les données soient un PDF, une page web, un fichier CSV, un fichier Word ou un dossier contenant du markdown, un chargeur les transforme en un objet Document standard doté de deux champs : page_content pour le texte et metadata indiquant d’où proviennent les données (page, URL, tout élément utile). Les métadonnées sont importantes par la suite, car elles permettent d’indiquer aux utilisateurs quelle source a fourni une réponse.
from langchain.document_loaders import PyPDFLoader
loader = PyPDFLoader("handbook.pdf")
documents = loader.load()
Cela produit un Document par page du PDF, chacun contenant son texte et son numéro de page. C’est une habitude à prendre tôt : il faut toujours associer l’origine d’un texte à celui-ci, car chaque étape ultérieure, de la segmentation en fragments à la citation d’une source dans la réponse finale, dépend de cette trace. Même en changeant le chargeur pour un autre type de fichier, la structure du résultat reste identique.
Segmentation
Un document peut être un PDF de 200 pages. Même avec une grande fenêtre de contexte, insérer des livres entiers dans chaque requête est gaspilleur, coûteux et souvent pire que de fournir au modèle des extraits ciblés. Les documents trop longs sont donc divisés en fragments de quelques centaines de tokens, et seuls les extraits les plus pertinents sont transmis au LLM lorsque quelqu’un pose une question.
Deux paramètres sont les plus importants. La taille des blocs correspond à la longueur de chaque partie : si elle est trop petite, on perd le contexte ; si elle est trop grande, du texte irrelevant est inclus. Environ 500 tokens constituent un bon point de départ. L’overlap indique dans quelle mesure les blocs voisins se chevauchent. Sans overlap, une phrase répartie sur deux blocs perd son sens, tandis qu’avec overlap, les deux blocs perçoivent le lien entre eux.
RecursiveCharacterTextSplitter est le choix habituel. Il privilégie les limites naturelles, en essayant d’abord les paragraphes, puis les phrases, ensuite les mots, et ne coupe qu’en milieu de mot que comme dernier recours. Il existe aussi des options spécialisées : MarkdownHeaderTextSplitter pour les documents contenant des titres, et TokenTextSplitter lorsque l’on souhaite compter des tokens réels plutôt que des caractères.
from langchain.text_splitter import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=500,
chunk_overlap=50
)
chunks = splitter.split_documents(documents)
Le découpage des données semble anodin, mais il a un impact considérable sur la qualité. C’est aussi la dernière configuration que les gens ajustent, bien qu’une mauvaise segmentation limite silencieusement tout le processus ultérieur. Si les blocs sont incorrects, aucun stratagème de récupération ne pourra sauver le système.
Embeddings
Les ordinateurs ne peuvent pas se demander « quel bloc ressemble à ma question ? » à partir de texte brut. Un modèle d’embedding, également appelé encodeur, convertit le texte en un vecteur, c’est-à-dire une liste de nombres, et la propriété clé est que les textes ayant un sens similaire obtiennent des vecteurs similaires.
Imaginez que chaque texte reçoive une adresse dans un espace à des centaines ou des milliers de dimensions. Un chien qui aboie et un chiot qui gémit obtiennent des adresses voisines, tandis qu’une crise boursière se situe bien plus loin. Comparer les significations devient alors une question de géométrie : à quelle distance se trouvent deux points ?
Deux précisions aident à comprendre. Premièrement, c’est l’encodeur qui crée les vecteurs, et non la base de données vectorielle. Chroma, Pinecone, Weaviate et Qdrant se contentent de stocker les vecteurs et de les rechercher rapidement, sans comprendre le langage. Les gens confondent constamment ces éléments. Deuxièmement, la taille des vecteurs dépend de l’encodeur : text-embedding-3-small d’OpenAI produit des vecteurs de 1536 dimensions, tandis qu’un modèle open source comme all-MiniLM-L6-v2 en produit 384. Plus il y a de dimensions, plus on peut capturer de nuances, mais cela entraîne un besoin accru d’espace de stockage et des recherches plus lentes.
Il est impossible d’imaginer des embeddings réels, c’est pourquoi on les projette en 2D ou 3D. En incorporant un ensemble de mots et en les projetant, des clusters apparaissent : les animaux ensemble, les fruits ailleurs, les véhicules dans un autre coin. Personne n’a indiqué au modèle ces catégories ; il a appris, à partir d’énormes quantités de texte, que « chien » et « chat » apparaissent dans des contextes similaires.
C’est ce qui permet au RAG de fonctionner. Lorsqu’un utilisateur demande ce que l’entreprise dit au sujet du congé de paternité, le vecteur de la question se situe près de fragments concernant les « avantages liés au congé familial » ou le « congé pour les nouveaux parents », même si ces termes diffèrent.
Le processus
Il y a deux phases. L’indexation a lieu une seule fois lors de la configuration : chargement, segmentation en blocs, intégration de chaque bloc et stockage des vecteurs. Les nouveaux documents sont ensuite simplement intégrés et ajoutés. La récupération s’exécute pour chaque question : l’question est intégrée à l’aide du même encodeur, les blocs les plus proches sont trouvés dans la base de données vectorielle, ils sont placés dans un prompt avec la question et envoyés à un LLM, qui fonde sa réponse sur vos documents et non sur des suppositions. Une seule base de données vectorielle sert les deux phases, étant écrite pendant l’indexation et lue pendant la récupération. Telle est toute l’architecture. Comme l’indexation représente un coût unique, la partie lente (l’intégration de milliers de blocs) n’affecte pas le temps d’attente de l’utilisateur. Seuls l’intégration de la requête, la recherche et l’appel à l’LLM ont un impact.
Lors de l’exécution, quatre acteurs interviennent : l’utilisateur, le code de votre application, le récupérateur d’informations et le LLM. L’utilisateur envoie une question à votre code. Votre code demande au récupérateur des extraits pertinents, qui intègre ensuite la question et effectue une recherche dans la base de données. Les K extraits les plus similaires sont retournés. Votre code crée ensuite une demande à partir de ces extraits ainsi que de la question originale, qu’il envoie au LLM, lequel génère une réponse à partir du contexte fourni. Votre code renvoie cette réponse.
LangChain couvre la majeure partie de ce processus grâce à deux abstractions. Le LLM (ou ChatModel) encapsule votre modèle, tandis que le Retriever gère la recherche dans le stockage vectoriel. En les reliant, le framework s’occupe du reste :
from langchain.chains import RetrievalQA
qa_chain = RetrievalQA.from_chain_type(
llm=ChatOpenAI(),
retriever=vectorstore.as_retriever(),
return_source_documents=True
)
response = qa_chain.invoke({"query": "What is our parental leave policy?"})
C’est un système RAG fonctionnel en quelques lignes à peine, et c’est là que l’on a envie d’arrêter. Ne le faites pas. Un système qui répond n’est pas encore un système fiable.
Pourquoi évaluer
Le piège consiste à poser une question, obtenir une réponse plausible et penser que c’est terminé. Le RAG échoue de manière subtile : le module de récupération peut sélectionner les extraits incorrects, le LLM peut produire des hallucinations même avec un bon contexte, et le découpage en extraits peut diviser une réponse en deux. L’évaluation vous indique quelle partie est défaillante : la récupération, la génération ou les deux, car chacune nécessite une solution différente. Elle vous fournit également un critère de mesure, car vous continuerez à essayer différentes tailles d’extraits, des encodeurs et des prompts différents, et sans mesure chaque changement reste une supposition.
Métriques de récupération
Quatre métriques standard, chacune vous informant d’un aspect différent :
- Recall@K : parmi les documents qui auraient dû apparaître, combien se trouvent dans les K meilleurs ? Avec 4 documents pertinents dans le corpus et 3 d’entre eux parmi les 5 meilleurs, le Recall@5 est de 0,75. Un taux de rappel élevé signifie que peu d’éléments sont manquants.
- Precision@K : parmi les K résultats retournés, combien étaient pertinents ? Trois bons résultats sur cinq donnent un score de 0,60. Une précision élevée signifie peu de bruit dans les résultats.
- MRR (Mean Reciprocal Rank) : seule la position du premier résultat correct compte. Un premier résultat à la position 2 rapporte 0,50, tandis qu’un résultat à la position 1 rapporte 1,0. Ce critère récompense la présentation rapide d’une réponse correcte.
- nDCG (Normalized Discounted Cumulative Gain) : c’est le critère le plus sophistiqué. Il prend en compte le degré de pertinence de chaque document ainsi que sa position ; les éléments pertinents ayant un rang élevé pèsent davantage, et il est comparé au meilleur classement possible.
Dans la pratique, on utilise deux pistes : l’une pour évaluer la complétude (Recall) et l’autre pour mesurer la qualité du classement (MRR ou nDCG).
Qualité de la réponse
Même une récupération parfaite peut donner une mauvaise réponse, il faut donc évaluer les réponses séparément. On part du même ensemble de tests soigneusement sélectionné, où chaque entrée associe une question aux extraits qui devraient être retournés ainsi qu’une réponse de référence. Les quatre métriques de récupération mentionnées ci-dessus couvrent la première partie. Pour les réponses elles-mêmes, on évalue selon d’autres critères :
- Fidélité : est-elle basée sur le contexte récupéré ou inventée ?
- Rélevance : répond-elle à la question ?
- Correctitude : en quoi diffère-t-elle de la vérité absolue ?
- Complétude : couvre-t-elle tout ce qui devrait l’être ?
La méthode habituelle consiste à utiliser un deuxième LLM en tant que juge. On lui fournit la question, la réponse et la vérité absolue, puis on lui demande d’évaluer chaque dimension. Ce n’est pas parfait, mais cela permet une échelle de mesure.
Le jeu de test
Chaque étape d’évaluation repose sur une chose : un ensemble de questions auxquelles on connaît les bonnes réponses.
C’est l’élément le plus sous-estimé. Sans lui, rien ne peut être évalué. Visez entre 50 et quelques centaines d’exemples, chacun comprenant une question réaliste, les parties contenant la réponse ainsi qu’une réponse idéale. Les sources peuvent être des requêtes réelles d’utilisateurs si le système est en ligne, ou des exemples synthétiques : faites passer vos documents à un LLM pour qu’il crée des paires question-réponse, puis revoyez-les et nettoyez-les.
Protégez ce jeu de données. Une fois qu’il existe, chaque modification est évaluée par rapport à lui, et c’est ainsi que l’on détermine si une modification a été utile.
Où cela devient plus difficile
En résumé, RAG consiste à diviser les documents en blocs, à vectoriser chaque bloc, à les stocker, à intégrer les requêtes des utilisateurs, à trouver les vecteurs les plus proches et à transmettre ces blocs à un LLM. LangChain fournit des composants standard pour chacune de ces étapes, et les évaluations montrent à quel point ce système est efficace. Un produit réel soulève des questions plus complexes : quelle taille de bloc est adaptée à vos documents, quel encodeur est le meilleur, comment maintenir l’index à jour, quand réordonner les résultats, et s’il convient d’utiliser une recherche hybride. Ces décisions distinguent une démonstration de laboratoire d’un outil vraiment utile, mais elles reposent sur les fondements présentés ci-dessus.