Как реализовать векторный поиск в Postgres
Внедрите векторный поиск в Postgres с pgvector: добавьте embeddings, ищите по косинусной дистанции, индексируйте HNSW или IVFFlat и сочетайте с полнотекстовым поиском.
pgvector добавляет в Postgres тип столбца vector, а запрос на поиск по схожести — это ORDER BY по оператору расстояния с последующим LIMIT.
Обычно эта задача возникает после того, как поиск по базе знаний ничего не находит по запросу «отменить мою подписку», потому что статья называется «Прекращение действия вашего тарифа», и кто-то предлагает развернуть управляемую векторную базу данных рядом с уже работающим Postgres. Второй сервис нужен крайне редко. Всё остальное в этой статье — полный путь на SQL: включение расширения, подбор размерности столбца под вашу модель эмбеддингов, хранение векторов, запросы по косинусному расстоянию, индексирование с помощью HNSW или IVFFlat, соединение результатов векторного поиска с обычными таблицами, а также те запросы, для которых векторный поиск — неподходящий инструмент и где его должен заменить полнотекстовый поиск Postgres.
Ключевые выводы
- Число внутри
vector(n)должно совпадать с длиной векторов, которые выдаёт ваша модель эмбеддингов, а векторы разных моделей нельзя осмысленно сравнивать между собой. - Оператор
<=>возвращает косинусное расстояние, поэтому сортировка по возрастанию ставит ближайшее совпадение первым; вычитайте результат из 1, когда нужна косинусная схожесть. - И HNSW, и IVFFlat — приближённые индексы; точный поиск вы получаете тогда, когда на столбце нет векторного индекса.
- Планировщик рассматривает векторный индекс только в том случае, если в запросе есть
ORDER BYнепосредственно по оператору расстояния, по возрастанию и сLIMIT. - Векторный поиск находит строки с тем же смыслом; полнотекстовый поиск находит строки, содержащие те же слова, и в production-поиске обычно выполняются оба, а их ранжированные списки объединяются.
Для чего используется векторный поиск?
Векторный поиск сопоставляет по смыслу, поэтому запрос «отменить мою подписку» может вернуть документ с названием «Прекращение действия вашего тарифа», хотя у них нет общих слов. Каждый фрагмент текста преобразуется моделью эмбеддингов в список чисел фиксированной длины, и тексты со схожим смыслом оказываются рядом в этом пространстве. Поиск сводится к нахождению сохранённых векторов, ближайших к вектору запроса. Подробнее о том, как работают эмбеддинги, см. Vector Databases Explained.
Это даёт отдачу в трёх областях: поиск по сайту, устойчивый к перефразированию, сопоставление обращений в поддержку (найти предыдущие тикеты, похожие на текущий) и извлечение данных для RAG, где ближайшие документы передаются языковой модели как контекст — об этом рассказывается во введении в RAG для веб-приложений.
Включение pgvector и добавление векторного столбца
pgvector включается один раз для каждой базы данных командой CREATE EXTENSION vector, а тип столбца — vector(n), где n — число измерений. Версия 0.8.6 поддерживает Postgres 13 и новее, однако поддержка Postgres 13 сообществом уже прекращена, поэтому практическим минимумом является версия 14 или выше.
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE documents (
id bigserial PRIMARY KEY,
title text NOT NULL,
body text NOT NULL,
embedding vector(1536) -- replace 1536 with your embedding model's output size
);
-- or, on a table you already have:
ALTER TABLE documents ADD COLUMN embedding vector(1536);
Число внутри vector(n) должно совпадать с длиной векторов, которые выдаёт ваша модель эмбеддингов. Например, text-embedding-3-small от OpenAI по умолчанию возвращает 1536-мерные векторы. Векторы разных моделей нельзя осмысленно сравнивать, поэтому смена модели означает повторное вычисление эмбеддингов для всего столбца.
Хранение эмбеддингов из любой модели
Эмбеддинги записываются так же, как значение любого другого столбца: вектор генерируется в коде приложения, а затем передаётся как bind-параметр с приведением к vector. pgvector не вызывает модель за вас, и подойдёт любой язык, для которого есть драйвер Postgres.
INSERT INTO documents (title, body, embedding)
VALUES ($1, $2, $3::vector);
-- re-embed an existing row without creating a duplicate
INSERT INTO documents (id, title, body, embedding)
VALUES ($1, $2, $3, $4::vector)
ON CONFLICT (id) DO UPDATE SET embedding = EXCLUDED.embedding;
Для первоначального заполнения pgvector рекомендует массовую загрузку через COPY ... FROM STDIN WITH (FORMAT BINARY) и построение индексов после загрузки данных, а не до неё.
Какой оператор расстояния pgvector выбрать?
Для текстовых эмбеддингов используйте <=>, если в документации вашей модели не указано иное. Он возвращает косинусное расстояние, поэтому сортировка по возрастанию ставит ближайшее совпадение первым; вычитайте результат из 1, когда для отображения нужна косинусная схожесть.
-- five closest documents; $1 is the query embedding
SELECT id, title, 1 - (embedding <=> $1::vector) AS similarity
FROM documents
ORDER BY embedding <=> $1::vector
LIMIT 5;
pgvector поддерживает в общей сложности шесть операторов расстояния, и каждому нужен соответствующий класс операторов индекса:
| Оператор | Что измеряет | Когда использовать | Класс операторов |
|---|---|---|---|
<=> | косинусное расстояние | текстовые эмбеддинги; обычный выбор по умолчанию | vector_cosine_ops |
<-> | L2-расстояние (евклидово) | когда величина вектора несёт смысл | vector_l2_ops |
<#> | отрицательное скалярное произведение | векторы уже нормализованы к длине 1 | vector_ip_ops |
<+> | L1-расстояние («манхэттенское») | редко для текста; только HNSW | vector_l1_ops |
<#> возвращает скалярное произведение с инвертированным знаком. Postgres сканирует индексы только по возрастанию, поэтому отрицательная форма оставляет наименьшее число ближайшим совпадением; умножьте на -1, чтобы получить обычное скалярное произведение. Если ваши векторы уже нормализованы к длине 1, скалярное произведение — самый быстрый вариант для точного поиска. <~> (Хэмминг) и <%> (Жаккар) существуют для бинарных векторов и в этой статье не рассматриваются.
HNSW или IVFFlat: какой индекс выбрать?
Без индекса pgvector сравнивает вектор запроса с каждой строкой и возвращает точных ближайших соседей. Добавление индекса HNSW или IVFFlat переключает это на приближённый поиск, который значительно быстрее, находит большинство истинных соседей и может вернуть строки, отличные от результата точного запроса. Оба типа индексов являются приближёнными. Точный поиск — это просто то, что происходит, когда на столбце вообще нет векторного индекса.
По умолчанию выбирайте HNSW, если время сборки или требования к памяти не заставляют перейти на IVFFlat. Класс операторов должен соответствовать оператору, который вы используете в запросах.
-- production: avoid blocking writes
CREATE INDEX CONCURRENTLY documents_embedding_hnsw
ON documents USING hnsw (embedding vector_cosine_ops);
-- IVFFlat alternative; create only after the table has data
CREATE INDEX ON documents USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);
| HNSW | IVFFlat | |
|---|---|---|
| Структура | многоуровневый граф | векторы распределены по спискам (bucket-ам) |
| Компромисс «скорость/полнота» | лучше | хуже |
| Время сборки и память | медленнее, больше | быстрее, меньше |
| Сборка на пустой таблице | да, без этапа обучения | нет; центроиды берутся из данных, имеющихся на момент сборки |
| Параметр запроса | hnsw.ef_search (по умолчанию 40) | ivfflat.probes (по умолчанию 1; начинайте с sqrt(lists)) |
У HNSW нет этапа обучения, поэтому индекс можно построить до того, как в таблицу попадёт хотя бы одна строка. У IVFFlat такой этап есть: его списки формируются из тех данных, которые присутствуют в момент построения индекса, поэтому сначала обеспечьте представительный набор строк. Повышение ef_search или probes через SET LOCAL внутри транзакции улучшает полноту для одного запроса за счёт скорости.
Планировщик рассмотрит векторный индекс только тогда, когда в запросе есть ORDER BY непосредственно по оператору расстояния, с сортировкой по возрастанию и вместе с LIMIT; ORDER BY 1 - (embedding <=> $1) DESC индекс не задействует. На небольшой таблице планировщик всё равно может предпочесть последовательное сканирование, поэтому проверяйте через EXPLAIN. Чтобы измерить, во сколько индекс обошёлся вам в плане полноты, принудительно выполните точный поиск и сравните два набора результатов:
BEGIN;
SET LOCAL enable_indexscan = off; -- exact search for comparison
SELECT id FROM documents ORDER BY embedding <=> $1::vector LIMIT 5;
COMMIT;
Нужна ли вам отдельная векторная база данных?
Если вы уже используете Postgres, отдельная векторная база данных вам, скорее всего, не нужна: pgvector даёт векторный поиск с теми же резервными копиями, теми же транзакциями и возможностью соединять результаты векторного поиска с обычными таблицами в одном запросе. Документ и его эмбеддинг записываются одним INSERT, поэтому нет конвейера синхронизации между двумя хранилищами и нет сценария отказа с «осиротевшими» векторами. Векторы проходят через журнал предзаписи (WAL) как любой другой столбец, поэтому репликам и восстановлению на момент времени (PITR) они достаются без дополнительных усилий.
Соединение таблиц — это то, где преимущество становится наглядным. Поиск ближайших тикетов поддержки с ограничением по корпоративным клиентам — это один оператор:
SELECT t.id, t.subject, c.plan, t.embedding <=> $1::vector AS distance
FROM tickets t
JOIN customers c ON c.id = t.customer_id
WHERE c.plan = 'enterprise'
ORDER BY t.embedding <=> $1::vector
LIMIT 10;
У приближённых индексов есть один подвох: сначала сканируется индекс, а затем условие WHERE применяется к тому, что он вернул, поэтому селективный фильтр может оставить вам меньше строк, чем запросил LIMIT. B-tree по столбцу фильтра часто даёт быстрые точные результаты; остальное решают итеративное сканирование и частичные индексы.
Специализированные векторные базы данных по-прежнему оправданы при миллиардах векторов или экстремальной интенсивности записи. Ниже этого порога дополнительный сервис — это операционные издержки без выгоды, заметной пользователю.
Где векторный поиск не справляется: гибридный поиск с полнотекстовым
Векторный поиск находит строки с тем же смыслом; полнотекстовый поиск находит строки, содержащие те же слова, и в production-поиске обычно выполняются оба, а два ранжированных списка объединяются. Точные идентификаторы, артикулы товаров, строки ошибок и имена людей не имеют для модели эмбеддингов полезного «смысла», и запрос SKU-4471 должен возвращать строку, содержащую именно этот токен, а не строки о похожих товарах. Такие запросы требуют полнотекстового поиска Postgres, который в документации pgvector объединяется с векторным поиском в гибридных запросах.
Начните с хранимого генерируемого столбца tsvector и GIN-индекса по нему:
ALTER TABLE documents
ADD COLUMN body_tsv tsvector
GENERATED ALWAYS AS (to_tsvector('english', title || ' ' || body)) STORED;
CREATE INDEX ON documents USING gin (body_tsv);
Затем объедините два ранжирования с помощью Reciprocal Rank Fusion, следуя форме собственного примера RRF из pgvector:
-- $1 = query embedding, $2 = raw query text
WITH semantic AS (
SELECT id, RANK() OVER (ORDER BY embedding <=> $1::vector) AS rnk
FROM documents
ORDER BY embedding <=> $1::vector
LIMIT 20
),
lexical AS (
SELECT id, RANK() OVER (ORDER BY ts_rank_cd(body_tsv, q) DESC) AS rnk
FROM documents, plainto_tsquery('english', $2) AS q
WHERE body_tsv @@ q
ORDER BY ts_rank_cd(body_tsv, q) DESC
LIMIT 20
)
SELECT COALESCE(s.id, l.id) AS id,
COALESCE(1.0 / (60 + s.rnk), 0.0) + COALESCE(1.0 / (60 + l.rnk), 0.0) AS score
FROM semantic s
FULL OUTER JOIN lexical l ON l.id = s.id
ORDER BY score DESC
LIMIT 10;
Каждый список вносит вклад 1 / (k + rank), поэтому документ, оказавшийся в верхней части любого из списков, получает высокий балл, а присутствующий в обоих — наивысший. Константа k = 60 взята из работы Cormack, Clarke и Büttcher, которые зафиксировали её в пилотном исследовании и отметили, что точное значение не критично. Рассматривайте этот запрос как иллюстративный и убедитесь с помощью EXPLAIN (ANALYZE, BUFFERS), что на ваших данных используются и HNSW-, и GIN-индексы.
С чего начать
Вся реализация — это столбец, оператор и индекс, причём всё внутри базы данных, которую вы уже резервируете и репликуете. Добавьте столбец vector(n) с размерностью под вашу модель, сначала напишите запрос ORDER BY ... <=> ... LIMIT без индекса, затем добавьте HNSW и проверьте разницу в полноте с помощью enable_indexscan = off. Когда семантические результаты станут выглядеть правильно, подключите полнотекстовую часть, потому что иначе первый же пользователь, вставивший номер заказа, обнаружит этот пробел.
Часто задаваемые вопросы
Может ли pgvector индексировать эмбеддинги с более чем 2000 измерений?
Только с помощью стандартного типа vector — нет. HNSW и IVFFlat индексируют векторные столбцы до 2000 измерений, хотя сам столбец хранит до 16 000. Для более крупных эмбеддингов приводите к halfvec в индексе по выражению (индексируется до 4000 измерений), используйте бинарное квантование с повторным ранжированием (до 64 000), индексируйте подвектор или запрашивайте у модели меньшее число выходных измерений — модели OpenAI text-embedding-3 поддерживают это через параметр dimensions.
Нужно ли перестраивать индекс pgvector после вставки новых строк?
Для HNSW — нет: новые строки добавляются в граф по мере вставки, именно поэтому индекс можно построить на пустой таблице. IVFFlat ведёт себя иначе. Центроиды его списков вычисляются методом k-means однократно при построении и больше не меняются, поэтому полнота может деградировать по мере роста таблицы или изменения распределения данных. Перестраивайте индексы IVFFlat после крупных загрузок или изменений распределения командой REINDEX INDEX CONCURRENTLY.
Почему после добавления индекса HNSW запрос возвращает меньше строк, чем LIMIT?
Потому что сканирование HNSW возвращает не более hnsw.ef_search кандидатов (по умолчанию 40), а любой фильтр WHERE применяется к этим кандидатам уже после. LIMIT больше 40, селективный фильтр или «мёртвые» кортежи — каждое из этого может привести к нехватке строк в результате. Повысьте hnsw.ef_search через SET LOCAL либо, в pgvector 0.8.0 и новее, включите итеративное сканирование индекса, установив hnsw.iterative_scan в strict_order, чтобы сканирование продолжалось до того, как наберётся достаточно подходящих строк.
Можно ли хранить эмбеддинги двух разных моделей в одной таблице?
Да, но не в одном общем индексируемом столбце. Используйте отдельный столбец vector(n) для каждой модели, с размерностью под выход соответствующей модели и с собственным индексом. pgvector также допускает нетипизированный столбец vector со смешанными размерностями, но индекс может охватывать только строки одной размерности и строится как индекс по выражению с приведением типа плюс частичное условие WHERE по идентификатору модели. Расстояния между векторами разных моделей не имеют смысла.
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.
Star on GitHub12k