Сразу отвечаю на вопрос, который вы задали бы в первом же комментарии: а почему не pgvector?

Короткий ответ: ровно то, что pgvector делает хорошо — генерацию кандидатов (найти top‑k ближайших по косинусу) — мы и советуем отдавать pgvector, если он у вас есть. Свой ANN мы написали, потому что (1) pgvector есть не везде, где должна работать наша память, и (2) самое ценное в нашей задаче — вообще не генерация кандидатов. Про это вся статья, так что давайте по порядку.

Где мы живём

vecmory — память для ИИ‑агента поверх обобщённой EAV‑модели: «контроллер» плюс «квартет» (id, up, t, val) (почему это не выродилось в болото — прошлая статья про Bad CaRMa). Факты‑узлы, у каждого эмбеддинг; между узлами — типизированные рёбра: similar_to, каузальные, временные. Память должна ехать туда же, куда едет контроллер, — в том числе на on‑prem‑инсталляции, где pgvector нет и не будет.

С этого и началось ключевое наблюдение: граф similar_to — это уже навигируемый граф соседей. То есть готовый индекс. Поиск top‑k = жадный спуск по этому графу, косинус считаем только на посещённых узлах. Число посещённых ограничено бюджетом (entryPoints + maxExpand·степень) и не растёт с N. Обе стороны ребра достаём из штатных индексов квартета. Ни jsonb, ни pgvector, ни новой инфраструктуры — NSW/HNSW «своими руками» поверх рёбер, которые и так есть.

Когда своими руками окупилось (числа)

Плоский NSW деградирует с ростом базы: recall@10 0.88 при N=400 → 0.75 при N=1000 на том же бюджете обхода. Иерархия (HNSW) — почти нет: 0.83@400 → 0.82@900. Бюджет обхода задан константами (entryPoints + maxExpand·степень) и от N не зависит; на нашей полосе это вышло ~0.29·N посещённых узлов — и сразу оговорюсь, что доля в 29% на N≈10³ никакой асимптотики не доказывает, замеров на 10k и 100k у нас нет. Что действительно измерено — recall не поехал там, где плоский NSW поехал. Плюс обход детерминирован: одинаковый вход даёт одинаковый выход. Последнее важнее, чем кажется — без детерминизма нельзя воспроизвести баг ранжирования, а значит нельзя и починить.

Стоило это двух неочевидных войн с планировщиком — и обе показательнее самого алгоритма.

Война первая: обход. Раскрытие соседей — два точечных index‑seek'а по рёбрам (edge_in/edge_out) плюс LATERAL за вектором. Как только в обход добавляли джойн к тексту узла, планировщик Postgres из‑за лага статистики на растущей таблице разворачивал точечный обход в проход по всем узлам — O(N) вместо O(log N). Лечение: MATERIALIZED‑CTE и не тянуть текст узла внутри обхода вообще.

Война вторая: точка входа. Эту нашли позже и случайно — готовя замеры для прошлой статьи про то, что EAV спасают индексы, а не надежда. Оказалось, что у нас самих один горячий запрос был надеждой. Поиск верхнего слоя HNSW шёл маской по маркеру ребра — val LIKE '<similar_to>:%', без t и up. Ни один существующий индекс не применим (все с ведущей колонкой t или up), а обычный btree в не‑C коллации (en_US.utf8) префиксный LIKE не обслуживает в принципе. Планировщик уходил в Seq Scan на каждый recall, линейно по размеру базы:

квартетов в таблице

время точки входа

32 223

1152 мс

133 731

3889–4119 мс

То есть вход в HNSW возвращал ровно тот O(N), ради устранения которого HNSW и писался. Красивый обход, детерминированный спуск, ограниченный бюджет visited — и всё это за линейным шлагбаумом, которого никто не видел, потому что смотрели на алгоритм, а не на план запроса. Лечение — частичный индекс (val text_pattern_ops) WHERE length(val) <= MARK_MAX плюс тот же страж длины в самом запросе (без него планировщик не докажет применимость частичного индекса). Предикат гарантирует, что в индекс попадают только короткие значения — маркеры; векторы и тексты в него не заходят, поэтому он крошечный: 688 КБ на 133 731 квартет. После — 0.15 мс на 32k и 0.09–0.12 мс на 133k, Index Scan. recall при этом не изменился (0.830 при N=400, 0.820 при N=900) — фикс убирает катастрофический случай, не трогая нормальный.

