Strona główna / Artykuły

Przepis Spring AI: filtrowanie wyników RAG za pomocą metadanych według tytułu gry

Przepis Spring AI na filtry metadanych RAG: oznaczaj dokumenty według tytułu gry, pozwól modelowi językowemu wybrać wartość z pytania, a następnie użyj jej do filtrowania wyszukiwania wektorowego.

Wydawca: REACTAPP.TOP9 min czytania2162 słów

Źródła: Spring AI Recipe: Filtering RAG Results with Metadata

Podobieństwo wektorowe pomaga znaleźć istotny tekst, ale sama istotność często nie wystarcza. Sklep może przechowywać tysiące fragmentów na różne tematy: produkty, klienci lub, w tym przypadku, gry planszowe. Gdy kilka fragmentów równie dobrze odpowiada na pytanie, jak zachować wyszukiwanie tylko w tym zestawie, który faktycznie jest relevantny? Dołącz metadane do każdego dokumentu i filtrowaj je podczas wyszukiwania. Wtedy model widzi tylko ten fragment danych, który wybrałeś.

Ustawienie: trzy małe gry

Punktem wyjścia jest aplikacja Spring AI, która realizuje technologię RAG bez oddzielnego bazę danych wektorowej, zmodyfikowana tak, by mogła przetwarzać kilka plików. Pliki demonstracyjne to zwięzłe streszczenia trzech wymyślonych gier: Camp Bowwow, Frog Panic i Taco Truck. Każda z nich znajduje się w katalogu src/main/resources/documents/. Najpierw Camp Bowwow, w formie pliku 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” znajduje się w pliku 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.

A Taco Truck w 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.

Konfiguracja pobierania musi teraz załadować wszystkie trzy pliki:

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

W porównaniu z wcześniejszą wersją różnica jest niewielka. Zamiast pojedynczej ścieżki typu String, właściwość rag.documents wprowadza tablicę obiektów Resource. Metoda load() przechodzi przez nie, dzieli każdy z nich na fragmenty i zapisuje je do magazynu wektorowego.

Zainstaluj teraz aplikację i zapytaj coś w stylu „Co mogę zrobić na swojej turze?”, a pojawi się problem. Wszystkie trzy streszczenia opisują turę gracza, więc fragmenty z którychkolwiek z nich mogą wydawać się istotne dla magazynu wektorowego.

Co się dzieje bez filtru

Odpowiedź może opisywać jedną grę lub, co bardziej prawdopodobne, połączenie zasad z kilku gier. Jeden z prób dał następujący wynik:

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.

Każdy fakt jest istotny dla pytania, ale mieszanie gier sprawia, że odpowiedź staje się bezużyteczna. Wyobraź sobie to samo z setkami gier w sklepie. Właśnie wtedy przydaje się filtrowanie metadanych.

Oznaczanie dokumentów tytułem

Aby to zademonstrować, każdy dokument otrzymuje tytuł gry jako metadane, a wyszukiwanie jest zawężane do jednej gry na podstawie tej wartości. Kod służący do przetwarzania danych ulega niewielkiej modyfikacji, tak aby tytuł był przechowywany razem z fragmentami danych:

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

Zanim dokument trafi do narzędzia dzielącego, kod wyodrębnia tytuł z jego nazwy pliku i umieszcza go w metadanych pod kluczem title. W przypadku pliku camp-bowwow.txt oznacza to:

title = camp-bowwow

Rozdzielanie przenosi metadane, więc każdy powstały fragment trafia do magazynu wraz z nimi. Embeddingi nadal dostarczają informacji o podobieństwie semantycznym, natomiast metadane dodają drugą osię do węższego filtrowania. Zamiast prosić o fragmenty podobne do pytania, można poprosić o fragmenty podobne do pytania, które należą również do konkretnej gry.

Określenie, o jaką grę chodzi

Następnie aplikacja musi zdecydować, który tytuł jest odpowiedni. Jest tu jednak problem: przechowywana wartość pochodzi od nazwy pliku typu camp-bowwow.txt, która różni się od sposobu, w jaki ktoś naturalnie zapisuje nazwę gry. LLM może zamknąć tę luki, tłumacząc nazwę z pytania na jedną z przechowywanych wartości:

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

