MAATRIX / Блог / Qdrant: медленный поиск по векторам — причины и решение

Qdrant: медленный поиск по векторам — причины и решение

Qdrant: медленный поиск по векторам — причины и решение

MAATRIX

Коллекция на полмиллиона векторов, запрос на десять ближайших — и полсекунды вместо единиц миллисекунд. 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 — десятки моделей в одном окне. Оплата картой РФ и по СБП.