Ce qui a défailli dans un pipeline RAG correctif pour les questions-réponses YouTube avec LangGraph
Le découpage en blocs, les limites d’incorporation, l’état de LangGraph, les pièges asynchrones et la vitesse de transcription : les erreurs rencontrées lors de la création d’un bot de questions-réponses pour YouTube et comment chacune a été corrigée.
Sources: Building a Corrective RAG Pipeline for YouTube Q&A: What Broke and Why?
Un système RAG correctif est une configuration de génération améliorée par la récupération d’informations qui comporte trois éléments supplémentaires par rapport à la version simple :
- Un modèle d’évaluation qui détermine si chaque fragment récupéré est pertinent pour la question de l’utilisateur.
- Un réécrivain de requête qui reformule la question afin d’obtenir de meilleurs résultats.
- Un mécanisme de recherche sur le Web en cas où aucun des éléments récupérés n’est pertinent.
Le projet à l’origine de ces notes était un bot de réponse aux questions pour les vidéos YouTube. On lui donne l’URL d’une vidéo et une question, et il répond. Il gère également les vidéos qui n’ont pas de transcription, ce qui est important car le chargeur YouTube de LangChain ne fonctionne que lorsqu’une transcription existe déjà. Le flux est le suivant : télécharger la vidéo, la transcrire à l’aide d’un modèle de reconnaissance vocale (Whisper), intégrer le texte, stocker les vecteurs dans ChromaDB, puis répondre aux questions grâce à un graphe LangGraph.
Puisque la tâche impliquait le suivi de l’état, les tentatives répétées et des solutions de secours, LangGraph s’y prêtait mieux qu’une chaîne LangChain ou une série de branches if-else. Travailler avec des nœuds, des arêtes et un objet d’état partagé permet également de comprendre le fonctionnement réel d’une application RAG. Plusieurs erreurs commises au cours du processus ont apporté plus d’enseignements que les succès, et elles sont répertoriées ci-dessous.
Chunking
Dans tout système RAG, le chunking est l’étape qui échoue silencieusement lorsqu’elle est mal réalisée. Les approches courantes sont :
- La segmentation récursive des caractères parcourt une liste de séparateurs et coupe le texte jusqu’à ce que les fragments respectent une limite de taille.
- Le chunking sémantique regroupe les phrases en fonction de leur sens, par exemple en évaluant la similarité entre les phrases à l’aide d’un modèle de type BERT et en fusionnant celles qui sont les plus proches.
- La segmentation spécifique au document suit le format de la source, ce qui est typique pour le code.
D’abord, le découpage sémantique semblait être un choix naturel. Les vidéos couvrent tous les sujets imaginables, donc regrouper par sens paraissait logique. Puis un phénomène étrange est apparu : pour les transcriptions de moins d’une dizaine de minutes, le séparateur renvoyait souvent un seul segment. Une analyse plus approfondie a expliqué pourquoi : les vidéos courtes traitent généralement en profondeur d’un seul sujet, et le découpage sémantique n’a rien à diviser lorsque tout le texte porte sur une seule chose.
Puis est venu le découpage récursif des caractères, accompagné d’une deuxième erreur : laisser chunk_overlap à zéro. Les segments eux-mêmes étaient acceptables, mais les résultats se sont détériorés. Sans chevauchement, un segment récupéré ne contenait aucune indication sur ce qui le précédait, si bien que le modèle de génération manquait de contexte concernant ce auquel le segment faisait référence et mélangeait souvent des détails entre eux.
L’approche retenue était la segmentation récursive avec un chevauchement réel, sans découpage sémantique pour ce cas d’usage. Elle gérait à la fois les vidéos courtes et longues et produisait des segments de transcription de meilleure qualité. Sur un petit ensemble d’essai composé de dix requêtes avec k=5, le pipeline a atteint un rang réciproque moyen de 0,81. Cet échantillon est très restreint, il convient donc de le considérer comme un indicateur approximatif et non comme une référence de performance.
Choix du modèle d’incorporation
Le bot doit gérer n’importe quelle vidéo, ce qui signifie que les sujets couvrent tous les domaines. Un modèle d’incorporation spécifique à un domaine peut rapidement ne plus fonctionner correctement lorsque la vidéo sort de sa zone d’entraînement. Pour un usage général, et compte tenu d’un matériel modeste, le choix s’est porté sur all-MiniLM-L6-v2, un modèle rapide et léger qui fonctionne de manière acceptable presque partout.
Le problème réside dans une limite d’entrée. Ce modèle ne prend en charge que quelque 256 tokens, et tout texte plus long est tronqué, ce qui signifie qu’un gros bloc de données n’est intégré que via ses 256 premiers tokens. La conséquence est une perte silencieuse de données et des embeddings inégaux, ce qui nuit à la qualité de l’application.
La solution consiste à maintenir chunk_size en deçà de la limite du modèle. Lorsque l’on divise le texte par caractères, il faut rester en dessous de la limite de tokens en se basant sur le principe approximatif selon lequel un token correspond à environ quatre caractères. Un séparateur réglé sur 800 caractères correspond à environ 200 tokens, ce qui est suffisant.
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=800, # Notice: 800 characters limit here (~200 tokens)
chunk_overlap=200,
length_function=len,
is_separator_regex=False,
)
docs = text_splitter.create_documents([text])
sentence_transformer = HuggingFaceEmbeddings(
model_name="sentence-transformers/all-MiniLM-L6-v2"
)
vector_store = Chroma(
collection_name=get_config("vectorDB_collection_name"),
embedding_function=sentence_transformer,
)
Avec les deux problèmes de récupération les plus courants résolus, les suivants concernent LangGraph lui-même.
État de LangGraph et ChromaDB
Chaque nœud dans une application LangGraph partage des données via un objet d’état. Chaque nœud reçoit cet état et le met à jour. Il existe deux façons courantes de le définir :
TypedDict, un schéma de suggestion de type pour les dictionnaires. Il permet un contrôle statique mais aucune validation en temps de exécution.- Pydantic, qui valide les données en temps de exécution et génère des erreurs en cas de différence de type.
Lorsque l’état est un BaseModel de Pydantic, chaque attribut est validé. Si l’on y place un objet complexe comme un client ChromaDB, une erreur apparaît immédiatement. En définissant arbitrary_types_allowed=True dans la classe d’état, les types personnalisés peuvent être utilisés sans problème.
Un problème plus profond persiste. Les fonctionnalités de LangGraph telles que les outils de vérification, la mémoire persistante et les outils d’observabilité LangSmith serialisent et copient parfois l’état. Les types simples se serialisent bien, mais une instance ChromaDB peut provoquer une erreur de serialisation. La serialisation consiste simplement à convertir des données en un format pouvant être stocké ou envoyé.
Une conception plus sûre consiste à tenir ChromaDB complètement à l’écart de l’état. Cela permet également une architecture plus propre, car une ressource de base de données ne devrait pas être mêlée à des données ordinaires. Les nœuds ayant besoin de la base de données peuvent néanmoins y accéder via l’argument config transmis à aupdate_state() :
app.aupdate_state(
config={
"configurable": {
"thread_id": thread_id,
"recursion_limit": 30,
"vector_store": vector_store, # Pass the Vector DB here
}
}
)
Exécution asynchrone et le piège input()
L’un des grands avantages de LangGraph est son exécution asynchrone. Il propose des équivalents asynchrones des appels synchrones : ainvoke() à la place de invoke() et aupdate_state() à la place de update_state(). Grâce à eux, vous pouvez créer des nœuds asynchrones et un application plus réactive.
La façon la plus simple de gâcher cela consiste à appeler input() à l’intérieur d’un nœud. Cela bloque l’exécution en attendant l’utilisateur, de sorte qu’une application entièrement asynchrone perd les avantages liés à l’asynchronisme.
La solution consiste à collecter les entrées de l’utilisateur en dehors des nœuds et à les envoyer dans le graphe via aupdate_state() :
await app.aupdate_state(
config={
"configurable": {"thread_id": thread_id, "recursion_limit": 30}
}, # adjust as needed
values={
"user_query": user_query # Send the user inputs using the 'values' property
},
)
Transcription locale ou en cloud
La transcription constitue le point de départ de tout le projet, mais elle est devenue un goulot d’étranglement. La première tentative a utilisé un modèle local de reconnaissance vocale, whisper-small, ce qui a posé deux problèmes :
- Précision. Il s’agit d’un modèle raisonnable pour les configurations locales nécessitant peu de ressources matérielles, mais sa précision reste modérée par rapport aux autres options.
- Délai de réponse. Avec des ressources limitées, la création de la transcription prenait beaucoup de temps. Le facteur temps réel sur la machine de test était d’environ 1,03, ce qui signifie que la transcription durait autant que la vidéo. Pour les vidéos de plus de cinq minutes, les utilisateurs devaient attendre autant de temps avant d’obtenir une première réponse.
Le facteur temps réel correspond au temps de transcription divisé par la durée audio. Comme les questions-réponses ne peuvent être traitées qu’une fois la transcription disponible, tout chiffre supérieur à 1 nuit la qualité de l’expérience utilisateur.
Les données n’étaient pas confidentielles, il était donc facile de justifier le passage à un service cloud via l’API Groq. Cela a résolu les deux problèmes. La précision s’est améliorée car Groq utilise whisper-large-v3-turbo, qui surpasse whisper-small dans la plupart des cas. La latence a également diminué, car du matériel cloud plus puissant a réduit le temps de réponse à environ 0,15.
Ce choix dépendait du fait que les données n’étaient pas sensibles. Pour des documents privés ou confidentiels, la décision serait très différente.
Une liste de vérification rapide
Les leçons ci-dessus se résument en quelques vérifications à effectuer avant de faire confiance à un tel pipeline :
- Vérifiez le nombre de fragments générés par un transcript court typique. Un comptage égal à un est un signe d’alerte, quel que soit l’engagement du séparateur.
input() ou des requêtes réseau synchrones avant de blâmer le framework pour sa lenteur.Ce qui est omis
Ces notes ne couvrent que certains des problèmes rencontrés lors de la première mise en œuvre de LangGraph. D’autres sujets méritent un traitement distinct : les prompts système, l’évaluation du système et de chacun de ses composants, la création d’interfaces REST, ainsi que le déploiement dans le cloud.