Заметки

pgvector: векторный поиск прямо в PostgreSQL

M
Markabus
·
Фотореалистичный кадр на рабочем столе: большая пробковая доска с сотнями цветных булавок, собранных в кучки по цветам, от одной красной булавки натянуты пять красных нитей к пяти ближайшим соседям, слева выдвинутый ящик деревянного библиотечного каталога с карточками — иллюстрация к статье о векторном поиске ближайших соседей в PostgreSQL с расширением pgvector.

Если у вас уже работает PostgreSQL, отдельная векторная база может не понадобиться. Расширение pgvector добавляет в Postgres новый тип данных, операторы расстояния и приближённые индексы — и семантический поиск начинает жить обычным SELECT, рядом с вашими таблицами, транзакциями и бэкапами. Ниже — как это устроено на практике, какой индекс выбрать, где pgvector упирается в потолок и когда всё-таки стоит поднимать отдельный движок.

Что делает pgvector

pgvector — это расширение PostgreSQL, которое умеет три вещи: хранить эмбеддинги (векторы чисел, в которые модель превращает текст) в отдельном типе колонки, считать между ними расстояние специальными операторами и строить по этим колонкам ANN-индексы. ANN (approximate nearest neighbor, приближённый поиск ближайших соседей) — это поиск, который находит почти самых похожих соседей, но за доли миллисекунды вместо перебора всей таблицы.

Ключевое отличие от отдельных движков вроде Qdrant: вектор лежит в той же строке, что и остальные данные. Значит, фильтровать можно обычным WHERE, связывать JOIN, писать в одной транзакции с бизнес-таблицами и забирать одним бэкапом. Для типичного RAG-сценария это заметно упрощает эксплуатацию.

На момент написания актуальная версия — 0.8.6, вышла 29 июля 2026. Важные вехи: половинная точность (halfvec), разреженные векторы (sparsevec) и индексация битовых векторов появились в 0.7.0; итеративные сканы индекса — в 0.8.0; поддержка Postgres 18 — в 0.8.1.

Установка и первая таблица

На своём сервере расширение ставится пакетом (APT, Yum, Homebrew, PGXN) или собирается из исходников, а в контейнере проще взять готовый образ pgvector/pgvector:pg17. Дальше — одна команда в нужной базе:

CREATE EXTENSION vector;

В управляемых облаках расширение обычно уже есть в списке доступных. В Yandex Managed Service for PostgreSQL, например, pgvector включается для версий СУБД с 14-й по 18-ю — но версия самого расширения отстаёт от апстрима (на момент проверки для PostgreSQL 16–18 предлагалась 0.8.0). Это стоит учитывать: часть свежих возможностей в облаке может быть недоступна.

Минимальная рабочая таблица для поиска по документам выглядит так:

CREATE TABLE docs (
  id         bigserial PRIMARY KEY,
  url        text NOT NULL,
  lang       text NOT NULL,
  chunk      text NOT NULL,
  updated_at timestamptz NOT NULL DEFAULT now(),
  embedding  vector(1536)
);

Размерность фиксируется прямо в типе. Сменить модель эмбеддингов «на лету» не получится: у другой модели другая размерность и другое пространство, так что колонку придётся пересчитывать целиком. Сразу продумайте и размер кусков текста — про это есть отдельный разбор чанкинга документов.

Операторы расстояния

Расстояние считается операторами, а не функциями — это принципиально для работы индекса:

  • <-> — евклидово расстояние (L2);
  • <=> — косинусное расстояние, самый частый выбор для текстовых эмбеддингов;
  • <#> — отрицательное скалярное произведение;
  • <+> — манхэттенское расстояние (L1);
  • <~> и <%> — Хэмминг и Жаккар для битовых векторов.

Сам запрос предельно скучный, и это хорошо:

SELECT id, url, chunk
FROM docs
ORDER BY embedding <=> $1
LIMIT 5;

Индексы: HNSW или IVFFlat

Без индекса Postgres честно переберёт все строки: точность стопроцентная, скорость падает линейно с ростом таблицы. На нескольких тысячах строк это нормально, на сотнях тысяч — уже нет.

HNSW строит многослойный граф ближайших соседей. Даёт лучшее соотношение «скорость/полнота», работает на пустой таблице, но строится дольше и ест больше памяти:

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

SET hnsw.ef_search = 100;  -- по умолчанию 40

Параметр m (по умолчанию 16) — число связей на слой, ef_construction (по умолчанию 64) — размер списка кандидатов при построении. Оба повышают полноту ценой времени сборки. hnsw.ef_search крутится уже на запросах: больше — точнее и медленнее.

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

