Recette Spring AI : Filtrage des résultats RAG à l’aide de métadonnées selon le titre du jeu
Une recette Spring AI pour les filtres de métadonnées RAG : taguez les documents par titre de jeu, laissez le LLM choisir la valeur dans la question, puis filtrez la recherche vectorielle à l’aide de cette valeur.
Sources: Spring AI Recipe: Filtering RAG Results with Metadata
La similarité vectorielle permet de trouver du texte pertinent, mais la pertinence seule n’est souvent pas suffisante. Un magasin peut contenir des milliers de fragments sur de nombreux sujets : produits, clients, ou dans ce cas, des jeux de société. Lorsque plusieurs fragments ressemblent de manière égale à une question, comment s’assurer que la récupération se fasse uniquement parmi ceux qui sont réellement pertinents ? Associez des métadonnées à chaque document et filtrez-les au moment de la recherche. Le modèle ne verra alors que la partie des données que vous avez choisie.
La configuration : trois petits jeux
Le point de départ est une application Spring AI qui utilise la technique RAG sans base de données vectorielle distincte, modifiée pour prendre en charge plusieurs fichiers. Les fichiers de démonstration sont des résumés en texte brut de trois jeux imaginaires : Camp Bowwow, Frog Panic et Taco Truck. Chacun se trouve dans src/main/resources/documents/. Commençons par Camp Bowwow, sous le nom de camp-bowwow.txt :
On each player's turn, they place a dog, food bowl, or toy into
one of the three play yards. The game ends when a player has
placed their last dog into a yard. Then each player who hasn't
played in that round gets to place one more item into a yard
until the last player so that all players have an equal number
of turns.
Le contenu de Frog Panic se trouve dans frog-panic.txt :
On their turn, a player starts by moving any flies on the board
to the same space as an adjacent frog (effectively the frog has
eaten that fly). Then the player can either place 1 fly adjacent
to a frog on the board as a distraction so that the frog won't
jump or they may place a frog of their player color onto any
empty space. If the frog is adjacent to another frog (even if it
is their own), the adjacent frog will jump two spaces in any
direction away from the frog that was just placed. The direction
of the jump is decided by the current player. If the frog jumps
off the board, then that frog is out of play.
The game ends when all players have placed all of their frogs
(not counting frogs that have jumped off the board). The player
with the most frogs remaining on the board wins.
Et le camion de tacos dans taco-truck.txt :
When it is a player's turn, they will draw a card from the
ingredients deck and add it to their hand. Then they may play
any ingredient card from their hand to add that ingredient to
one of up to three tacos in progress. Or, if they have a crunch
card in their hand, then they may play it to remove one
ingredient from any one opponent's taco. The first player to
completely build 3 tacos wins.
La configuration d’ingestion doit maintenant charger les trois fichiers :
@Configuration
public class RagIngestionConfig {
private static final Logger logger =
LoggerFactory.getLogger(RagIngestionConfig.class);
@Value("${rag.documents}")
Resource[] documentResources;
@Bean
@Order(-1)
ApplicationRunner load(VectorStore vectorStore) {
return args -> {
for (Resource documentResource : documentResources) {
var filename = documentResource.getFilename();
logger.info("Loading document from {}.", filename);
var reader = new TikaDocumentReader(documentResource);
var splitter = TokenTextSplitter.builder().build();
vectorStore.accept(
splitter.apply(
reader.get()));
}
logger.info("Document loading complete.");
};
}
}
Par rapport à la version précédente, la différence est minime. Au lieu d’un seul chemin de type String, la propriété rag.documents injecte un tableau d’objets Resource. La méthode load() parcourt ce tableau, divise chaque élément en fragments et les enregistre dans le stock vectoriel.
Exécutez l’application maintenant et posez une question du type « Que puis-je faire à mon tour ? », et un problème apparaîtra. Les trois résumés décrivent tous le tour d’un joueur, de sorte que des fragments provenant de n’importe lequel d’eux peuvent sembler pertinents pour le stock vectoriel.
Que se passe-t-il en l’absence de filtre ?
La réponse peut décrire un seul jeu ou, plus probablement, une combinaison de règles provenant de plusieurs jeux. Une tentative a donné ce résultat :
How can I help?
> What can I do on my turn?
- Draw a card from the ingredients deck and add it to your hand.
- Then you may either:
- Play any ingredient card from your hand to add that ingredient
to one of up to three tacos in progress, or
- Play a crunch card (if you have one) to remove one ingredient
from any one opponent's taco.
- On your turn you place a dog, a food bowl, or a toy into one of
the three play yards.
- The game ends when a player has placed their last dog; then players
who haven't yet played in that round get one more placement so all
players have equal turns.
Chaque fait est pertinent pour la question, mais mélanger des jeux rend la réponse inutile. Imaginez la même situation avec des centaines de jeux dans le magasin. C’est là que le filtrage par métadonnées devient essentiel.
Marquage des documents avec un titre
Pour démontrer cela, chaque document reçoit le titre du jeu en tant que métadonnée, ce qui permet de limiter la recherche à un seul jeu grâce à cette valeur. Le code d’ingestion est légèrement modifié afin que le titre soit stocké avec les fragments :
@Bean
@Order(-1)
ApplicationRunner load(VectorStore vectorStore) {
return args -> {
for (Resource documentResource : documentResources) {
var filename = documentResource.getFilename();
logger.info("Loading document from {}.", filename);
var reader = new TikaDocumentReader(documentResource);
var splitter = TokenTextSplitter.builder().build();
var titleTag =
filename.substring(0, filename.lastIndexOf('.'));
vectorStore.accept(
splitter.apply(
reader.get().stream()
.peek(document ->
document.getMetadata()
.put("title", titleTag))
.toList()));
}
logger.info("Document loading complete.");
};
}
Avant que un document n’atteigne l’outil de division, le code déduit un titre à partir de son nom de fichier et le place dans les métadonnées sous la clé title. Pour camp-bowwow.txt, cela signifie :
title = camp-bowwow
La division transmet les métadonnées, de sorte que chaque morceau résultant est stocké avec elles. Les embeddings fournissent toujours une similarité sémantique, tandis que les métadonnées ajoutent un deuxième axe pour affiner les résultats. Au lieu de demander uniquement des morceaux similaires à la question, on peut demander des morceaux similaires à la question qui appartiennent également à un jeu en particulier.
Déterminer quel jeu a été demandé
Ensuite, l’application doit déterminer quel titre s’applique. Il y a cependant un problème : la valeur stockée provient d’un nom de fichier comme camp-bowwow.txt, ce qui diffère de la manière dont quelqu’un écrirait naturellement le nom du jeu. Le LLM peut combler cette lacune en traduisant le nom de la question en l’un des valeurs stockées :
@Service
public class TitleHelper {
private final ChatClient chatClient;
public TitleHelper(ChatModel chatModel) {
this.chatClient = ChatClient.builder(chatModel).build();
}
public String determineGameTitle(String question) {
var title = chatClient.prompt()
.user(userSpec -> userSpec
.text("""
Your job is to try to determine the title of a game from
the question asked.
The game choices are:
- camp-bowwow
- frog-panic
- taco-truck
- unknown
If the game's title isn't explicitly mentioned in the
question, or you don't recognize the game's title, then
say "unknown".
The question is:
{question}
""")
.param("question", question))
.call()
.content();
return title.equals("unknown") ? null : title;
}
}
Notez que TitleHelper ne réutilise pas le bean principal ChatClient de l’application. Il prend un ChatModel et crée délibérément son propre client. Une petite tâche de classification n’a pas besoin de la mémoire, des conseillers RAG ou d’autres fonctionnalités configurées sur le client principal ; un simple ChatClient suffit.
La question est envoyée au modèle avec une liste fermée de titres autorisés correspondant aux valeurs des métadonnées. Si le modèle ne reconnaît aucun de ces jeux, il répond unknown, ce que determineGameTitle() convertit en null.
Application du filtre à la récupération
Avec l’outil prêt, la boucle de chat dans le bean ApplicationRunner nécessite seulement quelques modifications :
@Bean
ApplicationRunner go(
ChatClient chatClient,
TitleHelper titleHelper) {
return args -> {
System.out.println("How can I help?\n");
try (Scanner scanner = new Scanner(System.in)) {
while (true) {
System.out.print("> ");
if (!scanner.hasNextLine()) break;
var input = scanner.nextLine();
if (input.isBlank()) continue;
var requestSpec = chatClient.prompt(input);
var gameTitle =
titleHelper.determineGameTitle(input);
var answer = requestSpec
.advisors(spec -> {
spec.param(
ChatMemory.CONVERSATION_ID,
"DEMO");
if (gameTitle != null) {
spec.param(
QuestionAnswerAdvisor.FILTER_EXPRESSION,
String.format(
"title == '%s'",
gameTitle));
}
})
.call()
.content();
System.out.println("\n - " + answer);
}
}
};
}
Trois choses changent :
TitleHelperest injecté dans le moteur d’exécution.- Il est utilisé pour identifier le jeu auquel se réfère la question.
- Lorsqu’un titre est trouvé, une expression de filtre est envoyée à
QuestionAnswerAdvisor, de sorte que seuls les documents dont les métadonnéestitlecorrespondent sont pris en compte.
L’expression elle-même est brève :
title == 'camp-bowwow'
Ce qui compte, c’est l’endroit où a lieu le filtrage. Il ne s’agit pas d’une instruction demandant au LLM d’ignorer des documents non pertinents après qu’ils aient été récupérés. Le filtre limite la recherche vectorielle elle-même, de sorte que la récupération ne renvoie que des extraits du jeu choisi et que le modèle reçoit dès le début un contexte ciblé.
Lorsqu’aucun jeu n’est détecté, le filtre est simplement omis et la recherche couvre tout. Ce mécanisme de secours vaut la peine d’être conservé. Une estimation erronée ou manquante ne doit pas perturber le flux, et le comportement non filtré reste identique à avant.
Essayer
Démarrez l’application et demandez ce qu’un joueur fait à son tour, en indiquant cette fois le nom du jeu :
How can I help?
> I'm playing Camp Bowwow. What do I do when it's my turn?
- On your turn you place one item - either a dog, a food bowl,
or a toy - into one of the three play yards.
(For reference: the game ends when a player has placed their last
dog; then any players who haven't yet played in that round each get
one more item turn in order until the last player, so all players
have equal turns.)
La récupération est limitée aux blocs dont le title est égal à camp-bowwow ; par conséquent, la réponse ne peut pas entrer en conflit avec les règles de Taco Truck ou Frog Panic. Essayez-en un autre :
> I'm playing Frog Panic. What do I do when it's my turn?
- On your turn:
1. First, move any flies on the board onto the same space as an
adjacent frog (the frog eats that fly).
2. Then choose one action:
- Place 1 fly adjacent to a frog on the board as a distraction
(so that frog won't jump), or
- Place a frog of your color onto any empty space.
If the frog you place is adjacent to another frog (even your own),
that adjacent frog jumps two spaces away in any direction you choose.
If a frog jumps off the board it is out of play.
(For reference: the game ends when all players have placed all their
frogs; the player with the most frogs remaining on the board wins.)
Même question, filtre différent, ensemble de documents différent derrière la réponse.
Au-delà de la similarité
La recherche vectorielle est excellente pour déterminer quels documents ressemblent le plus à cette requête. Elle ne peut pas, à elle seule, exprimer tout ce que l’on sait sur les endroits où chercher. Les métadonnées permettent à ces connaissances supplémentaires d’être intégrées au processus de récupération.
Ici, le filtre est un simple titre de jeu, mais dans des applications réelles, les documents peuvent être étiquetés avec des informations sur le client, le produit, le type de document, la catégorie, la langue, la date, l’utilisateur, la portée de la sécurité ou tout autre attribut permettant de restreindre la recherche. La combinaison de la similarité sémantique et du filtrage par métadonnées rend la récupération plus précise et fournit au modèle un contexte plus restreint mais plus pertinent.
Quelques conseils pratiques avant d’utiliser ce modèle ailleurs. Gardez les valeurs des métadonnées cohérentes et issues d’un petit ensemble connu, car l’étape de classification ne fonctionne bien que lorsqu’elle peut choisir parmi une liste fixe. Déterminez à l’avance ce qui doit se passer lorsque la question ne correspond à rien. Et considérez les métadonnées liées à la sécurité, telles que le tenant ou la portée des permissions, comme une exigence stricte imposée par votre code et non quelque chose que le modèle est censé respecter.