Строили ANN руками — когда это того стоило, а когда нет. ann.. ann. HippoRAG.. ann. HippoRAG. hnsw.. ann. HippoRAG. hnsw. Personalized PageRank.. ann. HippoRAG. hnsw. Personalized PageRank. pgvector.. ann. HippoRAG. hnsw. Personalized PageRank. pgvector. PostgreSQL.. ann. HippoRAG. hnsw. Personalized PageRank. pgvector. PostgreSQL. векторный поиск.. ann. HippoRAG. hnsw. Personalized PageRank. pgvector. PostgreSQL. векторный поиск. ии-агенты.. ann. HippoRAG. hnsw. Personalized PageRank. pgvector. PostgreSQL. векторный поиск. ии-агенты. память агента.

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

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

Строили ANN руками — когда это того стоило, а когда нет - 1

Где мы живём

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.

Автор: ideavi

Источник