CREATE INDEX ON docs USING ivfflat (embedding vector_cosine_ops)
  WITH (lists = 1000);

SET ivfflat.probes = 32;  -- по умолчанию 1

Официальная рекомендация по listsrows / 1000 для таблиц до миллиона строк и sqrt(rows) для более крупных; стартовое значение probessqrt(lists). Дефолтная единица в probes почти всегда даёт слишком низкую полноту, поменять её — первое, что стоит сделать.

Сборку тяжёлого HNSW-индекса ускоряют два параметра сессии: индекс строится заметно быстрее, когда граф помещается в maintenance_work_mem, и когда ему разрешено больше параллельных воркеров (по умолчанию их два).

SET maintenance_work_mem = '8GB';
SET max_parallel_maintenance_workers = 7;  -- плюс сам лидер

И главная ловушка новичка: индекс подхватывается, только если в запросе есть ORDER BY по оператору расстояния (именно по оператору, не по выражению вокруг него) и LIMIT, и сортировка идёт по возрастанию. Проверяется это привычным способом — если вы настраивали индексы в MySQL, логика знакомая:

EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM docs ORDER BY embedding <=> $1 LIMIT 5;

Потолок в 2000 измерений

Тип vector хранит до 16 000 измерений, но проиндексировать можно только до 2000. Современные модели легко выдают 3072 — и тут выручает halfvec (16-битные числа вместо 32-битных), который индексируется до 4000 измерений. Индекс строится по приведению типа, и точно такое же приведение должно быть в запросе:

CREATE INDEX ON docs USING hnsw ((embedding::halfvec(3072)) halfvec_cosine_ops);

SELECT * FROM docs
ORDER BY embedding::halfvec(3072) <=> $1::halfvec(3072)
LIMIT 5;

Половинная точность заодно вдвое сокращает объём хранения и индекса, а качество поиска на текстовых эмбеддингах теряет считаные проценты.

Фильтры по метаданным и пустые результаты

Ради этого pgvector чаще всего и выбирают: фильтр — это обычный WHERE по вашим же колонкам.

SELECT id, url, chunk
FROM docs
WHERE lang = 'ru' AND updated_at > now() - interval '180 days'
ORDER BY embedding <=> $1
LIMIT 5;

Но здесь скрыта неприятность. ANN-индекс сначала достаёт свою порцию ближайших векторов, и только потом применяется фильтр. Если условие строгое, до фильтра доживёт меньше строк, чем просит LIMIT — и запрос молча вернёт два результата вместо пяти. Для этого в 0.8.0 и сделали итеративные сканы: индекс продолжает досматривать данные, пока не наберёт нужное количество подходящих строк.

SET LOCAL hnsw.iterative_scan = 'relaxed_order';
-- строгий порядок сортировки: 'strict_order'
-- для IVFFlat: SET LOCAL ivfflat.iterative_scan = 'relaxed_order';

Досмотр ограничен сверху: hnsw.max_scan_tuples (по умолчанию 20 000) и ivfflat.max_probes не дают запросу выродиться в полный перебор. Если же фильтр у вас постоянный и предсказуемый — дешевле построить частичный индекс с тем же условием в WHERE.

Когда места жалко: бинарная квантизация

Для действительно больших коллекций есть приём: индексировать не сами векторы, а их бинарные «отпечатки», а затем переранжировать небольшую выборку по точным значениям.

CREATE INDEX ON docs
  USING hnsw ((binary_quantize(embedding)::bit(1536)) bit_hamming_ops);

SELECT * FROM (
  SELECT * FROM docs
  ORDER BY binary_quantize(embedding)::bit(1536) <~> binary_quantize($1)
  LIMIT 100
) t
ORDER BY embedding <=> $1
LIMIT 5;

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

pgvector или отдельная векторная база

Практическая граница проходит примерно так. pgvector стоит брать, когда PostgreSQL уже в проекте, векторов — до нескольких миллионов, фильтрация по метаданным важна, а команда не хочет администрировать ещё один сервис: резервные копии, репликация, права доступа и мониторинг остаются теми же, что и для остальных данных.

Отдельный движок оправдан, когда векторов десятки и сотни миллионов, нужен шардинг и независимое масштабирование поиска, требуется встроенный гибридный поиск и квантизация «из коробки» или когда нагрузка на поиск начинает мешать основной OLTP-базе. Подробное сравнение вариантов — в обзоре векторных баз данных.

