Головна / Статті / Ми відмовилися від векторної бази: 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. Цей шлях міняє інвойс вендора на вашу власну увагу.

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

Що вдалося прибрати

Зникли дві тисячі чотириста рядків коду синхронізації, а разом із ними сервіс, канал алертів і ранбук. Найкращим інфраструктурним рішенням минулого року виявилося видалення. Прочитайте свій інвойс. Ви, найпевніше, знаєте, що там написано.