Ne mettez pas les projets RAG en production trop vite !

(version anglaise. Pouce appréciée)

Dans nos missions de conseil, nous croisons, depuis quelques mois, de nombreux projets utilisant les LLM. La grande majorité est de type RAG. Lorsque nous étudions en profondeur les applications, presque toutes présentent des faiblesses importantes, nécessitant une reprise du code avant une mise en production. Néanmoins, ces derniers sont quand même déployés, car les problèmes ne se manifestent que dans des scénarios aux limites, ou lors de fortes charges.

Le sujet qui nous préoccupe est la résilience des applications. La résilience consiste à avoir une application capable d’encaisser tout type de difficultés. C'est-à-dire qu’on doit pouvoir l’interrompre à tout moment, sans impact majeur sur la stabilité. Pourquoi une application serait interrompue brutalement ? (voir fallacies of distributed computing) Pour une mise à jour applicative, par exemple, ou à cause d’un crash serveur, d’un problème sur le réseau, l’activité d’un Chaos monkey, etc. La loi de Murphy vous rappelle que le pire arrivera toujours (La loi de l’emmerdement maximal).

Parcourons les différents problèmes que nous rencontrons, pour essayer de les comprendre et de proposer des solutions alternatives.

Pour commencer, un rapide rappel des architectures RAG. Pour plus de détails, nous vous invitons à relire l’article qui leur est consacré.

Une architecture RAG est découpée en deux parties. La première consiste à importer des documents de différentes sources (Site web, wiki, FAQ, PDF, etc), à les découper en portions plus ou moins consistantes, à calculer un vecteur sémantique (embedding) et à placer ce vecteur dans une base vectorielle.

La seconde partie consiste à prendre, en entrée, la question de l’utilisateur en langage naturel, à calculer le vecteur sémantique correspondant, puis à retrouver, par exemple, les quatre fragments de documents ayant un vecteur très proche. Il est fort probable que la réponse à la question se trouve dans l’un d’entre eux. Les quatre fragments sont alors injectés dans le prompt, ainsi que la question, et le LLM est invoqué pour proposer une réponse.

Nous allons maintenant regarder les différents problèmes que peut rencontrer un projet de type RAG. Nous avons identifié plusieurs scénarios problématiques, dans différentes applications:

  • L’impact de la gestion des différends de formats de fichiers depuis un cloud storage
  • La gestion du cycle de vie des chunks
  • L’accès simultané à l’application par de nombreux utilisateurs
  • La mise en œuvre de l’API
  • La mise en œuvre de l’interface utilisateur

Les démos ne fonctionnent qu’une seule fois

On trouve des centaines de démos de code montrant comment mettre en place ce type d’architecture. Souvent, elles ne fonctionnent qu’une seule fois, sans que le développeur ne s’en aperçoive. N’oublions pas que l’on souhaite retrouver les quatre vecteurs les plus proches.

Détaillons le scénario.

  • Une liste de document est découpée en chunk
  • Chaque chunk est déposé dans une base vectorielle
  • La question de l’utilisateur sert à retrouver les quatre chunks les plus proches
  • Le LLM est invoqué et répond à la question.

Pour le moment, tout va bien. Puis, on exécute à nouveau le même code (attention aux notebooks !).

  • La même liste de document est découpé en chunk (de même valeur que précédemment)
  • Chaque chunk est déposé dans la même base vectorielle
  • La question de l’utilisateur sert à retrouver au maximum les 4 chunks les plus proches. Mais en fait, comme chaque chunk est présent 2 fois, la base vectorielle retourne 2 fois le premier, puis 2 fois le second. Finalement, ces 4 chunks (en réalité 2 chunks) sont injectés dans le prompt.
  • Le LLM répond en n’utilisant que 2 chunks (et non quatre). Si le premier chunk ou le second suffisent pour répondre, personne ne s'aperçoit de rien. Toutefois, si c’est le troisième ou le quatrième qui a permis de répondre lors de la première invocation, la réponse n’est plus d’aussi bonne qualité.

Ainsi, l'application se dégrade de plus en plus, à chaque cycle. Nous devons précisément suivre le cycle de vie de tous les chunks.

Ce petit rappel nous aidera à comprendre les impacts de la présence de doublons dans une base vectorielle.

Comment est gérée la diversité des formats des fichiers ?

