Чому пам'ять чату знаходить лише 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%. Зміна бази даних не допоможе запиту, який і далі шукає не те.
Іноді система пам'яті взагалі нічого не забуває. Вона відповідає на питання, якого ніхто не ставив, бо запит і докази від самого початку шукали різне.