Месяц тюнил поиск по памяти, а починила всё одна строка в промпте
У меня телеграм-бот, который ведёт память по чату: вытаскивает из переписки факты и потом отвечает с оглядкой на них. Работает в полутора тысячах чатов.
В конце августа я закончил месяц работы над его поиском. Поднял Recall@5 на 17%, перебрал параметры BM25, нашёл оптимальный вес слияния. Потом померил, как это сказалось на самих ответах.
Никак. Прирост оказался статистически не значимым, p = 0.79.
А потом я посмотрел в промпт, поправил там одну формулировку и получил плюс 16 процентных пунктов качества за вечер.
Дальше по порядку: как я это мерил, где промахнулась литература, какие шесть приёмов не сработали и что именно было не так с промптом.
Если вы не занимались поиском по памяти: как это вообще работает
Бот хранит все сообщения чата. Когда человек о чём-то спрашивает, надо из тысяч сообщений достать те несколько, в которых лежит ответ, и показать их модели. Модель отвечает уже по ним, а не по всей переписке: целиком она туда не влезет.
Достают двумя способами.
Векторный поиск. Каждое сообщение заранее превращают в набор чисел, который грубо отражает его смысл. Вопрос превращают так же и ищут сообщения с близкими числами. Плюс: находит по смыслу, даже если слова разные. Минус: путается в точных деталях, именах и числах.
Поиск по словам (BM25). Обычный текстовый поиск, как в поисковой строке. Плюс: точно находит редкое слово. Минус: не понимает синонимов.
Гибрид это когда запускают оба и смешивают результаты. Про то, в какой пропорции смешивать, и идёт половина статьи.
Коротко, если не хотите читать целиком
Свой набор проверочных вопросов из настоящих сообщений: 140 вопросов, 41 чат.
Гибрид вектора и BM25 на леммах даёт +17% Recall@5 (0.543 → 0.636).
На качестве ответов это дало +3.3 п.п. при p = 0.79, то есть ничего.
Половина рекомендаций из статей на моих чатах не подтвердилась.
Из шести испробованных приёмов сработал один, и это был промпт.
Модель отказывалась отвечать, когда ответ лежал прямо перед ней.
Итог: 50% → 75.8%, из них 16 п.п. дала правка формулировки.
С чего началось
24 августа человек в моем чате TheCortes Chat четыре дня подряд писал боту, что ждёт бесконечный режим. «День 3/4», «день 4/4». Потом спросил: сколько дней я жду.
Бот ответил про саму функцию. Про то, сколько её ждет, он не сказал ничего, хотя все четыре сообщения лежали в его же базе.
Такие отказы попадались и раньше, но списывались на «модель не поняла», и до этого случая я ни разу не проверял, лежит ли нужное сообщение в базе вообще. Здесь было видно, что лежит. Значит, теряется по дороге.
Почему нельзя взять готовый бенчмарк
Первая мысль была взять публичный набор. Она не сработала по двум причинам, и обе, кажется, общие для любого продукта с памятью.
Реальных вопросов к боту почти нет. Люди не пишут «вспомни, что я говорил в мае», они просто разговаривают, а память срабатывает сама, и за всё время накопилось от силы два десятка запросов, где человек прямо просит бота что-то вспомнить. Двадцать штук. На бенчмарк не тянет.
А главное, для них нельзя разметить правильный ответ. Чтобы сказать, какое сообщение было нужно, пришлось бы найти его тем же поиском, который я и проверяю. Вывод замкнулся бы сам на себя, и метрика показала бы, что всё прекрасно.
Поэтому набор синтетический, но из подлинных данных. Беру настоящее сообщение из чата, прошу модель задать вопрос, ответ на который содержится ровно в нём. Тогда нужное сообщение известен точно, потому что вопрос из него и сделан.
Два фильтра, оба обязательные:
Вопросы, дословно повторяющие сообщение, отбрасываются. Иначе задача становится тривиальной для лексического поиска, и BM25 покажет несуществующее превосходство. Так отсеялось 77 вопросов из 218.
Вопросы по мусорным сообщениям тоже отбрасываются.
Осталось 140 вопросов из 41 чата, медиана дословных совпадений 0.14.
Про честность прогона. Кандидаты ограничены сообщениями того же чата, созданными раньше вопроса. Момент вопроса ставится на 7 дней позже эталонного сообщения, чтобы в пуле лежал накопившийся после него шум. Без этого поиск работает в тепличных условиях.
Что показал перебор способов поиска
Прогнал девять способов поиска на этих 140 вопросах.
Что означают R@1, R@5 и MRR в таблице
Для каждого вопроса я заранее знаю, какое сообщение правильное. Поиск выдаёт список. Дальше смотрю, попало правильное в этот список или нет.
R@5 (Recall@5) это доля вопросов, где нужное сообщение оказалось в первой пятёрке выдачи. R@5 = 0.543 значит, что в 54% случаев ответ был среди первых пяти, а в остальных 46% модель его вообще не увидела. R@1 то же самое, но про самую первую позицию, R@10 про первую десятку.
MRR учитывает не только попадание, но и место. Нашлось на первой позиции это 1.0, на второй 0.5, на третьей 0.33 и так далее. Полезно, когда важно, что именно модель прочитает первым.
Простое правило: R@5 отвечает на вопрос «нашли или нет», MRR на вопрос «насколько высоко».
способ поиска |
R@1 |
R@5 |
R@10 |
MRR |
мс |
к базе |
|---|---|---|---|---|---|---|
rrf_recency |
0.279 |
0.600 |
0.650 |
0.414 |
150 |
+11% |
convex 0.7 |
0.421 |
0.579 |
0.629 |
0.481 |
145 |
+7% |
rrf |
0.400 |
0.564 |
0.607 |
0.461 |
145 |
+4% |
вектор (было) |
0.421 |
0.543 |
0.564 |
0.468 |
144 |
база |
multi-query + rrf |
0.336 |
0.536 |
0.614 |
0.420 |
268 |
−1% |
bm25 |
0.214 |
0.393 |
0.443 |
0.285 |
1 |
−28% |
rrf + контекст диалога |
0.107 |
0.293 |
0.343 |
0.179 |
8 |
−46% |
вектор + контекст диалога |
0.179 |
0.279 |
0.307 |
0.219 |
183 |
−49% |
Первое, что бросается в глаза: BM25 сам по себе заметно слабее вектора, минус 28%. А в паре добавляет 4–7%, и это тот случай, когда две плохие по отдельности вещи складываются в хорошую, потому что ошибаются они на разных вопросах и вытягивают друг у друга то, что каждая теряет поодиночке.
Второе: подмешивание контекста диалога уронило качество вдвое. К этому вернусь ниже, там нашлось объяснение.
Лучшим оказалось взвешенное слияние вектора и BM25 на леммах: 0.636 против 0.543, плюс 17%. На глубине 20 разрыв растёт до 25%.
Отдельная находка про глубину:
k |
вектор |
гибрид |
|---|---|---|
5 |
0.543 |
0.636 |
8 |
0.564 |
0.664 |
10 |
0.564 |
0.679 |
15 |
0.564 |
0.693 |
20 |
0.571 |
0.714 |
Вектор упирается в потолок на восьми сообщениях. Дальше он не находит ничего нового: 0.564 и на восьми, и на десяти, и на пятнадцати. Гибрид продолжает набирать до двадцати.
Практический вывод: если у вас стоит ограничение глубины, оно, возможно, не мешает вектору, но мешает гибриду. У меня стояло пять.
Где промахнулась литература
Это, наверное, самая полезная часть для тех, кто собирается делать то же самое. Я честно взял параметры из статей, и половина из них на моих данных проиграла тем, что я подобрал перебором.
откуда |
рекомендация |
у меня |
|---|---|---|
стандарт BM25 |
b = 0.75 |
0.400 против 0.421 при b = 0.3 |
Mixed-initiative Query Rewriting, 2023 |
k1 = 1.24, b = 0.9 |
0.371, ещё хуже |
T2-RAGBench |
convex alpha = 0.5 |
0.593 против 0.636 при alpha = 0.8 |
RusBEIR |
BM25 + pymorphy сильный baseline для русского |
в одиночку −28% |
обзоры query expansion |
+14–37%, multi-query лучше HyDE |
multi-query −1% |
WANDS |
гибрид сильнее каждой ноги |
сошлось, +17% |
обзоры RAG-оценки |
recall не предсказывает качество ответа |
сошлось, о чём ниже |
Что за k1 и b и почему их вообще крутят
У BM25 два настроечных числа.
k1 отвечает за то, насколько сильно растёт вес слова от повторов. Если слово встречается в сообщении пять раз вместо одного, насколько это важнее.
b отвечает за поправку на длину. Идея в том, что в длинном тексте слово встретится случайно с большей вероятностью, поэтому длинные тексты штрафуют. При b = 0 поправки нет вовсе, при b = 1 она максимальная.
Значения по умолчанию (k1 = 1.2, b = 0.75) подобраны когда-то на новостных статьях и с тех пор кочуют из статьи в статью. На чатах они не работают, и дальше объясняю почему.
С параметром b объяснение простое, и оно, кажется, верно для любого чата. b управляет силой нормализации по длине сообщения. Сообщения в чате и так короткие, штрафовать за длину нечего, и нормализация вносит только шум. Чем ниже b, тем лучше, вплоть до 0.3, дальше начинается спад.
С весом слияния похоже. Рекомендованные 0.5 означают равный вклад лексики и вектора. В разговорной переписке поиск по словам слабее, и место ей корректирующее, а не равное: оптимум на 0.8–0.85.
Константа RRF не влияет вообще. Пробовал 10, 20, 60 и 100, везде 0.600. Подбирать нечего, что тоже полезно знать заранее.
Момент, когда всё сломалось
Дальше надо было проверить, что прирост поиска доходит до пользователя. Схема стандартная: модель отвечает только по найденному контексту, вторая модель-судья сверяет ответ с эталонным фактом. 60 вопросов.
способ поиска |
верных |
доля |
95% ДИ |
«не знаю» |
|---|---|---|---|---|
вектор |
28 из 60 |
46.7% |
[34.4%, 59.2%] |
24 |
гибрид |
30 из 60 |
50.0% |
[37.6%, 62.4%] |
22 |
Тест Макнемара: расхождений 14, из них 6 только вектор, 8 только гибрид. p = 0.79.
Что такое p-значение и тест Макнемара, если давно забыли
Я сравниваю два способа поиска на одних и тех же 60 вопросах. Один дал 28 верных ответов, другой 30. Вопрос: это правда разница или просто повезло?
Тест Макнемара для этого и нужен. Он смотрит только на вопросы, где способы разошлись: 6 решил только вектор, 8 только гибрид. Совпадения он выбрасывает, потому что они ничего не говорят о разнице.
p-значение это вероятность увидеть такое расхождение случайно, если разницы между способами нет вообще. Порог по традиции 0.05. У меня вышло 0.79, то есть такое расхождение получилось бы случайно в четырёх случаях из пяти. Никакого вывода из моих +3.3 процентных пункта сделать нельзя.
95% доверительный интервал в таблице про то же самое с другой стороны. Для вектора он [34.4%, 59.2%]: настоящее качество где-то там, а где именно, на 60 вопросах не разглядеть. Интервалы двух способов перекрываются почти целиком.
Чтобы уверенно различить разницу в 3 процентных пункта, нужно 300–500 вопросов.
Семнадцать процентов Recall превратились в 3.3 процентных пункта качества, неотличимые от шума. Чтобы различить разницу такого размера, нужно порядка 300–500 вопросов, а не 60.
Про это предупреждает любой обзор RAG-оценки, и я их читал, кивал и шёл дальше крутить параметры, потому что recall считается за минуту и без модели-судьи, а полный прогон стоит времени и денег. Одно дело читать. Другое увидеть на своих числах.
Гораздо интереснее оказалось другое число из той же таблицы. 22 вопроса из 60 остались без ответа даже с лучшим поиском. Больше трети. И вот это уже требовало объяснения.
Разложение потерь
Прежде чем чинить дальше, я разложил потери на две части: не нашли сообщение или нашли, но не ответили.
37% вопросов без ответа, потому что нужного сообщения нет в выдаче
13% вопросов: сообщение найдено, а ответ неверен
Тринадцать процентов на стороне модели это много: каждый восьмой вопрос, где поиск отработал честно и положил нужное сообщение модели под нос, всё равно заканчивался неверным ответом. Месяц я занимался первой половиной проблемы. На вторую не смотрел ни разу.
Шесть приёмов, сработал один
Дальше я проверил всё, что обычно советуют.
приём |
результат |
|---|---|
поднять глубину выдачи до 20 |
recall выше, качество ниже: модель тонет в лишних фрагментах |
автоматическое расширение запроса (PRF) |
0 / −3 / −8% в зависимости от настроек |
нарезка окнами |
0% |
нарезка по сессиям |
0% |
пересортировка выдачи быстрой моделью |
0% |
правка промпта |
50% → 66% |
Про глубину стоит сказать отдельно, потому что результат получился обратный ожиданию. Recall на двадцати сообщениях выше, чем на пяти, это видно по таблице выше, а качество ответов при этом падает, потому что модель получает двадцать фрагментов, половина из которых не по делу, и начинает отвечать по неправильному. Больше найти не значит лучше ответить.
То есть метрика поиска и качество ответа могут двигаться в разные стороны. Оптимизируя первое вслепую, легко ухудшить второе.
Что было не так с промптом
В секции с найденными фрагментами у меня стояло требование опираться исключительно на них и писать «не знаю», если ответа там нет.
Формулировка выглядела осторожной и правильной. На деле модель понимала её буквально: если ответа нет дословно, значит его нет вообще, и надо отказаться, даже когда до него один шаг рассуждения. Человек написал «день 3/4» и «день 4/4». На вопрос «сколько дней жду» бот отвечал, что не знает.
Что поменял:
убрал жёсткое требование писать «не знаю»
разрешил делать выводы по косвенным данным
добавил в фрагменты авторов сообщений
поднял обрезку фрагмента с 220 до 400 знаков
Результат:
отказов было 14 из 50, стало 1
точность ответа когда нужное сообщение найдено 77% → 96%
общее качество 50% → 66%
Про обрезку отдельно: у 8% эталонов правильный ответ попадал ровно в отрезанную часть фрагмента. Сообщение находилось, показывалось модели наполовину, и ответить по нему было нельзя.
Куда переехало узкое место
После правки промпта арифметика стала такой: recall 0.68, точность ответа при найденном сообщении 0.97, итого около 66%. Узкое место переехало обратно в поиск.
Дальше помогли две вещи, обе про количество, а не про алгоритм:
Сколько сообщений вектор достаёт на первом шаге с
max(top_k * 6, 18)доmax(top_k * 20, 300). На 200 кандидатах 66%, на 2000 уже 71.7%.Глубина выдачи с 5 до 15 и обрезка с 220 до 400 знаков. С пяти находок 65%, с пятнадцати 75.8%.
Заметьте, что глубина 20 раньше ухудшала качество, а 15 улучшает. Разница в том, что это уже после правки промпта: модель перестала теряться в лишних фрагментах, когда ей разрешили рассуждать, а не только цитировать.
Проверил на двух чатах, которых в проверочном наборе не было вообще: 85% и 90%.
Потолок, который никуда не делся
Честная часть. 13.6% вопросов не находятся ничем и ни на какой глубине. Потолок поиска на моих чатах 86.4%.
Примеры того, что не решается:
«бесконечные попытки» против «бесконечный режим»
«игра со словами» против «5 букв»
Ни лексика, ни вектор синонимию такого рода не мостят. Нужен либо другой эмбеддер, либо расширение запроса моделью, и то и другое упирается в скорость ответа, которую я в начале работы отверг именно по этой причине, а теперь измерения говорят, что зря. Эту дверь я пока не открывал.
Ещё одно наблюдение из теста на живых кейсах, оно объясняет странность из первой таблицы. Помните, что подмешивание контекста диалога дало −46% и −49%? На синтетическом наборе вопрос задаётся про случайное старое сообщение, и последние реплики чата к нему отношения не имеют, это чистый шум.
А на реальном переспросе всё наоборот. Человек спрашивает, бот отвечает не про то, человек уточняет «я не про это». Без контекста ни один способ не находит нужное, с контекстом находят обе.
Вывод: контекст диалога надо подмешивать, когда вопрос продолжает текущий разговор, и не подмешивать, когда не продолжает. Синтетический набор один такие вещи не покажет, нужны живые кейсы.
Что я бы сделал иначе
Разложил бы потери в первый день. Полчаса работы: посчитать, сколько вопросов провалилось из-за поиска, а сколько из-за самой модели. Это сэкономило бы недели три.
Мерил бы сразу весь путь, от вопроса до ответа. Recall удобен тем, что считается быстро и без модели-судьи. Но он не то, что нужно пользователю.
Не доверял бы параметрам из статей. Половина не подтвердилась, и это не потому, что статьи плохие. Просто данные другие.
Держал бы рядом набор живых кейсов. Синтетика показала, что контекст диалога вреден. Живой кейс показал, что он спасает. Правы оба, просто про разное.
Оговорки
Выборка маленькая. 140 вопросов на поиске и 60 на полном прогоне от вопроса до ответа это мало для статистики, о чём честно говорит p = 0.79. Все числа тут про порядок величины, а не про второй знак после запятой.
Данные специфические: русскоязычные групповые чаты, короткие сообщения, медиана 15 знаков. Выводы про параметр b почти наверняка не перенесутся на длинные документы.
Модель-судья тоже ошибается. Я сверял часть выборки руками, расхождения были, но на общую картину не влияли.
Индекс кешируется по чату, TTL десять минут, пересборка при росте на 15%. Первый запрос в чате 1791 мс, последующие 145 мс. При любой ошибке лексической ноги возвращается исходный порядок вектора, то есть гибрид это улучшение, а не обязательное звено.
Скрипты проверки и сырые выводы лежат в репозитории, могу выложить отдельно, если будет интерес.
Комментарии (2)

Ka463
06.09.2026 14:30Достают двумя способами.
Думаю вы сильно ошибаетесь, техник реализации памяти намного больше чем то что в текущем модном "тренде".
danilovmy
Нейрослоп нейрослопный. Коротко и по человечьи: был RAG и кривой промпт, стал RAG и прямой промпт -> профит. Зачем вся остальная часть статьи - непонятно. Почему именно RAG а не, например KAG или SS - непонятно. Что именно спрашивал тестовый пользователь - вообще ×p€H поймёшь. Автор @ItsDarkEz ты, реально, читал сам свое творение?