Navigation principale

Exemples pratiques de solutions RAG personnalisées

Des exemples concrets montrent comment des systèmes sur mesure de génération augmentée par récupération résolvent des problèmes complexes de gestion des connaissances en entreprise.

Aujourd’hui, le RAG a parfois mauvaise réputation : certains le jugent désormais totalement trivial (il l’est au démarrage, moins lors du passage à l’échelle), tandis que d’autres pensent qu’il a été dépassé par les « systèmes agentiques » (qui, lorsqu’on creuse un peu, ressemblent souvent très vite au RAG…).

Cet article présente un ou deux exemples concrets pour montrer comment nous relevons certains défis courants, à savoir :

  • Gérer des données mêlant texte et chiffres, et pourquoi elles mettent en échec un RAG naïf : les mots-clés se confondent et les nombres n’ont aucun sens sémantique.

  • Pourquoi concevoir des plongements axés sur les résumés est utile : générez un bref résumé descriptif pour chaque fragment, puis créez le plongement et effectuez les requêtes à partir de ce résumé.

  • Comment générer des résumés contextuels : incluez le contexte du document parent afin de distinguer les statistiques de forme similaire.

  • Quand s’appuyer sur du code et des modèles Pydantic : lorsque le contenu doit être reproduit mot pour mot, combinez du code personnalisé et/ou un modèle Pydantic avec des appels à un LLM pour plus de fiabilité.

Créer des solutions RAG personnalisées

Les principes de base

Les systèmes RAG alimentent aussi bien des bots d’assistance que des assistants internes de gestion des connaissances.

En coulisses, vous procédez généralement ainsi :

  1. Découpez vos documents sources en fragments

  2. Plongez chaque fragment dans un espace vectoriel

  3. Récupérez les K fragments les mieux classés au moment de la requête

  4. Générez une réponse fondée sur ces fragments

Des outils populaires comme LangChain, LlamaIndex et Filestore d’OpenAI rendent ces étapes presque triviales. Mais, dans les pipelines réels, vous rencontrerez des données qui ne se limitent pas à du texte dense, et un RAG élémentaire peut alors montrer ses limites. Dans les sections suivantes, nous présenterons des exemples concrets de difficultés liées aux données et construirons progressivement la solution à mesure que la complexité augmente.

Quand les choses se compliquent

  1. Lorsque vos données ne se limitent pas au texte (ce qui est assez courant)

Prenons le fragment de données suivant dans le contexte d’un jeu :

JSON

{ "Attack": { "Range": { "default": 5, "with_Draconic_Ascension": 5.5, }, "Speed": { "default": "2 seconds", "with_Draconic_Ascension": "2.2 seconds", }, "Damage": { "default": 10, "with_Draconic_Ascension": 12, } }} 

Les plongements fonctionnent grâce aux relations apprises entre les mots par l’intermédiaire du sens et des structures grammaticales. Dans les données ci-dessus, texte et nombres sont mélangés et, hors de ce contexte précis, les nombres n’entretiennent aucune relation avec les mots. On pourrait donc dire que ce fragment de données est essentiellement une combinaison de mots assez descriptifs suivis de nombres aléatoires.

Cela ne poserait aucun problème s’il s’agissait du seul type de données disponible, car nous pourrions toujours les récupérer grâce aux plongements des quelques mots descriptifs disponibles (ou simplement utiliser une conversion de texte en SQL). Mais que se passe-t-il si ce fragment est noyé au milieu de nombreux fragments de texte dense où ces mots apparaissent également ? Par exemple :

JSON

{"Draconic_Ascension": { "description": "Transform into dragon form. Attack range and speed are increased by 10%, damage is boosted by 20%.", "details": { "activation_conditions": "Can only be activated when HP is below 50%or Fury meter is full.", "visual_effects": "Wings unfurl, scales shimmer with embers, voice lines change to echoing growls.", "lore": "An ancient bloodline awakens. The bearer of the mark channels the soul of the last Flamewing Wyrm, becoming a living storm of fire and fury." } }}

Imaginons maintenant que nous voulions récupérer la réponse à la question « Quelle est la portée d’attaque avec l’Ascension draconique ? » Il est très probable que nous ne puissions pas récupérer le fragment pertinent, car il est noyé dans le bruit d’autres fragments contenant les mêmes mots-clés.

Le problème de fond est que nous ne parvenons pas à bien distinguer ces fragments de données, même s’ils contiennent différents types d’informations sur un même sujet. Pourrions-nous les enrichir ou les améliorer d’une manière ou d’une autre ? Bien sûr que oui :smile:

  1. Enrichissez vos données en les résumant : oui, vous avez bien lu

Au lieu de créer directement un plongement du fragment lui-même, nous pouvons d’abord générer un résumé décrivant le contenu des données, puis créer le plongement et effectuer la récupération à partir de ce résumé. Lors de l’étape de génération, nous continuerions à utiliser les données originales associées au résumé.

Pour les deux exemples de fragments ci-dessus, nous générerions donc des résumés de ce type :

  1. Statistiques de portée, de vitesse et de dégâts des attaques (par défaut et avec l’Ascension draconique).

  2. Description et détails de l’Ascension draconique, notamment ses conditions d’activation, ses effets visuels et son histoire.

Nous enrichissons ensuite aussi la requête pour l’« aligner » sur le résumé. Par exemple, nous transformerions « Quelle est la portée d’attaque avec l’Ascension draconique ? » en « Quelles sont les statistiques de portée d’attaque avec l’Ascension draconique ? » C’est particulièrement important lorsque la requête de récupération vient d’utilisateurs non spécialistes qui s’expriment en langage humain ~~« libre »~~ normal : après tout, ils ne savent pas nécessairement comment fonctionne un RAG et ne se soucient pas de maximiser sa précision et son rappel.

