pgvector для семантического поиска: рабочая настройка в Postgres
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 заводить не нужно: всё живёт в той же базе, что и обычные данные, и ты получаешь честные транзакции и привычные бэкапы из коробки.