Векторные базы: pgvector, Qdrant, Milvus, Weaviate, HNSW и метрики близости
Все базы данных, о которых шла речь раньше в этом треке, отвечают на вопрос «дай мне записи, точно удовлетворяющие предикату». WHERE status = 'active', WHERE created_at > '2026-01-01', полнотекстовый поиск по совпадению лексем. Предикат либо истинен, либо ложен.
Векторный поиск отвечает на принципиально другой вопрос: «дай мне записи, наиболее похожие на вот эту». Не «содержащие слово отмена подписки», а «про то же самое, чем бы это ни было выражено» — включая «как мне перестать платить», «хочу закрыть аккаунт» и «unsubscribe pls». Похожесть здесь не булева и не задана вручную: она вычисляется как геометрическая близость точек в пространстве из сотен или тысяч измерений, куда текст, картинки или аудио отобразила нейросеть.
Отсюда сразу две неприятные новости, определяющие всю статью. Первая: точный ответ стоит линейного прохода по всем данным — индекс в духе B-tree принципиально не работает, потому что «ближайший» не определён порядком на одной оси. Вторая: в высоких размерностях геометрическая интуиция ломается, и все структуры, работающие в двух-трёх измерениях (kd-деревья, R-деревья), деградируют до полного перебора. Промышленный векторный поиск — это набор способов честно обменять точность на скорость, и главный практический навык здесь — уметь измерять, сколько точности вы отдали.
Эмбеддинги: откуда берутся векторы
Эмбеддинг — это отображение объекта в точку R^d, обученное так, чтобы семантически близкие объекты оказывались рядом. Модель text-embedding-3-small даёт 1536 измерений, text-embedding-3-large — 3072, открытые модели вроде bge-m3, e5-large, gte — обычно 384–1024. Конкретные числа в векторе не значат ничего по отдельности: смысл несёт только взаимное расположение.
Три следствия, которые люди осознают слишком поздно:
- Вектор бесполезен без модели, которая его породила. Смена модели эмбеддингов — это полная перестройка всего корпуса. Вектор от
bge-m3и вектор от OpenAI несравнимы, даже если случайно совпала размерность. Планируйте миграцию модели с первого дня: храните рядом с вектором идентификатор модели и её версию. - Размерность — это деньги и латентность. Стоимость расстояния линейна по
d, объём памяти — тоже. 3072 измерения вместо 768 — это ровно вчетверо больше RAM и вчетверо больше CPU на каждое сравнение при выигрыше в качестве часто в единицы процентов. - Матрёшечные эмбеддинги (Matryoshka Representation Learning, arXiv:2205.13147) позволяют обрезать вектор. Модели OpenAI
text-embedding-3-*и многие открытые обучены так, что первые 256 или 512 координат сами по себе — осмысленный эмбеддинг. Обрезка 1536 → 512 обычно стоит 1–3 п.п. качества и экономит втрое память. Это лучший «бесплатный» рычаг оптимизации, и почти никто им не пользуется. Важно: после обрезки вектор нужно заново нормировать.
Метрики близости: их три, а по сути одна
близости")) Евклидово L2 корень из суммы квадратов разностей чувствительно к длине вектора pgvector оператор стрелка-минус-стрелка Косинусная угол между векторами игнорирует длину дефолт для текстовых эмбеддингов Скалярное произведение максимальное внутреннее произведение MIPS быстрее всего вычисляется корректно только для нормированных Прочие Манхэттенская L1 Хэмминга для бинарных кодов Жаккара для множеств
Ключевой факт, экономящий много сил: для нормированных векторов (длина = 1) все три главные метрики дают один и тот же порядок соседей. Если ‖a‖ = ‖b‖ = 1, то
cos(a, b) = a·b (косинус = скалярное произведение)
L2(a, b)² = 2 − 2·(a·b) (евклидово расстояние — монотонно убывающая функция от скалярного произведения)
Значит, отсортировать по возрастанию L2 — то же самое, что отсортировать по убыванию косинуса. Практический вывод: нормируйте векторы при записи и используйте скалярное произведение. Оно вычисляется быстрее всех (нет вычитания и корня), любой индекс его поддерживает, и вы избавляетесь от целого класса ошибок вида «построил индекс по L2, а ищу по косинусу».
Когда это правило не работает:
- Ненормированные векторы и MIPS по-настоящему. В рекомендательных системах длина вектора товара иногда кодирует популярность, и её намеренно не убирают. Тогда скалярное произведение и косинус дают разные ответы, и это осознанный выбор. Заметьте: MIPS плохо ложится на графовые индексы — у скалярного произведения нет неравенства треугольника, поэтому HNSW на нём работает, но с худшим recall; классическое решение — преобразование в L2-задачу с дополнительным измерением.
- Разреженные векторы. BM25-подобные представления и SPLADE — это тысячи измерений с десятком ненулей. Для них нужен отдельный тип (
sparsevecв pgvector, sparse-векторы в Qdrant и Milvus) и инвертированный индекс, а не HNSW.
Проклятие размерности: почему всё сложно
В двумерном пространстве «ближайший сосед» — понятие с ясной геометрией. В 1536-мерном — почти нет. Классическая работа Бейера и соавторов «When Is Nearest Neighbor Meaningful?» (1999) показывает: при широком классе распределений с ростом d отношение расстояния до самого дальнего соседа к расстоянию до самого ближнего стремится к единице. Все точки становятся примерно одинаково далеки от запроса.
Спасает векторный поиск то, что реальные эмбеддинги не заполняют пространство равномерно. Они лежат на многообразии существенно меньшей внутренней размерности (обычно 10–50 для текста), скомканном внутри R^1536. Именно поэтому приближённые алгоритмы работают: они эксплуатируют кластерную структуру данных, а не свойства всего пространства.
Отсюда вытекает главная методологическая опасность: синтетические бенчмарки на случайных векторах врут катастрофически. На равномерном шуме в 1536 измерениях HNSW покажет recall 0.3, на реальных эмбеддингах на тех же параметрах — 0.98. Всегда меряйте на своих данных.
Точный поиск: когда его достаточно
Прежде чем строить индекс, посчитайте, нужен ли он. Полный перебор с SIMD на современном ядре даёт примерно 1–2 ГБ/с обработки векторных данных.
| Векторов | Размерность | Объём | Латентность полного перебора (1 ядро) | Вердикт |
|---|---|---|---|---|
| 10 тыс. | 1536 | 61 МБ | ~30–60 мс | индекс не нужен |
| 100 тыс. | 1536 | 614 МБ | ~0.4–0.8 с | нужен, либо параллелизм |
| 100 тыс. | 384 | 154 МБ | ~0.1–0.2 с | на грани |
| 1 млн | 1536 | 6.1 ГБ | ~4–8 с | обязателен |
| 10 млн | 1536 | 61 ГБ | десятки секунд | обязателен + квантование |
Сложность точного поиска — O(N·d) по времени и O(N·d) по памяти, без структурной надстройки. И у него есть свойство, которого нет ни у одного ANN-индекса: recall ровно 1.0, всегда, при любых фильтрах. Если у вас 50 тысяч документов в базе знаний — не стройте HNSW. Возьмите SELECT ... ORDER BY embedding <=> $1 LIMIT 10 без индекса или индекс типа flat, и вы сэкономите себе весь этот раздел с его параметрами и деградацией.
Таксономия ANN-индексов
пространства"| IVF["IVF: k-means кластеризация
на nlist ячеек Вороного"] IVF --> IVF2["Ищем nprobe ближайших центроидов,
перебираем только их списки"] IVF2 --> IVFR["Дёшево строится, дёшево обновляется.
Recall падает у границ ячеек"] T -->|"Граф
близости"| G["HNSW / Vamana / NSG:
жадный обход графа соседства"] G --> G2["Лучший recall при равной латентности.
Дорогая сборка, весь граф в RAM"] T -->|"Хеширование"| L["LSH: близкие векторы
с большой вероятностью в одном бакете"] L --> L2["Теоретические гарантии,
на практике проигрывает графам"] T -->|"Сжатие"| PQ["PQ / SQ / бинарное:
уменьшаем размер вектора"] PQ --> PQ2["Ортогонально остальному:
комбинируется с IVF и HNSW"] T -->|"Диск"| D["DiskANN / Vamana:
граф на SSD, кэш в RAM"] D --> D2["Миллиарды векторов на одной машине.
Латентность десятки мс"] IVFR --> C["Выбор = точка на кривой
recall / QPS / память / стоимость сборки"] G2 --> C L2 --> C PQ2 --> C D2 --> C
Практически в 2026 году живых вариантов три: HNSW (дефолт для «всё в памяти»), IVF + PQ (когда память дороже recall), DiskANN (когда данных больше, чем RAM, а денег на RAM нет). LSH остался в учебниках и в задачах дедупликации.
HNSW изнутри
HNSW (Hierarchical Navigable Small World, Malkov & Yashunin, arXiv:1603.09320) — это идея skip-list, перенесённая на графы. Строится многослойная структура: нижний слой содержит все точки и их ближайших соседей, каждый следующий слой — экспоненциально меньшую случайную выборку с более длинными рёбрами. Поиск начинается на верхнем слое в одной точке входа и жадно спускается вниз: длинные рёбра быстро переносят нас в правильную область пространства, короткие уточняют результат.
Алгоритм поиска — псевдокод:
функция ПОИСК(q, k, ef):
ep ← точка входа # одна фиксированная вершина верхнего слоя
для l от L_max вниз до 1:
ep ← ЖАДНЫЙ_СПУСК(q, ep, слой=l, ef=1) # на верхних слоях ширина луча = 1
W ← ЖАДНЫЙ_СПУСК(q, ep, слой=0, ef=ef) # на слое 0 — луч ширины ef
вернуть k ближайших из W
функция ЖАДНЫЙ_СПУСК(q, ep, слой, ef):
кандидаты ← мин-куча {ep}, найденные ← макс-куча {ep}, посещённые ← {ep}
пока кандидаты не пусты:
c ← ближайший из кандидатов
если dist(c,q) > худший из найденных и |найденные| = ef: прервать
для каждого соседа e вершины c на этом слое:
если e не посещён:
посещённые ← посещённые ∪ {e}
если dist(e,q) < худший из найденных или |найденные| < ef:
добавить e в кандидаты и в найденные
если |найденные| > ef: удалить худший
вернуть найденные
Сложность поиска — O(log N) шагов по числу вершин, но на каждом шаге считается до ef × M расстояний по d измерениям, поэтому реальная стоимость запроса — O(ef · M · d · log N), и доминирует в ней не логарифм, а произведение ef · d. Сборка индекса — O(N · ef_construction · M · d), то есть на порядок дороже, чем вставка в B-tree.
Параметры и что они значат в деньгах
| Параметр | Что делает | Дефолт | Влияние |
|---|---|---|---|
M |
число соседей на верхних слоях (на слое 0 — 2M) |
16 | Память и recall. Рост с 16 до 48 даёт +2–4 п.п. recall и +40% памяти на граф |
ef_construction |
ширина луча при сборке | 64–128 | Только качество графа и время сборки. Не влияет на память и на скорость запроса |
ef_search (ef) |
ширина луча при поиске | 40–100 | Главный рычаг во время выполнения: recall против латентности, меняется без пересборки |
Оценка памяти на вектор для HNSW:
байт_на_вектор ≈ 4·d (сам вектор, float32)
+ 2·M·(4…6) (соседи слоя 0: id по 4–6 байт)
+ M·(4…6)/(M−1) (амортизированно — верхние слои, ~1/(M−1) от слоя 0)
+ служебные (~16–40 байт)
d=1536, M=16: 6144 + 128 + ~10 + 30 ≈ 6.3 КБ → на 10 млн векторов ≈ 63 ГБ
d=768, M=16: 3072 + 128 + ~10 + 30 ≈ 3.2 КБ → на 10 млн векторов ≈ 32 ГБ
d=768, M=16, int8-квантование: 768 + 168 ≈ 0.94 КБ → 9.4 ГБ
Обратите внимание: сам вектор доминирует над графом. Все разговоры про «HNSW жрёт память» на самом деле про то, что float32-эмбеддинги жрут память. Отсюда следует, что квантование даёт куда больше экономии, чем тюнинг M.
Ахиллесова пята: удаления
Ни в одной реализации HNSW нет настоящего удаления вершины: она удаляется логически (tombstone), а рёбра остаются. Граф постепенно деградирует — растёт доля «мёртвых» переходов, recall падает, латентность растёт. Все реализации решают это перестройкой: Qdrant и Milvus оптимизируют сегменты в фоне, pgvector полагается на VACUUM (который для HNSW чинит не всё) и в тяжёлых случаях на REINDEX CONCURRENTLY.
Практическое правило: если у вас больше 20–30% операций — обновления или удаления, HNSW перестраивается непрерывно, и вы платите за это CPU. Для нагрузок с высокой скоростью изменений IVF нередко удобнее, а иногда честнее оказывается регулярная полная пересборка индекса по расписанию.
Квантование: где на самом деле лежит экономия
| Метод | Сжатие | Типичная потеря recall | Когда брать |
|---|---|---|---|
float16 / halfvec |
2× | <1 п.п. | Всегда. Практически бесплатный выигрыш |
| Скалярное (int8, SQ) | 4× | 1–3 п.п. | Дефолт для продакшна. Рескоринг обычно не нужен |
| Product Quantization | 8–64× | 5–20 п.п. | Когда датасет не влезает в RAM ни при каких условиях |
| Бинарное (1 бит) | 32× | 20–40 п.п. без рескоринга, 2–5 п.п. с ним | Большие корпуса + обязательный рескоринг по полным векторам |
| RaBitQ | 32× | 3–8 п.п. с рескорингом | Современная замена бинарному, с доказанной оценкой ошибки |
Про RaBitQ (Gao & Long, SIGMOD 2024, arXiv:2405.12497) стоит знать отдельно: это бинарное квантование со случайным поворотом пространства и несмещённой оценкой расстояния с теоретической границей ошибки. Реализовано в расширении VectorChord для PostgreSQL и в ряде движков; выигрыш по памяти как у бинарного, а качество ближе к SQ.
Ключевой паттерн работы с любым агрессивным квантованием — двухфазный поиск с рескорингом:
но считаются в 32 раза быстрее
и всё влезает в RAM V-->>S: 40 идентификаторов S->>S: читаем 40 полных float32-векторов (SSD или отдельный сегмент) S->>S: точный пересчёт расстояний, сортировка S-->>C: точные top-10 из 40 кандидатов opt Качество важнее латентности C->>R: (запрос, 40 документов) R->>R: полноценный кросс-энкодер, O(40) прогонов модели R-->>C: переупорядоченные top-10, +5…15 п.п. nDCG, +50…200 мс end
Стоимость рескоринга по полным векторам — 40 случайных чтений по 6 КБ, то есть на NVMe около 0.5–2 мс. Это почти всегда выгодная сделка. Реранкер кросс-энкодером — отдельная история: он даёт самый большой прирост качества во всём конвейере RAG, но добавляет вызов модели.
pgvector: когда «просто PostgreSQL» и есть правильный ответ
Для подавляющего большинства проектов ответ на вопрос «какую векторную базу взять» звучит так: никакую, возьмите PostgreSQL с расширением pgvector. Не потому, что оно быстрее специализированных движков (оно не быстрее), а потому, что векторы почти никогда не живут отдельно от обычных данных: у чанка есть документ, у документа — владелец, права доступа, дата, статус модерации. Как только вы разносите векторы и метаданные по двум системам, вы получаете распределённую транзакцию, рассинхронизацию и «удалили документ, а он всё ещё находится в поиске».
Схема и индексы:
CREATE EXTENSION IF NOT EXISTS vector; -- pgvector 0.8+
CREATE TABLE chunks (
id bigserial PRIMARY KEY,
document_id bigint NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
tenant_id int NOT NULL,
ord int NOT NULL,
content text NOT NULL,
-- halfvec: float16, вдвое меньше памяти, потеря качества в пределах шума.
-- Индексируется до 4000 измерений (обычный vector — до 2000).
embedding halfvec(1536) NOT NULL,
content_fts tsvector GENERATED ALWAYS AS (to_tsvector('russian', content)) STORED,
created_at timestamptz NOT NULL DEFAULT now()
);
-- Векторы ХРАНИМ нормированными: тогда косинус ≡ скалярное произведение,
-- а расстояние L2 — монотонная функция от него.
ALTER TABLE chunks ADD CONSTRAINT chunk_vec_normalized
CHECK (abs(l2_norm(embedding::vector) - 1.0) < 1e-3);
-- HNSW по скалярному произведению (оператор <#> возвращает ОТРИЦАТЕЛЬНОЕ
-- внутреннее произведение, чтобы ORDER BY ... ASC давал самые похожие).
CREATE INDEX CONCURRENTLY chunks_emb_hnsw
ON chunks USING hnsw (embedding halfvec_ip_ops)
WITH (m = 16, ef_construction = 128);
CREATE INDEX chunks_fts ON chunks USING gin (content_fts);
CREATE INDEX chunks_tenant ON chunks (tenant_id);
Сборка индекса — самое болезненное место. Граф должен целиком поместиться в maintenance_work_mem, иначе pgvector переходит на дисковую сборку и она становится в десятки раз медленнее (в логе появится hnsw graph no longer fits into maintenance_work_mem after N tuples):
-- Разово на сессию сборки. 8 ГБ памяти хватает примерно на 2.5 млн halfvec(1536).
SET maintenance_work_mem = '8GB';
SET max_parallel_maintenance_workers = 7; -- параллельная сборка, pgvector 0.6+
Запросы и планы выполнения
-- Простой k-NN. Параметр приводим явно, иначе планировщик не увидит,
-- что ORDER BY совместим с индексом.
SET hnsw.ef_search = 100;
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, content, embedding <#> $1::halfvec AS score
FROM chunks
ORDER BY embedding <#> $1::halfvec
LIMIT 10;
Limit (cost=17.31..48.62 rows=10 width=... ) (actual time=2.104..3.887 rows=10 loops=1)
Buffers: shared hit=1712 read=98
-> Index Scan using chunks_emb_hnsw on chunks
(cost=17.31..3129.44 rows=994231 width=...) (actual time=2.101..3.878 rows=10 loops=1)
Order By: (embedding <#> '[...]'::halfvec)
Buffers: shared hit=1712 read=98
Planning Time: 0.183 ms
Execution Time: 3.951 ms
Здоровый план: Index Scan с Order By, никаких Sort наверху. Если вы видите Seq Scan + Sort — значит, индекс не применился. Три обычные причины: несовпадение оператора и класса операторов (<=> при индексе _ip_ops), приведение типов в ORDER BY, отсутствие LIMIT.
Проблема фильтрации — главная боль векторного поиска
Теперь добавим самый обычный фильтр по арендатору:
SELECT id, content FROM chunks
WHERE tenant_id = 42
ORDER BY embedding <#> $1::halfvec
LIMIT 10;
Limit (actual time=4.512..4.518 rows=3 loops=1) ← ВЕРНУЛОСЬ ТРИ СТРОКИ ВМЕСТО ДЕСЯТИ
-> Index Scan using chunks_emb_hnsw on chunks (actual time=4.509..4.515 rows=3 loops=1)
Order By: (embedding <#> '[...]'::halfvec)
Filter: (tenant_id = 42)
Rows Removed by Filter: 97
Вот она, классическая ловушка. HNSW обошёл граф, вернул ef_search = 100 кандидатов, из них фильтр оставил три. Запрос не упал, не предупредил — он молча вернул неполный результат. В RAG-конвейере это выглядит как «ассистент иногда не находит очевидные документы», и отлаживается неделями.
(tenant, права, дата)"] --> SEL{"Селективность
предиката?"} SEL -->|"Высокая: подходит
< 0.1% строк"| PRE["ПРЕ-фильтрация:
обычный индекс по предикату,
затем точный перебор отобранных"] PRE --> PREOK["recall = 1.0, быстро.
Векторный индекс не нужен"] SEL -->|"Низкая: подходит
> 20% строк"| POST["ПОСТ-фильтрация:
ANN-поиск, затем отсев"] POST --> POSTOK["Работает: почти все
кандидаты проходят фильтр"] SEL -->|"Средняя: 0.1…20%
— опасная зона"| MID{"Что умеет движок?"} MID -->|"pgvector 0.8+"| IT["iterative_scan:
индекс возвращает
новые порции, пока
не наберётся LIMIT"] MID -->|"Qdrant"| FH["Filterable HNSW:
доп. рёбра внутри
подграфов по payload"] MID -->|"Milvus / FAISS"| AC["ACORN / bitset:
обход графа сквозь
отфильтрованные вершины"] MID -->|"Ничего"| PART["Секционирование:
отдельный индекс
на каждого арендатора"] IT --> RES["Полный top-k при фильтре"] FH --> RES AC --> RES PART --> RES POSTOK --> RES PREOK --> RES
Решения в pgvector 0.8:
-- Итеративный скан: индекс продолжает выдавать кандидатов, пока не наберётся LIMIT
-- или пока не упрёмся в потолок. Появился в 0.8.0.
SET hnsw.iterative_scan = strict_order; -- строгий порядок по расстоянию
-- SET hnsw.iterative_scan = relaxed_order; -- быстрее, порядок приблизительный
SET hnsw.max_scan_tuples = 100000; -- защита от полного прохода, дефолт 20000
Но самое надёжное решение для мультиарендности — не тюнинг, а секционирование или частичные индексы:
-- Вариант А: частичные индексы для крупных арендаторов.
CREATE INDEX CONCURRENTLY chunks_emb_hnsw_t42 ON chunks USING hnsw (embedding halfvec_ip_ops)
WHERE tenant_id = 42;
-- Вариант Б: секционирование по арендатору (pgvector умеет индексы на секциях).
CREATE TABLE chunks (...) PARTITION BY LIST (tenant_id);
Внутри частичного индекса фильтр выполнен по построению: ANN-обход идёт только по данным этого арендатора, и ef_search тратится по назначению. Это же рецепт и для Qdrant (там роль играют payload_m и мультиарендные коллекции), и для Weaviate (мультитенантность через отдельные шарды).
Что pgvector не умеет
Честный список ограничений, за которые придётся платить переходом на специализированный движок:
- Одна нода. Векторный поиск не распараллеливается между репликами автоматически: реплика чтения помогает по QPS, но датасет всё равно должен помещаться в одну машину. Шардирование — только вручную или через Citus.
- Сборка индекса блокирует ресурсы.
CREATE INDEX CONCURRENTLYна 10 млн векторов — это часы CPU, во время которых страдает вся база. - Нет встроенной модели квантования уровня Qdrant. Есть
halfvec,bitс бинарным квантованием вручную иsparsevec; PQ и автоматического рескоринга нет (это даёт VectorChord/pgvecto.rs, но это уже другое расширение). - MVCC против HNSW. Каждое обновление строки создаёт новую версию, а значит новую вершину в графе; мёртвые версии остаются в индексе до
VACUUM. См. статью про PostgreSQL — здесь MVCC работает против вас сильнее, чем на обычных индексах.
Специализированные движки
Qdrant
Написан на Rust, ставится одним бинарником, конфигурируется явно. Главное отличительное свойство — фильтруемый HNSW: при построении индекса Qdrant, зная о payload-индексах, достраивает дополнительные рёбра внутри подграфов, соответствующих часто встречающимся значениям полей. Это ровно та проблема, о которой шла речь выше, решённая на уровне структуры данных, а не костылём во время запроса.
# конфигурация коллекции (фрагмент, REST/gRPC API)
vectors:
size: 1536
distance: Dot # векторы нормируем на стороне приложения
on_disk: true # полные float32-векторы держим на диске
hnsw_config:
m: 16
ef_construct: 128
full_scan_threshold: 10000 # при менее чем 10k кандидатов после фильтра — точный перебор
payload_m: 16 # дополнительные рёбра для подграфов по payload-полям
max_indexing_threads: 0 # 0 = по числу ядер
quantization_config:
scalar:
type: int8
quantile: 0.99 # обрезаем хвосты распределения перед квантованием
always_ram: true # квантованные векторы — в RAM, полные — на диске
optimizers_config:
default_segment_number: 4
indexing_threshold: 20000 # сегменты меньше этого размера ищутся полным перебором
Такая конфигурация — канонический продакшн-паттерн: в памяти живут только int8-коды (в 8 раз меньше исходного), полные векторы лежат на SSD и читаются только для рескоринга. Для 10 млн × 1536 это разница между 63 ГБ RAM и 16 ГБ RAM.
from qdrant_client import QdrantClient, models
client = QdrantClient(url="http://qdrant:6333")
hits = client.query_points(
collection_name="chunks",
query=query_vector,
limit=10,
# Фильтр применяется ВНУТРИ обхода графа, а не после него.
query_filter=models.Filter(
must=[
models.FieldCondition(key="tenant_id", match=models.MatchValue(value=42)),
models.FieldCondition(key="lang", match=models.MatchValue(value="ru")),
],
must_not=[
models.FieldCondition(key="is_deleted", match=models.MatchValue(value=True)),
],
),
search_params=models.SearchParams(
hnsw_ef=128, # ширина луча, меняется на каждый запрос
quantization=models.QuantizationSearchParams(
rescore=True, # пересчитать по полным векторам
oversampling=3.0, # взять 30 кандидатов, чтобы вернуть точные 10
),
),
).points
Обязательное условие работы фильтров: payload-поля должны быть проиндексированы явно (create_payload_index). Без индекса Qdrant не сможет оценить кардинальность и выберет плохую стратегию.
Распределённость: шардирование по хешу идентификатора, репликация с настраиваемыми write_consistency_factor и уровнем чтения, метаданные кластера через Raft. Данные при этом не линеаризуемы — это модель конечной согласованности, и относиться к Qdrant надо как к производному хранилищу, а не как к источнику истины.
Milvus
Milvus — это не база, а распределённая система с разделением вычислений и хранения, устроенная как настоящий data-стек: метаданные в etcd, данные в объектном хранилище (S3/MinIO), поток записи через журнал сообщений, отдельные пулы узлов для записи, индексации и поиска.
сегмент открыт Growing --> Sealed: достигнут лимит размера
или таймаут Sealed --> Flushed: сегмент записан
в объектное хранилище Flushed --> Indexed: узлы индексации строят
HNSW / IVF / DiskANN Indexed --> Loaded: query-узлы поднимают
сегмент в память Loaded --> Compacting: накопились удаления
или мелкие сегменты Compacting --> Indexed: слитый сегмент
переиндексирован Loaded --> Released: коллекция выгружена
(данные остаются в S3) Released --> Loaded: повторная загрузка Loaded --> [*] note right of Growing Поиск по Growing-сегментам — полный перебор. Отсюда «свежие данные ищутся медленнее». end note note right of Indexed Между Flushed и Indexed данные уже видимы, но ещё не ускорены. end note
Эта картинка объясняет главную особенность Milvus в эксплуатации: свежезаписанные данные ищутся полным перебором, пока сегмент не запечатан и не проиндексирован. Задержка от вставки до «индексированного» состояния — от секунд до минут. Отсюда же уровни согласованности, которые надо выбирать осознанно:
| Уровень | Гарантия | Латентность | Когда брать |
|---|---|---|---|
Strong |
видны все записи, подтверждённые до запроса | самая высокая | тесты, сверки, критичная свежесть |
Bounded (дефолт) |
отставание не более настроенного окна | средняя | обычный продакшн |
Session |
свои записи видны сразу | средняя | «пользователь добавил документ и сразу ищет» |
Eventually |
без гарантий | минимальная | офлайн-аналитика, разогрев кэшей |
Milvus разумно брать, когда: (1) векторов сотни миллионов и больше, (2) нужен GPU-индекс (GPU_CAGRA даёт кратный выигрыш по QPS на больших батчах), (3) нужно разделять хранение и вычисления ради стоимости. Ценой идёт эксплуатационная сложность: полноценный кластер — это etcd, объектное хранилище, брокер сообщений и пять типов узлов. Для локальной разработки есть Milvus Lite (pip install pymilvus, встраиваемый режим), для продакшна без DevOps-команды — управляемый Zilliz Cloud.
Weaviate
Weaviate написан на Go и делает ставку на «батарейки в комплекте»: векторизация встроена модулями (text2vec-openai, text2vec-cohere, text2vec-transformers) — вы кладёте текст, эмбеддинг считается сервером. Это резко сокращает код на старте и одновременно привязывает вас к схеме, где модель эмбеддингов — часть конфигурации базы.
Сильные стороны: зрелый гибридный поиск из коробки (BM25F + вектор с параметром alpha и слиянием по RRF), настоящая мультиарендность (тенант = шард, с выгрузкой холодных тенантов в объектное хранилище), богатый выбор сжатия (PQ, BQ, SQ, RQ), GraphQL-интерфейс поверх графа ссылок между объектами.
import weaviate.classes as wvc
collection = client.collections.get("Chunk")
resp = collection.query.hybrid(
query="как отменить подписку", # идёт и в BM25, и в векторизатор
alpha=0.7, # 1.0 — только вектор, 0.0 — только ключевые слова
limit=10,
filters=wvc.query.Filter.by_property("lang").equal("ru"),
return_metadata=wvc.query.MetadataQuery(score=True, explain_score=True),
)
Прочее, что вы встретите
- FAISS (arXiv:1702.08734) — не база, а библиотека от Meta. Эталонная реализация IVF, PQ, HNSW и GPU-индексов; внутри многих движков лежит именно она или её идеи. Берите, когда пишете свой сервис поиска и вам не нужны персистентность, фильтры и API.
- DiskANN / Vamana (NeurIPS 2019) — граф, оптимизированный под SSD: миллиард векторов на одной машине с ~64 ГБ RAM ценой латентности в десятки миллисекунд. Доступен в Milvus и в Azure AI Search.
- Elasticsearch / OpenSearch —
dense_vectorповерх Lucene HNSW. Если у вас уже есть кластер и полнотекстовый поиск (см. статью про поиск и временные ряды), добавить векторы дешевле, чем ставить новую систему. Гибридный поиск там наиболее зрелый. - ClickHouse — с недавних пор умеет векторные индексы, но по сути это аналитическая колоночная БД; её сила — фильтрация по миллиардам строк с полным перебором векторов, а не k-NN на низкой латентности.
- Redis с модулем Search — векторный поиск в памяти с латентностью в единицы миллисекунд; см. статью про Redis.
- Pinecone — полностью управляемый serverless-сервис. Нулевая эксплуатация, максимальная привязка к поставщику и оплата по запросам.
Честное сравнение
| pgvector | Qdrant | Milvus | Weaviate | Elasticsearch | |
|---|---|---|---|---|---|
| Модель данных | реляционная, векторы — колонка | точки: вектор + JSON-payload | коллекции со схемой, партиции | объекты со схемой + ссылки | документы Lucene |
| Индексы | HNSW, IVFFlat | HNSW (фильтруемый) | FLAT, IVF*, HNSW, DiskANN, GPU_CAGRA | HNSW, flat, dynamic | HNSW (Lucene) |
| Квантование | halfvec, bit (вручную) | SQ, PQ, BQ + рескоринг | SQ, PQ, BQ | PQ, BQ, SQ, RQ | int8, BBQ |
| Фильтрация | итеративный скан (0.8+), частичные индексы | фильтруемый HNSW, оценка кардинальности | bitset / ACORN | пре-фильтрация по инвертированному индексу | пре-фильтрация Lucene |
| Согласованность | полноценные ACID-транзакции | конечная, Raft для метаданных | 4 настраиваемых уровня | конечная, Raft для схемы | конечная (near-real-time) |
| Масштабирование | вертикальное; шардирование вручную | шарды + реплики | горизонтальное, разделены хранение и вычисления | шарды + реплики | шарды + реплики |
| Эксплуатация | ничего нового, если уже есть Postgres | один бинарник, простая | etcd + S3 + брокер + 5 ролей узлов | средняя | тяжёлый JVM-кластер |
| Гибридный поиск | вручную (tsvector + RRF в SQL) |
sparse-векторы + слияние | sparse + BM25 | из коробки, alpha |
лучший в классе |
| Практический потолок | 10–50 млн векторов | 100–500 млн на кластер | миллиарды | сотни миллионов | сотни миллионов |
| Когда НЕ брать | нужны сотни миллионов векторов или сложные фильтры при высоком QPS | нужна транзакционность с бизнес-данными | нет команды эксплуатации; менее 50 млн векторов | нужен полный контроль над векторизацией | нет кластера — не ставьте ради векторов |
Гибридный поиск: почему один вектор не справляется
Векторный поиск отлично находит смысл и катастрофически проваливается на точных совпадениях: артикулах, кодах ошибок, именах, номерах версий. Запрос ORA-01555 эмбеддинг превратит в «что-то про базы данных и ошибки», и найдётся десяток похожих-но-не-тех документов. Лексический поиск найдёт ровно нужный.
Промышленный ответ — гибрид со слиянием рангов (Reciprocal Rank Fusion): слить два списка не по сырым оценкам (они несопоставимы), а по позициям.
-- Гибридный поиск в чистом PostgreSQL: RRF по векторному и полнотекстовому спискам.
WITH vec AS (
SELECT id, ROW_NUMBER() OVER (ORDER BY embedding <#> $1::halfvec) AS rnk
FROM chunks WHERE tenant_id = $3
ORDER BY embedding <#> $1::halfvec LIMIT 60
),
fts AS (
SELECT id, ROW_NUMBER() OVER (ORDER BY ts_rank_cd(content_fts, q) DESC) AS rnk
FROM chunks, websearch_to_tsquery('russian', $2) q
WHERE tenant_id = $3 AND content_fts @@ q
ORDER BY ts_rank_cd(content_fts, q) DESC LIMIT 60
)
SELECT c.id, c.content,
COALESCE(1.0 / (60 + v.rnk), 0) + COALESCE(1.0 / (60 + f.rnk), 0) AS rrf_score
FROM chunks c
LEFT JOIN vec v ON v.id = c.id
LEFT JOIN fts f ON f.id = c.id
WHERE v.id IS NOT NULL OR f.id IS NOT NULL
ORDER BY rrf_score DESC
LIMIT 10;
Константа 60 в знаменателе — из оригинальной работы Кормака и соавторов по RRF; она сглаживает вклад хвоста списков. Эмпирически гибрид даёт +10–20 п.п. к nDCG@10 относительно чистого векторного поиска на реальных корпусах поддержки и документации. Это самое дешёвое улучшение качества RAG из всех существующих. Подробнее о конвейерах поиска — в треке AI engineering.
Как мерить: recall, а не «кажется, стало лучше»
Единственная осмысленная метрика качества ANN-индекса — recall@k: какая доля истинных k ближайших соседей найдена. Считается относительно точного перебора, а не относительно другого приближённого индекса.
"""Измерение кривой recall/QPS для векторного индекса.
Сложность: подготовка эталона O(Q·N·d) — считаем один раз и кэшируем."""
import time
import numpy as np
def build_ground_truth(corpus: np.ndarray, queries: np.ndarray, k: int) -> np.ndarray:
"""Точные top-k для каждого запроса. corpus и queries — НОРМИРОВАНЫ,
поэтому максимум скалярного произведения = минимум расстояния."""
scores = queries @ corpus.T # (Q, N) — O(Q·N·d)
return np.argpartition(-scores, k, axis=1)[:, :k]
def recall_at_k(truth: np.ndarray, got: np.ndarray) -> float:
"""Доля истинных соседей среди возвращённых. Порядок внутри top-k не важен."""
hits = [len(set(t) & set(g)) for t, g in zip(truth, got)]
return float(np.mean(hits)) / truth.shape[1]
def sweep(search_fn, queries, truth, k, ef_values):
"""Кривая компромисса. search_fn(q, k, ef) -> список id."""
for ef in ef_values:
got, lat = [], []
for q in queries:
t0 = time.perf_counter()
got.append(search_fn(q, k, ef))
lat.append((time.perf_counter() - t0) * 1000)
lat = np.array(lat)
print(f"ef={ef:4d} recall@{k}={recall_at_k(truth, np.array(got)):.4f} "
f"p50={np.percentile(lat, 50):6.2f} ms p99={np.percentile(lat, 99):6.2f} ms "
f"QPS={1000 / lat.mean():7.1f}")
# ef= 40 recall@10=0.9210 p50= 1.31 ms p99= 2.94 ms QPS= 721.4
# ef= 80 recall@10=0.9684 p50= 2.09 ms p99= 4.51 ms QPS= 452.8
# ef= 160 recall@10=0.9891 p50= 3.72 ms p99= 7.88 ms QPS= 256.1
# ef= 320 recall@10=0.9967 p50= 6.94 ms p99= 14.20 ms QPS= 138.6
# ef= 640 recall@10=0.9991 p50= 13.40 ms p99= 27.60 ms QPS= 71.9
Эта таблица — суть всей темы, и она примерно одинакова для любого движка на HNSW. Отдача от ef резко убывает. Переход с recall 0.92 на 0.97 стоит вдвое; с 0.97 на 0.99 — ещё вдвое; с 0.99 на 0.999 — ещё вчетверо. Дальше 0.99 идти для RAG почти всегда бессмысленно: если из десяти чанков один заменён на одиннадцатый по близости, ответ модели не изменится. А вот для дедупликации или поиска по лицам пропущенный сосед — это ошибка бизнес-логики, и там recall 0.999 может быть обязательным.
Методологические грабли, на которые наступают все:
- Эталон построен приближённым индексом. Тогда вы меряете совпадение двух ошибок. Только полный перебор.
- Запросы взяты из корпуса. Вектор находит сам себя, recall завышен. Берите отложенную выборку.
- Замеры без прогрева. Первые сотни запросов идут по холодному page cache; разница с прогретым — 10–50 раз.
- Один поток и «QPS = 1/латентность». Настоящий QPS меряется под конкурентной нагрузкой, там появляются блокировки и конкуренция за память.
- Средняя латентность вместо p99. У ANN тяжёлый хвост: запросы в разреженных областях пространства обходят граф намного дольше.
Публичная точка отсчёта — ann-benchmarks.com (Аумюллер, Бернхардссон, Фейтфулл): единая методология, кривые recall/QPS на стандартных датасетах. Для сравнения самих баз (а не алгоритмов) есть VectorDBBench. Помните про конфликт интересов: почти все публичные сравнения векторных баз опубликованы вендорами.
Стоимость: считаем на 10 миллионах векторов
Задача: 10 млн чанков, 1536 измерений, 200 QPS, p99 < 50 мс, фильтр по арендатору.
| Конфигурация | RAM под данные | Инфраструктура | Ориентир, $/мес | recall@10 |
|---|---|---|---|---|
pgvector, vector float32, HNSW |
63 ГБ | инстанс на 128 ГБ | 900–1400 | 0.98 |
pgvector, halfvec float16, HNSW |
32 ГБ | инстанс на 64 ГБ | 450–700 | 0.98 |
pgvector, halfvec + обрезка до 512 изм. |
11 ГБ | инстанс на 32 ГБ | 250–400 | 0.96 |
| Qdrant, int8 в RAM + float32 на SSD | 16 ГБ | 2 узла по 32 ГБ | 400–650 | 0.97 (с рескорингом) |
| Qdrant, бинарное + рескоринг | 2 ГБ | 2 узла по 16 ГБ | 200–350 | 0.94 |
| Milvus, DiskANN | 8 ГБ кэша | кластер + S3 | 800–1500 | 0.95 |
| Pinecone serverless | — | управляемый | 400–1200 (зависит от QPS) | ~0.97 |
Числа — порядковые ориентиры для типовых облачных цен, не прайс-лист. Но пропорции устойчивы, и из них следуют выводы:
- Первое, что нужно сделать для снижения стоимости, — уменьшить вектор, а не менять базу.
float16плюс матрёшечная обрезка до 512 измерений дают шестикратную экономию с потерей 2 п.п. recall. Это переводит задачу из «нужен кластер» в «влезает в один инстанс». - Специализированный движок выигрывает по стоимости на порядок только вместе с агрессивным квантованием. Сравнивать «pgvector float32» с «Qdrant с бинарным квантованием» некорректно: вы сравниваете не базы, а форматы хранения.
- Не забывайте стоимость эмбеддингов. 10 млн чанков по 500 токенов — это 5 млрд токенов. У OpenAI по прейскуранту
text-embedding-3-smallэто тысячи долларов разово, и столько же при каждой смене модели. Часто это доминирующая статья расходов первого года, а не хостинг. - Стоимость перестройки. Индекс на 10 млн векторов собирается часами. Заложите это в план миграции и в оценку инцидента «индекс деградировал».
Типичные проблемы в проде
Recall тихо деградирует со временем. Индекс собран на 1 млн векторов, стало 10 млн, ef_search тот же. Recall упал с 0.98 до 0.90, никто не заметил, качество ответов ассистента медленно ухудшилось. Лечение: отложенная выборка из 500 запросов, ночной прогон замера recall против точного перебора, алерт на падение. Это единственная метрика, которая ловит проблему до жалоб пользователей.
Фильтр съедает результаты. Разобрано выше: LIMIT 10 возвращает три строки. Лечение: явная проверка len(results) < k в коде с логированием, iterative_scan, частичные индексы, движок с фильтруемым HNSW.
Смешаны векторы разных моделей. После смены модели эмбеддингов часть корпуса переиндексирована, часть нет. Расстояния между векторами разных моделей формально считаются и дают правдоподобный мусор. Лечение: model_id в первичном ключе таблицы эмбеддингов, фильтр по нему в каждом запросе, CHECK-ограничение на размерность.
Векторы не нормированы, а метрика косинусная. Расстояние считается верно, но медленнее, а часть кода использует скалярное произведение — и ранжирует иначе. Лечение: нормировать на входе, поставить CHECK, как в схеме выше.
Сборка индекса не влезла в память. pgvector молча переходит на дисковую сборку, CREATE INDEX идёт не 40 минут, а 12 часов. Лечение: считать нужный maintenance_work_mem заранее (примерно 1.1 × размер_данных_индекса), читать серверный лог.
Дубликаты забивают выдачу. Верхние 10 результатов — десять почти идентичных чанков одного документа. Формально recall идеален, практически — контекст для модели бесполезен. Лечение: дедупликация по документу на этапе постобработки или диверсификация (MMR, Maximal Marginal Relevance).
Разбиение на чанки важнее выбора базы. Самая частая причина плохого RAG — не индекс, а нарезка текста: чанки по 2000 токенов размывают смысл, по 100 — теряют контекст. Практический ориентир — 300–600 токенов с перекрытием 10–15%, с уважением к структуре документа. Ни одна векторная база не спасёт от плохих чанков.
Пиковая память при перестройке. Пересборка HNSW требует места под старый и новый индекс одновременно. На инстансе, где данные занимают 70% диска, REINDEX кончается «no space left on device» в три часа ночи.
Когда векторная база — неправильный ответ
- Данных меньше миллиона. Точный перебор в NumPy или
pgvectorбез индекса проще, быстрее в разработке и даёт recall 1.0. Отдельная база здесь — чистые накладные расходы. - Нужны точные совпадения. Артикулы, идентификаторы, коды — это B-tree и полнотекстовый поиск, а не эмбеддинги.
- Фильтры селективнее самого поиска. Если
WHERE tenant_id = 42 AND created_at > ...оставляет 500 строк, отфильтруйте сначала и переберите точно. ANN-индекс тут только мешает. - Векторная база как источник истины. Ни один векторный движок не даёт транзакций, внешних ключей и точечного восстановления на момент времени. Источник истины — реляционная БД или объектное хранилище; векторный индекс — производное представление, которое обязано пересобираться из источника.
- Задача на самом деле про рекомендации со свежестью. Если ранжирование зависит от кликов последних минут, вам нужна фича-платформа и реранкер, а k-NN — лишь один из сигналов.
- «У нас будет ИИ» без задачи поиска. Самый частый случай. Векторная база решает задачу поиска по смыслу; если такой задачи нет, наличие эмбеддингов ничего не даёт.
Мини-итог
- Векторный поиск — это обмен точности на скорость, и главный навык здесь не выбор движка, а измерение recall@k против точного перебора на своих данных и своих запросах.
- Нормируйте векторы и используйте скалярное произведение: для нормированных векторов косинус, L2 и внутреннее произведение дают один и тот же порядок соседей, а
<#>считается быстрее всех. - HNSW — дефолт:
O(log N)шагов обхода, лучший recall при равной латентности; платит памятью (весь граф и все векторы в RAM) и деградацией при удалениях.ef_searchкрутится на лету,Mиef_construction— только пересборкой. - Память съедают сами векторы, а не граф. Порядок оптимизаций: обрезка размерности (матрёшечные эмбеддинги) →
float16→ int8 → бинарное с рескорингом. Только потом — смена базы. - Фильтрация — главная практическая проблема. Наивный HNSW с фильтром молча возвращает меньше
kрезультатов. Решения: пре-фильтрация при высокой селективности,iterative_scanв pgvector 0.8+, фильтруемый HNSW в Qdrant, частичные индексы и секционирование для мультиарендности. - Начинайте с pgvector. Векторы почти всегда живут рядом с бизнес-данными, и разнос по двум системам стоит дороже, чем выигранные миллисекунды. Переходите на Qdrant/Milvus, когда упрётесь в измеренный потолок, а не заранее.
- Гибридный поиск (вектор + BM25 через RRF) — самое дешёвое улучшение качества, обычно +10–20 п.п. nDCG. Чистый векторный поиск проваливается на артикулах, кодах и именах.
Источники
- Malkov, Yashunin, «Efficient and robust approximate nearest neighbor search using Hierarchical Navigable Small World graphs», TPAMI 2018 — оригинальная работа по HNSW.
- Jégou, Douze, Schmid, «Product Quantization for Nearest Neighbor Search», TPAMI 2011 — основа всех схем сжатия векторов.
- Johnson, Douze, Jégou, «Billion-scale similarity search with GPUs» и документация FAISS.
- Subramanya et al., «DiskANN: Fast Accurate Billion-point Nearest Neighbor Search on a Single Node», NeurIPS 2019.
- Gao, Long, «RaBitQ: Quantizing High-Dimensional Vectors with a Theoretical Error Bound», SIGMOD 2024.
- Beyer et al., «When Is “Nearest Neighbor” Meaningful?», ICDT 1999 — проклятие размерности строго.
- Kusupati et al., «Matryoshka Representation Learning», NeurIPS 2022 — почему вектор можно обрезать.
- pgvector README — типы, операторы, параметры индексов, итеративные сканы, рекомендации по производительности.
- Qdrant documentation — особенно Filtrable HNSW и Quantization.
- Milvus documentation — архитектура, жизненный цикл сегментов, уровни согласованности.
- Weaviate documentation — гибридный поиск, мультиарендность, схемы сжатия.
- ann-benchmarks.com — воспроизводимая методология сравнения ANN-алгоритмов.
- Cormack, Clarke, Buettcher, «Reciprocal Rank Fusion outperforms Condorcet and individual Rank Learning Methods», SIGIR 2009.
Что дальше
Векторный индекс — производное представление: он обязан уметь пересобираться из источника истины, а сами эмбеддинги, исходные документы и артефакты моделей должны где-то лежать дёшево и надёжно. Milvus строит на этом всю архитектуру, вынося данные в объектное хранилище и оставляя в памяти только рабочие сегменты. Следующая статья — про этот фундаментальный слой: Объектные хранилища: S3, MinIO, слои хранения и lakehouse.