Un framework comme Langchain, propose de nombreux Loader. Ce sont des composants capables d’extraire un flux de texte de tout type de fichiers. Si on regarde un peu le code, souvent, le processus s’effectue en plusieurs étapes. Par exemple, si le fichier que l’on souhaite manipuler est un CSV présent dans un cloud storage s3 (aws), il faut le télécharger dans un répertoire temporaire avant de pouvoir l’analyser. Trop fréquemment, les fichiers temporaires ne sont pas supprimés à la fin du traitement. À la longue, le disque va saturer. En développement, cela passe facilement. Mais en production ?

La question à se poser est la suivante : Comment évolue le répertoire temporaire lors de l’utilisation de l’application ?

Les fichiers sont considérés comme sains lors de leurs analyses. Ce n’est pas toujours le cas. En effet, on trouve régulièrement des fichiers partiels (le processus de copie a été interrompu) ou instable (le fichier est actualisé au même moment que le programme le charge en mémoire), ou plus simplement, le fichier utilise des fonctionnalités que le parseur ne connaît pas et plante.

Question : Est-ce que mon code tolère les erreurs lors de l’import des données ?

Développons avec une approche défensive. C'est-à-dire qu’il faut tester et tolérer ces fichiers malformés. Il y a plusieurs stratégies. Nous vous en proposons quelques-unes.

Une exception spécifique est souvent remontée. Le code peut alors, au minimum, écrire un warning dans les logs.

Il peut également déplacer les fichiers incorrects dans un répertoire dédié, un peu comme les dead-letter-queues des technologies de messages.

Une autre stratégie consiste à “commité sur disque”. C'est-à-dire à utiliser une extension spécifique le temps de la copie, et seulement au dernier moment, faire un basculement entre l’ancienne version et la nouvelle. Ce n'est pas si simple. Voici, par exemple, un scénario de mise en œuvre:

  • Lors de la copie du fichier toto.csv, le copier sous le nom toto.csv.download
  • Lors de la lecture de fichier, ne prendre que les fichiers *.csv en oubliant les fichiers *.csv.download qui ne sont là que de façon transitoire
  • Lorsque le fichier toto.csv.download a terminé d’être copié, renommer le fichier toto.csv en toto.csv.old, puis renommer toto.csv.download en toto.csv, et enfin, supprimer toto.csv.old
  • Au début de l’importation, partir à la recherche de fichiers *.csv.old. Ce sont des fichiers qui n’ont pas terminé le processeur de renommage. S’il y en a:
    • Regarder s’il existe un fichier toto.csv avec une date supérieur à la date du fichier toto.csv.old. Si c’est le cas, on peut effacer le fichier toto.csv.old
    • Si ce n’est pas le cas, on renomme le fichier toto.csv.old en toto.csv. On vient de faire un rollback sur l’import avec des fichiers.

Attention, ce scénario ne fonctionne que si le gestionnaire de fichier garantit l’atomicité du rename. Autrement dit dès qu’un fichier est renommé, tout le monde le voit immédiatement. Ce n’est pas le cas dans tous les gestionnaires de fichiers ! (attention à ftp par exemple)

Avec les cloud storages, les fichiers ne sont visibles qu’au terme du chargement. Donc, c’est plus simple.

Gérez-vous le cycle de vie des chunks ?

Une autre question que vous devez vous poser pour vérifier la qualité de votre application est la suivante : Que se passe-t-il lors de la mise à jour des chunks ?

Les développements que nous rencontrons sont généralement très naïfs. Ils imaginent que rien ne peut arriver, que tout fonctionne toujours parfaitement. Et que : “en cas de problème, ce n’est pas grave, il faut juste relancer le traitement”.

Qu’est-ce qui va fatalement arriver en production ? Des erreurs temporaires:

  • Une saturation du nombre de connexions à la base de données.
  • Le réseau peut être surchargé temporairement et n'a pas d’autre moyen que de sacrifier des paquets. Tout finit par rentrer dans l’ordre, mais après plusieurs secondes sans communications entre les composants. Cela va déclencher des timeouts ou des ralentissements importants
  • Des crashs de middleware (extinction des VM par les cloud providers, arrêt brutale des containers par Kubernetes ou autre orchestrateur)

Des erreurs non résolvable

  • Des formats de fichiers incorrects
  • Une explosion de la consommation mémoire (l’import de fichier s’effectue habituellement en chargeant tout en mémoire, pour ensuite dupliquer la mémoire consommée en coupant chaque fichier en chunk, avant injection dans la base vecteur, via généralement une troisième copie en mémoire). Si le code n’est pas économe en mémoire, bonne chance pour ré-importer une grosse base documentaire !

