Базы данных Векторные базы: pgvector, Qdrant, Milvus, Weaviate, HNSW и метрики близости
0%

Векторные базы: pgvector, Qdrant, Milvus, Weaviate, HNSW и метрики близости

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

Метрики близости: их три, а по сути одна

Ключевой факт, экономящий много сил: для нормированных векторов (длина = 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-индексов

Практически в 2026 году живых вариантов три: HNSW (дефолт для «всё в памяти»), IVF + PQ (когда память дороже recall), DiskANN (когда данных больше, чем RAM, а денег на RAM нет). LSH остался в учебниках и в задачах дедупликации.

HNSW изнутри

HNSW (Hierarchical Navigable Small World, Malkov & Yashunin, arXiv:1603.09320) — это идея skip-list, перенесённая на графы. Строится многослойная структура: нижний слой содержит все точки и их ближайших соседей, каждый следующий слой — экспоненциально меньшую случайную выборку с более длинными рёбрами. Поиск начинается на верхнем слое в одной точке входа и жадно спускается вниз: длинные рёбра быстро переносят нас в правильную область пространства, короткие уточняют результат.

Иерархия слоёв HNSW и жадный спуск от точки входа к ближайшему соседу

Алгоритм поиска — псевдокод:

функция ПОИСК(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 нередко удобнее, а иногда честнее оказывается регулярная полная пересборка индекса по расписанию.

Квантование: где на самом деле лежит экономия

Схема кодирования вектора product-квантованием и асимметричного вычисления расстояния

Метод Сжатие Типичная потеря recall Когда брать
float16 / halfvec <1 п.п. Всегда. Практически бесплатный выигрыш
Скалярное (int8, SQ) 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.

Ключевой паттерн работы с любым агрессивным квантованием — двухфазный поиск с рескорингом:

Стоимость рескоринга по полным векторам — 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-конвейере это выглядит как «ассистент иногда не находит очевидные документы», и отлаживается неделями.

Решения в 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), поток записи через журнал сообщений, отдельные пулы узлов для записи, индексации и поиска.

Эта картинка объясняет главную особенность 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 / OpenSearchdense_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 может быть обязательным.

Методологические грабли, на которые наступают все:

  1. Эталон построен приближённым индексом. Тогда вы меряете совпадение двух ошибок. Только полный перебор.
  2. Запросы взяты из корпуса. Вектор находит сам себя, recall завышен. Берите отложенную выборку.
  3. Замеры без прогрева. Первые сотни запросов идут по холодному page cache; разница с прогретым — 10–50 раз.
  4. Один поток и «QPS = 1/латентность». Настоящий QPS меряется под конкурентной нагрузкой, там появляются блокировки и конкуренция за память.
  5. Средняя латентность вместо 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. Чистый векторный поиск проваливается на артикулах, кодах и именах.

Источники

Что дальше

Векторный индекс — производное представление: он обязан уметь пересобираться из источника истины, а сами эмбеддинги, исходные документы и артефакты моделей должны где-то лежать дёшево и надёжно. Milvus строит на этом всю архитектуру, вынося данные в объектное хранилище и оставляя в памяти только рабочие сегменты. Следующая статья — про этот фундаментальный слой: Объектные хранилища: S3, MinIO, слои хранения и lakehouse.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

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

Доска запросов