ADG Оставить заявку
Блог Данные и аналитика 5 мин чтения

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. Теперь аргумент «взять выделенное решение ради алгоритма поиска» стал слабее. Аргумент «взять выделенное решение ради масштабируемости» пока остаётся - но это уже другая точка принятия решения.

Задачи в области аналитики и работы с данными часто выглядят одинаково снаружи, а внутри упираются именно в такие детали инфраструктуры.

Контакт

Нужна такая же инженерная работа?

Опишите задачу и контекст. Ответим в течение рабочего дня, при необходимости подпишем NDA.