La solution naïve à ces problèmes consiste simplement à rejouer le batch, avec la présence de doublon au terme de la nouvelle tentative. Et nous avons vu l’impact des doublons sur la qualité des réponses. Cela ne résout pas les erreurs non résolvables, et dans le cas des erreurs temporaires, cela dégrade la base vecteurs. En effet, des fragments identiques sont alors présents plusieurs fois. Comme on l’a déjà vu, les réponses se dégradent sans que personne trouve de lien de cause à effet.

Une autre approche consiste à détruire complètement la base vecteur et à tout importer de nouveau. Attention à la mémoire dans ce cas et pensez à gérer l’indisponibilité du service, le temps que la base de données soit à nouveau opérationnelle. C’est souvent une mauvaise idée, surtout si vous invoquez plusieurs fois un LLM pour chaque document lors de l’import. La facture va être salée.

Langchain propose l’API index() pour garder une trace du lien entre les documents et leurs chunks (calcule de hash du contenu, associé à l’identifiant du vecteur dans la base de données). Ainsi, seuls les documents ayant été modifiés sont rafraîchis dans la base vectorielle. Tous les chunks associés à un document sont supprimés, puis les nouveaux chunks sont injectés.

Question : Est-ce que l’association entre les données de la base vecteurs et la traçabilité des différents chunks reste stable, s’il y a un crash, pendant l’import ? Que faire si un crash arrive ? Quelle est la garantie transactionnelle de l’import des données entre la base vecteur et la base SQL qui maintient le lien entre les documents et les vecteurs ?

À notre connaissance, il n’existe pas de base vectorielle compatible avec le commit à deux phases, permettant de garantir qu’en cas de crash, et la base vectorielle et la base SQL seront rollbackées.

Ceux que nous souhaitons, c’est qu’en cas d’import, tous les imports soient validés simultanément, ou qu’aucun ne le soit.

Un autre problème similaire est présent, lors de la sauvegarde de l’historique de la conversation. La classe SQLChatMessageHistory ne garantie pas que l’ensemble de l’échange sera sauvegardé dans la même transaction SQL. En crash entre la sauvegarde de la question et de la réponse, et vous avez une session instable (sans compter l’implémentation asynchrone qui laisse à désirer)

Pgvector est une bonne piste d’intégration, mais il présente d’autres problèmes que nous résolverons via des PR et un prochain article. Par exemple, il n’implémente pas d’api asynchrone.

Est-ce que l’invocation du LLM accepte simultanément plusieurs utilisateurs ?

Les projets LLM exploitent la GPU pour prédire les mots suivants. Une GPU ne se partage pas aussi facilement qu’une CPU. Surtout avec les modèles de langages. Ils sont initialement développés pour ne gérer qu’une seule inférence à la fois.

Si vous utilisez les instances proposées par le fournisseur de cloud, comme Bedrocks ou Vertex, les API OpenAI, Claude, Mistral, etc. vous n’avez pas à vous soucier de cela. Toutefois, si vous souhaitez maitriser l’hébergement du modèle, que cela soit “on premise” ou sur le cloud, avec des GPU dédiés, vous devez vous interroger sur la qualité de votre exposition du modèle.

Un développeur naïf peut simplement utiliser un LLM localement, pour développer. Lorsqu’il désire proposer une API (généralement via FastAPI) pour le LLM, il prend conscience qu’une seule invocation est possible simultanément. S’il y a plusieurs utilisateurs désirant utiliser le LLM, il faut maintenir une file d’attente. Si la génération représente beaucoup de tokens, il faudra attendre que chaque invocation soit terminée avant de recevoir le premier token du job suivant.

Pour améliorer cela, la première idée consiste à créer des micro-batchs avec quelques requêtes d’inférence, pouvant être combinées sans dépasser la limite du nombre de tokens. Dans ce scénario, le traitement le plus long devra être terminé avant le lancement du prochain micro-batch.

Une amélioration est alors proposée pour permettre d’injecter une nouvelle requête dès qu’un des traitements est terminé dans le micro-batch. Une sorte de micro-batch en flux. Ainsi, l’exploitation du modèle est plus efficiente.

