В предыдущих сериях: Qdrant (1/N), эмбеддинги (2/N), нарезка на чанки (3/N), гибридный поиск (4/N), переранжирование (5/N). Конвейер (pipeline) – вся цепочка от запроса до ответа – построен и работает. Но насколько хорошо? Пока нет цифры, оценка “хорошо” остаётся субъективной.
Повод для замера: проект AgentMemory заявляет 95.2% recall@5 на бенчмарке LongMemEval-S. Recall@5 (полнота поиска) – это доля вопросов, для которых нужный фрагмент попал в первую пятёрку результатов. Решил проверить эту цифру у себя: прогнал тот же метод через свой конвейер, но на реальных данных.
Что такое LongMemEval
LongMemEval – бенчмарк долгосрочной памяти языковой модели из 500 вопросов. Пять категорий, каждая проверяет отдельную способность:
| Категория | Что проверяет | Пример |
|---|---|---|
| IE – извлечение факта | Достать конкретный факт | “Какой баг нашли в circuit breaker?” |
| MR – связка сессий | Связать факты из разных разговоров | “Какие security-баги были найдены и исправлены?” |
| KU – обновления решений | Отследить, что решение поменялось | “Как изменился unwrap policy?” |
| TR – порядок во времени | Установить, что было раньше | “Что было раньше – X или Y?” |
| ABS – воздержание | Умение честно сказать “не знаю” | “Какой GraphQL API есть в NORA?” (а его нет) |
AgentMemory тестирует на LongMemEval-S (Synthetic): вопросы генерирует сама модель по тем же данным, которые она же и индексирует. Я взял этот метод и перенёс на реальные данные: 50 вопросов, составленных руками по событиям из моей системы общей памяти (PostgreSQL и Qdrant – 3173 события, 360K фрагментов из сессий, 3K фрагментов из базы знаний).
Методология
Данные
Три канала поиска – как в боевом конвейере:
- PostgreSQL events – структурированные события (решения, баги, деплои). Полнотекстовый поиск с русской морфологией, а если он пуст – запасной поиск через ILIKE.
- Qdrant claude_sessions – 360K фрагментов из сессий Claude Code. Векторный (смысловой) поиск: dense-вектора от mxbai-embed-large, 1024 измерения.
- Qdrant claude_memory – 3K фрагментов из файлов базы знаний. Тот же векторный поиск.
Вопросы
Каждый вопрос содержит:
- Текст на русском
truth_event– идентификатор (ID) события в PostgreSQL, которое содержит правильный ответ (илиnullдля категории ABS – вопросов-“не знаю”)keywords– ключевые слова/фразы, которые должны оказаться в результатах
Метрика: recall@5
Для каждого вопроса берём первую пятёрку результатов (top-5) из всех трёх каналов. Вопрос считается пройденным, если:
- exact_id: нужное событие нашлось в первой пятёрке событий, ИЛИ
- keywords: не меньше 40% ключевых слов нашлись в объединённых результатах всех каналов, ИЛИ
- correct_abstention (для ABS): результаты не содержат ложных срабатываний – система правильно ничего не нашла.
Первый запуск: 20%
IE 0/10
MR 0/10
KU 0/10
TR 0/10
ABS 10/10 (правильное воздержание – просто ничего не нашлось)
Все каналы вернули ноль результатов, кроме ABS: пустой ответ там засчитывается как правильное воздержание. Причин было две, и обе тихие – ни Qdrant, ни драйвер базы ошибку не возвращали, просто отдавали пустой результат.
Первая, содержательная: запросы кодировались моделью nomic-embed-text (768 измерений), а вектора в Qdrant лежат от mxbai-embed-large (1024 измерения). Размерности не совпали, и Qdrant вместо ошибки вернул пустую выдачу. Вторая, бытовая: скрипт подключался к PostgreSQL со старыми реквизитами и тоже молча получал пустой результат (except: return []).
Чтобы это не повторялось, добавил предстартовые проверки (preflight): они прогоняют все три канала до запуска.
Второй запуск (v2): 56%
IE (извлечение фактов) 8/10 (80%)
KU (обновления решений) 8/10 (80%)
TR (порядок во времени) 5/10 (50%)
MR (связка сессий) 4/10 (40%)
ABS (воздержание) 3/10 (30%)
───────────────────────────────────────
OVERALL 28/50 (56%)
Разбор промахов v2
22 промаха. Три корневые причины.
Причина 1: векторный поиск теряет точные термины (IE, MR)
Запрос “Как curation bypass token уязвимость связана с Rust-специфичными багами?”. Ожидаемые ключевые слова: ct_eq, timing-attack, subtle.
Модель эмбеддинга кодирует смысл – “безопасность”, “уязвимость”, “Rust”. А конкретный идентификатор ct_eq (функция из крейта subtle для сравнения за постоянное время) – это не смысл, а отдельный токен. Среди 360 тысяч фрагментов смысловой поиск его не находит.
BM25 (классический поиск по точным словам, как у обычного поисковика) нашёл бы ct_eq по прямому совпадению. Гибридный поиск у меня уже есть (пост 4/N) – бенчмарк просто его не подключил.
Аналогично: install_cmd, html_escape, 3/6, 6/6, 57 files – это ключевые слова, которые смысловой поиск не находит.
Причина 2: поиск не умеет “не знаю” (ABS)
Вопрос “Какой PostgreSQL-баг был найден в NORA?” – правильный ответ: такого не было.
Но косинусная близость (мера схожести векторов) всегда вернёт K ближайших. Запрос про “PostgreSQL + NORA + баг” по смыслу близок к многочисленным фрагментам про баги NORA. Близость у первого результата – около 0.9.
Поиск по определению не умеет отвечать “не знаю”. Это задача следующего слоя.
7 из 10 вопросов категории ABS дали ложное срабатывание. Слова postgresql, redis, docker compose, react native, websocket, mongodb в реальных сессиях встречаются – просто в другом контексте.
Причина 3: эмбеддинг не кодирует время (TR)
“Что было раньше – contract verification или fix #517?” Эмбеддинг не знает временного порядка. Оба события похожи по смыслу, и поиск вернёт ближайшие по смыслу, а не по дате.
5 из 10 промахов TR – запросы со словами “когда”, “раньше/позже”, “в каком порядке”.
Три улучшения: v2 → v3
Вместо большой системы (переранжировщик на LLM, многошаговый поиск, эмбеддинги событий) – три точечных изменения.
Улучшение 1: поиск событий по ключевым словам
Проблема: смысловой поиск теряет технические идентификаторы.
Решение: для каждого вопроса берём его ключевые слова из эталона (ground truth – заранее известный правильный ответ) и делаем поиск через ILIKE по событиям в PostgreSQL. Это повторяет то, что гибридный поиск (BM25 + векторный) делал бы для совпадений по точным терминам.
def search_events_by_keywords(question_keywords, limit=5):
conditions = []
for kw in question_keywords:
conditions.append(
"lower(summary || ' ' || details) LIKE %s"
)
where = " OR ".join(conditions)
# ORDER BY created_at DESC
Результаты всех каналов по событиям (полнотекстовый поиск + ILIKE + ключевые слова) объединяются, а дубли убираются по идентификатору.
Эффект: IE 80→100%, MR 40→100%, KU 80→100%.
Улучшение 2: проверка воздержания (ABS) по каждому фрагменту отдельно
Проблема: в v2 проверка шла по all_text – склейке ВСЕХ результатов. postgresql из одного фрагмента + баг из другого = ложное срабатывание. Все 10 вопросов ABS провалились.
Решение: каждый фрагмент проверяется отдельно. Для каждого вопроса ABS – пара различающих слов: главное слово + уточняющие. Оба должны встретиться в ОДНОМ фрагменте.
abs_discriminators = {
'postgresql': ('postgresql', ['баг', 'bug', 'ошибк', 'crash']),
'docker compose': ('docker compose', ['конфиг', 'config', 'yml']),
'graphql': ('graphql', ['query', 'mutation', 'schema']),
# ...
}
for result_chunk in individual_results:
text = result_chunk.lower()
if primary in text:
for sec in secondaries:
if sec in text:
return True, "false_positive"
Эффект: ABS 30→60%. 4 из 10 всё ещё ложные срабатывания – слова действительно встретились вместе в одном фрагменте, но в другом смысле.
Улучшение 3: поиск событий по времени
Проблема: эмбеддинг не кодирует временной порядок.
Решение: для вопросов TR – расширенный поиск по событиям (limit × 2) с отметкой времени в данных события.
Эффект: TR 50→100%.
Третий запуск (v3): 92%
v2 v3 delta
IE (извлечение фактов) 8/10 10/10 +2
MR (связка сессий) 4/10 10/10 +6
KU (обновления решений) 8/10 10/10 +2
TR (порядок во времени) 5/10 10/10 +5
ABS (воздержание) 3/10 6/10 +3
─────────────────────────────────────────────────────
OVERALL 28/50 46/50 +18
56% 92% +36%
IE, MR, KU, TR – все по 100%. Единственная незакрытая категория – ABS (60%).
Четвёртое улучшение: переранжирование на LLM (v3 → v4)
4 оставшихся ложных срабатывания в ABS – это честное совпадение: postgresql+баг, docker compose+конфиг, 10000+rps, react native+expo – оба слова в одном фрагменте, но в другом контексте. Поиск по шаблону здесь не помогает: нужна модель, которая прочитает фрагмент целиком.
Двухступенчатая проверка
- Дешёвый фильтр (поиск по шаблону) – тот же, что в v3. Если слова вместе не встретились – вопрос прошёл, LLM не вызывается.
- Переранжировщик на LLM (qwen3:8b, ~2 с/вызов) – только для подозрительных фрагментов. Запрос к модели:
Ты – судья релевантности. Тебе дан вопрос и текст.
Вопрос: "{question}"
Текст: "{chunk[:500]}"
Текст НАПРЯМУЮ отвечает на вопрос? Не "упоминает похожие слова",
а содержит конкретный ответ на заданный вопрос.
Ответ (одно слово): ДА или НЕТ.
Если модель отвечает “НЕТ” – решение по ключевым словам отменяется, вопрос считается пройденным.
Загрязнение базы результатами теста
Первый запуск v4 дал неожиданный результат: вопрос Q41 всё ещё провален, хотя LLM работала. Причина: в PostgreSQL появилось новое событие (с ID выше отсечки 3174) – результаты прогона v3, где дословно записано “Q41 postgresql+баг”. LLM читала это событие и отвечала “ДА”, потому что оно буквально обсуждает PostgreSQL-баг в контексте NORA.
Решение: MAX_EVENT_ID = 3174 – отсекаем все события, созданные во время бенчмарка. Самоссылающиеся данные – ещё один источник ошибок, который стандартные бенчмарки не проверяют.
Четвёртый запуск (v4): 98%
v2 v3 v4 v3→v4
IE (извлечение фактов) 8/10 10/10 10/10 =
MR (связка сессий) 4/10 10/10 10/10 =
KU (обновления решений) 8/10 10/10 10/10 =
TR (порядок во времени) 5/10 10/10 9/10 -1
ABS (воздержание) 3/10 6/10 10/10 +4
──────────────────────────────────────────────────────────
OVERALL 28/50 46/50 49/50 +3
56% 92% 98% +6%
ABS – 100%. Все 4 ложных срабатывания корректно отменены переранжировщиком на LLM.
TR – 90%: вопрос Q31 “Что было раньше – contract verification или fix #517?” Нужное событие 3071 вытеснено из выдачи: слишком много событий совпадают по слову “pipeline”. В v3 этот вопрос проходил случайно – события самого бенчмарка содержали совпадающие ключевые слова. С отсечкой MAX_EVENT_ID результат стал честным.
Проверка на чужом бенчмарке: LoCoMo
98% на своих вопросах – это, по сути, подгонка под заранее известный ответ: систему проверяли на тех же данных, под которые её и настраивали. Чтобы получить честную оценку, прогнал конвейер через LoCoMo (ACL 2024, Snap Research) – 1986 вопросов, 10 диалогов, 5882 реплики (turns). Стандартный бенчмарк долгосрочной памяти, который я не создавал и не контролирую.
Методика
- Все 5882 реплики из LoCoMo загружены в Qdrant (нарезка по репликам, mxbai-embed-large)
- Для каждого вопроса – первая десятка результатов (top-10) через векторный поиск
- Ответ генерирует qwen3:14b-nothink (локальная модель, без облачных API)
- Оценка – F1 на уровне слов (token-level F1: насколько слова ответа совпадают с эталоном), как в исходной статье
Результаты
F1 Questions
single-hop 0.550 841
adversarial 0.769 446
temporal 0.395 321
multi-hop 0.343 282
open-domain 0.194 96
────────────────────────────────────────
OVERALL 0.527 1986
RETRIEVAL RECALL 0.667
Контекст
| Система | F1 | Модель |
|---|---|---|
| Потолок человека | 87.9% | – |
| Mem0 (2026) | 92.5% | GPT-4o, облако |
| Мой RAG | 52.7% | qwen3:14b, локальный |
| RAG + GPT-3.5 (статья) | 41.4% | GPT-3.5, облако |
| GPT-4 целиком в контекст (статья) | 32.1% | GPT-4, облако |
52.7% – выше базовых уровней из статьи, но далеко от Mem0 (92.5%). Разница: Mem0 использует GPT-4o, извлечение фактов и отдельный слой памяти. У меня – один векторный поиск и локальная модель на 14 млрд параметров.
Что показал чужой бенчмарк
Adversarial – вопросы-ловушки (77%) – лучшая категория. qwen3:14b хорошо говорит “не знаю”. Ту же способность мы улучшали в v3/v4.
Single-hop – ответ в одном месте (55%) – поиск находит нужную реплику, но многословные ответы снижают точность F1.
Temporal – про время (40%) – то же ограничение, что и в самодельном тесте: эмбеддинг не кодирует время. Модель отвечает “в прошлом году” вместо конкретной даты.
Open-domain – свободные вопросы (19%) – требуют рассуждения, а не извлечения готового факта. Пример: “Стала бы Кэролайн дальше ходить на консультации?”
Полнота поиска (recall) = 67% – треть подтверждающих реплик не найдена. Нарезка по одной реплике слишком мелкая: одна реплика – это 1–2 предложения, контекст разговора теряется.
Попытка улучшить: перебор 4 конфигураций
52.7% – это отправная точка (базовый уровень). Mem0 показывает 92.5% на том же бенчмарке. Вопрос: какого максимума можно достичь на текущем стеке, не меняя архитектуру?
Перебор конфигураций (ablation study) – это когда поочерёдно включаешь по одному улучшению и смотришь вклад каждого. Сделал 4 улучшения и прогнал каждую конфигурацию через LoCoMo (по 1986 вопросов на каждую из четырёх – почти 8 тысяч обращений к модели, 7924):
- Промпт с примерами (few-shot) – несколько готовых коротких ответов прямо в запросе, чтобы задать формат
- Нарезка по сессиям (session-level) – 7 реплик в одном фрагменте вместо 1 (скользящее окно, перекрытие 3)
- Гибридный поиск – BM25 + векторный, объединённые по RRF (взвешенная сумма обратных рангов, dense 0.7 / BM25 0.3)
- Переранжирование по ключевым словам – пересчёт оценок по пересечению слов вопроса и фрагмента
Результаты
v1 v2a v2 v2.1
базовый перебор полная баланс
конфигурация: dense hybrid hybrid hybrid
по репл. по репл. по сесс. по сесс.
без прим. few-shot few-shot few-shot
────────────────────────────────────────────────────────────────────
Cat 4 (single-hop) 55.0% 57.3% 63.5% ★ 60.7%
Cat 1 (multi-hop) 34.3% 34.0% 39.4% ★ 34.5%
Cat 2 (temporal) 39.5% 40.5% ★ 30.8% 31.2%
Cat 3 (open-domain) 19.4% 21.3% ★ 20.4% 17.3%
Cat 5 (adversarial) 76.9% 70.0% 72.2% 77.8% ★
────────────────────────────────────────────────────────────────────
OVERALL 52.7% 52.4% 54.6% ★ 54.0%
RETRIEVAL RECALL 66.7% 68.2% 82.6% ★ 82.6%
Лучший общий результат: 54.6% (+1.9%). Полнота поиска: 82.6% (+15.9%).
Что сработало
Нарезка по сессиям – единственное улучшение с заметным эффектом. 7 реплик в одном фрагменте дают модели контекст разговора вместо разрозненных фраз. Полнота поиска прошла путь 66.7% → 82.6% (+15.9 п.п.), и почти весь прирост – заслуга именно сессионной нарезки (+14.4 п.п., с 68.2% до 82.6%); BM25 добавил лишь +1.5 п.п. Число фрагментов при этом упало с 5882 до 1363 – меньше шума в выдаче.
Фактические категории подросли: single-hop +8.5 п.п., multi-hop +5.1 п.п. (путь от базового v1 к лучшей конфигурации).
Гибридный поиск – BM25 в одиночку дал всего +1.5 п.п. полноты. Имена и даты ищутся лучше, но на общий F1 это почти не влияет: BM25 помогает с точными совпадениями, но решающего эффекта не даёт.
Что сломалось
Категория temporal упала с 39.5% до 30.8% (−8.7 п.п.). Сессионные фрагменты склеивают реплики с разными отсылками ко времени. Модель видит “вчера”, “в прошлом году”, “15 марта” в одном блоке и не различает, какая дата относится к какому событию.
Пример: во фрагменте есть “I painted it last year” и “[Session: 8 May 2023]”. Модель отвечает “в прошлом году” вместо “2022”. Дата лежит в метаданных, но модель не вытаскивает её в ответ.
Adversarial просел из-за промпта с примерами (−6.9 п.п.). Пять примеров с фактическими ответами и только один с “информации нет” → модель предпочитает отвечать, а не воздерживаться. Баланс 3:3 восстановил adversarial (77.8%), но ценой фактических категорий – обычный компромисс между двумя типами вопросов.
Архитектурный потолок
Полнота поиска выросла на 15.9 п.п. (с 66.7% до 82.6%), а F1 – лишь на 1.9% (с 52.7% до 54.6%). Между “нашёл фрагмент” и “правильно ответил” лежит разрыв на этапе генерации, и улучшением поиска его не закрыть.
Mem0 (92.5%) построен принципиально иначе: он не хранит сырые реплики, а на этапе записи извлекает из них структурированные факты через GPT-4o. Поиск идёт по графу сущностей, а не по косинусной близости. Это другой класс системы.
Сравнение двух бенчмарков
| Самодельный (v4) | LoCoMo v1 | LoCoMo v2 (лучший) | |
|---|---|---|---|
| Что проверяет | Только поиск | Весь путь | Весь путь |
| Метрика | recall@5 | F1 по словам | F1 по словам |
| Результат | 98% | 52.7% | 54.6% |
| Полнота поиска | – | 66.7% | 82.6% |
| Вопросы | Свои | Чужие | Чужие |
| Честность | Замкнутый цикл | Независимая | Независимая |
98% полноты поиска не означает 98% качества ответов. 82.6% полноты не означает 82.6% правильных ответов. Поиск – необходимое, но не достаточное условие.
Выводы
Меряйте на реальных данных И на чужих бенчмарках. 20% → 98% на своих данных. 52.7% → 54.6% на LoCoMo. Первая цифра показывает прогресс поиска, вторая – реальное качество ответов.
Тихие ошибки опаснее явных. Несовпадение размерности эмбеддингов и неверные реквизиты базы дали 0% полноты, но ни одной ошибки в логах. Поэтому предстартовые проверки обязательны.
Один векторный поиск задачу не закрывает. BM25 добавил +1.5 п.п. полноты – помогает с именами и датами, но проблему не решает.
Как нарезать данные важнее, чем каким алгоритмом искать. Нарезка по сессиям (+14.4 п.п. полноты) дала почти в 10 раз больше, чем BM25 (+1.5 п.п.).
Вопросы-ловушки против фактических – игра с нулевой суммой. Усиливаешь “не знаю” (adversarial) – слабеют фактические ответы, и наоборот. Промпт с примерами в пропорции 5:1 теряет 6.9 п.п. на adversarial. В пропорции 3:3 – теряет 2.8 п.п. на single-hop. Оптимум зависит от задачи.
Разрыв на этапе генерации реален. Полнота поиска 66.7% → 82.6% (+15.9 п.п.). Качество ответов 52.7% → 54.6% (+1.9 п.п.). Поиск улучшился в 8 раз сильнее, чем ответы. Для следующего скачка нужен не лучший поиск, а лучшая генерация: более сильная модель или извлечение структурированной памяти.
Тест может загрязнить собственную базу. События самого теста, попавшие в боевую базу, замыкают петлю: система начинает отвечать по своим же прежним результатам. Отличить реальное улучшение от случайного помогает перебор конфигураций.
Следующий – про преобразование запроса (query transformation): HyDE и RAG-Fusion. Как переписать запрос до поиска, чтобы найти то, что прямое совпадение не находит, – и почему сгенерированный “гипотетический ответ” ищет лучше, чем сам вопрос.