Через день ти же грабли прилетели с другой стороны: чтение служебного состояния (счётчики бандита) тоже искалось префиксом и тоже шло Seq Scan'ом. Один и тот же трюк с коллацией, дважды за неделю, в двух не связанных местах.

Скучная, но показательная мораль: «свой ANN» — это не про красивый алгоритм, а про то, чтобы планировщик БД не переиграл тебя на ровном месте. Алгоритмическая часть заняла дни, а асимптотику в проде определяли не она, а два индекса.

Сухой итог по этой части: окупилось средне. Мы получили масштабо‑инвариантный recall без внешних зависимостей — но если у вас есть pgvector, он сделает генерацию кандидатов быстрее, надёжнее и без войн с планировщиком. Никакого геройства здесь нет. Есть pgvector — отдайте кандидатов ему.

Ещё одна граница: когда ANN проигрывает brute — и это норма

Свой ANN окупается только на построенном и достаточно большом графе соседей. Массовое наполнение памяти (импорт истории тикетов целого репозитория — тысячи узлов за один проход) пишет узлы без рёбер similar_to: строить граф соседей на каждый узел на вставке слишком дорого, откладываем на потом.

И тут вылезает коварство: жадному спуску по пустому графу соседей навигировать нечем. Он не падает с ошибкой — он молча возвращает почти случайные точки входа. Замер был безжалостный: узел не находился даже по своему собственному тексту (не попадал в топ-32), тогда как тупой brute‑перебор давал его топ-1 с косинусом 0.84. Молчаливый мусор хуже явной ошибки — его видно только замером против ground truth.

Вывод, зашитый в код: если рёбер similar_to меньше числа узлов (граф ещё не построен) — честно уходим в brute, а не в спуск по пустоте. Там же второй порог, ниже которого спуск не нужен вовсе: до 2000 узлов точный перебор и дешевле, и точнее. Оба условия вместе значат, что на обоих наших живых корпусах — личном (сотни узлов) и тикетном (тысячи, но без построенного графа) — сегодня работает brute, а не HNSW. Такова честная позиция: ANN у нас пока страховка на рост, а не рабочая лошадь.

И тут же поправка к самим себе: «brute — это миллисекунды» оказалось неправдой

В первой редакции этой статьи стояло: для реалистичных размеров памяти одного агента (сотни — десятки тысяч узлов) brute — это миллисекунды. Потом мы включили память на живом корпусе тикетов рабочего репозитория и померили. Замер на 4439 узлах с векторами (384 измерения), холодный процесс, кэш выключен:

что

время

достать пул векторов из Postgres

4910 мс (холодный), 1180–1474 мс (прогретый)

посчитать косинус по всему пулу в памяти

7–8 мс

Косинус действительно миллисекунды. Дорог не перебор, а доставка: векторы лежат в квартете как текстовые значения и на каждый recall вычитываются целиком — 35.7 МБ на этот корпус. Соотношение «достать» к «посчитать» — примерно 700:1. Разложили тот же замер на части: работа на сервере без передачи строк — 0.6–1.0 с, полный фетч на клиент — 1.0–2.1 с, парсинг текста в числа — ещё 0.35–1.3 с. Дорого не в одном месте, а в скане, проводе и parseFloat сразу. На маленьком личном корпусе (247 узлов) это 93–256 мс против ~0 — незаметно; на тысячах — уже приговор.

И вред тут не «пользователь подождёт». Recall у нас вызывается хуком с таймаутом 8 секунд. При нескольких агентах разом запрос в таймаут не укладывается — хук возвращает пустую строку и молчит. Агент работает без памяти, ответ выглядит нормально, в логе ничего. Это второй за статью случай, когда механизм ломается тихо, и оба раза видно только замером.

Лечение оказалось не алгоритмическим: кэш пула кандидатов в процессе тёплого сервиса (TTL, ранжирование не трогаем — кэшируются исходные строки, результат бит‑в-бит прежний) — recall 2.5 с → 0.2 с; плюс фоновый прогрев пула на старте, чтобы цену не платил первый промпт сессии — 4.6 с → 0.52 с.

Честный хвост, потому что статья про то, как оно на самом деле: прогрев чинит не до конца. Фоновый тик зовёт ту же функцию пула, а она при свежести младше TTL отдаёт кэш, не перечитывая; при интервале 0.8·TTL каждый второй тик — no‑op, и пул стоит протухшим примерно 37% времени. Замер на живом сервисе: тёплый запрос 0.12–0.35 с, запрос через 15 с после истечения TTL — 2.46 с, первый запрос при аптайме 19 часов — 2.89 с. Лечится форсированным рефетчем в прогреве; на момент публикации — не залечено.