Ces stratégies sont complexes, car elles demandent une gestion, voire une modification fine du modèle, et un suivi de la mémoire entre la CPU et la GPU, pour être mise en œuvre. Heureusement, des projets Open Source comme vLLM ou d’autres s’occupent de tout. Il faut alors déployer un modèle de LLM à côté de l’application. Vertex de Google s’occupe d’exposer votre modèle via vLLM (ancienne version de l’API vLLM pour le moment).

Question : Est-ce que le LLM sait gérer plusieurs requêtes simultanément ?

Si ce n’est pas le cas, partez à la recherche de solutions élastiques, capable d’optimiser l’utilisation des GPU pour ce type d’usage. Ne vous lancez pas sur un développement à la main.

Est-ce que vous n’utilisez que les versions async des API ?

Il est important de comprendre que Python n’est pas un langage multitâche. Il est incapable d’exécuter plusieurs flux simultanément. Une seule instruction python est exécutée à la fois, quel que soit le nombre de cœurs ou de thread que vous utilisez. Cela est contrôlé par un verrou, le GIL (Global Interpreter Lock). Pourquoi cette contrainte ? Entre autres, parce que python utilise des compteurs de références pour gérer la mémoire. Il est impossible d’utiliser cette stratégie avec un processeur multicœur, à cause des caches de chacun.

Lorsque l’on demande à python de gérer plusieurs threads, en fait, il utilise deux stratégies. Toutes les API utilisant des I/O débloquent le GIL. Cela permet à d'autres threads de reprendre leurs exécutions. L’interpréteur de byte code de python change alors le flux de traitement, mais il continue à n’exécuter qu’une instruction à la fois. La seconde stratégie consiste à chronométrer le temps accordé à un flux de traitement python. S’il dépasse un seuil, entre deux instructions Python, l’interpréteur peut partir s’occuper d’un autre thread. Il est à noter que chaque thread consomme de la mémoire afin de maintenir une pile pour les appels. Le nombre de threads actifs est alors limité par la taille de la mémoire nécessaire pour chaque pile d’exécution.

Pour améliorer cela, et être capable de gérer plusieurs milliers d’utilisateurs, Python propose le framework async/await. C’est un framework intégré dans le compilateur Python, pour découper chaque méthode ou function async, en tranche. Chaque tranche est délimitée par les mots clés async et/ou await. Le compilateur se charge de convertir votre méthode ou votre fonction en un ensemble de coroutine (des fonctions asynchrones), et à organiser l'enchaînement des appels. Un seul thread est à l'écoute d’une file de coroutine, avec chaque fragment de code à exécuter. Cette file évolue suivant si les résultats attendus arrivent ou non. À la résolution d’un future (à la réception de l’objet attendu par le await), le bloc de code suivant est ajouté à la file de coroutine. Il sera traité par l’unique thread, lorsque cela sera son tour. Ce thread est alors capable de gérer des milliers d’utilisateurs (comme le fait nodejs par exemple). Il n’est plus nécessaire d’avoir un pool de thread, forcément limité en taille.

Ce framework impose une collaboration fine du développeur pour permettre la gestion de nombreux utilisateurs. C'est-à-dire que le développeur doit respecter sa part du contrat, pour que cela fonctionne. Il faut que dans toutes les tranches de codes async, il n’y ait aucun appel réseau, aucun appel disque ni aucun appel bloquant à la GPU. Il faut également rendre la CPU au plus vite.

La plupart des serveurs WEB sous Python sont compatibles ASGI (Asynchronous Server Gateway Interface). Il s’agit d’un lien normalisé entre un protocole HTTP et une application Python. C’est le cas de FastAPI, Flask et de bien d’autres frameworks. Le terme important est “Asynchronous”.

Ce qu’il faut faire lors du développement si votre code à vocation à être exposé via ASGI :

Généralement, les développeurs construisent leurs chaînes de traitement dans un notebook, puis, en quelques lignes, publient le code dans une API. Avec cette approche naïve, un seul traitement sera exécuté, quel que soit le nombre d'utilisateurs.

Que se passe-t-il si on ne respecte pas le framework ? Et bien, il n’utilise qu’un seul flux de traitement pour exécuter chaque bloc de code, l’un après l’autre. Si l’un d’eux garde la main, tout est bloqué. Imaginez qu’un problème réseau temporaire entraîne un délai de 10 secondes lors de l’invocation de votre LLM. Plus aucun utilisateur ne progresse. Tout est bloqué. Peu importe le nombre d’instances de LLM que vous avez activé. C'est identique si le disque présente un souci ou pour toute autre dégradation de l’infrastructure.

