На днях наш агент собрался дежурно отчитаться об успехе. Прежде чем нажать «готово», он сверился с собственной памятью — и нашёл там запись из прошлой сессии: эту идею он уже проверял на реальных данных, и она провалилась. Он остановил сам себя, до ложного отчёта.

Это не рекламная зарисовка. Это лог. Ниже — два таких лога подряд, и во втором память заставила нас выбросить фичу, которой мы гордились.
Что мы вообще делаем
Мы пишем vecmory — «память по смыслу» для ИИ-агента. СТОП! Договоримся: фраза «у нас есть векторный поиск» — это не то, о чем статья. Векторный поиск умеют все. Мы про другое: чтобы агент не наступал на одни и те же грабли дважды.
Три операции: recall (вспомнить релевантное), remember (запомнить), link (связать два факта). recall — не плоский top-k. Мы называем это «гирляндой»: находим ближайшие по косинусу узлы-зёрна и разворачиваем от них каузально-временной граф связей. Векторы считаем локально, мультиязычной моделью MiniLM (384 измерения).
Отдельная деталь, важная для всей истории: vecmory пишет тот самый агент, который им пользуется. Он ведёт заметки о собственной разработке в собственную память. Дальше вы поймёте, почему это оказалось не забавным совпадением, а сутью.
Сцена первая: роутер, который «показал 0.98»
В одну из сессий агент предложил улучшить ранжирование выдачи. Идея: сделать роутер, который по запросу решает, чем ранжировать — чистым косинусом или графовым методом (Personalized PageRank). Собрал синтетический бенчмарк. Корреляция предиктора с идеальным выбором — 0.98. Почти идеально. Оставалось отрапортовать и мержить.
Прежде чем отрапортовать, агент сделал recall по своей же памяти. И достал запись из прошлой сессии: эта самая идея уже проверялась на реальных данных — и провалилась.
Резонный вопрос: почему же он вообще взялся её строить — память ведь была на месте?
Потому что recall семантический, и что он поднимает, зависит от запроса. На старте запрос был широкий — «улучшить ранжирование», — под него всплывали топово-центральные заметки про ранг вообще, а специфичная «этот конкретный роутер уже проверялся и провалился» тонула под ними (ровно тот закон «частое топит редкое», к которому мы придём ниже). Она поднялась, только когда рабочий контекст сузился до конкретного — «cos-distribution роутер, PPR против косинуса, 0.98»: recall на этом тексте наконец её зацепил. Память не молчала — она всплыла ровно тогда, когда запрос стал достаточно точным, чтобы её достать. (Кстати, это и аргумент за детерминированный хук: полагаться на то, что нужная заметка сама всплывёт под каждый запрос, нельзя.)
Почему 0.98 было ложью
Синтетика честна ровно настолько, насколько честны синтетические эмбеддинги. А у мультиязычной MiniLM, на которой мы считаем векторы, косинус живёт в совсем другой геометрии, чем на синтетике. Мы это перемерили прямо на своём живом корпусе памяти (246 узлов), пока писали эту статью:
несвязанные, случайные пары дают косинус в среднем 0.53 (p5–p95: 0.24–0.76);
настоящие ближайшие соседи — в среднем 0.80 (p5–p95: 0.57–0.91);
а на синтетике из случайных векторов несвязанное сидит на 0.00 (±0.08).
Разница видна сразу. На синтетике «похоже» отделено от «не похоже» пропастью — любой разумный порог режет чисто. На реальных данных облака «соседи» и «случайные» перекрываются: случайные пары на верхнем перцентиле (0.76) залезают выше, чем соседи на нижнем (0.57). Порог, который на синтетике работает как скальпель, на реальных векторах проходит внутри облака случайного шума и не разделяет ничего.
Запись в памяти была ровно про это: у реальных эмбеддингов высокий сжатый базовый косинус, абсолютный порог с синтетики не переносится — опираться на РАНГ (top-k), а не на порог. Агент прочитал собственную заметку и убил идею — до того, как написал «готово, корреляция 0.98».
Все три числа выше — воспроизводимы: это замер на нашем живом графе памяти, а не картинка из презентации. И здесь та же закономерность, что погубила роутер: абсолютное значение косинуса ничего не значит в отрыве от модели и корпуса — оно едет от версии эмбеддера, от состава данных, от прогона. Поэтому мы ранжируем (top-k), а не двигаем пороги, и меряем на своих данных, а не переносим чужой — или свой вчерашний — порог.
Вот это — а не «у нас есть векторный поиск» — то, что мы, собственно, строим. Агент без памяти гордо представит 0.98 и будет формально не виноват: бенчмарк зелёный. Агент с памятью поймал себя на секунду раньше.
Сцена вторая: агент выкинул свою же фичу
Первый случай можно списать на удачу. Второй — тот же паттерн, но больнее: память заставила нас выбросить фичу, которую мы уже успели похвалить в README.
По умолчанию vecmory ранжировал выдачу не только по косинусу, но и с добавкой «важности» узла — его входящей степени в графе (сколько записей на него ссылаются). Логика красивая: центральный, многократно упомянутый факт всплывает выше. Мы зашили это в дефолт.
Потом — измерили. Не recall@k (это про точность поиска), а именно качество РАНГА, на реальных парах «запрос → правильный ответ»:
чистый косинус: MRR 0.81;
наш «умный» бленд с важностью: MRR 0.41.
Важность топила точные ответы. Редкий специфический факт, на который никто не ссылается, проседал под общеупотребительными «хабами».
Дальше — интереснее. На синтетическом «состаренном» графе с явными хабами всё наоборот: косинус зарывал хаб (MRR 0.033), а важность его вытаскивала (до 1.0). То есть знак пользы от важности зависит от интента запроса: для «дай мне точный факт» она вредит, для «дай мне про эту тему вообще» — помогает. Единого статического веса, выигрывающего оба класса, нет.
Мы перепробовали четыре способа примирить сигналы — взвешенную сумму, гейт, RRF и PPR — и сошлись на неприятном выводе: дело не в формуле смешивания, а в самом сигнале. Глобальная важность узла — это приор популярности, а не релевантности.
Вывод для дефолта был однозначный: для нашего основного кейса (точечный recall) лучший ранг — чистый косинус. И мы убрали важность из дефолтного ранжирования — свою же фичу, о которой уже написали в README. Графовый метод (query-seeded PPR) оставили, но опцией, а не по умолчанию: он честно вытаскивает хабы на широких запросах и справедливо проигрывает косинусу на точечных.
Что мы поняли про «важность» на самом деле
Если глобальная популярность (входящая степень) как сигнал провалилась, какой сигнал правильный? Мы пришли к неожиданно очевидному ответу: важно не то, на что много ссылок, а то, что человек исправлял повторно.
Это измеримо. Посмотрели на закрытые PR в одном из наших рабочих репозиториев — сколько раз одна и та же поправка возвращалась: revert — 39 раз, xsrf — за 60, token — 14, cookie — 10. Вот это и есть выстраданное знание — не самый популярный узел, а самые частые грабли. Такие уроки мы теперь подмешиваем первым блоком в каждый recall — они всплывают на каждом ходу, а не когда «повезёт с косинусом».
И вот здесь value proposition становится честным. Мы продаём не «графовую память» (снова коммодити). Мы продаём: агент перестаёт бить по одним и тем же граблям, которые ты уже правил.
Это не единичные случаи, а закономерности
Оба эпизода легко списать на удачу. Но когда чистишь всякий хлам в памяти достаточно долго (мы всю разработку vecmory ведём в vecmory же), видно: это не флуктуации, а несколько устойчивых законов. Сейчас будет свод — тремя откровениями, без историй «однажды я попал».
Что память даёт снова и снова. Правило «Сверься перед „готово“» отменило и роутер (сцена 1), и нашу долгожданную фичу (сцена 2) — один механизм, разные жертвы. Потому что на запрос-симптом память достаёт по причинно-временны́м рёбрам (caused_by — «из-за», followed_by — issue→PR) не «похожие слова», а конкретную причину и прошлый фикс. Плоский top-k так не умеет — это и есть «связать точки». И это точно измеримо, а не на глаз.
Взяли живой репозиторий тикетов (4299 узлов: 1993 issue + 2306 PR) и разметку, которую не сами придумали: стандартный GitHub-паттерн Closes #N в теле PR — готовые пары «симптом → его фикс», извлечённые голым regex, без всякого LLM. На 300 held-out запросов-симптомов чистый косинус достаёт нужный PR-фикс в 38% случаев (иногда фикс делит словарь с симптомом), а обход причинного графа — в 87%. Разница в 49 пунктов — это ровно вклад графа поверх сходства слов. Скрипт замера лежит в репозитории, число воспроизводится командой, а не берётся с потолка.
Что стабильно мешает — каждый раз, а не однажды. Во-первых, агент сам память не зовёт: без принудительного хука recall не вызывается систематически, каждую сессию. Память, которую надо «не забыть спросить», не работает — спрашивать должен детерминированный триггер, а не добрая воля агента. Во-вторых, абсолютный порог по косинусу не переносится нигде: синтетика → реал, вчера → сегодня, одна модель → другая, широкий корпус → узкий (на демо-корпусе одного домена, где «всё похоже», косинус почти перестаёт различать). Лечение всегда одно: ранжируй top-k, не угадывай пороги.
Наконец, один закон, который объясняет половину наших граблей: частый, плотный сигнал топит редкий, но ценный. Он всплывает в трёх типичных местах:
глобальная популярность узла топит точные редкие факты — и проваливает все четыре способа примешать её к рангу (взвешенная сумма, гейт, RRF, PPR);
узлы-хабы зарывают specific-факты в косинусном ранге, и наоборот;
плотные авто-
similar_toрёбра заглушают редкие причинныеcaused_by— поэтому причинный recall приходится выносить в отдельный изолированный режим.
Когда воспринимаешь это как единый закон, лечение очевидно: редкое-но-ценное нельзя смешивать с частым-но-общим — достань его отдельным механизмом (изолированный обход, граф-осведомлённый ранг), а не надейся, что оно само всплывёт в общем top-k. Тот же закон стоит и за нашим сигналом важности: цену имеет не популярное, а то, что человек исправлял повторно.
Почему мы стали заниматься этим? Потому что перестало хватать терпения в сотый раз долбить одно и то же. Да и ругательные слова закончились — в тикетах мы не ругаемся, а в терминале эмпирическим путем вычислили, что «тупица» работает лучше всего. Агент спотыкается в самый неудачный момент, когда забывает то, что знал при ответе на предыдущий вопрос.
Выводы
Обе истории — про одно: выкинь мантру «память помогает агенту» и прозрей: вот два лога, где память сработала против нас самих — против нашего оптимизма и нашей же фичи.
Агент с памятью отличается от агента без памяти не тем, что он знает больше, а тем, что может поймать себя на «bingo!» за мгновение до коварного отчёта — потому что в прошлый раз это уже было и уже не сработало. И тем, что готов выбросить собственную фичу, когда факты показали, что она хуже.
Память, которая подтверждает то, что тебе приятно, — это не память, а эхо. Память приобретает исключительную ценность тогда, когда она возражает своему автору.
? Как мы намерили 87% — методология, цифры, воспроизведение (для тех, кто любит всё проверить)
Разметку мы не придумывали. Ground truth — стандартный GitHub-паттерн Closes / Fixes / Resolves #N в теле PR: это одновременно и пара «проблема → её фикс», и ребро графа. Извлекается голым regex, без всякого LLM — никакого риска «модель неправильно поняла причинность». Запрос — заголовок issue (симптом); фикс сформулирован иначе (fix(#N): …), по словам далёк — это и делает задачу held-out.
Две руки, 300 запросов (не cherry-pick):
корпус: 4299 узлов (1993 issue + 2306 PR), 1684 gold-пары issue→PR прогнано: 300 запросов-симптомов (a) семантика-только (обычный recall): chain-hit 38.0% ← косинус сам достаёт фикс (b) причинный recall (обход графа): chain-hit 87.0% вклад графа: +49 процентных пунктов
Воспроизводимо (скрипт demo/causal-recall-eval.mjs в репозитории):
vecmory ingest --repo <owner/name> --dsn <dsn> --schema mem # наполнить граф из тикетов DSN=<dsn> SCHEMA=mem REPO=<owner/name> EVAL_MAX=300 \ node demo/causal-recall-eval.mjs # прогнать eval
Живой кейс — можно открыть и проверить. issue #4155 «задание не привязано к заказу, пустые партии сырья» (симптом) → закрывающий его PR #4157 fix(atex #4155): продолжение дробления по дням несёт «Партию сырья» головы (фикс сформулирован иначе). По тексту симптома косинус вытащит десяток похожих issue того же модуля; правильный фикс достаёт ребро followed_by, а не сходство слов.
Честная граница. Оставшиеся 13% — там, где заголовки одного модуля начинаются одинаково (общий путь файла), и обход садится на брата-issue: семантическое заякоривание на плотном корпусе неточно. Отсюда 87, а не 99. Ту же дисциплину — назвать слабое место, а не спрятать — мы применяем и здесь.
Это первая статья из трёх. Дальше:
2. «Почему мы не написали ещё один Bad CaRMa» — про то, как гипер-универсальная модель данных обычно превращается в катастрофу, и что мы сделали, чтобы наша — не превратилась.
3. «Строили ANN руками — когда это того стоило, а когда нет» — открытый разбор, зачем мы вообще написали HNSW сами и в каких случаях правильный ответ был бы «возьми pgvector и не выделывайся».
eanea
Да память ценна не как эхо прошлого, а как горизонт настоящего!
ideavi Автор
Точно. А горизонт настоящего включается только когда recall-пинок принудительный и попадает в момент решения