Почему граф соседей всё ещё не строим

Напрашивается: постройте similar_to после массового импорта — и brute не нужен. Мы это обсуждали и отложили, и причина не в лени. Инкрементальное построение на вставке отключено, потому что медленное; batch‑KNN — O(N²) (на 4300 узлах ≈ 18 млн косинусов, на 100k уже невозможно). Но главный риск не в цене, а в качестве: как только рёбер станет больше числа узлов, переключатель уведёт нас с brute на ANN, а brute сегодня гарантирует точный топ. На корпусе с near‑дублями (у сотен тикетов почти одинаковые заголовки) плохо построенный граф даст соседей‑клонов — и мы поменяем точный ответ на быстрый и худший. Построение графа — не бесплатное улучшение, а размен, который надо мерить отдельно.

Это ещё один довод к главному тезису статьи: генерация кандидатов — не то место, где стоит геройствовать. Наш ручной ANN честно нужен на большом построенном графе без pgvector; ниже и на неподготовленном графе brute и проще, и не врёт — но и он не бесплатный, и цена у него не там, где ожидаешь.

Где своими руками было единственным вариантом: зарытый хаб

А теперь то, ради чего всё и затевалось и чего не решает ни один vector‑DB — потому что это вообще не задача поиска ближайших соседей.

Проблема «зарытого хаба» (buried hub): на «широком»/тематическом запросе правильный ответ — часто центральный узел‑хаб (та самая выстраданная заметка «осторожно, вот здесь все спотыкаются»), а чистый косинус его зарывает. Измерили: на синтетическом состаренном графе косинус кладёт хаб на MRR 0.033 — практически теряет.

Почему это важнее, чем звучит: цена ошибки асимметрична. Пропустить один частный факт — минус деталь. Пропустить хаб, к которому сходятся грабли, — значит дать агенту снова на них наступить. Это наше рабочее допущение (честно — допущение, а не замер): промах по хабу мы штрафуем кратно сильнее промаха по детали.

А вот что уже замер: хабы в графе реально концентрируют связи. На нашем корпусе (252 узла, 3062 ребра) у самого центрального узла входящая степень 48 против медианы 10, а верхние 10% узлов держат 27.7% всех рёбер. Оптимизировать recall@k по чистому косинусу, игнорируя эту концентрацию, — значит оптимизировать не ту метрику.

Перемерили перед публикацией, спустя неделю жизни корпуса (249 узлов, 3056 рёбер): степень 48 против медианы 11, верхние 10% держат 27.0%. Число держится — это не разовый снимок.

