pgvector или Qdrant: выбор для данных проекта определяется договором владения записями. Когда источники строк живут в PostgreSQL и нужны JOIN и одна транзакция, кандидат — расширение pgvector. Когда нужен самостоятельный поисковый сервис с отдельным жизненным циклом, кандидат — Qdrant. Порог по задержке на большом корпусе решается замером.
Требования проекта задают кандидата
Сравнение начинается с перечисления требований, которые проект предъявляет к хранению векторов. Универсального рейтинга здесь нет: два маршрута закрывают разные договоры данных, и победитель выводится из конкретного набора условий. Ниже — четыре требования, каждое из которых сразу отсекает один из маршрутов или оставляет оба.
Требование A: источники и владельцы строк уже живут в PostgreSQL, обязательны JOIN и одна транзакция. Здесь кандидат — pgvector. Расширение хранит векторы рядом с остальными данными и даёт доступ к обычным возможностям Postgres: соответствие ACID, восстановление на точку во времени, соединения таблиц. Это документированное свойство расширения, заявленное в его README.
Требование B: нужен самостоятельный поисковый сервис с отдельным жизненным циклом. Здесь кандидат — Qdrant. Документация проекта описывает запуск сервиса отдельным контейнером, собственные REST и gRPC интерфейсы, веб-панель и отдельное хранилище данных. Сервис живёт по своему графику обновлений и масштабирования.
Требование C: порог по задержке на большом корпусе обязателен. Здесь выбор делается после замера, и в этом разборе победителя нет. Ни общее число векторов, ни неподтверждённые скорости, ни объём оперативной памяти не дают основания назвать маршрут заранее. Учебный пример ниже намеренно мал и служит для проверки семантики фильтра.
Требование D: нужна общая авторизация арендаторов. Здесь оба маршрута требуют спроектированного доверенного контроля доступа. Условие tenant внутри запроса само по себе авторизацией не является: фильтр в SQL и payload-фильтр в Qdrant ограничивают выборку строк, а проверку прав пользователя выполняет отдельный слой приложения.
Заполненное решение по каждому требованию выглядит так.
| Требование | Кандидат | Что проверить до внедрения |
|---|---|---|
| A: строки в PostgreSQL, JOIN, одна транзакция | pgvector | Совместимость версии Postgres, план запроса, наличие индекса по колонке фильтра |
| B: отдельный поисковый сервис | Qdrant | Схему коллекции, размерность, метрику, индексы payload |
| C: порог задержки на большом корпусе | решается замером | Свой корпус, свои фильтры, свой профиль нагрузки |
| D: общая авторизация арендаторов | оба маршрута | Доверенный identity и access control вне фильтра |
Такой разбор отвечает на вопрос «что выбрать» через договор данных. Читатель получает четыре условия и четыре решения, каждое из которых можно проверить на своём проекте.
Дальше нужен полный набор входов, на котором оба маршрута дают один и тот же ожидаемый ответ. Без него сравнение остаётся на уровне описаний.
Полный набор входов и ручные эталоны
Учебный набор состоит из трёх записей и одного запроса. Идентификаторы записей — числовые 1, 2 и 3; метки id1, id2, id3 ниже служат только для чтения и соответствуют этим числам. В PostgreSQL идентификатор имеет целочисленный тип, в Qdrant точки получают числа 1, 2 и 3.
Полные записи:
- id1: tenant=A, active=true, vector=[1,0]
- id2: tenant=B, active=true, vector=[1,0]
- id3: tenant=A, active=false, vector=[0,1]
Запрос: tenant=A И active=true, ближайший к вектору [1,0]. Ручной эталон для этого запроса — только id1. При tenant=B И active=true ручной эталон — только id2. При tenant=C ручной эталон — пустой результат.
Отдельно фиксируется проверка без фильтра с лимитом 2. Векторы id1 и id2 равны [1,0] и равноудалены от запроса, поэтому результат может вернуть id1 и id2 в любом порядке. При лимите 1 требовать возврата обоих идентификаторов нельзя: запрос просит одну ближайшую запись.
Ручной эталон — это ожидание, записанное до запуска. Он не является результатом выполнения и не подтверждает поведение продукта. Его роль — дать точку сверки: если маршрут вернул не тот идентификатор, дальше разбирается причина расхождения.
Полный набор входов для PostgreSQL. Схема объявляет колонку с размерностью 2, затем идут три вставки. Это учебный точный пример, обязательный ANN-индекс в нём не нужен.
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE items (
id bigserial PRIMARY KEY,
tenant text NOT NULL,
active boolean NOT NULL,
embedding vector(2)
);
INSERT INTO items (tenant, active, embedding) VALUES
('A', true, '[1,0]'),
('B', true, '[1,0]'),
('A', false, '[0,1]');
Запрос с фильтром и лимитом 1:
SELECT id
FROM items
WHERE tenant = 'A' AND active = true
ORDER BY embedding <=> '[1,0]'::vector
LIMIT 1;
Оператор <=> — косинусная дистанция, документированная в README расширения вместе с L2, внутренним произведением, L1, Хэмминговым и Жаккаровым расстояниями. Для воспроизводимости важны все три вставки: без id2 проверка tenant=B не имеет входа, без id3 не проверяется условие active=false.
Полный набор входов для Qdrant. Коллекция имеет размерность 2 и метрику Cosine, векторы и метаданные совпадают с таблицей выше.
from qdrant_client import QdrantClient, models
client = QdrantClient(url="http://localhost:6333")
client.create_collection(
collection_name="items",
vectors_config=models.VectorParams(size=2, distance=models.Distance.COSINE),
)
client.upsert(
collection_name="items",
wait=True,
points=[
models.PointStruct(id=1, vector=[1.0, 0.0], payload={"tenant": "A", "active": True}),
models.PointStruct(id=2, vector=[1.0, 0.0], payload={"tenant": "B", "active": True}),
models.PointStruct(id=3, vector=[0.0, 1.0], payload={"tenant": "A", "active": False}),
],
)
Соответствующий payload-фильтр: must=[tenant==A, active==true]. Верный результат обоих маршрутов — id1. Идентификаторы сравниваются по содержанию, порядок тай-брейка в сверке не участвует.
Набор собран, эталоны записаны. Следующий шаг — разделить то, что уже документировано, и то, что ещё предстоит измерить.
Документированные возможности и неизмеренные свойства
Документированные возможности подтверждаются первичными страницами проектов. Неизмеренные свойства требуют собственного замера и в готовый вывод не переносятся.
По pgvector README расширения фиксирует следующее. Поддерживаются точный и приближённый поиск ближайших соседей. Доступны типы single-precision, half-precision, binary и sparse векторов. Расширение работает с любым языком, для которого есть клиент Postgres. Индексы HNSW и IVFFlat создаются для каждой используемой функции расстояния. Тип vector индексируется до 2000 измерений, halfvec — до 4000, bit — до 64000, sparsevec — до 1000 ненулевых элементов. Хранение векторов допускает до 16000 измерений, и это отдельное число от предела индексирования.
По умолчанию pgvector выполняет точный поиск ближайших соседей и даёт полную полноту. Приближённый индекс меняет этот режим: после его добавления запросы начинают возвращать другие результаты. Параметры HNSW — m со значением 16 по умолчанию и ef_construction со значением 64 по умолчанию; параметр поиска hnsw.ef_search по умолчанию равен 40. Для IVFFlat число списков подбирается от объёма таблицы, а число проб по умолчанию равно 1.
Фильтрация в pgvector устроена так: при приближённом индексе условие применяется после сканирования индекса. Если условие отбирает 10% строк, при HNSW и значении hnsw.ef_search 40 в среднем совпадут 4 строки. Для большего числа строк README предлагает итеративные сканирования индекса, включаемые параметром hnsw.iterative_scan. Мультиарендность описана отдельно: общий приближённый индекс между арендаторами означает, что векторы одного арендатора влияют на полноту и скорость для других. Для изоляции арендаторов README предлагает секционирование по списку или отдельные таблицы.
По Qdrant документация quickstart фиксирует следующее. Сервис запускается контейнером, данные по умолчанию лежат в каталоге ./qdrant_storage. Доступны REST API на порту 6333, веб-панель на 6333/dashboard и gRPC API на порту 6334. Клиентские библиотеки существуют для Python, JavaScript, Rust, Java, C#, Go и других языков. Коллекция создаётся с указанием размерности и метрики. В документации отмечено, что по умолчанию Qdrant стартует без шифрования и аутентификации, и любой, у кого есть сетевой доступ к машине, получает доступ к контейнеру. Qdrant может работать встроенно внутри приложения; Qdrant Edge — облегчённый поисковый движок для Python и Rust, работающий в процессе.
По Qdrant filtering документация фиксирует следующее. Условия задаются на payload и на id точки. Для производительной фильтрации создаются индексы payload, и документация рекомендует создавать их до загрузки данных. Клаузы must, should, must_not соответствуют логическим AND, OR и NOT, клаузы вкладываются друг в друга. Условие match применяется к keyword, integer, bool и UUID payload. Условие range работает с float и integer, отдельный datetime range поддерживает формат RFC 3339. Геоусловия охватывают прямоугольник, радиус и полигон. Вложенные ключи используют точечную нотацию и [] для элементов массива. Вложенный фильтр проверяет каждый элемент массива отдельно, и для ускорения нужны поэлементные индексы.
Неизмеренные свойства перечисляются отдельно, чтобы их не смешивать с документацией. К ним относятся: фактическая задержка на вашем корпусе, потребление памяти под индексы, полнота приближённого поиска при вашем профиле фильтрации, поведение при вашем объёме данных и вашем соотношении tenant. Эти величины определяются замером на вашем наборе.
| Свойство | Статус | Источник |
|---|---|---|
| Типы векторов и функции расстояния pgvector | документировано | README расширения |
| Пределы измерений по типам pgvector | документировано | README расширения |
| Применение фильтра после сканирования индекса | документировано | README расширения |
| Изоляция арендаторов через секционирование | документировано | README расширения |
| Порты и отсутствие аутентификации по умолчанию в Qdrant | документировано | quickstart проекта |
| Клаузы фильтрации и типы условий Qdrant | документировано | раздел filtering |
| Задержка на вашем корпусе | требует замера | ваш стенд |
| Полнота ANN при вашей фильтрации | требует замера | ваш стенд |
Разделение важно для честного вывода. Документированное свойство можно планировать. Неизмеренную величину можно только измерить.
Отдельно стоит сказать о чужих числах. Бенчмарки, тарифы и значения по умолчанию из сторонних обзоров в этот разбор не переносятся: они сняты на другом корпусе, другом железе и другом профиле запросов. Перенос такого числа в решение о своём проекте даёт ложную опору.
Проверка отрицательного случая и доступности API
Отрицательный случай проверяется на том же наборе. Запрос tenant=C И active=true обязан вернуть пустой результат в обоих маршрутах. Пустой ответ — это ожидаемый исход, и он подтверждает, что фильтр действительно ограничивает выборку.
Порядок проверки для PostgreSQL:
- Выполнить запрос с tenant=A И active=true и сверить результат с эталоном id1.
- Выполнить запрос с tenant=B И active=true и сверить результат с эталоном id2.
- Выполнить запрос с tenant=C И active=true и убедиться, что строк нет.
- Выполнить запрос без фильтра с лимитом 2 и убедиться, что вернулись id1 и id2 в любом порядке.
- Выполнить запрос без фильтра с лимитом 1 и убедиться, что вернулась одна запись.
Порядок проверки для Qdrant:
- Выполнить поиск с фильтром
must=[tenant==A, active==true]и сверить результат с эталоном id1. - Выполнить поиск с фильтром
must=[tenant==B, active==true]и сверить результат с эталоном id2. - Выполнить поиск с фильтром
must=[tenant==C, active==true]и убедиться, что точек нет. - Выполнить поиск без фильтра с лимитом 2 и убедиться, что вернулись id1 и id2 в любом порядке.
- Выполнить поиск без фильтра с лимитом 1 и убедиться, что вернулась одна точка.
Проверка доступности требуемого API идёт до этих шагов. Для pgvector нужно убедиться, что расширение установлено в целевой базе и что команда CREATE EXTENSION vector выполняется без ошибки. README перечисляет способы установки: сборка из исходников, Docker, Homebrew, PGXN, APT, Yum, pkg, APK, conda-forge, а также предустановка в Postgres.app и у ряда хостинг-провайдеров. Для Qdrant нужно убедиться, что сервис отвечает на выбранном интерфейсе: REST на 6333, gRPC на 6334. Клиент инициализируется адресом сервиса.
Актуальность продуктового пути проверяется отдельно. Для pgvector README описывает обновление расширения командой ALTER EXTENSION vector UPDATE и проверку установленной версии запросом к pg_extension. Для Qdrant документация ведёт от локального запуска к облачному развёртыванию и отдельно упоминает production checklist для проверки безопасности и оптимизации перед выкладкой.
Частая ошибка на этом шаге — считать, что фильтр в запросе закрывает доступ между арендаторами. Условие tenant ограничивает набор строк, которые попадут в результат. Права пользователя на эти строки проверяет отдельный слой. В обоих маршрутах проектируется доверенный identity и access control, и фильтр его дополняет.
Вторая частая ошибка — сверять порядок тай-брейка. Две записи с одинаковым вектором равноудалены от запроса, и требовать конкретного порядка между ними нельзя. Сверка идёт по содержанию результата.
Третья ошибка — проверять только положительный случай. Запрос с tenant=C проверяет, что фильтр действительно отсекает всё лишнее. Без этой проверки ошибка в условии остаётся незамеченной.
Ограничения учебного примера и ручного эталона
Учебный пример мал намеренно. Три записи и один запрос проверяют семантику фильтра: как условие tenant и active сочетается с поиском ближайшего соседа. Этот набор не измеряет скорость, не показывает поведение приближённого индекса на большом корпусе и не даёт основания для вывода о победителе по объёму.
Ручной эталон — это ожидание, записанное до запуска. Он отвечает на вопрос «какой идентификатор должен вернуться». Он не отвечает на вопрос «сколько времени это заняло» и «какую полноту дал приближённый индекс». Смешивать эти роли нельзя: эталон проверяет правильность, замер проверяет производительность.
Полнота приближённого поиска при фильтрации оценивается отдельным замером. README pgvector описывает механику: при приближённом индексе условие применяется после сканирования, и при узком условии число совпавших строк ограничено размером динамического списка кандидатов. Итеративные сканирования расширяют область поиска, и их поведение настраивается параметрами hnsw.max_scan_tuples и hnsw.scan_mem_multiplier. Как эти параметры поведут себя на вашем корпусе, показывает замер.
Разные настройки планировщика запросов и приближённого индекса не подменяются предсказанным сбоем. Если запрос вернул не тот идентификатор, причины разбираются по порядку: проверяется условие фильтра, проверяется наличие индекса по колонке фильтра, проверяется план запроса. README pgvector отдельно описывает случай, когда запрос не использует индекс: для этого нужны ORDER BY и LIMIT, и ORDER BY должен быть результатом оператора расстояния в возрастающем порядке. Выражение вместо оператора индекс не задействует.
Для Qdrant аналогичный разбор начинается с индексов payload. Документация рекомендует создавать их до загрузки данных и отмечает, что без индекса условие всё равно работает, но проверяется по каждой точке. Для вложенных фильтров нужны поэлементные индексы: обычный индекс по полю массива только сужает набор кандидатов, после чего Qdrant читает payload каждой оставшейся точки.
Ограничение по авторизации остаётся в силе для обоих маршрутов. Условие tenant в SQL и payload-фильтр в Qdrant ограничивают выборку. Проверку прав выполняет доверенный слой identity и access control, спроектированный отдельно.
Ограничение по чужим числам тоже остаётся. Пороги по миллионам векторов, объёму памяти и измерения из сторонних обзоров в решение не переносятся. Свой порог определяется своим замером на своём корпусе.
Проверка решения перед переносом данных
Зафиксируйте выбор в карточке проекта. Для варианта с PostgreSQL укажите, какие исходные строки должны изменяться вместе с векторами и какие запросы используют связи с другими таблицами. Для отдельного поискового сервиса укажите владельца загрузки и момент, когда обновлённая запись становится доступна поиску. Это требования приложения. В учебном примере состояния заданы заранее, поэтому он проверяет только смысл фильтра на неизменном наборе.
Перед первым запросом сверьте числовой ID, tenant, active и вектор. Для отдельной проверки active выполните дополнительный запрос tenant=A И active=true с лимитом 2: в SQL замените LIMIT 1 на LIMIT 2, в Qdrant задайте limit=2. Ручной эталон — только ID 1. Затем в этом дополнительном запросе удалите только active, сохранив tenant=A и лимит 2: ручной эталон — ID 1 и ID 3. Так пропущенный active становится видимым. В исходном запросе с лимитом 1 запись 3 дальше от запроса, поэтому её отсутствие не проверяет это условие. Эти ответы заданы как ручные эталоны; запуски продуктов здесь не выполнялись.
В журнале запишите оба условия и множество ожидаемых ID до запуска. После запуска сохраните фактически возвращённые ID. Для этого набора при tenant A и active true ожидается только 1, при tenant B — только 2, при tenant C — пустое множество. Различие типов ID между клиентами нельзя скрывать приведением всех значений к произвольному виду: здесь заранее выбран числовой договор, и именно его сохраняют при загрузке.
Результат маленького примера подтверждает согласованность фильтра с заданным вводом. Решение о задержке и поведении индекса на большом корпусе потребует собственного измерения. До такого измерения карточка содержит незаполненное требование по скорости. Это позволяет принять архитектурное решение по известным условиям и сохранить видимой часть работы, для которой данных пока нет.
Частые вопросы
pgvector vs qdrant — что выбрать для проекта на PostgreSQL?
Когда источники строк уже живут в PostgreSQL и нужны JOIN и одна транзакция, кандидат — расширение pgvector: векторы хранятся рядом с остальными данными, а расширение даёт доступ к обычным возможностям Postgres, включая соответствие ACID и восстановление на точку во времени. Когда нужен самостоятельный поисковый сервис с отдельным жизненным циклом, кандидат — Qdrant. Решение выводится из договора владения записями.
Как проверить, что фильтр работает одинаково в обоих маршрутах?
Соберите полный набор входов: три записи с полями tenant, active и вектором, затем запрос с условием tenant=A И active=true. Ручной эталон для этого запроса — одна конкретная запись. Дополнительно проверьте tenant=B, tenant=C с пустым результатом и запрос без фильтра с лимитом 2, где две равноудалённые записи возвращаются в любом порядке.
Почему нельзя требовать конкретный порядок при равных векторах?
Две записи с одинаковым вектором равноудалены от запроса, и расстояние у них совпадает. Порядок между ними определяется внутренним правилом тай-брейка, и он не является частью ожидаемого результата. Сверка сопоставляет множества вернувшихся идентификаторов.
Заменяет ли условие tenant в запросе авторизацию арендатора?
Нет. Условие tenant в SQL и payload-фильтр в Qdrant ограничивают набор строк, которые попадут в результат поиска. Права пользователя на эти строки проверяет отдельный слой приложения. В обоих маршрутах проектируется доверенный identity и access control, и фильтр его дополняет.
Что нужно измерить перед выбором по скорости?
Замер делается на своём корпусе, своём профиле фильтрации и своём железе. Общее число векторов, чужие бенчмарки и неподтверждённые значения памяти основанием для выбора не служат. Полнота приближённого поиска при фильтрации оценивается отдельным замером, поскольку условие применяется после сканирования индекса.
