Мой агент постоянно терял сохраненные факты
AIdaemon работает полностью на моей машине, с использованием open-source моделей, таких как Gemma 4, без каких-либо вызовов облачных API. Он сохраняет факты обо мне по мере нашего разговора, а затем ищет их, когда я спрашиваю. Сохранение уже работало. Поиск был тем, что меня подводило. Я спрашивал о чем-то, что, как я знал, было сохранено, и получал бесполезный ответ или два факта, которые были на нужную тему, но упускали суть.
Поиск работает на основе смысла. Каждый факт становится вектором при сохранении, мой вопрос становится вектором при запросе, и он ранжирует факты по близости двух векторов. Проблема в том, что близость — это не то же самое, что правильность. Запрос вытягивает все, что имеет тот же предмет, поэтому несколько многословных фактов об одном и том же вытесняют короткий факт, который на самом деле содержит ответ. При одном запросе, который постоянно не удавался, я проверял, где находится ответ. Примерно на 30-м месте. Поиск прочитал первые несколько и остановился задолго до того, как добрался до него.
Почему краткого изложения недостаточно
Первый поиск использует би-энкодер, небольшую модель под названием all-MiniLM-L6-v2. Он кодирует каждый факт отдельно при сохранении и ваш запрос отдельно при поиске, а затем сравнивает их. Он никогда не видит их вместе. В этом вся причина его скорости. Вы встраиваете все один раз, а поиск — это просто дешевые математические операции над хранилищем, без вызова модели при запросе.
Это также причина, по которой он неправильно расставляет порядок. Би-энкодер улавливает общую тему и упускает точное совпадение. Спросите об одном, и он вытянет все, что находится поблизости, в то время как нужный факт утонет. Он достаточно дешев, чтобы работать со всеми вашими фактами, именно поэтому вы обращаетесь к нему, когда все должно работать на вашем собственном оборудовании. Он просто недостаточно точен, чтобы хорошо их ранжировать. В большинстве случаев этого достаточно. Когда мне нужен конкретный ответ, он разваливается.
Позволить второй модели реально читать
Решение — это реранкер. Это кросс-энкодер, поэтому он читает мой вопрос и один кандидатный факт вместе, за один проход, и оценивает, насколько хорошо этот факт отвечает на вопрос. Там, где би-энкодер сравнивал два резюме, созданных независимо друг от друга, этот читает пару. Лучше вопрос, лучше ответ. Недостаток — скорость. Это один прогон модели на факт, поэтому вы не можете направить его на все хранилище памяти и ждать.
Путь к этому стар и скучен, и он работает. Сначала извлечь, потом переранжировать. Позвольте дешевой модели захватить большую кучу потенциально релевантных фактов, а затем используйте дорогую только для этой короткой кучи.
В AIdaemon би-энкодер извлекает топ-50 кандидатов при слабом пороге в 0.22. Я держу его более слабым, чем 0.30, которое я использую для памяти, которая вставляется в промпты сама по себе, потому что синонимы оцениваются низко, и строгий порог отбросил бы правильный факт до того, как реранкер его увидел. Первый этап не обязательно должен быть правильным. Он просто должен найти ответ где-то в пределах 50. Затем кросс-энкодер перечитывает все 50 против вопроса и переупорядочивает их. Факт, застрявший на 30-м месте, наконец-то читается на своих условиях, поднимается и возвращается. Я ничего не изменил в том, как хранятся факты, только в том, как они упорядочиваются при выводе.
// этап 1: би-энкодер забрасывает широкую сеть (косинус по всем активным фактам)
let mut pool = cosine_rank(&query_vec, &facts, MIN_SCORE); // 0.22
pool.truncate(CANDIDATE_POOL); // 50
// этап 2: кросс-энкодер перечитывает каждую пару (запрос, факт) и переупорядочивает
let docs: Vec<String> = pool.iter().map(|c| c.text()).collect();
let ranked = reranker.rerank(&query, docs, false, None)?;
Реранкер работает на той же машине, что и все остальное, через крейт fastembed как ONNX-модель, без API реранкинга для вызовов. Я использую Jina Reranker v2 Base Multilingual, и я намеренно выбрал многоязычный вариант, поскольку мои заметки для AIdaemon переключаются между английским и испанским, и реранкер, работающий только на английском, спотыкался бы именно на тех фактах, которые мне важнее всего правильно получить.
Сохранение дешевизны
Вторая модель — это вторая вещь, которая может сломаться, поэтому она находится на коротком поводке. Она загружается только при первом поиске, поскольку загрузка немаленькая. Если загрузка не удается, поиск возвращается к обычному косинусному порядку, который использовался ранее. Ничего не ломается, просто становится менее точным. И он работает только тогда, когда я явно прошу агента что-то найти. Память, которая вставляется в каждый промпт сама по себе, по-прежнему использует дешевый путь. Прогон 50 фактов через кросс-энкодер разумно выполнить один раз, когда я задал вопрос. При каждом сообщении это было бы пустой тратой.
Извлечение с последующим переранжированием не ново. Поисковые системы опираются на это годами. Просто оказалось, что это хорошо подходит и для памяти агента. Первая модель захватывает большую кучу, вторая модель правильно читает эту кучу, и это второе чтение почти ничего не стоит: пятьдесят коротких строк через одну модель. Именно поэтому AIdaemon теперь возвращает мне сохраненный факт вместо того, чтобы выдавать пустой ответ, когда я спрашиваю.