И сразу оговорка, без которой из этого сделают неверный вывод: концентрация — свойство графа похожести, а не «графа вообще». На тикетном корпусе того же движка (5609 узлов, рёбра issue→PR, построенные из ссылок «Closes #N») картина ровно обратная и плоская: максимальная входящая степень 3, медиана 1, верхние 10% держат 11.5%. Так и должно быть — связь «тикет → его фикс» почти один‑к-одному по построению. Хабы живут там, где рёбра рисует семантика, а не конвенция коммитов. Значит и лечение хабов (ниже) осмысленно ровно на том графе, где хабы есть: включать его на причинной цепочке — оптимизировать несуществующую проблему.

Nearest‑neighbor DB здесь бессилен принципиально. «Зарытый хаб» — это не «где ближайшие по вектору», а «какие узлы центральны относительно этого запроса в графе связей». Это граф‑релевантность. Наш ответ — query‑seeded Personalized PageRank (по мотивам HippoRAG): power‑iteration прямо в приложении по тем же рёбрам, засев — косинусные соседи запроса, damping 0.5, без обучаемых весов. Он честно вытаскивает зарытый хаб (на синтетике HUB → 1.0).

И тут же честная оговорка, чтобы не продавать серебряную пулю: на точечных запросах («дай конкретный факт») PPR проигрывает чистому косинусу (MRR 0.71 против 0.81) — граф торгует точность на recall хабов. Поэтому PPR у нас опция (weights: ppr), а не дефолт: включаешь под широкие/диагностические запросы, оставляешь косинус под точечные.

Проверка на чужом корпусе: сколько на самом деле весит графовая часть

Всё выше — про нашу кухню, и справедливый вопрос: может, граф вам нужен потому, что вы плохо сделали семантику? Поэтому мы взяли чужой публичный репозиторий — YDB Яндекса, 19 000 узлов из истории тикетов, 293 золотые пары «issue → PR, который её починил», разметка детерминированная («Closes #N»), корпус открыт.

Результат: чистая семантика находит нужный фикс в 32.1% случаев (95% ДИ 27.0–37.6), обход по причинным рёбрам — в 94.5% (91.3–96.6). Вклад графа — +62.5 п.п. Контрольный прогон на другом срезе (8000 узлов, 47 пар): 36.2% против 95.7%. То же самое на рабочем корпусе тикетов: 60 золотых пар, 85% попаданий, причём 23 из 51 найдены только причинной цепью — семантика их не доставала вовсе.

Дело не в том, что семантика плохая. Дело в том, что у симптома и у фикса разный словарь: тикет написан на языке боли («после релиза поехали сроки»), а PR — на языке кода (recalcPlan: учитывать смещение). Косинус между ними честно низкий, и никакой ANN, pgvector или более жирная модель этого не переставят — потому что искомое не является ближайшим соседом запроса. Оно связано с ним ребром. (Про этот замер будет отдельная статья с методикой и разметкой.)

Вот ради чего стоило писать руками. Не ради того, чтобы найти top‑k быстрее.

Самая большая прибавка пришла вообще не от индекса

И финальный урок, который дороже всех предыдущих. Когда память подключили к живому рабочему репозиторию, качество выдачи оказалось плохим — и мы полезли смотреть в ранжирование. Виноватым оказался входной текст.

Во‑первых, промпт агенту в такой работе часто выглядит как голая ссылка на тикет. Эмбеддинг такой «фразы» садится на URL‑boilerplate: из 5.7 КБ инъекции 4.4 КБ были обрывками чужих тикетов, не имеющих к запросу отношения. Во‑вторых, в заголовках тикетов живут пути к файлам, общие для сотен записей, — и вектор тянуло на них.

Лечение: разворачивать ссылку (#N, URL issue/PR) в текст тикета до эмбеддинга и вычищать из запроса URL, вложения и пути. Замер на 40 золотых парах: нужный PR в топ-12 семантики — 27% → 62%. Ни строчки в ANN, ни одного индекса, ни смены модели.

Туда же второй сюжет: причинный граф в какой‑то момент молча перестал расти. Рёбра строились из тела PR (“Closes #N”), а конвенция репозитория переехала в заголовок (fix(module #4409):) и в имя ветки: у свежих PR ссылок в теле — 0%, в заголовке или ветке — 87%. Граф не сломался и не заругался, он просто перестал видеть новое; добор после починки — +402 ребра. Собственная граф‑релевантность — это не только «написать обход», это ещё и следить, что рёбра всё ещё появляются.

Тридцать пять процентных пунктов от гигиены запроса против нуля от очередной итерации по алгоритму поиска — хорошая калибровка для того, кто прямо сейчас выбирает, на что потратить неделю.

Разделение труда, к которому мы пришли

Если сжать статью в один тезис:

  • Генерация кандидатов (top‑k ближайших по косинусу) — коммодити. Есть pgvector — берите pgvector. Наш ручной ANN здесь оправдан только отсутствием pgvector в среде (on‑prem, обобщённый контроллер) плюс бонусом «ноль новой инфраструктуры».

  • Каузальный / граф‑обход (гирлянда, зарытый хаб, PPR) — наша собственная, никем из коробки не решённая задача. Vector‑DB её не закрывает, потому что это не поиск соседей.

  • Ни цена, ни качество не живут в алгоритме поиска. Цена оказалась в плане запроса (Seq Scan на входе HNSW) и в транспорте (17 МБ векторов на запрос), качество — во входном тексте (27% → 62% от разворачивания ссылки) и в живости рёбер (+402 после починки). Алгоритм при этом всё время был правильный.

Мораль для тех, кто прямо сейчас пишет свой ANN: спросите себя, что вы на самом деле строите. Если генерацию кандидатов — скорее всего, зря; возьмите готовое. Если граф‑релевантность поверх ваших собственных связей — готового нет, и вот здесь руки оправданы. А потом всё равно померьте план запроса, транспорт и то, что вы кладёте в эмбеддинг: с большой вероятностью ваша следующая победа лежит там, а не в обходе.


Статья 3 в серии по опыту разработки vecmory. Предыдущие: 1 — «Агент, пойманный на вранье его же памятью», 2 — «Почему мы не написали ещё один Bad CaRMa». Следующая — внешний бенчмарк причинного recall на публичном корпусе YDB.