Spring AI-Rezept: Filtern von RAG-Ergebnissen mithilfe von Metadaten nach Spieltitel
Ein Spring AI-Rezept für RAG-Metadatenfilter: Taggen Sie Dokumente nach Spieltitel, lassen Sie das LLM den Wert aus der Frage auswählen und filtern Sie anschließend die Vektorsuche damit.
Quellen: Spring AI Recipe: Filtering RAG Results with Metadata
Die Vektorähnlichkeit findet relevanten Text, doch allein die Relevanz reicht oft nicht aus. Ein Geschäft kann Tausende von Textabschnitten zu verschiedenen Themen speichern: Produkte, Kunden oder in diesem Fall Brettspiele. Wenn mehrere Abschnitte eine Frage genauso gut ähneln, wie kann man die Suche auf die tatsächlich passenden einengen? Fügen Sie Metadaten zu jedem Dokument hinzu und filtern Sie diese bei der Suche. Das Modell sieht dann nur den von Ihnen ausgewählten Datensatz.
Die Einrichtung: drei kleine Spiele
Der Ausgangspunkt ist eine Spring AI-Anwendung, die RAG ohne eine separate Vektordatenbank durchführt und dafür angepasst wurde, mehrere Dateien zu verarbeiten. Die Demo-Dateien sind Textzusammenfassungen von drei erfundenen Spielen: Camp Bowwow, Frog Panic und Taco Truck. Jede liegt in src/main/resources/documents/. Zuerst Camp Bowwow als 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.
Frog Panic befindet sich in 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.
Und der Taco Truck in 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.
Die Eingabekonfiguration muss nun alle drei Dateien laden:
@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.");
};
}
}
Verglichen mit der früheren Version ist der Unterschied gering. Anstelle eines einzigen String-Pfads injiziert die Eigenschaft rag.documents ein Array von Resource-Objekten. Die load()-Methode durchläuft diese Objekte, teilt jedes in Blöcke auf und schreibt diese in den Vektor-Speicher.
Führen Sie die Anwendung jetzt aus und stellen Sie eine Frage wie „Was kann ich in meiner Reihe tun?“, dann tritt ein Problem auf. Alle drei Zusammenfassungen beschreiben die Reihe eines Spielers, wodurch Blöcke aus jedem von ihnen für den Vektor-Speicher relevant erscheinen können.
Was geht schief ohne Filter
Die Antwort kann ein einziges Spiel beschreiben oder, was wahrscheinlicher ist, eine Mischung aus Regeln mehrerer Spiele. Ein Versuch ergab Folgendes:
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.
Jeder Fakt ist für die Frage relevant, doch das Mischen von Spielen macht die Antwort nutzlos. Stellen Sie sich dasselbe mit Hunderten von Spielen im Store vor. Genau da kommt das Metadatenfiltern ins Spiel.
Dokumente mit einem Titel kennzeichnen
Zur Veranschaulichung erhält jedes Dokument den Titel des Spiels als Metadaten, wodurch die Suche auf ein bestimmtes Spiel eingeschränkt wird. Der Einpflegungscode wird leicht geändert, damit der Titel zusammen mit den Datenblöcken gespeichert wird:
@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.");
};
}
Bevor ein Dokument den Splitter erreicht, ermittelt der Code einen Titel aus dem Dateinamen und fügt ihn in die Metadaten unter dem Schlüssel title ein. Für camp-bowwow.txt bedeutet das:
title = camp-bowwow
Durch das Aufteilen werden die Metadaten mit übertragen, sodass jeder entstehende Teilabschnitt zusammen mit ihnen im Speicher landet. Embeddings liefern weiterhin eine semantische Ähnlichkeit, während die Metadaten eine zweite Dimension zur Verfeinerung hinzufügen. Anstatt nur nach Teilabschnitten zu suchen, die der Frage ähneln, kann man auch nach Teilabschnitten suchen, die der Frage ähneln und gleichzeitig zu einem bestimmten Spiel gehören.
Ermittlung des gefragten Spiels
Danach muss die App entscheiden, welcher Titel zutrifft. Es gibt jedoch ein Problem: Der gespeicherte Wert stammt von einem Dateinamen wie camp-bowwow.txt, was sich von der Art und Weise unterscheidet, wie jemand den Spielnamen normalerweise schreibt. Das LLM kann diese Lücke schließen, indem es den Namen in der Frage in einen der gespeicherten Werte übersetzt:
@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;
}
}
Beachten Sie, dass TitleHelper das Haupt-ChatClient-Objekt der Anwendung nicht wiederverwendet. Es nimmt stattdessen ein ChatModel entgegen und erstellt absichtlich seinen eigenen Client. Eine einfache Klassifizierungsaufgabe benötigt weder den Speicher noch RAG-Berater oder andere Funktionen, die auf dem primären Client eingerichtet sind – ein einfacher ChatClient reicht aus.
Die Anfrage wird an das Modell mit einer geschlossenen Liste zulässiger Titel gesendet, die den Metadatenwerten entsprechen. Wenn das Modell keine dieser Spiele erkennen kann, antwortet es mit unknown, was von determineGameTitle() in null umgewandelt wird.
Anwendung des Filters bei der Suche
Sobald der Helper bereit ist, erfordert der Chat-Loop im ApplicationRunner-Objekt nur wenige Anpassungen:
@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);
}
}
};
}
Drei Dinge ändern sich:
TitleHelperwird in den Runner eingefügt.- Er wird verwendet, um herauszufinden, auf welches Spiel sich die Frage bezieht.
- Sobald ein Titel gefunden wird, wird eine Filterausdruck an
QuestionAnswerAdvisorübermittelt, sodass nur Dokumente berücksichtigt werden, derentitle-Metadaten übereinstimmen.
Der Ausdruck selbst ist kurz:
title == 'camp-bowwow'
Wichtig ist, wo die Filterung stattfindet. Es handelt sich nicht um eine Anweisung an das LLM, nach dem Abrufen unverwandte Dokumente zu ignorieren. Der Filter beschränkt vielmehr die Vektorsuche selbst, sodass nur Teile des ausgewählten Spiels zurückgegeben werden und das Modell von Anfang an einen fokussierten Kontext erhält.
Wenn kein Spiel erkannt wird, wird der Filter einfach weggelassen und die Suche umfasst alles. Dieser Rückfallmechanismus sollte beibehalten werden. Eine falsche oder fehlende Schätzung sollte den Ablauf nicht stören, und das unfilterte Verhalten ist dasselbe wie zuvor.
Outfit ausprobieren
Starten Sie die App und fragen Sie, was ein Spieler in einer Runde tut – nennen Sie diesmal auch das Spiel:
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.)
Die Abfrage ist auf Blöcke beschränkt, deren title dem Wert camp-bowwow entspricht, sodass die Antwort nicht in die Regeln von Taco Truck oder Frog Panic abdriften kann. Versuchen Sie etwas anderes:
> 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.)
Dieselbe Frage, anderer Filter, andere Dokumentensammlung als Grundlage für die Antwort.
Jenseits der Ähnlichkeit
Die Vektorsuche ist hervorragend darin, herauszufinden, welche Dokumente dieser Abfrage am ähnlichsten sind. Sie kann jedoch allein nicht all das ausdrücken, was man über die Suchorte weiß. Metadaten sind der Weg, über den dieses zusätzliche Wissen in die Suche einfließt.
Hier ist der Filter ein einfacher Spieltitel, doch in echten Anwendungen können Dokumente mit Informationen zu Kunden, Produkten, Dokumenttypen, Kategorien, Sprachen, Datumsangaben, Nutzern, Sicherheitsbereichen oder jedem anderen Attribut versehen werden, das die Suche eingrenzt. Die Kombination aus semantischer Ähnlichkeit und Metadatenfilterung macht die Suche präziser und liefert dem Modell einen kleineren, relevanteren Kontext.
Einige praktische Hinweise, bevor Sie dieses Muster an anderer Stelle verwenden. Halten Sie die Metadatenwerte konsistent und beziehen Sie sie aus einer kleinen, bekannten Menge, da der KlassifizierungsSchritt nur dann gut funktioniert, wenn er aus einer festen Liste wählen kann. Entscheiden Sie im Voraus, was geschehen soll, wenn die Frage mit nichts übereinstimmt. Behandeln Sie sicherheitsrelevante Metadaten wie Tenant oder Berechtigungsrahmen als strenge Anforderung, die von Ihrem Code durchgesetzt wird, und nicht als etwas, das vom Modell respektiert werden soll.