Home / Articles

Spring AI Recipe: Filtering RAG Results with Metadata by Game Title

A Spring AI recipe for RAG metadata filters: tag documents by game title, let the LLM pick the value from the question, then filter the vector search with it.

Publisher: REACTAPP.TOP9 min read2162 words

This article is published in English.

Sources: Spring AI Recipe: Filtering RAG Results with Metadata

Vector similarity finds relevant text, but relevance alone often isn't sufficient. A store can hold thousands of chunks on many subjects: products, customers, or in this recipe, board games. When several chunks resemble a question equally well, how do you keep retrieval inside the set that actually applies? Attach metadata to each document and filter on it at search time. The model then only sees the slice of data you chose.

The setup: three small games

The starting point is a Spring AI app that does RAG without a separate vector database, modified to ingest several files. The demo files are plain-text summaries of three made-up games: Camp Bowwow, Frog Panic and Taco Truck. Each sits in src/main/resources/documents/. Camp Bowwow first, as 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 goes 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.

And 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.

The ingestion config now has to load all three files:

@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.");
    };
  }
}

Compared with the earlier version, the difference is small. Rather than a single String path, a rag.documents property injects an array of Resource objects. The load() method loops through them, splits each into chunks and writes those into the vector store.

Run the app now and ask something like "What can I do on my turn?" and a problem shows up. All three summaries describe a player's turn, so chunks from any of them can seem relevant to the vector store.

What goes wrong without a filter

The reply may describe one game or, more likely, a blend of rules from several. One attempt produced this:

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.

Every fact there is relevant to the question, yet mixing games makes the answer useless. Imagine the same thing with hundreds of games in the store. That's where metadata filtering earns its keep.

Tagging documents with a title

To demonstrate, each document gets the game's title as metadata, and retrieval is narrowed to one game by that value. The ingestion code changes slightly so the title is stored with the chunks:

@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.");
  };
}

Before a document reaches the splitter, the code derives a title from its filename and puts it into the metadata under the key title. For camp-bowwow.txt that means:

title = camp-bowwow

Splitting carries the metadata along, so every resulting chunk lands in the store with it. Embeddings still supply semantic similarity, while metadata adds a second axis for narrowing. Instead of asking only for chunks similar to the question, you can ask for chunks similar to the question that also belong to one particular game.

Working out which game was asked about

Next the app must decide which title applies. There's a catch: the stored value comes from a filename like camp-bowwow.txt, which differs from how someone naturally writes the game's name. The LLM can bridge that gap by translating the name in the question into one of the stored values:

@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;
  }
}

Note that TitleHelper doesn't reuse the application's main ChatClient bean. It takes a ChatModel and builds its own client, deliberately. A tiny classification task doesn't need the memory, RAG advisors or other features set up on the primary client, and a plain ChatClient is enough.

The question goes to the model with a closed list of allowed titles that match the metadata values. If the model can't recognize any of those games, it answers unknown, which determineGameTitle() converts to null.

Applying the filter to retrieval

With the helper ready, the chat loop in the ApplicationRunner bean needs only a few edits:

@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);
      }
    }
  };
}

Three things change:

  • TitleHelper gets injected into the runner.
  • It is used to identify which game the question refers to.
  • When a title is found, a filter expression goes to QuestionAnswerAdvisor, so only documents whose title metadata matches are considered.

The expression itself is brief:

title == 'camp-bowwow'

What matters is where the filtering happens. It isn't a prompt asking the LLM to ignore unrelated documents after they were fetched. The filter limits the vector search itself, so retrieval only returns chunks from the chosen game and the model receives focused context from the start.

When no game is detected, the filter is simply left out and the search covers everything. That fallback is worth keeping. A wrong or missing guess shouldn't crash the flow, and the unfiltered behavior is the same as before.

Trying it out

Start the app and ask what a player does on a turn, this time naming the game:

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.)

Retrieval is restricted to chunks with title equal to camp-bowwow, so the answer can't wander into Taco Truck or Frog Panic rules. Try another:

> 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.)

Same question, different filter, different pool of documents behind the answer.

Beyond similarity

Vector search is excellent at answering which documents are most like this query. It can't, on its own, express everything you know about where to search. Metadata is how that extra knowledge enters retrieval.

Here the filter is a simple game title, but real applications can tag documents with customer, product, document type, category, language, date, tenant, security scope or any attribute that narrows a search. Combining semantic similarity with metadata filtering makes retrieval more precise and hands the model a smaller, more relevant context.

A few practical notes before using this pattern elsewhere. Keep metadata values consistent and drawn from a small known set, since the classifier step only works well when it can choose from a fixed list. Decide in advance what should happen when the question matches nothing. And treat security-related metadata, such as tenant or permission scope, as a hard requirement applied by your code and not something the model is asked to respect.