Привет! Я из тех занудных инженеров по данным, которые на вопрос «а не проще ли добавить ещё одну базу данных в инфраструктуру?» отвечают получасовой лекцией о total cost of ownership.
Сегодня хочу поговорить о том, как векторный поиск перестал быть игрушкой для RAG-демо и превратился в часть production data plane — и что это значит для всех нас, кто уже обслуживает один-два (три? пять?) кластера в продакшене.
Спойлер: история включает IVF, SPANN, RRF, и ещё несколько аббревиатур, которые выглядят страшно, но за каждой скрывается один простой инженерный компромисс. К концу статьи вы будете знать, когда имеет смысл добавить vector search в OLAP-движок, а когда — нет.
Если вы думаете, что «всё это для больших компаний» — дочитайте до раздела с бенчмарками. Там есть pgvector с 2c8g, и его результаты заставляют задуматься.
Часть 1. Почему vector search перестал жить в отдельной «коробке»
До недавнего времени типичный сценарий выглядел так: у вас есть основное хранилище данных (Postgres, ClickHouse, что угодно), и рядом стоит векторная база — Milvus, Qdrant, Pinecone. Приложение пишет данные в оба места, синхронизация происходит где-то в прослойке, консистентность — молитвами и надеждой.
Такая архитектура имела смысл, пока vector search был нужен только для одной конкретной задачи: «найди N ближайших соседей вот к этому embedding». Классические сценарии — семантический поиск документов, рекомендации похожих товаров, дедупликация записей.
Но потом началось. RAG-пайплайны начали требовать не просто «похожие векторы», а «похожие векторы от пользователя из тенанта 42 с датой создания за последние 30 дней и категорией "finance"». Рекомендательные системы захотели фьюзить vector recall с BM25 текстовым поиском и ранжировать результаты в одном запросе. Мультимодальный retrieval потребовал комбинировать визуальные embedding'и с текстовыми фильтрами и метаданными.
Иными словами, «чистый» ANN-поиск оказался редкостью. Большинство production-задач требуют гибридных запросов: vector + structured filters, vector + full-text, vector + joins.
И тут возникает неудобный вопрос: если почти каждый vector query всё равно идёт вместе со structured-фильтром — а оба они в итоге смотрят на одни данные — зачем держать для них разные системы?
Часть 2. Три места, где может жить векторный индекс
Прежде чем смотреть на конкретные реализации, полезно понять, что вообще бывает.
Вариант А: специализированная векторная база
Milvus, Qdrant, Pinecone, Weaviate — системы, построенные вокруг ANN с нуля. Преимущество: глубокая оптимизация именно под vector workload, поддержка GPU, продвинутые варианты индексов, большая экосистема инструментов.
Недостаток: это ещё одна система в вашем стеке. Это двойная запись, это синхронизация схем, это отдельный мониторинг, отдельные on-call дежурства. А главное — гибридные запросы с join'ами к основным данным требуют либо денормализации данных в векторную базу, либо join'а на уровне приложения.
Вариант Б: расширение к транзакционной базе
pgvector для PostgreSQL, HeatWave для MySQL. Дёшево войти, просто настроить. Ограничение — в том, что underlying storage и движок никогда не были предназначены для большого vector workload. На миллионе векторов ещё терпимо, на сотнях миллионов начинаются проблемы с concurrency и latency.
(pgvector в бенчмарке ниже на конфигурации 2c8g показывает 10.63 QPS. Это честный результат — но важно понимать, что это другой масштаб задач и другое железо.)
Вариант В: нативный vector search в аналитической базе
Elasticsearch (dense_vector), ClickHouse (ANN индексы), Apache Doris (vector indexes). Преимущество: векторный поиск разделяет columnar storage, distributed execution, vectorized query engine с остальными запросами. Это делает его естественным для гибридных сценариев — оптимизатор видит весь запрос целиком и сам решает, что делать сначала.
Инженерный вызов — интегрировать ANN глубоко достаточно, чтобы это была не галочка в маркетинговых материалах, а реально работающий инструмент под нагрузкой. Посмотрим, что сделала команда Apache Doris в версии 4.1.
Часть 3. Четыре вопроса, на которые пришлось ответить
Команда Doris явно формулирует задачу через четыре конкретных вопроса. Это честная инженерная рамка, которую я позаимствую для структуры статьи:
- Память: как контролировать потребление памяти по мере роста от миллионов до миллиардов векторов?
- Latency: может ли vector search работать в рамках online-SLA?
- Гибридные запросы: могут ли vector search со structured filters и BM25 выполняться эффективно в одном SQL-запросе?
- Recall: достаточно ли качество поиска для целевой задачи?
Часть 4. Память — почему HNSW дорого обходится на больших данных
Возьмём конкретные числа. Миллион 768-мерных float32-векторов — это примерно 3 ГБ сырых данных. Не так страшно.
Классический алгоритм для ANN — HNSW (Hierarchical Navigable Small World). Это граф, в котором навигация до ближайших соседей работает за логарифмическое время. Хорош тем, что даёт высокий recall при небольшой задержке. Плохо тем, что граф должен полностью жить в памяти.
При типичных параметрах (M=16, efConstruction=200) HNSW примерно удваивает footprint сырых данных за счёт resident-графа. На миллионе записей (≈6 ГБ суммарно) это ещё терпимо.
Теперь масштабируем до миллиарда. Одни только сырые 768-мерные float32-векторы занимают около 3 ТБ, а граф HNSW добавляет сверху отдельный memory budget. Даже если конкретная цифра зависит от параметров индекса и реализации хранения, порядок проблемы понятен: deployment cost становится тем, что большинство команд просто не подпишет.
IVF: другой подход к структуре
IVF (Inverted File) решает проблему памяти принципиально иначе.
Идея простая:
- Построение: запускаем k-means по всем векторам, получаем
nlistкластеров (центроидов). Каждый вектор приписывается к ближайшему центроиду — это его «bucket». - Поиск: для нового запроса сравниваем его с
nlistцентроидами, берёмnprobeближайших buckets, вычисляем точные расстояния только внутри них.
Recall падает — потому что нужный вектор мог оказаться в bucket'е, который мы не проверили. Но выигрыш по памяти значительный: в RAM нужно держать только центроиды и маппинг bucket→ID. Сами векторы можно отправить на диск. Именно это свойство делает возможным следующий раздел.
В Doris HNSW-индексы появились в 4.0, а в 4.1 добавили IVF как второй путь для более крупных наборов данных. DDL выглядит так:
CREATE TABLE vecs (
id BIGINT NOT NULL,
embedding ARRAY<FLOAT> NOT NULL,
INDEX idx_emb (embedding) USING ANN PROPERTIES (
"index_type" = "ivf",
"metric_type" = "l2_distance",
"dim" = "768",
"nlist" = "1024"
)
) ENGINE=OLAP
DUPLICATE KEY(id)
DISTRIBUTED BY HASH(id) BUCKETS 8
PROPERTIES ("replication_num" = "1");
Параметр nprobe — session-level, то есть один и тот же индекс можно двигать по кривой recall/latency в runtime, без пересборки под разные целевые значения.
Небольшой ориентир по параметрам: nlist обычно берут в диапазоне от √N до 4√N от числа векторов. nprobe стартуют с 1–5% от числа buckets и увеличивают, пока не достигнут нужного recall. Но это общие рекомендации — конкретное значение зависит от распределения ваших данных, так что валидируйте на реальной выборке.
По fit'у IVF обычно имеет смысл на десятках миллионов и выше, вплоть до примерно миллиарда векторов. Ниже 100 тысяч записей или в задачах, где recall особенно чувствителен, HNSW по-прежнему может быть лучшим вариантом. В Doris 4.1 IVF не заменяет HNSW, а дополняет его.
Часть 5. Billion scale — выносим данные на диск по заветам SPANN
Итак, IVF экономит память за счёт структуры индекса. Но сами векторы всё равно растут линейно с датасетом. Миллиард float32 768-мерных векторов — это ~3 ТБ. Хранить это в RAM по-прежнему нереально.
Стандартный ответ индустрии: storage tiering — навигационные структуры держим в памяти, данные — на SSD. Ключевые работы здесь — DiskANN (Microsoft, NeurIPS 2019) и SPANN (NeurIPS 2021).
Реализация IVF_ON_DISK в Doris 4.1 следует общей идее SPANN:
- центроиды — в памяти (это мегабайты, ничтожно);
- posting list каждого bucket — на диске в последовательных файлах;
- горячие buckets кешируются; холодные читаются через файловую систему.
В итоге billion-scale поиск работает на commodity железе: в памяти — только centroid и рабочий кеш, всё остальное — на локальном SSD.
Интуитивно «дисковый индекс» звучит как что-то заведомо медленное, но при нормальной настройке кеша QPS у IVF_ON_DISK может быть близок к in-memory IVF. Причина в locality: похожие запросы часто попадают в одни и те же кластеры, поэтому hot buckets держатся в кеше, а не читаются с SSD каждый раз.
CREATE TABLE vecs_large (
id BIGINT NOT NULL,
embedding ARRAY<FLOAT> NOT NULL,
INDEX idx_emb (embedding) USING ANN PROPERTIES (
"index_type" = "ivf_on_disk",
"metric_type" = "l2_distance",
"dim" = "768",
"nlist" = "4096"
)
) ENGINE=OLAP
DUPLICATE KEY(id)
DISTRIBUTED BY HASH(id) BUCKETS 32
PROPERTIES ("replication_num" = "1"); Что нужно учитывать перед adoption:
- Cold-start latency: первое обращение к холодному bucket'у — это дисковое I/O. Для latency-sensitive сервисов нужен warmup-шаг в деплое.
- SSD обязателен: дизайн рассчитан на случайные чтения. Механические диски не подходят.
- Cache ratio: слишком большой кеш — возвращаемся к in-memory. Слишком маленький — latency jitter. Нужна нагрузочная калибровка под конкретный query pattern.
- Build time: дисковый индекс строится на 30–50% дольше in-memory IVF из-за дополнительных шагов разметки.
Часть 6. Компрессия — когда 3072 байта можно превратить в 64
Storage tiering решает проблему нехватки RAM за счёт SSD. Но размер самих векторов никуда не девается. Следующий рычаг — квантизация.
Два основных подхода:
Scalar quantization сжимает каждое измерение независимо: float32 (32 бита) → INT8 (4x compression) или INT4 (8x compression). Просто, дёшево в построении, ошибка предсказуема.
Product Quantization (PQ) — хитрее: делим 768-мерный вектор на m подвекторов, для каждого запускаем k-means и получаем кодбук. Каждый вектор кодируется как набор индексов кодовых слов. Степень сжатия выше и гибко настраивается.
Пример PQ-индекса в Doris 4.1 поверх дискового IVF:
CREATE TABLE vecs_pq (
id BIGINT NOT NULL,
embedding ARRAY<FLOAT> NOT NULL,
INDEX idx_emb (embedding) USING ANN PROPERTIES (
"index_type" = "ivf_on_disk",
"metric_type" = "l2_distance",
"dim" = "768",
"nlist" = "4096",
"quantizer" = "pq",
"pq_m" = "64",
"pq_nbits" = "8"
)
) ENGINE=OLAP
DUPLICATE KEY(id)
DISTRIBUTED BY HASH(id) BUCKETS 32
PROPERTIES ("replication_num" = "1");
Здесь pq_m=64 означает, что 768 измерений делятся на 64 сегмента по 12 измерений. pq_nbits=8 даёт 256 центроидов на сегмент. Итог: каждый вектор сжимается с 3072 байт до 64 — 48x compression.
Компромиссы. По данным оригинальной статьи, scalar quantization (4x–8x compression) обычно стоит 1–3% recall. PQ при более агрессивном сжатии теряет больше — конкретная цифра зависит от данных и параметров.
| Метод | Compression | Build cost |
|---|---|---|
| INT8 | 4x | Низкий |
| INT4 | 8x | Низкий |
| PQ | до 48x | Высокий |
Практическая рекомендация авторов: начните с INT8, убедитесь, что recall на вашем датасете приемлем, и только потом решайте, стоит ли переходить на PQ ради дополнительного сжатия.
Часть 7. Latency — 4x ускорение за счёт отказа от лишнего чтения
ANN-запрос состоит из трёх операций: выбор кандидатных buckets, вычисление расстояний до кандидатных векторов, ранжирование top-K. В колоночном движке вычисление расстояний требует случайных чтений из колонки с исходными векторами — и именно они доминируют в latency при большом nprobe × средний размер bucket'а.
Вопрос: если в результате нужны только ID и расстояния, зачем вообще читать исходную векторную колонку?
Реляционные базы данных давно знают этот приём — index-only scan: если индекс уже покрывает все нужные колонки, чтение базовой таблицы не нужно.
В Doris 4.1 это реализовано как Ann Index Only Scan: вычисление расстояний идёт по данным, уже хранящимся в индексе (или в его квантованной форме), без обращения к исходной колонке embedding.
На официальном бенчмарке (16 ядер, 64 ГБ, 1M × 768-мерных векторов, top-10 nearest neighbor) оптимизация даёт ~4x ускорение end-to-end, доводя итог до 900 QPS при 97% recall.
Ограничения:
- срабатывает только если в результатах не нужна сама векторная колонка. Запрос
SELECT embedding, ...идёт по обычному пути; - при квантованных индексах расстояния приблизительные. Для accuracy-sensitive задач можно включить reranking: сначала фильтруем кандидатов по квантованным расстояниям, потом вычисляем точные top-K по исходным векторам. Recall возвращается — ценой небольшого latency overhead;
- цифра 4x специфична для конкретной тестовой конфигурации. На ваших данных, вашем размере embedding и вашем железе будет по-другому. Нагрузочное тестирование обязательно.
Часть 8. Hybrid search — один SQL вместо двух систем
В hybrid search нативный vector search внутри аналитической БД работает иначе, чем в специализированных системах.
Задача 1: structured filters + vector search
У специализированных векторных баз два классических подхода к этой задаче, и оба имеют недостатки:
Post-filter (сначала vector, потом фильтр): ANN возвращает top-K, фильтр применяется в приложении. Проблема: если pass rate у фильтра 30%, в результате останется только K × 30% строк. Чтобы компенсировать, приходится увеличивать K с запасом, а это растит latency.
Pre-filter (сначала фильтр, потом vector): структурный фильтр сужает кандидатное множество, потом по нему считаются расстояния. Проблема: подмножество потеряло структуру IVF/HNSW, нужен brute-force по кандидатам. Latency растёт линейно от размера подмножества.
В аналитической базе у оптимизатора есть вся информация: selectivity предикатов, наличие индексов, статистика по данным. Он сам выбирает pre-filter или post-filter в зависимости от конкретного запроса — приложению не нужно это решать заранее.
SELECT
id,
name,
price,
l2_distance(embedding, [0.12, 0.08, ..., 0.31]) AS distance
FROM products
WHERE category = 'sneakers'
AND in_stock = TRUE
AND price BETWEEN 50 AND 200
ORDER BY l2_distance(embedding, [0.12, 0.08, ..., 0.31])
LIMIT 20; Оптимизатор смотрит на selectivity фильтра. Фильтр высокоселективный (маленькое подмножество)? Запускает его первым, считает точные расстояния. Фильтр слабоселективный? Сначала vector index, потом фильтрует результаты. Всё в рамках одного SQL-запроса.
Задача 2: fusion BM25 + vector search через RRF
Смешивать текстовый поиск с векторным — задача сложнее, потому что у них несопоставимые шкалы оценок: BM25 и L2-дистанция измеряют разные вещи. Взвешенное суммирование работает плохо.
Стандартное решение — Reciprocal Rank Fusion (RRF) (Cormack, Clarke, Buettcher, SIGIR 2009). Идея: вместо нормализации и сравнения сырых оценок берём только ранг каждого результата в каждом канале и суммируем 1 / (k + rank). Никакой нормализации, устойчивость к выбросам, легко добавить новый канал recall.
В Doris 4.1 весь пайплайн помещается в один SQL:
WITH
text_raw AS (
SELECT id, score() AS bm25
FROM hackernews
WHERE (`text` MATCH_PHRASE 'hybrid search'
OR `title` MATCH_PHRASE 'hybrid search')
AND dead = 0 AND deleted = 0
ORDER BY score() DESC
LIMIT 1000
),
vec_raw AS (
SELECT id, l2_distance_approximate(`vector`, [0.12, 0.08, ...]) AS dist
FROM hackernews
ORDER BY dist ASC
LIMIT 1000
),
text_rank AS (
SELECT id, ROW_NUMBER() OVER (ORDER BY bm25 DESC) AS r_text FROM text_raw
),
vec_rank AS (
SELECT id, ROW_NUMBER() OVER (ORDER BY dist ASC) AS r_vec FROM vec_raw
),
fused AS (
SELECT id, SUM(1.0 / (60 + rank)) AS rrf_score
FROM (
SELECT id, r_text AS rank FROM text_rank
UNION ALL
SELECT id, r_vec AS rank FROM vec_rank
) t
GROUP BY id
ORDER BY rrf_score DESC
LIMIT 20
)
SELECT f.id, h.title, h.text, f.rrf_score
FROM fused f
JOIN hackernews h ON h.id = f.id
ORDER BY f.rrf_score DESC;
Четыре шага: инвертированный индекс и ANN-индекс работают параллельно, каждый берёт свой top-N; результаты ранжируются по своим оценкам; для каждого кандидата суммируется 1/(k+rank) по всем каналам; финальный top-K идёт в основную таблицу только за display-колонками.
Важно: ценность такого подхода не в самом RRF. RRF можно реализовать хоть в приложении. Практическая польза SQL-слоя в другом: оба канала retrieval смотрят на одни и те же данные, живут в одной transactional visibility и не требуют отдельной синхронизации между системами. Если потом нужно добавить третий канал — например, сигнал по времени, категории или профилю пользователя — он добавляется в тот же pipeline.
Практические оговорки:
- точность selectivity estimation у оптимизатора не идеальна для сложных предикатов. Рекомендуется проверять план через
EXPLAINперед выходом в прод; - индексы на структурных колонках критически важны — без них высокая cardinality не превращается в эффективный pre-filter;
- RRF выбрасывает информацию о силе сигнала. Если очень высокий BM25-скор у одного документа важен, стоит добавить reranking-модель поверх RRF-результатов;
- если workload почти всегда pure-vector и редко использует structured filters или multi-channel fusion, преимущество аналитической БД в этом разделе становится менее важным.
Часть 9. Бенчмарки — читаем аккуратно
VectorDBBench, публичные данные январь 2026, датасет 1M × 768-мерных векторов:
| Система (конфигурация) | QPS | Recall | Время индексации |
|---|---|---|---|
| Milvus 2.2.12 (HNSW, 16c64g) | 1 259 | 0.9799 | 581.8 с |
| Qdrant Cloud 1.14.1 (16c64g) | 1 242 | 0.9474 | 1 500 с |
| OpenSearch 2.17 (16c128g) | 950.6 | 0.9140 | 3 572 с |
| Apache Doris 4.1 (mixture, 16c64g) | 895 | 0.9764 | 397 с |
| S3Vectors | 199.5 | 0.8717 | 2 971 с |
| Weaviate Cloud (business_critical) | 67.91 | 0.9909 | 3 674 с |
| Weaviate Cloud (standard) | 63.14 | 0.9910 | 3 581 с |
| pgvector (2c8g) | 10.63 | 0.8898 | 10 250 с |
Источник: VectorDBBench public test, January 2026. «mixture» — hybrid index configuration, которую Doris использует в этом benchmark.
Важная оговорка, которую легко пропустить: конфигурации железа неодинаковы. OpenSearch — 16c128g (в два раза больше RAM). pgvector — 2c8g. Weaviate Cloud и S3Vectors — managed services, где железо выбирает платформа. Прямое сравнение строк в таблице работает только если вы держите в голове эти различия.
Как читать каждое измерение:
- Время индексации. Doris 4.1 (397 с) — быстрее всех в таблице: примерно на 30% быстрее Milvus HNSW и на порядок быстрее pgvector. Это важно для batch cold-starts, перестройки индекса после смены embedding-модели, исторических backfill'ов.
- QPS. Milvus и Qdrant лидируют на уровне 1200+. Doris 4.1 (895) и OpenSearch (950.6) — второй эшелон. Weaviate Cloud и pgvector — ниже, но там другая задача.
- Recall. Оба Weaviate-конфига (~0.991) — лучшие. Milvus, Doris, Qdrant кластеризуются выше 0.94. OpenSearch — 0.914. Ниже 0.9 — pgvector и S3Vectors.
Данные не говорят «кто лучше». Каждая система находится в своей точке трёхмерного пространства: recall × throughput × build cost. Высокий QPS часто сопровождается более низким recall (Qdrant — 0.947 при 1242 QPS). Высокий recall — более низким QPS (Weaviate). Doris показывает лучшее время индексации при средне-высоком QPS и recall.
Практический вывод: смотрите на то измерение, которое критично именно для вашей задачи. Частые перестройки индекса? Вес времени индексации растёт. Жёсткий p99 или recall floor? Нагрузочные тесты на своих данных обязательны, таблица их не заменяет.
Часть 10. Границы применимости — когда это имеет смысл, а когда нет
В оригинальной статье границы сформулированы явно.
Хорошо подходит, если:
- пайплайн уже работает на Doris (или миграция запланирована), и добавлять ещё одну систему только ради vector search нежелательно;
- запросы смешанные: vector + structured filters, или vector + BM25, или всё вместе;
- масштаб: от миллионов до миллиардов векторов;
- цель — сократить количество систем и operational overhead;
- приемлемо потерять 1–3% recall в обмен на архитектурную простоту.
Плохо подходит, если:
- workload чисто векторный и зависит от специфичных фич специализированных систем: GPU acceleration, нестандартные distance functions, особые варианты ANN;
- очень большой масштаб (десятки миллиардов и выше) с жёсткими требованиями к p99 — перед принятием решения нужны таргетированные нагрузочные тесты;
- structured data в принципе не нужна, и цели консолидировать стек нет.
Эти границы не статичны. По мере развития оптимизатора и появления новых типов индексов часть «плохо подходит» будет постепенно переходить в «хорошо подходит».
Часть 11. Матрица выбора
Вместо «кто лучше» — «кто для чего»:
| Характеристика workload | Рекомендация | Логика |
|---|---|---|
| Пайплайн уже на аналитической БД | Native (Doris и аналоги) | Нет двойной записи, гибридные запросы в SQL |
| Vector + structured filters (e-commerce, RAG, рекомендации) | Native | Оптимизатор сам планирует pre/post-filter |
| Чистый vector workload с требованиями к QPS или p99 | Dedicated vector DB | Глубокая специализация, зрелый инструментарий |
| Billion-scale, чистый vector, cost-sensitive | Зависит от стека | У обоих вариантов есть жизнеспособные пути |
| Малый и средний масштаб, строгие транзакционные требования | Расширение к transactional DB | Транзакционная консистентность — жёсткое требование |
| GPU, нестандартные функции расстояния, специфичные ANN | Dedicated vector DB | Зрелая поддержка специализированного железа |
Итог
История про Doris 4.1 — это история про архитектурный выбор: когда имеет смысл интегрировать vector search в существующий аналитический стек, а не добавлять новую систему.
Цифры: 900 QPS при 97% recall на 16c64g машине, 1M × 768-мерных векторов. Самое быстрое время построения индекса в публичном бенчмарке. Гибридные запросы — BM25 + vector + structured filters — в одном SQL.
Ограничения: второй эшелон по throughput относительно специализированных Milvus/Qdrant, hardware в бенчмарках неодинаков, оптимизатор ещё зреет.
Для команды, которая уже работает с Apache Doris и хочет добавить семантический поиск в пайплайн — это очевидный первый вариант для оценки. Для команды с pure-vector workload, где нужны максимальный QPS и recall на уровне 0.99+ — специализированные системы по-прежнему сильнее.
Самое полезное, что можно сделать с этой статьёй: сформулировать свой workload по четырём вопросам из третьего раздела (память, latency, гибридность, recall) и смотреть на системы через призму именно своих приоритетов, а не строчек в бенчмарке.