Дефолтные параметры HNSW из туториалов — это гарантированный Out Of Memory на проде при попытке загрузить базу чуть больше игрушечной. Я вообще запрещаю тащить этот алгоритм в проект, пока не будет жёстко обоснован бюджет по памяти узла и SLA на latency. Графовые индексы жрут RAM как не в себя.
— «Постойте, но все блоги пишут, что HNSW — это state of the art для векторного поиска. Ставишь M=16, ef_construction=200 и оно летит».
Летит оно только на датасете из ста тысяч векторов. Когда у вас их десятки миллионов, а железо не резиновое, эти догадки заканчиваются переполнением памяти, выселением в своп и деградацией p99 latency до секунд. Векторный поиск на edge-устройствах или выделенных железках клиента не прощает инженерии наугад.
HNSW тюнинг руками: M, ef_construction и ef_search без догадок
Чтобы управлять системой, нужно понимать физику её работы. Никакой магии в аббревиатурах нет.
Параметр M — это максимальное количество двунаправленных связей (рёбер), которые алгоритм создаёт для каждого нового узла на базовом уровне графа. Это прямой мультипликатор потребления оперативной памяти. Каждое ребро — это указатель (обычно 4 байта). Рост M с 16 до 64 делает граф более плотным, что критически важно для высокоразмерных датасетов сложной топологии. Точность поиска (recall) растёт. Но вместе с ней кратно растёт объём метаданных узлов.
— «Но ведь M=64 всегда даёт лучшую точность, почему не поставить максимум?»
Потому что граф перестаёт влезать в L3-кэш процессора. HNSW — это memory-bound алгоритм. Как только метаданные узла становятся слишком толстыми, обход графа превращается в череду cache misses. Вы платите за прирост recall астрономическим падением пропускной способности (throughput) и скачками latency на случайном доступе к RAM.
ef_construction — размер динамического списка ближайших соседей, который используется только при построении индекса. Чем шире этот луч, тем качественнее алгоритм находит истинных соседей для прокладки рёбер. Высокий ef_construction поднимает потолок достижимой точности графа, но делает процесс индексации мучительно долгим. Если этот параметр изначально занижен, граф получится «рыхлым» с локальными оптимумами — и никакой тюнинг при поиске это уже не исправит.
ef_search — размер луча при поиске. Единственный параметр, который можно дёргать в рантайме без перестроения индекса. Балансирует между скоростью ответа и качеством выдачи.
Методика подбора под SLA
— «Зачем вникать в физику, если можно прогнать grid search по всем трем параметрам и найти оптимум?»
Потому что перебор M и ef_construction требует полного перестроения графа с нуля на каждой итерации. На 100 миллионах векторов ваш наивный grid search завершится примерно в следующем году.
Правильный тюнинг идёт от бизнес-требований. Сначала мы фиксируем целевую метрику качества. Допустим, продакт требует recall@10 не ниже 0.95. Дальше считаем жёсткий лимит по RAM, который мы можем отдать под индекс, и вычисляем максимально допустимый параметр M.
Формула простая: размер вектора + объём рёбер. Если мы упираемся в память, M придётся резать. После фиксации M и сборки графа с умеренным ef_construction (например, 100-200), мы начинаем калибровку.
def calibrate_ef_search(index, test_queries, ground_truth, target_recall=0.95):
# Идём от быстрого к точному
for ef in [10, 20, 50, 100, 200, 400, 800]:
index.set_ef(ef)
start_t = time.perf_counter()
labels, _ = index.knn_query(test_queries, k=10)
latency = time.perf_counter() - start_t
recall = compute_recall(labels, ground_truth)
if recall >= target_recall:
return ef, latency, recall
raise RuntimeError("Target recall unreachable. Rebuild index with higher M or ef_construction.")Если цикл выбросил RuntimeError, значит ваш граф физически не содержит нужных путей. Увеличение ef_search до бесконечности будет просто сканировать бесполезные узлы, убивая latency, но не принося новых релевантных векторов. Только в этот момент вы имеете техническое обоснование пойти и увеличить M или ef_construction, заплатив временем билда.
Иллюзия идеального графа: квантизация и мусорные вектора
— «Если граф не влезает в память при нужном M, мы просто включим квантизацию, и HNSW ужмётся в 10 раз!»
Ужмётся. И вытащит на свет другие проблемы. Квантизация векторов — это всегда потеря информации. На практике мы используем три подхода:
- Scalar Quantization (SQ8): Сжимает float32 в int8. Практически бесплатно по latency, режет потребление RAM под векторы в 4 раза, но откусывает 1-3% от итогового recall. Самый частый дефолт в проде.
- Product Quantization (PQ): Дробит вектор на подпространства и хранит только индексы центроидов. Позволяет сжать базу в 10-20 раз, но добавляет мощный CPU-оверхед на асимметричное вычисление дистанций во время обхода графа.
- Binary Quantization: Экстремальный сценарий. Векторы становятся битовыми строками, дистанция вычисляется через XOR (расстояние Хэмминга). Граф летает, но это работает только если модель эмбеддингов изначально обучалась выдавать бинарные вектора. Иначе recall пробивает дно.
Но есть одна фундаментальная проблема HNSW, которую не скроет никакая квантизация. Этот алгоритм отвратительно работает с частыми обновлениями и удалениями.
Архитектура графа не позволяет физически вырвать узел из середины сложной структуры, не сломав связность сети. Поэтому удалённые векторы просто помечаются логическим флагом (tombstones). Граф сохраняет свой объём в памяти, а при поиске алгоритм тратит такты процессора на обход «мёртвых» душ. Если в вашей базе происходит постоянная ротация данных, HNSW быстро деградирует. Потребуются регулярные и ресурсоёмкие ребилды графа сбоку, с последующим горячим свапом индексов.
Честный трейд-офф заключается в том, что HNSW — это король статических или редко меняющихся баз при наличии избытка оперативной памяти. Если же ваша база активно мутирует каждую секунду, или бюджет RAM на edge-устройстве жёстко ограничен гигабайтом, связка классического Inverted File (IVF) и PQ даст гораздо более предсказуемый профиль нагрузки. Она хуже по recall на сверхвысоких требованиях, но не упадёт с OOM посреди ночи.