Почему падает recall HNSW-индекса?
Падение recall HNSW диагностируют через exact baseline, распределение расстояний, фильтры, обновления коллекции и параметры обхода.

Падение recall в HNSW почти всегда пытаются лечить не с той стороны. Команда видит плохую выдачу, поднимает ef_search, получает лишние миллисекунды и объявляет инцидент закрытым. Через неделю recall снова проседает, потому что причиной был новый эмбеддер, смешанные версии векторов, фильтр после поиска или неполная загрузка коллекции.
HNSW не знает, что документ устарел, что текст нарезали другим чанкером или что половина записей в новом сегменте получила векторы без L2-нормализации. Он обходит граф для тех чисел, которые ему дали. Поэтому диагностика начинается с жесткого разделения двух вопросов: точный поиск по текущим векторам по-прежнему находит ожидаемые документы, и насколько хорошо HNSW повторяет этот точный поиск.
Оригинальная работа Malkov и Yashunin описывает HNSW как иерархический граф приближенного поиска. Его скорость возникает из ограниченного обхода графа, а не из обещания всегда вернуть точный top-k. Документация Faiss прямо отделяет исчерпывающий IndexFlat от IndexHNSWFlat, а документация OpenSearch связывает более высокий ef_search с лучшим recall и большей задержкой. Это полезная отправная точка, но не диагноз.
Сначала докажите, что ухудшился именно HNSW
Падение recall HNSW означает, что приближенный top-k хуже совпадает с точным top-k на идентичных данных. Оно не означает, что пользователи стали получать менее полезные ответы.
В продакшене обычно смешивают три разных показателя. Первый, продуктовый, отвечает на вопрос, нашел ли retrieval документ, который помогает пользователю. Второй, векторный, измеряет качество эмбеддингов: попадает ли нужный документ в точный top-k по выбранной метрике. Третий, ANN recall, показывает, сколько элементов из точного векторного top-k дошло через HNSW.
Если продуктовая метрика упала, а ANN recall стабилен, не трогайте параметры графа. Проверяйте модель эмбеддингов, разбиение документов, язык запросов, правила доступа и reranker. Если ANN recall упал, а точный поиск по вектору выглядит осмысленно, тогда есть основания разбирать индекс и путь обновления данных.
Есть и более неприятный случай: точный top-k сам стал плохим. Это бывает после смены эмбеддера, изменения нормализации, преобразования текстов или добавления большого домена документов с другой терминологией. HNSW в такой ситуации может честно повторять плохой эталон с recall 0.99.
Зафиксируйте определение метрики до любых экспериментов:
recall@k = |ANN_top_k ∩ exact_top_k| / k
Сравнивайте именно идентификаторы документов или чанков. Не подменяйте recall средней similarity. Средняя similarity может почти не меняться, хотя HNSW систематически не находит один или два наиболее близких элемента.
Эталон должен использовать тот же снимок коллекции
Точный и приближенный поиск нужно запускать по одному снимку данных, иначе измерение не имеет смысла.
Для каждого контрольного прогона сохраняйте как минимум: версию коллекции, число живых точек, версию эмбеддера, размерность вектора, метрику, статус нормализации, параметры фильтра и список query ID. Если точный поиск выполнили до ночной загрузки, а HNSW проверили после нее, вы измерили изменение корпуса вместе с качеством индекса.
Ниже минимальный контрольный расчет для cosine similarity. Он годится для выгрузки контрольного набора в NumPy и намеренно не зависит от конкретной векторной базы.
import numpy as np
# db: float32 [n_vectors, dim], q: float32 [n_queries, dim]
# ids: int64 [n_vectors], ann_ids: int64 [n_queries, k]
def normalize(x):
norms = np.linalg.norm(x, axis=1, keepdims=True)
return x / np.clip(norms, 1e-12, None)
def exact_topk_cosine(db, ids, q, k):
db = normalize(db.astype(np.float32))
q = normalize(q.astype(np.float32))
scores = q @ db.T
pos = np.argpartition(-scores, kth=k - 1, axis=1)[:, :k]
row = np.arange(q.shape[0])[:, None]
pos = pos[row, np.argsort(-scores[row, pos], axis=1)]
return ids[pos], scores[row, pos]
def recall_at_k(exact_ids, ann_ids):
hits = []
for truth, found in zip(exact_ids, ann_ids):
hits.append(len(set(truth.tolist()) & set(found.tolist())) / len(truth))
return float(np.mean(hits))
exact_ids, exact_scores = exact_topk_cosine(db, ids, q, k=20)
print({
"recall_at_20": recall_at_k(exact_ids, ann_ids),
"queries": len(q),
"exact_first_score_median": float(np.median(exact_scores[:, 0]))
})
Форма вывода должна быть скучной и постоянной:
{'recall_at_20': 0.9475, 'queries': 400, 'exact_first_score_median': 0.7812}
Не используйте для эталона другой SDK, который молча округляет векторы, применяет другой оператор расстояния или добавляет post-filter. Точный расчет должен получать те же массивы, что и индекс. В частности, cosine через скалярное произведение корректен только если вы нормализовали и базу, и запросы одинаково.
Дрейф данных меняет геометрию даже без смены модели
Дрейф данных означает, что распределение векторов, запросов или их соответствие друг другу изменилось. Это не то же самое, что поломка графа.
Представьте базу инструкций по банковским продуктам, где документы годами лежали в относительно ровных тематических кластерах. Затем в нее массово добавили короткие журналы операций, шаблонные уведомления и дублирующиеся выдержки из регламентов. Модель эмбеддингов не менялась. Но плотные островки почти одинаковых векторов выросли, а старые запросы теперь получают десятки кандидатов с очень близкими расстояниями.
В такой базе могут одновременно происходить два события. Пользовательское качество падает, потому что точный top-k забивается почти дубликатами. ANN recall по ID тоже может снизиться, потому что у точного top-k много почти равноценных соседей, и HNSW выбирает другой элемент из плотной группы. Второй эффект выглядит как проблема индекса, хотя сначала надо проверить стабильность самого эталона.
Смотрите не только на среднюю дистанцию первого соседа. Сохраняйте для каждого запроса:
- расстояние или similarity первого точного соседа;
- зазор между первым и k-м соседом;
- зазор между k-м и k+1-м соседом;
- долю результатов из каждого среза данных;
- долю почти дублирующихся чанков в top-k.
Для cosine similarity маленький разрыв score[k] - score[k+1] означает, что граница top-k хрупкая. Один документ может выпасть из точного списка при минимальном численном различии, а другой займет его место. В этом режиме полезно считать еще и recall@50 при пользовательском k=10, либо оценивать попадание в более широкий пул кандидатов. Это не оправдание плохого HNSW. Это защита от ложного вывода по нестабильной границе.
Сравнивайте распределения отдельно для старого и нового корпуса, а также для старых и свежих запросов. Если exact-first similarity, разрывы и состав топа изменились, данные дрейфуют. Если распределения точного поиска прежние, а ANN все чаще пропускает очевидные точные соседства, подозрение переходит к индексу.
Рост ef_search отделяет дефицит обхода от остальных причин
Если recall заметно растет вместе с ef_search, HNSW не успевает исследовать нужную часть графа при текущем бюджете обхода.
ef_search задает число векторов, которые поиск рассматривает при HNSW-обходе. OpenSearch формулирует компромисс прямо: большее значение улучшает recall, но повышает задержку. В Faiss тот же параметр называется глубиной исследования. Его нужно подбирать по измеренной кривой, а не выбирать один раз по чужому примеру.
Проведите один эксперимент на зафиксированном снимке. Не меняйте параллельно модель, реплики, сжатие, фильтр и состав запросов.
- Возьмите frozen-набор запросов, где есть обычные, редкие и проблемные запросы.
- Выполните точный поиск и сохраните top-20.
- Прогоните HNSW с несколькими возрастающими значениями
ef_search. - Для каждого значения запишите recall@10, recall@20, p50 и p95 задержки, а также результаты по срезам.
- Повторите запуск после перезапуска клиента, чтобы исключить эффект прогрева и случайно иного маршрута запроса.
Интерпретация обычно проста. Резкий рост recall с приемлемым увеличением задержки говорит о недостаточном поисковом бюджете. Почти плоская кривая указывает на другую проблему: неверную метрику, испорченные векторы, ошибку фильтра, квантование или расхождение между экспортом для exact-поиска и реальной коллекцией.
Не делайте вывод по среднему значению. Один плотный языковой сегмент или один тип документов может терять половину соседей, пока общий recall выглядит терпимо. Для систем RAG это особенно заметно на редких юридических формулировках, названиях тарифов, кодах ошибок и запросах с несколькими ограничениями.
Параметры построения нельзя исправить одним запросом
Высокий ef_search компенсирует часть слабого графа, но не возвращает связи, которых индекс не создал при построении.
На структуру HNSW сильнее всего влияют M и ef_construction или ef_construct, в зависимости от движка. M ограничивает число связей, а ef_construction определяет объем поиска кандидатов при добавлении вектора. Документация OpenSearch указывает, что более высокий ef_construction строит более точный граф ценой более медленной индексации, а Qdrant отдельно описывает m, ef_construct и поисковый ef как разные ручки.
Это различие часто размывают. ef_search выбирает, насколько глубоко вы обходите уже существующий граф. ef_construction влияет на то, каким этот граф получился. Повышать первое после массовой загрузки с низким вторым иногда разумно, но это плата задержкой за старое решение о качестве построения.
Проверка проста: создайте из одного экспортированного снапшота временный индекс с текущими параметрами и второй индекс с измененными параметрами построения. На обоих выполните одинаковый набор запросов с одинаковыми значениями ef_search. Если новый граф выигрывает при равном поисковом бюджете, перестройка оправдана. Если разницы нет, не запускайте дорогой rebuild ради ощущения контроля.
Не копируйте параметры между коллекциями без измерений. Корпус с короткими однотипными объявлениями, многоязычный архив обращений и база технической документации дают разную плотность кластеров. Параметр, который хорошо работал на одной из них, на другой может тратить память без ощутимого выигрыша.
Обновления коллекции ломают измерение чаще, чем граф
После обновления коллекции сначала ищите несогласованность данных, а не мистическую деградацию HNSW.
Самая частая ошибка выглядит буднично. Пайплайн сначала записал новые payload, затем упал до записи вектора. Другой воркер повторил задачу, но использовал новый чанкер. Третий процесс удалил старые ID только в одном шарде. По метрике число документов почти правильное, а по факту exact-экспорт и рабочая коллекция содержат разные объекты.
Проверьте пять инвариантов перед настройкой индекса:
- каждый живой ID имеет ровно один вектор нужной размерности;
- версия эмбеддера едина внутри измеряемой коллекции;
- векторы прошли одинаковую нормализацию;
- документы и чанки не меняют ID при повторном запуске без причины;
- фильтруемые поля и права доступа присутствуют в точном эталоне и ANN-запросе одинаково.
Особое внимание уделите upsert. Замена текста под старым ID допустима только если вы гарантируете, что новый payload и новый вектор попали в одну логическую версию. Если процесс читает документ, считает вектор и пишет его отдельными операциями без версии, конкурентное обновление легко оставляет текст одной редакции и эмбеддинг другой.
Удаления и сегментация тоже требуют проверки. Некоторые движки могут искать полным перебором в небольших сегментах, другие строят граф позже, третьи оптимизируют сегменты асинхронно. Например, Qdrant документирует порог, при котором планировщик использует full scan вместо HNSW, и отдельно описывает условия переиндексации сегментов. Поэтому «после загрузки стало хуже» может означать, что запросы попали в другой режим выполнения.
Соберите контрольный отчет до и после обновления: число точек, число уникальных ID, число векторов по версиям, размерность, доля нулевых векторов, распределение норм и доля документов в каждом сегменте. Он часто находит причину раньше, чем график recall успеет стать предметом спора.
Фильтры и квантование создают отдельные виды потерь
Filtered ANN и поиск по сжатым векторам нужно оценивать отдельно, потому что они меняют саму задачу поиска.
Фильтр не является косметическим условием после top-k. Если сначала получить двадцать ближайших кандидатов по всей базе, а потом отбросить запрещенные, можно вернуть меньше k результатов и пропустить допустимых соседей, которые находились чуть глубже. Документация OpenSearch прямо предупреждает, что post-filter при строгом предикате может вернуть значительно меньше k, тогда как фильтрация во время k-NN-поиска работает иначе.
Эталон для фильтрованного запроса должен вычисляться так:
exact_top_k = top_k(metric(query, vector)) среди документов, прошедших тот же фильтр
Не сравнивайте ANN после фильтра с exact top-k по полной коллекции. Это ошибка эксперимента, которая неизбежно покажет «падение recall» на строгих правах доступа, датах, языках и tenant ID.
Квантование требует еще одного разреза. У вас могут быть исходные float-векторы, сжатое представление и HNSW поверх сжатого представления. Чтобы понять источник потери, измерьте три уровня:
- exact по исходным векторам;
- exact по представлению, которое реально хранится для поиска;
- ANN по этому же представлению.
Разница между первым и вторым уровнем принадлежит сжатию. Разница между вторым и третьим принадлежит приближенному поиску. Компенсировать потерю квантования ростом ef_search бессмысленно: HNSW станет точнее искать соседей в уже искаженной геометрии.
Перестройка нужна после доказательства, а не по календарю
Перестраивайте HNSW, когда измерение показывает, что тот же набор векторов получает лучший ANN recall на свежем графе или когда путь обновления не может гарантировать целостность текущей структуры.
Нормальная процедура rebuild не начинается с удаления боевой коллекции. Создайте новую версию рядом со старой, загрузите в нее один проверенный экспорт, проверьте инварианты, постройте индекс, выполните frozen-набор и сравните exact, ANN и задержки. После этого переключайте чтение. Оставьте старую версию достаточно долго, чтобы расследовать расхождения по конкретным query ID.
Не делайте периодическую перестройку единственной политикой качества. Она может временно скрыть смешанные эмбеддинги, неверные фильтры или дрейф корпуса, но эти ошибки вернутся в следующем обновлении. Хорошая система ловит их на входе: версия эмбеддера входит в метаданные записи, контрольные запросы запускаются после загрузки, а exact-эталон живет отдельно от онлайн-пути.
Для команд, которые используют единый OpenAI-совместимый слой, AI Router может упростить контроль версии эмбеддингов и маршрута модели, но он не отменяет эту дисциплину. Векторная коллекция остается вашей ответственностью: храните, какой моделью и каким преобразованием получен каждый вектор.
Держите два набора проверок, а не один общий recall
Один агрегированный recall скрывает и деградацию индекса, и деградацию данных. Держите два набора запросов с разными задачами.
Первый набор нужен для ANN-инженерии. Он фиксирован, покрывает трудные геометрические случаи и всегда сравнивается с точным поиском по одному снапшоту. Его назначение состоит в том, чтобы видеть влияние ef_search, параметров построения, сегментов, фильтров и сжатия.
Второй набор нужен продукту. Он содержит реальные запросы и проверенные релевантные документы, обновляется вместе с доменом и измеряет качество retrieval до reranker и после него. Его назначение состоит в том, чтобы видеть, когда модель, чанкер или корпус перестали отвечать на пользовательский вопрос.
Когда эти наборы расходятся, это хорошая новость: вы знаете, где искать. Падает только первый, разбирайте HNSW и хранение. Падает только второй, разбирайте данные и семантику. Падают оба, сначала зафиксируйте новую версию коллекции и пересчитайте exact-эталон. Поднимать ef_search до этого момента означает покупать задержку вместо ответа.
Часто задаваемые вопросы
Что измеряет recall@k для HNSW?
Recall@k сравнивает пересечение топ-k результатов приближенного поиска с топ-k результатов точного поиска на той же коллекции, с тем же вектором запроса и той же метрикой. Если в точном эталоне изменились документы или фильтры, число уже не говорит только о качестве HNSW.
Как правильно сравнить HNSW с точным поиском?
Сначала зафиксируйте снимок коллекции, набор запросов, модель эмбеддингов, нормализацию и правила фильтрации. Затем посчитайте точный top-k и ANN top-k для одинаковых входов. Без этой пары результатов нельзя уверенно назвать падение проблемой индекса.
Всегда ли нужно повышать ef_search при падении recall?
Часто помогает, но это диагностический тест, а не ремонт. Если recall резко растет при увеличении ef_search, граф или поисковый бюджет ограничивают обход. Если рост слабый, ищите смену метрики, векторов, фильтров, квантования или ошибки обновления коллекции.
Можно ли понять качество поиска по средним distance или similarity?
Нет. Низкая абсолютная дистанция не гарантирует хорошую выдачу, а высокая дистанция не доказывает ошибку HNSW. Смотрите распределение зазоров между первым и последующими точными соседями: при почти равных кандидатах даже точный top-k нестабилен по идентификаторам.
Нужно ли переиндексировать HNSW после смены модели эмбеддингов?
Да. Если новая модель меняет размерность, порядок координат, нормализацию или смысл метрики, старые векторы нельзя смешивать с новыми. Коллекцию нужно переэмбеддить как единый снимок и строить эталон уже по новым векторам.
Могут ли удаления и обновления документов ухудшить HNSW?
Удаления сами по себе не всегда портят математическую корректность ответа, но могут ухудшить эффективность обхода, оставить устаревшие сегменты или совпасть с ошибкой процесса слияния. Проверяйте живое число точек, долю tombstone, фактические сегменты и recall до и после принудительной перестройки.
Почему recall падает только при фильтрах?
Фильтр меняет пространство допустимых соседей. Сравнивать filtered ANN с точным поиском по полной коллекции бессмысленно, а post-filter может вернуть меньше k документов даже при хорошем графе. Стройте точный эталон с тем же предикатом фильтра.
Как квантование влияет на recall HNSW?
Если вы сжимаете векторы, сначала разделите потерю от кодирования и потерю от обхода графа. Сравните точный поиск по исходным float-векторам, точный поиск по сжатому представлению и ANN по тому же сжатому представлению. Иначе один показатель смешивает две разные причины.
Нужно ли запускать exact search на всей продакшен-коллекции?
Для небольших и средних контрольных наборов да, если полный перебор укладывается в память и время. Для большой коллекции держите отдельный frozen-набор запросов и кандидатов или запускайте точный расчет пакетно. Эталон не обязан работать в онлайн-пути, но обязан быть воспроизводимым.
Почему высокий ANN recall не гарантирует качество RAG?
Тогда измеряйте две вещи раздельно: retrieval recall относительно релевантных документов и ANN recall относительно точного векторного top-k. HNSW может находить почти все точные векторные соседи, а пользователи все равно будут недовольны, если модель эмбеддингов перестала выражать их намерение.