Notre conseil pour bien saisir l’impact d’une erreur : choisissez une fonction de votre API, et ajoutez un time.sleep(120) (attention pas un await asyncio.sleep(120)) et essayez d’utiliser votre code avec plusieurs utilisateurs dont l’un est sur l’API patché. Assurez-vous d’utiliser un seul worker, pour que le test soit plus instructif (--workers 1) Vous risquez d’avoir quelques sueurs froides.

Dans les faits, lancer FastAPI avec les paramètres par défaut, va lancer autant d'instance Python qu’il y a de vCore du processeur (à chacun son GIL et son GC, pas de cache partagé entre les instances Python). Donc, cela cache les limitations évoquées (2 ou 4 traitements simultanés dans autant d'instances Python). Mais elles apparaissent dès que le nombre d’utilisateurs simultanés dépasse le nombre de vCore.

La question que vous devez vous poser est la suivante : Est-ce que le code utilise uniquement les API async de tous les frameworks ? Cela concerne les drivers de bases de données, les API des bases vecteurs, l’invocation d’API, l’invocation des calculs d’embedding ou de LLM, etc.

Langchain propose deux jeux d’API dans le même framework. Ce n’est pas pour faire joli, mais bien parce que c’est nécessaire. Pratiquement toutes les méthodes sont doublées. L’une est synchrone, l’autre est asynchrone et doit être utilisée systématiquement si votre code doit être exposé via un serveur ASGI.

D’expérience, on croise rarement un code propre vis-à-vis de cela et c’est même parfois difficile. Par exemple, le driver pgvector de Langchain ne propose pas d’API asynchrone… (Nous avons proposé un patch pour rendre pgvector asynchrone)

Vous utilisez quoi pour l’interface utilisateur ?

La plupart des démos de type Chat, utilisent des frameworks ou composants permettant de proposer rapidement une interface utilisateur. On peut citer : streamlit, gradio, chainlit.

Problème : ces frameworks ne sont généralement pas compatibles avec le framework async/await. Et on retombe sur le problème précédent. (Notez qu’ils présentent également d’autres problèmes de sécurités, de résiliences, etc)

Question : Est-ce que l’interface utilisateur utilisée est compatible avec async ?

Si ce n’est pas le cas, partez à la chasse à d'autres solutions, ou implémentez vous-même l’interface web, qui ne fait qu’invoquer une API.

Pourquoi personne ne s’en aperçoit ?

Comme vous pouvez le voir, il existe de nombreuses mauvaises pratiques que l’on retrouve trop souvent dans ces applications. Vous vous reconnaissez peut-être dans certaines de ces situations. Mais alors, pourquoi peu de développeurs réagissent ?

Les projets de LLM sont nouveaux et les coûts d’exécution sont élevés. Pour le moment, ils sont ouverts à un petit nombre de personnes. Cela cache les difficultés. C’est plus tard, avec le succès, qu’il faudra sûrement revoir de fond en comble le code du projet.

Si je devais être MLOps, étant incapable de garantir leur résilience, je refuserais probablement quelques applications à passer en production. La loi de murphy étant ce qu'elle est, le pire scénario est certain d’arriver, un moment ou à un autre.

Il est temps de prendre en charge la résilience des applications.

Pour vos projets d’IA générative, posez-vous ces différentes questions, avant une mise en production :

  • Comment évolue le répertoire temporaire lors de l’utilisation de l’application ?
  • Est-ce que le code tolère les erreurs lors de l’import des données ?
  • Est-ce que le code est protégé contre l’ajout de vecteurs identiques ?
  • Est-ce que l’association entre les données de la base vecteurs et la traçabilité des différents chunks reste stable, s’il y a un crash, pendant l’import ?
  • Est-ce que le LLM sait gérer plusieurs requêtes simultanément ?
  • Est-ce que le code utilise uniquement les API async de tous les frameworks ?
  • Est-ce que l’interface utilisateur utilisée est compatible avec async ?

Il faut s’intéresser à tous les bugs qui n’arrivent pas lors du développement. C’est peut-être comme cela qu’on devient un “expert”.

Un prochain article proposera des solutions pour le framework langchain. Elles ne sont pas simples, puisque langchain en l’état (version 0.2.0) ne permet pas de proposer une application résiliente ! Néanmoins, nous vous proposerons des solutions.

Nous espérons que vous n’êtes pas dans ces situations. Si cela était le cas, n'hésitez pas à nous contacter, nous pourrions vous aider  😉