Schéma illustrant les situations où les choses se compliquent.

  1. Ne sortez pas les choses de leur contexte (conseil généralement valable dans la vie)

Voyons maintenant un autre scénario : traiter une multitude de fragments de données qui se ressemblent, comme ci-dessous :

Plain Text

# Chunk one{ "Attack": { "Range": { "default": 9, }, ... }}# Chunk two{ "Attack": { "Range": { "default": 6, }, ... }}# Chunk three{ "Attack": { "Range": { "default": 7, }, ... }}

En conservant la même approche, imaginons que l’on demande : « Quelle est la portée d’attaque du personnage X ? » Avec les résumés que nous venons de générer, nous jouerions à un jeu de devinettes fondé sur la chance, car ils seraient eux aussi très similaires. Comment pourrions-nous donc les différencier ?

La réponse est simple : fournir du contexte. Nous pourrions simplement inclure dans le fragment une référence à son document parent, par exemple {« personnage » : « X »} dans ce cas. Nous pourrions alors récupérer précisément les bonnes données pour le personnage X, même si nous disposons des mêmes données pour les personnages Y et Z.

Une approche meilleure et plus généralisable consisterait toutefois à générer un résumé contextuel du fragment. Autrement dit, plutôt que de résumer uniquement le fragment de données, nous pourrions transmettre à la fois son document parent et le fragment afin de générer un résumé contextuel général indiquant comment ce fragment s’intègre à son document parent. Par exemple :

  1. Ce fragment fournit des statistiques détaillées sur… pour le personnage X. Le fragment s’intègre au document complet en illustrant la puissance de X en matière de vitesse d’attaque…

  2. Ce fragment fournit des statistiques détaillées sur… pour le personnage Y. Le fragment s’intègre au document complet en montrant les statistiques améliorées de Y grâce à sa capacité spéciale…

  3. Ce fragment fournit des statistiques détaillées sur… pour le personnage Z. Le fragment s’intègre au document complet en montrant que les statistiques de Z sont parfaitement adaptées à un rôle de tank lors des parties en équipe…

Cette méthode (en partie inspirée par Anthropic) peut sembler excessive pour l’exemple ci-dessus. Elle est toutefois très efficace pour les fragments susceptibles d’être mal interprétés « hors contexte » et fournit en outre une approche unifiée applicable à tous les fragments, ce qui permet de conserver un pipeline d’ingénierie bien structuré.

Schéma illustrant les situations où les choses se compliquent.

  1. Quand il faut être ~~maniaque du contrôle~~ rigoureux

En général, nous recevons des données complètes que nous découpons en fragments pour un système RAG. Dans cet exemple, nous présentons un cas un peu différent : des données déjà découpées, mais en mauvais fragments. Il s’agit de sections arbitraires provenant d’un même fragment logique et qui doivent en réalité être regroupées. Un fragment logique désigne un ensemble de contenu qui doit naturellement rester groupé, comme une sous-section de document ou un paragraphe cohérent.

Schéma illustrant les situations où les choses se compliquent.

Notre première tentative consiste à transmettre toutes ces données lors d’un appel à un LLM, à lui demander de les regrouper comme il le juge pertinent, puis à renvoyer le contenu regroupé. Un LLM devrait être assez doué pour cela, non ? Eh bien, oui et non.

Nous avons constaté, à plusieurs autres occasions également, que les LLM ont tendance à se montrer paresseux et peu fiables lorsqu’on leur demande de restituer l’intégralité exacte d’un contenu, surtout lorsque le contexte est long. Ce qui est parfaitement logique. Mais c’était rédhibitoire pour ce cas d’usage précis, car nous avions besoin du contenu exact, mot pour mot : aucun résumé et aucune omission dans le contenu original. Nous ne pouvons omettre aucun détail.

Bien sûr, le côté « oui » était qu’il comprenait remarquablement bien la sémantique et la structure des fragments incorrectement découpés. À condition qu’il ne refuse pas de reproduire le contenu exact. Bon sang :/

Comment exploiter ce qu’un LLM sait bien faire tout en évitant les tâches pour lesquelles il manque de fiabilité ? Nous nous sommes tournés vers notre bon vieil ami, le code (comprenez : une fonction Python personnalisée). Et vers un modèle Pydantic on ne peut plus simple. Voici la solution :

  • Parcourir les sections tout en conservant un fragment logique courant

  • Pour chaque section, demander au LLM : cette section appartient-elle au fragment logique courant ? Répondre par oui ou non (selon le modèle Pydantic).

  • Si oui, ajouter la section au fragment ; sinon, enregistrer le fragment logique courant puisqu’il est terminé, puis en créer un nouveau avec cette section.

Schéma illustrant les situations où les choses se compliquent.

Bien sûr, nous utilisons ici un peu plus de jetons qu’avec un passage unique sur l’ensemble du contenu. Mais, pour ce cas d’usage précis où la conservation du contenu exact constitue la priorité absolue, le léger surcoût en valait largement la peine.

Cette solution est très simple, mais elle respecte un principe important : lorsqu’il faut être rigoureux, mieux vaut ne pas dépendre uniquement des LLM, qui restent après tout probabilistes.

Du code et des fonctions personnalisés ainsi que des modèles Pydantic peuvent permettre d’obtenir un résultat prévisible et fiable, tout en exploitant pleinement les capacités des LLM.

Pour conclure

Créer une solution d’IA générative est autant un défi d’ingénierie qu’un défi d’IA. Nous espérons que ces exemples vous auront donné envie de relever vos propres défis. Pour en savoir plus sur les solutions d’IA générative axées sur l’ingénierie, consultez notre article consacré à la conception de systèmes agentiques fondés sur un routeur.

Auteur

Cynthia Yu