Отдельно держите в голове память: HNSW-индекс должен помещаться в RAM, иначе поиск упрётся в диск. Прикиньте размер заранее — это самая частая причина, по которой «всё работало на тесте и встало на проде».

Чек-лист перед продом

  • Зафиксировали модель эмбеддингов и размерность — миграция стоит полного пересчёта.
  • Выбрали индекс: HNSW по умолчанию, IVFFlat — если критично время сборки.
  • Строите индекс после массовой загрузки, с поднятыми maintenance_work_mem и параллельными воркерами.
  • Подобрали hnsw.ef_search / ivfflat.probes по замеру полноты на своём наборе запросов, а не по умолчанию.
  • Проверили через EXPLAIN (ANALYZE, BUFFERS), что индекс реально используется.
  • Включили итеративные сканы там, где есть строгие фильтры.
  • Оценили размер индекса относительно доступной оперативной памяти.

В сумме pgvector закрывает потребности большинства проектов, где семантический поиск — важная, но не единственная часть системы. Начать можно с одной команды CREATE EXTENSION, а переезжать на отдельный движок — только когда цифры действительно этого потребуют.

Частые вопросы

Q.Нужна ли отдельная векторная база, если уже есть PostgreSQL?

В большинстве случаев нет. До нескольких миллионов векторов pgvector справляется, а вы получаете фильтрацию обычным WHERE, JOIN с бизнес-таблицами, транзакции и общий бэкап. Отдельный движок имеет смысл при десятках миллионов векторов, потребности в шардинге или когда поиск начинает мешать основной OLTP-нагрузке.

Q.Почему запрос идёт мимо индекса и делает полный перебор?

Индекс подхватывается, только если в запросе есть ORDER BY по оператору расстояния (именно по оператору, а не по выражению вокруг него) и LIMIT, а сортировка идёт по возрастанию. Проверьте план через EXPLAIN (ANALYZE, BUFFERS). Если индекс строился по приведению типа — например, к halfvec, — точно такое же приведение должно быть и в запросе.

Q.Что делать с эмбеддингами на 3072 измерения?

Тип vector хранит до 16 000 измерений, но индексируется только до 2000. Используйте halfvec: он индексируется до 4000 измерений, вдвое экономит место и почти не теряет в качестве поиска. Индекс создаётся по выражению embedding::halfvec(3072), и то же приведение повторяется в запросе.

Q.Почему поиск с фильтром возвращает меньше результатов, чем просит LIMIT?

ANN-индекс сначала достаёт свою порцию ближайших векторов, и только потом применяется WHERE. При строгом фильтре до него доживает меньше строк, чем нужно. Решение — итеративные сканы: SET LOCAL hnsw.iterative_scan = 'relaxed_order' (или strict_order). Для постоянных условий выгоднее частичный индекс.

Источники

Предыдущая
Агенты в Claude Code: как работают субагенты

Читайте также

Тёмный рабочий стол разработчика: на мониторе четыре параллельных окна терминала с разными логами, рядом клавиатура, кабели и сетевой коммутатор с индикаторамиЗаметки
21 сентября 2026 г.

Агенты в Claude Code: как работают субагенты

Субагент уносит грязную работу — поиск по репозиторию, разбор логов, обход десятков файлов — в отдельное контекстное окно и возвращает только результат. Разбираем, где лежат файлы агентов, какие поля есть в YAML-заголовке, как основной диалог выбирает исполнителя и на чём здесь чаще всего спотыкаются.

Читать →
Рабочий стол в полумраке: монитор с черновиком статьи в текстовом редакторе, рядом распечатка с правками красной ручкой и блокнот с планомЗаметки
18 сентября 2026 г.

Промпт для текста: как получить от нейросети статью, а не воду

Короткий запрос «напиши статью про X» всегда даёт одно и то же — общие слова и списки без цифр. Разбираем, почему так происходит, и собираем каркас промпта из шести блоков, который даёт текст, готовый к вычитке.

Читать →
Тёмный рабочий стол ночью: крупный монитор с плотным мелким текстом, одна строка выделена оранжевым маркером и отмечена красным флажком на полях; рядом в расфокусе второй экран с документацией и зелёным терминалом, клавиатура, стикеры и кружкаЗаметки
17 сентября 2026 г.

Галлюцинации нейросети: почему модель выдумывает и как проверять факты

Модель выдаёт несуществующую ссылку тем же уверенным тоном, что и верный ответ. Разбираем, почему это заложено в самом устройстве моделей и в том, как их оценивают, и собираем рабочий процесс проверки фактов.

Читать →