Receta de Spring AI: Filtrado de resultados RAG con metadatos por título del juego
Una receta de Spring AI para filtros de metadatos RAG: etiquete los documentos por título del juego, deje que el LLM seleccione el valor de la pregunta y luego filtre la búsqueda vectorial con él.
Fuentes: Spring AI Recipe: Filtering RAG Results with Metadata
La similitud vectorial permite encontrar texto relevante, pero la relevancia por sí sola a menudo no es suficiente. Una tienda puede albergar miles de fragmentos sobre diversos temas: productos, clientes o, en este caso, juegos de mesa. Cuando varios fragmentos se parecen igualmente a una pregunta, ¿cómo se mantiene la recuperación dentro del conjunto que realmente es aplicable? Se deben adjuntar metadatos a cada documento y filtrarlos en el momento de la búsqueda. De este modo, el modelo solo ve la porción de datos que se haya seleccionado.
La configuración: tres juegos pequeños
El punto de partida es una aplicación de Spring AI que realiza RAG sin una base de datos vectorial separada, modificada para procesar varios archivos. Los archivos de demostración son resúmenes en texto plano de tres juegos inventados: Camp Bowwow, Frog Panic y Taco Truck. Cada uno se encuentra en src/main/resources/documents/. Primero, Camp Bowwow, como 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.
El contenido de Frog Panic está en 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.
Y el camión de tacos en 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 configuración de ingestión ahora debe cargar los tres archivos:
@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.");
};
}
}
En comparación con la versión anterior, la diferencia es pequeña. En lugar de un único camino String, la propiedad rag.documents inserta un array de objetos Resource. El método load() recorre esos objetos, los divide en fragmentos y los escribe en el almacén vectorial.
Ejecuta la aplicación ahora y pregunta algo como “¿Qué puedo hacer en mi turno?”, y aparecerá un problema. Los tres resúmenes describen el turno de un jugador, por lo que los fragmentos de cualquiera de ellos pueden parecer relevantes para el almacén vectorial.
Qué sucede sin un filtro
La respuesta puede describir un juego o, más probablemente, una combinación de reglas de varios juegos. Un intento produjo lo siguiente:
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.
Cada hecho es relevante para la pregunta, pero mezclar juegos hace que la respuesta sea inútil. Imagina lo mismo con cientos de juegos en la tienda. Ahí es donde el filtrado de metadatos resulta útil.
Etiquetado de documentos con un título
Para demostrarlo, cada documento recibe el título del juego como metadato, y la búsqueda se limita a un solo juego mediante ese valor. El código de ingestión cambia ligeramente para que el título se almacene junto con los fragmentos:
@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.");
};
}
Antes de que un documento llegue al divisor, el código deriva un título a partir de su nombre de archivo y lo coloca en los metadatos bajo la clave title. Para camp-bowwow.txt eso significa:
title = camp-bowwow
Al dividir el texto, los metadatos se transfieren junto con él, de modo que cada fragmento resultante se almacena junto con esos metadatos. Las representaciones embedding siguen proporcionando similitud semántica, mientras que los metadatos añaden un segundo eje para restringir la búsqueda. En lugar de solicitar solo fragmentos similares a la pregunta, se pueden solicitar fragmentos similares a la pregunta que también pertenezcan a un juego en particular.
Determinar de qué juego se trata
A continuación, la aplicación debe decidir a qué título se refiere. Hay un problema: el valor almacenado proviene de un nombre de archivo como camp-bowwow.txt, lo cual difiere de la forma en que alguien escribiría naturalmente el nombre del juego. El LLM puede superar esta diferencia traduciendo el nombre de la pregunta a uno de los valores almacenados:
@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;
}
}
Tenga en cuenta que TitleHelper no reutiliza el bean principal ChatClient de la aplicación. Intencionalmente toma un ChatModel y crea su propio cliente. Una tarea de clasificación sencilla no necesita la memoria, los asesores RAG ni otras funciones configuradas en el cliente principal; basta con un ChatClient común.
La pregunta se envía al modelo con una lista cerrada de títulos permitidos que coinciden con los valores de los metadatos. Si el modelo no puede reconocer ninguno de esos juegos, responde unknown, lo cual determineGameTitle() convierte en null.
Aplicación del filtro a la recuperación
Una vez listo el helper, el bucle de chat en el bean ApplicationRunner solo necesita algunas modificaciones:
@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);
}
}
};
}
Tres cosas cambian:
TitleHelperse inyecta en el ejecutor.- Se utiliza para identificar a qué juego se refiere la pregunta.
- Cuando se encuentra un título, una expresión de filtro se envía a
QuestionAnswerAdvisor, de modo que solo se consideran los documentos cuyos metadatostitlecoincidan.
La expresión en sí es breve:
title == 'camp-bowwow'
Lo importante es dónde tiene lugar el filtrado. No se trata de una instrucción que pida al LLM que ignore documentos no relacionados una vez que han sido recuperados. El filtro limita la búsqueda vectorial en sí, de modo que la recuperación solo devuelve fragmentos del juego elegido y el modelo recibe un contexto enfocado desde el principio.
Cuando no se detecta ningún juego, simplemente se omite el filtro y la búsqueda abarca todo. Vale la pena mantener esta opción de respaldo. Una suposición incorrecta o faltante no debería interrumpir el proceso, y el comportamiento sin filtrado es el mismo que antes.
Probándolo
Inicie la aplicación y pregunte qué hace un jugador en su turno, esta vez indicando el nombre del juego:
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 recuperación está restringida a los fragmentos cuyo title sea igual a camp-bowwow; por lo tanto, la respuesta no puede incluirse en las reglas de Taco Truck o Frog Panic. Intente otro:
> 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.)
Misma pregunta, filtro diferente, conjunto de documentos distinto detrás de la respuesta.
Más allá de la similitud
La búsqueda vectorial es excelente para determinar qué documentos se parecen más a esta consulta. Por sí sola, no puede expresar todo lo que se sabe sobre dónde buscar. Los metadatos son la forma en que ese conocimiento adicional se incorpora en la recuperación de información.
Aquí el filtro es un simple título de juego, pero en aplicaciones reales se pueden etiquetar los documentos con información sobre clientes, productos, tipo de documento, categoría, idioma, fecha, arrendatario, alcance de seguridad o cualquier atributo que permita restringir la búsqueda. Combinar la similitud semántica con el filtrado por metadatos hace que la recuperación sea más precisa y proporciona al modelo un contexto más pequeño y relevante.
Algunas notas prácticas antes de utilizar este patrón en otros contextos. Mantenga los valores de los metadatos consistentes y provenientes de un conjunto pequeño y conocido, ya que el paso de clasificación solo funciona bien cuando puede elegir entre una lista fija. Decida de antemano qué ocurrirá cuando la pregunta no coincida con nada. Además, trate los metadatos relacionados con la seguridad, como el propietario o el alcance de permisos, como un requisito estricto impuesto por su código y no como algo que se le pida al modelo que respete.