lenec ru

← все посты

pgvector для семантического поиска: рабочая настройка в Postgres

13K

pgvector прижился у меня в нескольких проектах в этом году: от поиска по базе знаний внутри компании до разметки похожих карточек товара. Расскажу, как я его собираю, какие индексы выбираю и куда лучше не наступать.

Контекст: предполагаю, что эмбеддинги ты уже умеешь получать. Какой именно моделью — не суть, для нашего разговора это просто массивы чисел нужной размерности. Postgres 16 или 17, pgvector 0.7+, размерность векторов в примерах — 1024 (одна из ходовых).

Установка

На Ubuntu/Debian с PGDG-репозиторием:

sudo apt install postgresql-17-pgvector

В managed Postgres у Yandex/Selectel расширение обычно уже доступно — достаточно включить его в настройках кластера. После — в нужной базе:

CREATE EXTENSION IF NOT EXISTS vector;

Проверить версию: SELECT extversion FROM pg_extension WHERE extname = 'vector';. У меня сейчас 0.7.x — рекомендую именно её, в 0.5 ещё не было половины полезных фишек.

Таблица под эмбеддинги

CREATE TABLE doc_chunks (
  id          bigserial PRIMARY KEY,
  doc_id      bigint NOT NULL,
  chunk_index int    NOT NULL,
  content     text   NOT NULL,
  embedding   vector(1024) NOT NULL,
  created_at  timestamptz DEFAULT now()
);

Размерность фиксируется в DDL. Если поменяешь модель эмбеддингов — придётся либо новую таблицу, либо ALTER с пересчётом, по-простому это не делается. Поэтому я обычно держу две колонки на переходный период, или версионирую таблицу.

Какой индекс выбрать

В pgvector два типа индексов: ivfflat и hnsw. У них разные сильные стороны.

HNSW

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

CREATE INDEX doc_chunks_embedding_hnsw
  ON doc_chunks
  USING hnsw (embedding vector_cosine_ops)
  WITH (m = 16, ef_construction = 64);

Параметры:

  • m — количество связей в графе. Больше — точнее и медленнее на построение и поиск. 16 — хороший дефолт.
  • ef_construction — глубина поиска при построении. 64 для среднего корпуса; 128 — если хочешь точнее, но строится дольше.

На запросе можно покрутить hnsw.ef_search:

SET hnsw.ef_search = 100;

Чем больше — тем больше точность за счёт времени. На корпусе в 200 тысяч документов я держу 64 — хватает.

IVFFlat

IVFFlat делит пространство на lists кластеров и при поиске обходит несколько ближайших. Строится быстрее, ест меньше памяти, но точность хуже на маленьких lists.

CREATE INDEX doc_chunks_embedding_ivf
  ON doc_chunks
  USING ivfflat (embedding vector_cosine_ops)
  WITH (lists = 100);

Правило-эвристика: lists ≈ sqrt(rows). На миллионе строк ставлю 1000. На запросе:

SET ivfflat.probes = 10;

10–20 probes у меня обычно дают приемлемое качество. IVFFlat беру, когда корпус большой и память дорогая.

Метрики расстояния

В pgvector три ходовых:

  • <=> — косинусное расстояние, оператор vector_cosine_ops.
  • <-> — евклидово, vector_l2_ops.
  • <#> — отрицательное скалярное произведение, vector_ip_ops.

Нужно следить за двумя вещами. Во-первых, метрика индекса должна совпадать с метрикой запроса. Если индекс с vector_cosine_ops, а в запросе ты пишешь <->, индекс не сработает. Во-вторых, нормированные эмбеддинги (модули = 1) дают одинаковый порядок результатов для cosine и для скалярного произведения, но cosine стабильнее на ненормированных моделях.

Запрос

SELECT id, doc_id, content,
       embedding <=> $1 AS distance
FROM doc_chunks
ORDER BY embedding <=> $1
LIMIT 10;

Тонкость: чтобы индекс сработал, ORDER BY должен быть ровно по тому же выражению, что и в SELECT. И обязательно LIMIT, иначе планнер уйдёт в Seq Scan.

Гибридный поиск: вектор плюс текст

Большинство практических задач — не «дай ближайший по смыслу», а «найди по смыслу и по фильтрам». В реальности у меня запрос такой: «найди ближайшие по смыслу к фразе X, но только в проекте Y, только за последний месяц, и желательно похожие по словам».

