Рэцэпт Spring AI: фільтрацыя рэзультатаў RAG за дапамогою метаданых за заглавлам гры.
Рэцэпт Spring AI для фільтраў метаданаў RAG: пазначайце дакументы па назве гэму, нехай LLM выберае значэнне з запытання, а пасля фільтруйце пошук вектараў з яго.
Крыніцы: Spring AI Recipe: Filtering RAG Results with Metadata
Вектарная схожасць дапамагае знаходзіць рэлевантны тэкст, але сама по сабе рэлевантнасць часта не ўсё. Магазін можа хаваць тыячы фрагментаў на разныя тэмы: продукты, кліянты або, у даным прыкладзе, настольныя гэмы. Калі калькі фрагментаў аднакова добра падходзяць да пытання, як заставіць сістэму знаходзіць толькі тыя, якія даслівна пасуюць? Неабходна прыкрэпіць метаданы да кожнага дакумента і фільтруваць іх пад час пошуку. Тады мадэль бачыць толькі тыя даны, якія вы выбралі.
Установка: тры маленькія гэмы
Выходны пункт — аплікацыя Spring AI, якая выконвае тэхніку RAG без аддзінальнай вектарной базы дадзэння, адрэдагаваная для прыймання калькі файлаў. Файлы-дэманстрацыі — гэта стандартныя тэкстовыя апісанні трох вымышлёных гэм: Camp Bowwow, Frog Panic і Taco Truck. Кожны з іх знаходзіцца ў падметку src/main/resources/documents/. Пачнем з Camp Bowwow у файле 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» знаходзіцца ў 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.
А таксі з такаў в файле 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.
Канфігурацыя абсарбавання цяпер должна завантажыць усе тры файлы:
@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.");
};
}
}
Параўняўчы з пярэднім версіяй, разлік ў мінімуме. Уместо аднаго шляху тыпу String атрыбут rag.documents задае масэвы об’ектаў тыпу Resource. Метод load() праходзіць па іх, дзеліць кожны на часткі і запісвае іх у схованню вектараў.
Зараз запусціце прыклад прыемлівасці і запытайце ў такім стылі: «Што я можу зрабіць на сваю ходу?», і з’явіцца задача. Усе тры падсумкі опісваюць ход гравца, таму часткі з будзь-кага з іх можу здавацца рэлевантнымі для сховання вектараў.
Што выходзіць з-пад контролю без фільтра
Адпаведзь можа опісваць адну гру або, што частае, сумешанне правіл з кальколькі гэтых гэр. Адна з спроб дала такі рэзультат:
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.
Кожны факт тут стосуецца пытання, але сумешчанне гэймов робіць адказ беспарадным. Уявіце тое ж самае, калі ў магазіне ёсць сотні гэймов. Самэлькі корыстна метаданные-фільтрацыя.
Пазначэнне дакументаў заглавлём
Для дэманстрацыі кожны дакумент атрымлеў заглавлё гэйма як метаданные, і пошук звяртаецца толькі да адного гэйма за дапамогою гэтага значэння. Код для прыёму дакументаў змініцца незначна, ўпрымкі заглавлё будуць зберагваны разам з часткамі дакумента:
@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.");
};
}
Перад тым, як дакумент прайдзе да супараўчыка, код вычысляе заглавле з імені файлу і размешчае яго ў метаданых пад ключам title. Для camp-bowwow.txt гэта значыць:
title = camp-bowwow
Практыка раздзелення перадае метаданы, таму кожны отраслёвы фрагмент знаходзіцца ў базе дадзеных разам з ямі. Эмбеддынгі як і раней забезпечваюць сэмантычную сэроднечнасць, а метаданы дадзаюць другую вектарную прамеру для ўсечкі рэзультатаў. У замяне таго, каб прасіць толькі пра фрагменты, сэроднія запытку, можна прасіць пра фрагменты, сэроднія запытку і якія таксама належаць да адной конкрэтнай гры.
Выявленне гры, пра якую быў заданы запыт
Далей прыстрой павінен выявіць, якая гра падходзіць. Ёсць адна проблема: значэнне, якое зберагаецца, выходзіць з назвы файла, напрыклад camp-bowwow.txt, якая не збігаецца з тым, як людзі зазвычай пішу назву гры. LLM можа прыкрасіць гэты разлік, перакладзіўшы назву з запытку ў адна з збераглых значэнняў:
@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;
}
}
Заўважкі: TitleHelper не вікорыстоўвае галоўны элемент ChatClient прыкладнення. Ён намеравана берэ ChatModel і стварае свой сабэй кліента. Для простага задачы класыфікацыі не трэба памяці, кансалтантаў RAG чы іншых функцыяй, налаштаваных у галоўным кліенце, і дастатній звычайны ChatClient.
Запыт направляецца да модэлю з закрытым спіскам дазволеных заглаваў, якія падходзяць да значэнняў метаданых. Калі модэль не можа адгукнуцца ні да аднаго з гэтых гэмав, ён адпавядае unknown, якое determineGameTitle() ператварае ў null.
Застосаванне фільтра для адзыскання
Калі хелпер готавы, цыкл чату ў элеменце ApplicationRunner трэба зменіць лішэ некалькіма пунктамі:
@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);
}
}
};
}
Зменяецца тры ўсё:
TitleHelperдадаецца ў ранер.- Яго выкарыстоўваюць для выявлення таго, да якой гры стосуецца запіт.
- Калі знаходзіцца загалоўка, выраз фільтра надаецца
QuestionAnswerAdvisor, таму беруцься ў расчыт толькі тые дакументы, чыя метаданыtitleпадходзяць.
Сам выраз ўсьмохнены:
title == 'camp-bowwow'
Важна тое, дзе адбываецца фільтрацыя. Гэта не запіт, які прасіць LLM не зважаць на некаляваныя дакументы пасля ўзяць іх. Фільтр обмежвае сама вектарная пашук, таму падчас выкарыстоўвання рэзультатаў вяртаюцься толькі фрагменты з выбранай гры, а мадэль з самага пачатку отрымае сфокусаваны кантэкст.
Калі гра не выканаюцца, фільтр проста не выкарыстоўваецца, і пашук охопляе все. Такі варыант адступлення варта застаўляць. Неправільны або недастатковы прыказ не павінен спанаваць працэс, а поведзенне без фільтрацыі застаецца такім жа, як раней.
Пробаўце самі
Запусціце прыкладнік і запытайце, што робіць гэрой на сваю ход, але ці раз назвіце гру:
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.)
Адзыяўка даступна толькі для чаканаў, у якіх title равна camp-bowwow; таму адказ не можа парадзецься ў правілах Taco Truck чы Frog Panic. Прабуйце іншы:
> 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.)
Тая ж самая запытка, але іншы фільтр, іншы набор дакументаў, якія выкорыстоўваюцца для адпаведзі.
За межамі сэмантычнай супадзімасці
Вектарны пошук чырпаецца ў тым, каб выявіць, якія дакументы найбольш супадаюць з данай запыткай. Але ён сам по сабе не можа выразіць усе, што вядома пра тое, дзе трэба шукаць. Метаданы — гэта спосаб, якім гэты дапаможныя знанні практычна вплываюць на працэс пошуку.
Хоця тут фільтр — проста назва гэйму, у рэальных застосоўаннях дакументы можна пазначаць атрыбутамі, такімі як кліент, продукт, тып дакумента, катэгорыя, мова, дата, арэнднік, рэгламенты безпекі чы іншыя, якія дапамагаюць сузіць пошук. Спалучэнне сэмантычнай супадзімасці з фільтрацыяю метаданаў робіць працэс выкарыстоўвання інформацыі болей тачным і дае модэлю меншы, але болей рэлевантны кантэкст.
Калькі практычныя прытамулкі перад выкарыстаннем гэтага шаблону ў іншых месцах. Зберагаюце цэні з метаданных адносовымі і выбірайце іх з малага, вядомага набора, адколі крок класыфікатора працюе належна толькі тады, калі ён можа выбіраць з фіксаванага спіску. Загадзя вярніцеся, што будзе дзеяць, калі запіт не падпадае пад нічыя з катэгорыяў. А метаданныя, зв’язаныя з безпекай, такія як тэнант або дыячы спяволі, трэба спрыяваць як строгую патрабованню, якае выпалняецца вашым кодам, а не як чынніст, якую модэль павінна адзірваць.