Navigation principale

Exemples pratiques de solutions RAG personnalisées

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

De nos jours, la RAG a parfois mauvaise réputation, soit parce qu’on la croit désormais tout à fait triviale (elle l’est au départ, moins à grande échelle), soit parce qu’on pense qu’elle a été dépassée par les « systèmes agentiques » (qui, dans bien des cas, ressemblent très vite à la RAG dès qu’on creuse un peu…).

Ce billet présente un ou deux exemples concrets pour montrer comment nous relevons certains défis courants, notamment :

  • La gestion de données mixtes, textuelles et numériques, et pourquoi elle met en échec une RAG naïve : les mots-clés se confondent et les nombres n’ont aucun sens sémantique.

  • Pourquoi concevoir des représentations vectorielles axées sur les résumés est utile : générez un court résumé descriptif pour chaque segment, puis vectorisez et interrogez ce résumé.

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

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

Concevoir des solutions RAG personnalisées

Les notions de base

Les systèmes RAG alimentent aussi bien les robots d’assistance que les assistants de connaissances internes.

En coulisses, vous devez généralement :

  1. Segmenter vos documents sources

  2. Vectoriser chaque segment dans un espace vectoriel

  3. Récupérer les K meilleurs segments au moment de la requête

  4. Générer une réponse fondée sur ces segments

Des trousses d’outils populaires — 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 résument pas à du texte dense, et une RAG de base peut avoir du mal à les traiter. Dans les sections suivantes, nous présenterons des exemples concrets de défis liés aux données et élaborerons progressivement la solution à mesure que la complexité augmente.

Quand les choses se compliquent

  1. Quand vos données ne sont pas seulement du texte (ce qui est plutôt courant)

Prenons le segment de données suivant dans un contexte de 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 représentations vectorielles fonctionnent grâce aux relations apprises entre les mots par leur sens sémantique et les règles grammaticales. Dans les données ci-dessus, du texte et des nombres sont combinés; hors de ce contexte précis, les nombres n’ont aucun lien avec les mots. On pourrait donc dire que ce segment de données est essentiellement une combinaison de mots assez descriptifs suivis de nombres aléatoires.

Ce ne serait pas un problème s’il s’agissait du seul type de données dont nous disposions, car nous pourrions encore les récupérer grâce aux représentations vectorielles des quelques mots descriptifs disponibles (ou simplement utiliser text-to-SQL). Mais que se passe-t-il si ce segment est enfoui parmi de nombreux segments de texte dense où ces mots figurent aussi? 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." } }}

Imaginez maintenant que nous voulions récupérer « Quelle est la portée d’attaque avec Draconic Ascension? »Très probablement, nous ne pourrions pas récupérer le segment pertinent, car il serait noyé dans le bruit des autres segments contenant les mêmes mots-clés.

Le problème fondamental est que nous ne pouvons pas bien distinguer ces segments de données, même s’ils contiennent différents types d’information sur un même sujet. Pourrions-nous enrichir ou améliorer cela 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 vectoriser directement le segment, nous pouvons d’abord générer un résumé décrivant le sujet des données, puis vectoriser et interroger ce résumé. À l’étape de génération, nous utiliserions toujours les données originales liées au résumé.

Pour les deux exemples de segments ci-dessus, nous générerions donc des résumés comme ceux-ci :

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

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

Nous enrichissons ensuite aussi la requête afin de l’« aligner » sur le résumé. Par exemple, nous transformerions « Quelle est la portée d’attaque avec Draconic Ascension? »en « Quelles sont les statistiques de portée d’attaque avec Draconic Ascension? »C’est particulièrement important lorsque la requête de récupération vient d’utilisateurs non techniques qui s’expriment dans un langage humain normal ~~« libre »~~, puisqu’ils n’ont pas à savoir comment fonctionne une RAG pour maximiser la précision et le rappel.

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

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

Examinons maintenant un autre scénario : traiter une multitude de segments 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, imaginez que nous demandions : « 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 se ressembleraient aussi beaucoup. Alors, comment pourrions-nous les distinguer?

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

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

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

  2. Ce segment fournit des statistiques détaillées sur… pour le personnage Y. Le segment s’intègre au document complet en montrant les statistiques améliorées de Y lorsqu’elle utilise sa capacité spéciale…

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

Cette méthode (qui s’inspire en partie d’Anthropic) peut sembler excessive pour l’exemple ci-dessus, mais elle est très efficace pour les segments susceptibles d’être mal interprétés « hors contexte ». Elle offre aussi une approche unifiée applicable à tous les segments, ce qui permet de conserver un pipeline d’ingénierie bien ordonné.

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

  1. Quand vous devez être ~~maniaque du contrôle~~ rigoureux

Habituellement, nous recevons les données sous forme de contenus complets, puis nous les divisons en segments pour un système RAG. Dans cet exemple, nous montrons un cas un peu différent : des données divisées en mauvais segments, c’est-à-dire des sections aléatoires d’un segment logique qui doivent être regroupées. Un segment logique est un contenu qui devrait 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 à tout transmettre dans un appel à un LLM, à lui demander de regrouper les éléments comme il le juge approprié, puis de renvoyer le contenu regroupé. Un LLM devrait être plutôt bon dans ce domaine, non? Eh bien, oui et non.

Nous avons constaté, à plusieurs autres occasions également, que les LLM ont tendance à agir paresseusement et ne sont pas fiables lorsqu’on exige un contenu complet et exact, surtout lorsque le contexte est long. Ce qui est tout à fait logique. Mais c’était rédhibitoire pour ce cas d’utilisation 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.

Et bien sûr, le côté « oui », c’est qu’il comprenait remarquablement bien la sémantique et la structure des segments fragmentés. À condition qu’il ne refuse pas de reproduire le contenu exact. Zut :/

Comment pouvions-nous donc exploiter les forces d’un LLM tout en évitant les tâches pour lesquelles il est peu fiable? Nous nous sommes tournés vers notre vieil ami, le code (autrement dit, une fonction Python personnalisée). Et un modèle Pydantic « on ne peut plus simple ». Voici la solution :

  • Parcourir les sections tout en conservant un segment logique courant

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

  • Si oui, ajouter la section au segment; sinon, produire le segment logique courant puisqu’il est terminé, puis en créer un autre 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 seul passage sur l’ensemble du contenu. Toutefois, pour ce cas d’utilisation précis où la conservation du contenu exact est la priorité absolue, ce léger coût supplémentaire en valait largement la peine.

C’est une solution très simple, mais elle respecte un principe important : lorsqu’il faut être rigoureux, nous ne voulons pas dépendre uniquement des LLM, puisqu’ils sont, après tout, probabilistes.

Du code et des fonctions personnalisés ainsi que des modèles Pydantic peuvent produire des résultats prévisibles et fiables tout en libérant les capacités des LLM.

En conclusion

La conception d’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 ont incité à relever vos propres défis uniques. Pour en savoir plus sur les solutions d’IA générative axées sur l’ingénierie, consultez notre billet sur la conception de systèmes agentiques fondés sur un routeur.

Auteur

Cynthia Yu