WITH ranked AS (
  SELECT id, doc_id, content,
         (embedding <=> $1) AS dist
  FROM doc_chunks
  WHERE doc_id = ANY($2)
    AND created_at > now() - interval '30 days'
  ORDER BY embedding <=> $1
  LIMIT 50
)
SELECT *
FROM ranked
ORDER BY dist
LIMIT 10;

Я ограничиваюсь LIMIT 50 на индексном этапе и потом уже сортирую и режу — это позволяет планнеру использовать pgvector-индекс, а условия WHERE применяются как фильтр поверх. Если фильтрующий WHERE очень селективный — отдельный B-tree-индекс по doc_id и created_at рядом тоже не помешает.

Гибрид с полнотекстом получается так:

WITH semantic AS (
  SELECT id, 1 - (embedding <=> $1) AS sem_score
  FROM doc_chunks
  ORDER BY embedding <=> $1
  LIMIT 100
),
lexical AS (
  SELECT id, ts_rank(search, websearch_to_tsquery('russian', $2)) AS lex_score
  FROM doc_chunks
  WHERE search @@ websearch_to_tsquery('russian', $2)
)
SELECT c.id, c.content,
       coalesce(s.sem_score, 0) * 0.7 + coalesce(l.lex_score, 0) * 0.3 AS score
FROM doc_chunks c
LEFT JOIN semantic s ON s.id = c.id
LEFT JOIN lexical l  ON l.id = c.id
WHERE s.id IS NOT NULL OR l.id IS NOT NULL
ORDER BY score DESC
LIMIT 10;

Веса 0.7/0.3 — стартовая точка, у тебя будут свои в зависимости от данных. На внутренней базе знаний у меня в итоге получилось 0.6/0.4, на каталоге товаров — 0.5/0.5. Работа над весами ровно такая: тестовый набор запросов, ручная разметка релевантности, подбор.

Подводные камни

Размер индекса

HNSW на векторе 1024 и миллионе записей весит порядка 4–6 ГБ. Это не «маленькая надстройка», это основной потребитель памяти. На VPS с 8 ГБ RAM я бы ставил IVFFlat с агрессивным probes.

Долгое построение HNSW

На корпусе в миллион записей HNSW строится десятки минут до часов. Если пишешь много — задумайся над инкрементальным построением: pgvector умеет добавлять строки в существующий индекс, без полной перестройки.

Vacuum

Удалённые строки помечаются как мёртвые, как и в обычных таблицах. На jsonb/vector это особенно заметно: автоваккум должен успевать. Если удаляешь и обновляешь часто — мониторь bloat и поправляй autovacuum_vacuum_scale_factor в сторону уменьшения.

Параллельные запросы

В pgvector планы pgvector-индекса в текущих версиях идут одним воркером. Если у тебя много CPU и хочется parallel scan — учти, что выигрыш от параллельности ограничен индексным сканом, который и так быстрый.

Как у меня лежит на сервисе

На рабочей конфигурации внутреннего поиска: ~250 тысяч чанков по 200–400 токенов, embedding 1024. HNSW (m=16, ef_construction=64), индекс ~1.4 ГБ, поиск — медиана 12 мс, p95 30 мс. Связка с фильтром по категории и датой через CTE с лимитом 50.

На каталоге товаров: ~3 миллиона позиций, embedding 768. IVFFlat (lists=2000, probes=20). Поиск ~25 мс, индекс ~3 ГБ. Точность чуть ниже HNSW, но память дешевле.

Что запомнить

  • HNSW — для качества, IVFFlat — для большого объёма и экономии памяти.
  • Метрика в индексе и в запросе должна совпадать. ORDER BY — ровно тем же выражением.
  • Гибридный поиск с lexical-частью почти всегда лучше чистого вектора.
  • Размер вектора фиксируется в DDL — выбирай модель осознанно.
  • Мониторь bloat и автоваккум на горячих таблицах с эмбеддингами.

pgvector — компактный и удобный инструмент, который в большинстве кейсов делает задачу «семантический поиск» рутинной. Никаких отдельных Elasticsearch и Faiss заводить не нужно: всё живёт в той же базе, что и обычные данные, и ты получаешь честные транзакции и привычные бэкапы из коробки.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.