Почему память чата находит лишь 25% фактов и как починить поиск
Реальное фриланс-задание: система памяти с идеальным хранением, но полнотой поиска 25%. Переписывание запросов, HyDE, реранкинг и многошаговый поиск, плюс совет — чинить или менять.
Память чата, которая держится в пределах одной сессии, а потом ничего не помнит о том, что было больше нескольких месяцев назад, — картина, которую в приложениях-компаньонах на базе ИИ видишь снова и снова. Фриланс-заказ, опубликованный на Upwork, превращает эту размытую жалобу в измерения, поэтому разбирать его полезнее любого выдуманного примера.
Цифры клиента
Собственный подсчёт клиента: примерно 5000 сохранённых сообщений, 300 эпизодических событий и около 3500 семантических выводов. Они выбрали восемь конкретных фактов, о которых пользователь говорил ещё в июне, и спросили о них так, как спросил бы реальный пользователь сегодня. Вернулись только два, то есть 25% попаданий, хотя каждое исходное сообщение по-прежнему хранилось слово в слово. При записи воспоминаний ничего не теряется.
К последнему утверждению стоит отнестись серьёзно. Что бы ни ломалось, хранилище тут ни при чём.
И это был не прототип, собранный за выходные. Стек работал на Postgres с pgvector, поиск шёл в два этапа с реранкером, а оркестрация и код извлечения были написаны под заказ. Система хранила несколько видов памяти (эпизодические события, семантические выводы, реестр людей из жизни пользователя и процедурный стиль), и у каждого был свой маршрут поиска. Каждый ход логировался: что было найдено, что отброшено и что в итоге попало в модель. Команда также прогнала mem0 на 120 своих сообщениях и держала экземпляр Hindsight в теневом режиме. Разумные эксперименты, а полнота поиска так и осталась на уровне 25%.
Где на самом деле ломается
Одна проверка делает проблему очевидной. Введите "какие слова использует мой папа", и система вернёт прошлые сообщения, в которых пользователь жалуется на отца. Единственное сообщение, где тот процитирован, так и не появляется.
Это не причуда конкретного кода. Поиск по сходству эмбеддингов в логе чата ведёт себя именно так, потому что ближе всего к вопросу почти всегда оказывается другой вопрос. Запрос и ответ на него живут в разных районах одного векторного пространства, и косинусное сходство не может понять, что они связаны. Так что с хранилищем памяти всё в порядке. Слабое место — в оркестрации, то есть в том, что отбирается, ранжируется и упаковывается в контекст в момент генерации.
Четыре исправления по возрастанию усилий
Переписывайте запрос. Не стройте эмбеддинг по исходному вопросу. Пусть LLM сгенерирует несколько формулировок в том виде, в каком выглядел бы ответ: точные фразы, которые говорит отец пользователя, слова, которыми он его называет, прямые цитаты с его авторством. Тогда поиск нацелен на то, как выглядит ответ, а не на то, как выглядит вопрос.
HyDE. Гао и соавторы в 2022 году предложили пойти ещё дальше. Не превращайте вопрос в поисковые термины, а попросите LLM написать гипотетический ответ и постройте эмбеддинг по нему. Придуманный ответ оказывается гораздо ближе к настоящему, чем сам вопрос. Для именно этого сбоя это, пожалуй, самое прямое лекарство.
Реранжируйте не только по сходству. Когда пул кандидатов уже собран, сортировка только по смысловой близости воспроизводит исходную ошибку в меньшем масштабе. Оценивайте совпадение сущностей, вероятность того, что фрагмент является ответом, соседство с ходом, который правдоподобно его содержит, и временной контекст. Но помните о потолке: реранкер лишь переставляет то, что ему отдал поиск, и не может вытащить то, что так и не всплыло.
Ищите в несколько шагов. Один проход справляется с вопросами об одном факте. На агрегирующих он ломается. Ответ на вопрос вроде "какие прозвища давал мне папа" разбросан по пяти сообщениям за три месяца. Чтобы с этим справиться, нужен широкий первый поиск, группировка кандидатов по типу, проверка, достаточно ли собрано, и ещё один поиск, если нет. Это та же схема «найти, подумать, решить, искать ли снова», что и у Self-RAG и Corrective RAG (CRAG). Дайте ей жёсткую остановку — ограничение числа шагов или проверку уверенности, — иначе она будет крутиться вечно или сделает каждый ответ дорогим.
Минимальный цикл выглядит так:
def retrieve_memory(user_query, vector_db, llm, max_hops=2):
rewrites = llm.rewrite(
query=user_query,
instruction=(
"Turn this question into 3-5 retrieval-oriented phrasings "
"that describe what the ANSWER would say, not what the "
"question says."
),
)
candidates = []
for q in rewrites:
candidates += vector_db.search(q, top_k=20)
ranked = []
for hop in range(max_hops):
ranked = rerank(
user_query,
candidates,
factors=[
"semantic_relevance",
"entity_match",
"answer_likelihood",
"conversational_relationship",
"temporal_context",
],
)
if llm.is_sufficient(user_query, ranked[:10]):
break
follow_up = llm.next_query(user_query, ranked[:10])
candidates += vector_db.search(follow_up, top_k=20)
return ranked[:10]
Ничего экзотического тут нет. Важна последовательность: сначала почините полноту поиска и построение запроса, потом реранкинг, и только после этого думайте о новой инфраструктуре.
Совет клиенту
Запрос на аудит состоял из трёх частей: где память утекает между хранением и генерацией, выдержит ли архитектура в десять раз больше данных и честная рекомендация — чинить то, что есть, или переходить на что-то новое.
Где утекает. Между поиском и генерацией, а точнее — на этапе построения запроса, ещё до того, как реранкинг вообще может помочь.
При 10-кратном росте. Архитектура с несколькими типами памяти и логирование надёжны и будут масштабироваться. Однопроходный поиск по эмбеддингам — нет, потому что с ростом данных за те же места среди кандидатов конкурирует всё больше похожих вопросов. Слои переписывания и многошагового поиска превратятся из желательных в обязательные задолго до этой отметки.
Чинить или менять. Чинить своё. mem0 и Hindsight — оба слои хранения памяти. Ни один не касается оркестрации, а именно там и пропадают недостающие 75%. Смена базы данных не поможет запросу, который по-прежнему ищет не то.
Иногда система памяти вовсе ничего не забывает. Она отвечает на вопрос, которого никто не задавал, потому что запрос и доказательства с самого начала искали разное.