Qdrant: медленный поиск по векторам — причины и решение
Коллекция на полмиллиона векторов, запрос на десять ближайших — и полсекунды вместо единиц миллисекунд. Qdrant медленно работает почти всегда по одной из пяти конкретных причин, и три из них видны в единственном ответе /collections/{name}. Разберём, как отличить полный перебор от HNSW, фильтр без индекса от нехватки памяти, а проблему сервера — от проблемы клиента.
Содержание
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →Сначала цифры: сервер или клиент
Нужны два числа: сколько потратил Qdrant и сколько прождал клиент. Разница — цена сети, TLS и клиентской библиотеки.
Первое Qdrant сообщает сам: каждый ответ REST API содержит поле time — длительность обработки в секундах.
curl -s -X POST http://127.0.0.1:6333/collections/docs/points/query \
-H 'Content-Type: application/json' -H "api-key: $QDRANT_API_KEY" \
-d @query.json | jq '.time'
curl -s -o /dev/null -X POST http://127.0.0.1:6333/collections/docs/points/query \
-H 'Content-Type: application/json' -H "api-key: $QDRANT_API_KEY" -d @query.json \
-w 'conn=%{time_connect} ttfb=%{time_starttransfer} total=%{time_total} size=%{size_download}\n'
timeблизко кtotal, оба большие — тормозит поиск: разделы со второго по пятый.timeтри миллисекунды,total— четыреста. Qdrant ни при чём: сеть, TLS, клиент — шестой раздел. Самый частый случай, когда приложение и база в разных дата-центрах.size_downloadв мегабайтах — вы тащите payload целиком. Медленный не поиск, а передача.
В динамике то же дают GET /metrics (средняя длительность по эндпоинтам) и GET /telemetry?details_level=3 с разбивкой по сегментам.
curl -s http://127.0.0.1:6333/metrics | grep -E 'responses_(avg_duration|total)'
curl -s 'http://127.0.0.1:6333/telemetry?details_level=3' | jq '.result.requests'
Первый запрос после старта всегда медленнее — сегменты подтягиваются в страничный кэш. Прогоняйте запрос десяток раз и смотрите на установившееся значение.
Полный перебор вместо HNSW: главная причина
Самая частая причина — индекса HNSW нет, и Qdrant перебирает все векторы подряд. На десяти тысячах точек незаметно, на миллионе — уже секунды.
curl -s http://127.0.0.1:6333/collections/docs -H "api-key: $QDRANT_API_KEY" \
| jq '.result | {status, optimizer_status, points_count, indexed_vectors_count, segments_count}'
Ключевая пара — points_count и indexed_vectors_count. Миллион точек и ноль проиндексированных векторов означают, что каждый запрос — линейный проход по всей коллекции.
Qdrant не строит HNSW сразу: сегмент индексируется, когда объём непроиндексированных векторов превысит indexing_threshold, по умолчанию 20000 килобайт — на маленьком сегменте перебор и правда быстрее обхода графа. Документированный приём ускорения загрузки — выставить indexing_threshold: 0, чтобы оптимизатор не мешал записи, но ноль означает «не индексировать никогда», и вернуть значение забывают через раз.
curl -X PATCH http://127.0.0.1:6333/collections/docs \
-H 'Content-Type: application/json' -H "api-key: $QDRANT_API_KEY" \
-d '{"optimizers_config": {"indexing_threshold": 20000}}'
Все значения поля status разобраны в статье про частые ошибки Qdrant; здесь пригодится один нюанс, которого там нет. Серый статус сразу после массовой загрузки почти всегда значит именно нулевой порог, а не поломку — в веб-панели рядом появляется кнопка Trigger Optimizers — запустить индексацию вручную, не дожидаясь новых вставок.
Второй способ получить перебор — включить его руками: "params": {"exact": true} выключает HNSW. Полезно разово — снять эталонную выдачу и посчитать полноту; оставленный в боевом коде, параметр гарантирует линейное время.
Развернуть за пару минут
Готовый образ на VPS MAATRIX: NVMe, AMD EPYC, root-доступ. Локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Развернуть QdrantФильтры без payload-индекса
Второй по частоте сценарий: чистый векторный поиск летает, а с filter задержка растёт на порядок. Причина одна — по полю фильтра не создан payload-индекс.
Планировщик оценивает кардинальность фильтра и выбирает стратегию: обход HNSW с проверкой условия, точный перебор по отфильтрованному подмножеству (если оно меньше full_scan_threshold, по умолчанию 10000 килобайт) или фильтруемый HNSW. Без payload-индекса кардинальность не оценивается вообще, и выбор идёт вслепую — обычно в обход графа, где почти каждый сосед отбраковывается фильтром.
curl -X PUT 'http://127.0.0.1:6333/collections/docs/index?wait=true' \
-H 'Content-Type: application/json' -H "api-key: $QDRANT_API_KEY" \
-d '{"field_name": "tenant_id", "field_schema": {"type": "keyword", "is_tenant": true}}'
- Тип имеет значение.
keywordдля идентификаторов и категорий,integerиfloatдля диапазонов,datetimeдля дат,uuidдля UUID,textдля полнотекстового условия. Индексtextпод фильтр точного совпадения не работает. is_tenant: true— для поля арендатора: при оптимизации Qdrant соберёт векторы одного тенанта рядом, чтение с диска станет последовательным. Поле только одно, а глобальные запросы без фильтра станут медленнее.- Порядок операций. Фильтруемый HNSW строит дополнительные рёбра с учётом payload-полей в момент построения графа. Индекс, созданный позже, связей не добавит, пока сегмент не переиндексируется: заводите payload-индексы до загрузки.
Чтобы не наступить на это повторно, включите строгий режим коллекции: при unindexed_filtering_retrieve: false фильтр по неиндексированному полю возвращает 400 вместо тихого замедления.
{"status":{"error":"Bad request: Index required but not found for \"tenant_id\" of one
of the following types: [keyword]. Help: Create an index for this key or use a
different filter."},"time":0.0002}
Там же max_query_limit, search_max_hnsw_ef и search_allow_exact: false — потолки, за которые приложение не выйдет.
Память и диск: когда векторы не помещаются в RAM
Индекс построен, фильтры проиндексированы, а поиск вязкий и задержка плавает — почти наверняка вы читаете с диска. Обход HNSW — россыпь случайных обращений к памяти: пока векторы и связи в RAM, шаг стоит микросекунды, а в mmap-файлах, которые не удержал страничный кэш, каждый шаг превращается в случайное чтение. На сетевом хранилище это катастрофа: Qdrant прямо не рекомендует держать storage там.
vmstat 1 5 # si/so — своп; wa в блоке cpu — ожидание диска
free -m # available и buff/cache: сколько осталось под кэш
iostat -x 1 3 # r_await и %util по устройству с /qdrant/storage
Высокий wa и r_await в десятки миллисекунд — приговор однозначный. Формула из документации даёт потребность в памяти: число векторов × размерность × 4 байта × 1.5, полуторный коэффициент — под связи графа. Миллион векторов размерности 1024 — около 6,1 ГБ под векторную часть, плюс payload и система. Подробный расчёт — в материале сколько RAM нужно для Qdrant.
Если памяти меньше, а расширяться прямо сейчас нельзя:
- Уберите payload из RAM. Флаг
on_disk_payload: trueоставляет в памяти только векторы: десять чтений на выдачу — не то же, что сотни случайных обращений при обходе графа. - Держите в памяти квантованные векторы, оригиналы на диске — самый эффективный размен, о нём ниже. На своп не рассчитывайте: ядро выгружает как раз те страницы, что нужны обходу.
- Проверьте диск.
%utilпод сотню при скромной нагрузке — вопрос не к Qdrant; как измерить, в статье медленный диск на VPS.
Легко упустить ещё причину — много удалённых точек: удаление лишь помечает вектор, из графа он исчезает при оптимизации, и пока доля не превысила deleted_threshold (0.2 при минимуме 1000 векторов на сегмент), поиск ходит по мёртвым узлам. Частое «удалить и записать заново» вместо обновления даёт именно такие просадки.
Параметры поиска и квантование
Здесь настоящий размен между скоростью и полнотой, и здесь же чаще всего крутят не то.
hnsw_ef — ширина луча: сколько кандидатов алгоритм держит в очереди на каждом шаге. Задаётся в запросе, а если не передать, Qdrant возьмёт ef_construct из конфигурации индекса, то есть по умолчанию 100. Самая дешёвая ручка: время растёт линейно, полнота — логарифмически, так что выставленные кем-то 512 «для качества» стоят кратной задержки почти без пользы.
{"query": [0.2, 0.1, 0.9], "limit": 10, "params": {"hnsw_ef": 64}}
m и ef_construct менять на живой коллекции дорого — нужна переиндексация. m (16) — число связей на узел: поднимать до 32–64 осмысленно на высоких размерностях, когда не хватает полноты, но память под граф растёт пропорционально. ef_construct (100) на скорость поиска не влияет вовсе.
Квантование — самый действенный способ ускориться при упоре в память: скалярное int8 сокращает векторную часть вчетверо.
curl -X PATCH http://127.0.0.1:6333/collections/docs \
-H 'Content-Type: application/json' -H "api-key: $QDRANT_API_KEY" \
-d '{"quantization_config": {"scalar": {"type": "int8", "quantile": 0.99, "always_ram": true}},
"vectors": {"": {"on_disk": true}}}'
always_ram: true держит квантованные векторы в памяти, on_disk: true отправляет оригиналы на диск: обход графа идёт по компактным векторам, полные читаются только для уточнения. Миллион векторов размерности 1024 в int8 — около гигабайта вместо шести.
И тут же ловушка: rescore с большим oversampling съедает весь выигрыш. При oversampling: 4.0 и limit: 10 Qdrant отберёт сорок кандидатов и пересчитает расстояния по оригиналам — сорок случайных чтений с диска на запрос, если оригиналов нет в кэше.
{"query": [0.2, 0.1, 0.9], "limit": 10,
"params": {"quantization": {"rescore": true, "oversampling": 2.0}}}
Начните с oversampling: 2.0, замерьте полноту относительно эталона с "exact": true, опускайте до 1.5 или отключайте rescore. Бинарное квантование сжимает до тридцати двух раз, но на компактных векторах вроде 384 полнота падает так, что поиск теряет смысл.
Клиент, сеть и конкуренция за процессор
Вернёмся к случаю из первого раздела: сервер отвечает за три миллисекунды, приложение ждёт полсекунды. Крутить hnsw_ef тут бесполезно.
Протокол. Qdrant слушает два порта: 6333 — REST, 6334 — gRPC. Векторы в JSON едут текстовыми списками чисел: размерность 1024 занимает около 10–12 КБ вместо 4 КБ в бинарном виде.
from qdrant_client import QdrantClient
client = QdrantClient(url="https://vec.example.com", port=6333, grpc_port=6334,
api_key=API_KEY, prefer_grpc=True, timeout=30)
Один клиент на приложение. Классическая ошибка — создавать QdrantClient внутри обработчика запроса: каждый вызов — новое TCP-соединение и TLS-рукопожатие, лишние два-три RTT до того, как Qdrant увидит запрос. Держите клиент глобальным (он потокобезопасен), а десять запросов шлите не десятью кругами, а одним client.query_batch_points(...).
Размер ответа. Векторы по умолчанию не возвращаются, а payload — целиком, и если в нём текст чанка, ответ на limit: 50 становится мегабайтным: просите нужные поля через with_payload: ["title", "url"]. И не листайте выдачу большим offset — Qdrant получит offset + limit результатов и выбросит лишние.
Тайм-ауты. ResponseHandlingException: The read operation timed out у HTTP-клиента и DEADLINE_EXCEEDED у gRPC — сообщение о том, что терпение кончилось, а не диагноз.
Конкуренция за процессор. Поиск в HNSW — целиком процессорная работа, а одновременно с ним оптимизатор может строить индекс. Отсюда «раз в час всё тормозит».
storage:
performance:
max_search_threads: 0 # 0 = по числу ядер
optimizer_cpu_budget: 0 # 0 = авто; отрицательное = оставить N ядер свободными
optimizer_cpu_budget: -2 оставит два ядра поиску даже при тяжёлой переиндексации. Проверьте и лимит открытых файлов: сегментов с mmap много, и в логе появляется Too many open files (os error 24) — лечится строкой LimitNOFILE=65535 в юните systemd или ключом --ulimit nofile=65535:65535 в Docker.
Наконец, проверьте, каким методом ходит код: универсальный /points/query появился в 1.10 и заменил /points/search, в 1.18 устаревшие методы удалены совсем. Обновляйтесь с оглядкой: 1.17 убрала RocksDB в пользу Gridstore, прямой переход с 1.15 не поддерживается — только через 1.16. Разбор типовых поломок — в статье Qdrant на сервере: частые ошибки.
Какой сервер под Qdrant брать в MAATRIX
Профиль нагрузки специфический: нужны память и быстрый диск, а процессор важен в момент запроса и во время построения индекса.
Честный минимум: 2 vCPU, 4 ГБ RAM, 40 ГБ NVMe. Хватает на базу знаний в пару сотен тысяч чанков: 300 000 векторов размерности 768 по формуле дают около 1,4 ГБ, остальное — payload, кэш и система. Ограничение называю прямо: рядом не поместится ни модель эмбеддингов, ни Ollama, а построение индекса на двух ядрах подтормаживает поиск — массовую загрузку планируйте на ночь.
Комфортный вариант: 4 vCPU, 16 ГБ RAM, 160 ГБ NVMe. Полтора миллиона векторов размерности 1024 целиком в памяти либо пять-шесть миллионов с int8-квантованием. Диск берите с запасом в два-три раза от объёма векторов: оптимизатору нужно место под новый сегмент рядом со старым, а снапшот через POST /collections/{name}/snapshots займёт столько же, сколько коллекция. Не переподписывайте ядра: обход графа однопоточный, и предсказуемая p99 требует выделенных ресурсов.
Локация — Лондон (UK). Причина из шестого раздела: RAG делает несколько обращений к базе на один ответ, и лишние 80 мс RTT умножаются на их число — держите базу и приложение на одной площадке. Британский узел даёт низкий пинг до Европы и до российских пользователей, понятный правовой контур для документов европейских контрагентов. Франция закрывает тот же сценарий, США (Нью-Йорк) — если эмбеддинги считаются через API OpenAI, Россию — под 152-ФЗ.
Qdrant из каталога apps.maatrix.io ставится автоматически при заказе сервера — команды вводить не нужно, работает на Ubuntu и на Debian. Адрес панели и API-ключ появятся в личном кабинете, в разделе «Доступ»: останется создать коллекцию, сразу завести payload-индексы по полям фильтрации и только потом заливать векторы. Оплата картами российских банков, по СБП, криптовалютой или токеном MAAT. Как собирается стек целиком — в материале как поднять RAG по своим документам.
Развернуть за пару минут
Готовый образ на VPS MAATRIX: NVMe, AMD EPYC, root-доступ. Локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Развернуть QdrantОбсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →Частые вопросы
После загрузки миллиона точек поиск стал занимать секунды.
Почти наверняка перед загрузкой выставили indexing_threshold: 0, и HNSW не строится. Проверьте indexed_vectors_count в /collections/{name}: если он сильно меньше points_count, верните порог в 20000 через PATCH и дождитесь зелёного статуса.
Без фильтра запрос идёт 5 мс, с фильтром — 400 мс. Индекс же построен.
Построен векторный, а не payload-индекс. Без него планировщик не оценивает кардинальность фильтра и выбирает худшую стратегию. Создайте индекс нужного типа через PUT /collections/{name}/index и включите строгий режим, чтобы такие запросы отклонялись, а не тормозили.
Включил квантование, а стало медленнее.
Оригиналы уехали на диск, а rescore с большим oversampling заставляет читать их обратно на каждом запросе. Поставьте always_ram: true для квантованных векторов, снизьте oversampling до 2.0 и сверьте полноту с эталоном, полученным при "exact": true.
Нужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.