pgvector 0.4 и HNSW: мигрируем RAG-пилот с Chroma на PostgreSQL
pgvector 0.4.0 добавил HNSW-индекс - теперь векторный поиск в PostgreSQL сопоставим с выделенными решениями. Мигрируем RAG-пилот с Chroma и разбираем компромиссы.
pgvector 0.4.0 выпустил поддержку HNSW-индекса, что делает векторный поиск прямо в PostgreSQL конкурентоспособным с выделенными решениями
В марте, когда мы разбирали выбор векторного хранилища, один из аргументов против pgvector звучал так: IVFFlat-индекс требует предварительного знания числа кластеров, плохо работает на холодных данных и уступает HNSW по скорости поиска при высоких требованиях к recall. Выделенные решения типа Weaviate или Qdrant использовали HNSW с рождения. pgvector тогда тоже анонсировал HNSW, но его не было. И вот - 0.4.0 вышел, HNSW внутри.
Мы как раз держали один из RAG-пилотов на Chroma в клиент-серверном режиме. Решили воспользоваться поводом и проверить: действительно ли теперь pgvector закрывает тот gap, или это маркетинговое «теперь у нас тоже есть».
Что поменялось в 0.4.0
Главное - новый тип индекса:
CREATE INDEX ON document_chunks
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
Параметры стандартные для HNSW: m - количество связей на узел, ef_construction - размер очереди при построении. Чем выше - тем точнее индекс и дольше строится. Дефолты (16 и 64) - разумный старт для большинства случаев.
При поиске отдельно задаётся ef_search - аналог ef в запросе:
SET hnsw.ef_search = 100;
SELECT id, content, metadata,
embedding <=> query_embedding AS distance
FROM document_chunks
ORDER BY embedding <=> query_embedding
LIMIT 5;
В отличие от IVFFlat, HNSW не требует знать заранее число кластеров и не нужно вызывать VACUUM после массовых вставок для перестройки. Индекс обновляется инкрементально - добавил запись, индекс обновился. Для RAG-сценариев, где документы добавляются регулярно, это принципиальная разница.
Миграция с Chroma
Пилот, который переезжал - поиск по технической документации клиента, около 40 тысяч чанков с эмбеддингами ada-002 (размерность 1536). Chroma работала в клиент-серверном режиме на отдельной машине, LangChain обращался к ней через HTTP.
Схема на PostgreSQL получилась такая:
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE document_chunks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
doc_id TEXT NOT NULL,
content TEXT NOT NULL,
metadata JSONB,
embedding vector(1536),
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX ON document_chunks
USING hnsw (embedding vector_cosine_ops);
CREATE INDEX ON document_chunks (doc_id);
CREATE INDEX ON document_chunks USING gin (metadata);
Экспорт из Chroma - через Python, стандартный collection.get(include=['embeddings', 'documents', 'metadatas']). Дальше пакетная вставка в pg через psycopg2 с execute_values. Сорок тысяч записей ушли примерно за 15 минут - большая часть времени ушла на сериализацию векторов, не на сам INSERT.
Построение HNSW-индекса заняло ещё около 5 минут. IVFFlat в своё время строился быстрее, но на запросах HNSW заметно лучше.
Что получилось по качеству
Сравнивали по набору из ~80 тестовых вопросов, который составили по ходу пилота. Recall@5 (доля вопросов, где правильный чанк попал в top-5) на Chroma был около 0.78. На pgvector 0.4 с HNSW дефолтными параметрами получили 0.81 - небольшой плюс, но стабильный.
Латентность на одиночных запросах: Chroma давала 80-120 мс, pgvector - 40-70 мс при ef_search = 100. Здесь pgvector выиграл, хотя сравнение не совсем честное: Chroma шла через HTTP, pg - через локальный сокет. Но суть в том, что разница есть и она в нужную сторону.
Фильтрация по метаданным - это отдельная история в пользу SQL. В Chroma фильтр передаётся отдельным параметром в query(), синтаксис специфический. В pgvector это просто WHERE:
SELECT id, content, metadata,
embedding <=> query_embedding AS distance
FROM document_chunks
WHERE metadata->>'department' = 'engineering'
AND created_at > '2023-01-01'
ORDER BY embedding <=> query_embedding
LIMIT 5;
При этом планировщик PostgreSQL сам решает, как комбинировать HNSW-индекс с фильтрами. Иногда он делает это не идеально - при очень селективных фильтрах полный скан с последующей сортировкой может оказаться быстрее. EXPLAIN ANALYZE в помощь.
Где pgvector пока проигрывает
Честно: некоторые вещи в выделенных решениях всё ещё удобнее.
Нет встроенного шардирования. Если векторов станет несколько миллионов - pgvector упирается в возможности одного PostgreSQL-инстанса. Weaviate и Qdrant шардируются нативно.
Параллельное построение индекса появилось только частично. Построение HNSW на большом корпусе занимает заметное время и грузит процессор. Выделенные решения с этим работают эффективнее.
Мультивекторный поиск (несколько эмбеддингов на документ с разными весами) через SQL реализуемый, но громоздкий. В специализированных БД это из коробки.
Для нашего пилота ни одно из этих ограничений не критично - 40 тысяч чанков это не миллионы, шардирование не нужно. Но понимать потолок надо.
Операционный итог
Chroma в клиент-серверном режиме - это отдельный процесс, отдельный бэкап, отдельный мониторинг, отдельная точка отказа. pgvector живёт внутри того PostgreSQL, который уже есть у клиента: реплицируется вместе с остальными данными, попадает в тот же pg_dump, мониторится теми же инструментами. Для команды без выделенного dba это ощутимое упрощение жизни.
Релиз 0.4.0 закрыл главную техническую претензию к pgvector - отсутствие HNSW. Теперь аргумент «взять выделенное решение ради алгоритма поиска» стал слабее. Аргумент «взять выделенное решение ради масштабируемости» пока остаётся - но это уже другая точка принятия решения.
Задачи в области аналитики и работы с данными часто выглядят одинаково снаружи, а внутри упираются именно в такие детали инфраструктуры.