Należy pamiętać, że TitleHelper nie wykorzystuje ponownie głównego obiektu ChatClient aplikacji. Celowo bierze ChatModel i tworzy własnego klienta. Małe zadanie klasyfikacyjne nie wymaga pamięci, doradców RAG ani innych funkcji zainstalowanych w głównym kliencie – wystarczy zwykły ChatClient.

Pytanie jest kierowane do modelu z zamkniętą listą dozwolonych tytułów odpowiadających wartościom metadanych. Jeśli model nie potrafi rozpoznać żadnej z tych gier, odpowiada unknown, co funkcja determineGameTitle() przekształca na null.

Zastosowanie filtru do wyszukiwania

Gdy helper jest już gotowy, pętla czatu w obiekcie ApplicationRunner wymaga jedynie kilku modyfikacji:

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

Trzy rzeczy się zmieniają:

  • TitleHelper jest wstrzykiwany do narzędzia wykonywania.
  • Wykorzystuje się go do określenia, do której gry odnosi się pytanie.
  • Gdy znajdzie się tytuł, wyrażenie filtrujące trafia do QuestionAnswerAdvisor, dzięki czemu bierze się pod uwagę tylko te dokumenty, których metadane title są zgodne.

Samo wyrażenie jest krótkie:

title == 'camp-bowwow'

Ważne jest to, gdzie odbywa się filtrowanie. Nie chodzi o polecenie skierowane do modelu LLM, by ignorował niepowiązane dokumenty po ich pobraniu. Filtr ogranicza samą wyszukiwarkę wektorową, dzięki czemu wyniki zawierają tylko fragmenty z wybranej gry, a model od samego początku otrzymuje skoncentrowany kontekst.

Gdy nie zostanie wykryta żadna gra, filtr po prostu jest pomijany, a wyszukiwanie obejmuje wszystko. Taka opcja awaryjna jest przydatna. Błędna lub nieprawidłowa próba nie powinna zakłócić procesu, a zachowanie bez filtrowania jest takie samo jak wcześniej.

Próba działania

Zainstaluj aplikację i zapytaj, co robi gracz w danej turze, tym razem podając nazwę gry:

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

Wyszukiwanie jest ograniczone do fragmentów, w których title wynosi camp-bowwow, więc odpowiedź nie może dotyczyć zasad Taco Truck ani Frog Panic. Spróbuj innego:

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

To samo pytanie, inny filtr, inna baza dokumentów stanowiąca podstawę odpowiedzi.

Poza podobieństwem

Wyszukiwanie wektorowe doskonale radzi sobie z określeniem, które dokumenty są najbardziej podobne do danego zapytania. Samo w sobie nie jest jednak w stanie odzwierciedlić wszystkich informacji na temat miejsca przeprowadzenia wyszukiwania. Metadane umożliwiają włączenie tych dodatkowych informacji do procesu wyszukiwania.

Tutaj filtrem jest prosty tytuł gry, ale w rzeczywistych zastosowaniach dokumenty mogą być oznaczane takimi atrybutami jak klient, produkt, typ dokumentu, kategoria, język, data, użytkownik, zakres bezpieczeństwa lub dowolnym innym atrybutem, który pomaga zawęzić zakres wyszukiwania. Połączenie podobieństwa semantycznego z filtrowaniem za pomocą metadanych sprawia, że wyszukiwanie jest dokładniejsze, a model otrzymuje mniejszy, bardziej istotny kontekst.

Kilka praktycznych uwag przed użyciem tego wzorca w innych miejscach. Zachowuj spójność wartości metadanych i czerp je z małego, znanego zbioru, ponieważ krok klasyfikacji działa dobrze tylko wtedy, gdy może wybierać z ustalonej listy. Zdecyduj z góry, co ma się stać, gdy pytanie nie odpowiada żadnemu wynikowi. Traktuj metadane związane z bezpieczeństwem, takie jak użytkownik lub zakres uprawnień, jako ścisły wymóg realizowany przez twój kod, a nie coś, co model ma szanować.