Главная / Статьи / Мы отказались от векторной базы: pgvector спокойно обслуживает 40 млн эмбеддингов

Мы отказались от векторной базы: pgvector спокойно обслуживает 40 млн эмбеддингов

История миграции: замена платного векторного хранилища на pgvector на 40 миллионах строк — как мы делали бэкфилл, какая ошибка с полнотой пряталась за дашбордами и когда переходить не стоит.

1132 слов

Во что на самом деле обходилась вторая база

Счёт складывался из одиннадцати инвойсов, примерно четырёх тысяч долларов в месяц, плюс воркер синхронизации, который регулярно будил кого-нибудь по ночам. Второй вендор хранил список чисел, место которым было рядом со строками, и так принадлежавшими компании. Одобрить этот инвойс одиннадцать раз было легко. А произнести вслух на встрече, что это значит, — и наступала та тишина, когда все уже всё знают, но никто не хочет говорить первым.

И всё же главной ценой были не деньги. Главной ценой был шов между двумя системами. Две базы — это два источника истины, а значит, воркер синхронизации, а значит, очередь недоставленных сообщений, а значит, инженер, который в 3 часа ночи читает JSON с телефона. Вот архитектура в том виде, в каком она реально работала, а не как её нарисовали в дизайн-документе:

app ──> postgres        (rows, permissions, deletes)
   │
   └────> vector store    (embeddings)
              ▲
              │
        sync worker ──> DLQ ──> me, awake, coffee going cold

Когда пользователь удалял документ, Postgres удалял строку в той же транзакции. Векторное хранилище удаляло свою копию, когда воркер успевал догнать, поэтому примерно девяносто секунд удалённый контент всё ещё находился поиском. Это не проблема задержки. Это тикет в поддержку с приложенной юридической проверкой.

Одна таблица, один индекс, никакого воркера

На замену пришла одна таблица с одним индексом и без фоновых процессов:

create extension vector;

create table docs (
  id bigserial primary key,
  team_id int not null,
  body text,
  emb vector(768)
);

Команду покорило то, как к ней обращаться, — это обычный SQL:

select id, body
from docs
where team_id = 42
order by emb <=> $1
limit 10;

Эмбеддинг не может разойтись со своей строкой, если живёт внутри этой строки. Исчез целый класс багов, а вместе с ним и ранбук, написанный под них.

Миграция 40 миллионов строк без остановки продукта

Отчёты о миграциях обычно пропускают этот этап, хотя он занял больше всего времени. Бэкфилл не использовал OFFSET. Глубокая пагинация по сорока миллионам строк вырождается в последовательное сканирование, и задача замедляется по мере продвижения — ровно противоположное тому, что нужно долгой задаче. Воркеры шли по первичному ключу диапазонами и сохраняли чекпоинт с последним обработанным id:

select id, body from docs
where emb is null and id > $1
order by id
limit 2000;

Каждый батч содержал две тысячи строк, одновременно работали восемь воркеров, а намеренная пауза между батчами держала задержку репликации под контролем. Следили именно за отставанием реплик, а не за пропускной способностью, потому что реплики всё это время обслуживали живой трафик.

Индекс строили в последнюю очередь. Если создать HNSW до загрузки, граф придётся непрерывно переписывать по мере поступления данных, и построение никогда не уложится в приемлемые сроки.

set maintenance_work_mem = '16GB';
set max_parallel_maintenance_workers = 7;

create index concurrently on docs
using hnsw (emb_h halfvec_cosine_ops)
with (m = 16, ef_construction = 64);

Сбой, который не показывал ни один дашборд

Поиск запустили на Postgres, и дашборды выглядели отлично: ровная задержка, ноль ошибок. Никто не праздновал, потому что казалось, будто ничего и не произошло. Через четыре дня клиент сообщил, что документ, который он точно загружал, не появляется в результатах.

Причина была в том, что hnsw.ef_search так и не задали. Он оставался со значением по умолчанию — список кандидатов слишком узкий для графа такого размера, — и полнота сползла чуть выше 90%. Каждый дашборд измерял, вернул ли запрос ответ. Ни один не измерял, вернул ли он правильный ответ.

Исправление — выставить значение 100, а затем перебрать диапазон значений и найти, где полнота выходит на плато. После этого появилась проверка, которая должна была быть с первого дня: замороженный набор тестовых запросов, у каждого известный правильный ответ, и алерт, если доля совпадений падает.

set hnsw.ef_search = 100;

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

Результаты измерений

Тест шёл на сорока миллионах строк размерностью 768 на шестнадцати виртуальных CPU, чтение обслуживали реплики.

                        vector store     pgvector
  p50 latency              18 ms           24 ms
  p95 latency              41 ms           57 ms
  recall at 10             0.985           0.981
  filtered p95            190 ms           61 ms
  index size                 n/a           45 GB
  monthly cost            $4,100            $280

Запросы без фильтров работают на шесть миллисекунд медленнее. На фоне вызова модели в девятьсот миллисекунд этой разницы никто не замечает. Важнее случай с фильтрами, потому что именно в нём и кроется настоящий аргумент. Поиск в реальном продукте фильтруется по команде, правам доступа или дате. В старой схеме приходилось вытаскивать из одной системы набор кандидатов больше необходимого и проверять его в другой. С одной базой планировщик может разом взвесить фильтр и порядок сортировки и выбрать, как их выполнить.

Возражения, которые у вас, вероятно, уже появились

HNSW плохо переносит интенсивные обновления. Это правда. Наши документы пишутся один раз и постоянно читаются, так что граф остаётся стабильным, но если вы переписываете эмбеддинги каждый час, хорошо подумайте.

Построение индекса стоит дорого. Тоже правда. Наше заняло чуть больше часа, а параллельное построение позволило всё это время продолжать запись.

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

Когда стоит остаться на текущем решении

Оставайтесь на текущей схеме, если у вас больше ста миллионов векторов и их число растёт, если нужна задержка в единицы миллисекунд на поиске без фильтров или если никто в команде не хочет разбираться с настройками памяти Postgres. Этот путь меняет инвойс вендора на ваше собственное внимание.

Для всех остальных база данных уже содержит функцию, за аренду которой они кому-то платят.

Что удалось убрать

Ушли две тысячи четыреста строк кода синхронизации, а вместе с ними сервис, канал алертов и ранбук. Лучшим инфраструктурным решением прошлого года оказалось удаление. Прочитайте свой инвойс. Вы, скорее всего, знаете, что там написано.