Как фильтрованный векторный поиск теряет recall у арендаторов
Фильтрованный векторный поиск теряет recall у редких арендаторов. Разбираем тесты сегментов, фильтров, HNSW и точного эталона.

Фильтрованный векторный поиск ломается не на среднем запросе. Он ломается там, где один арендатор хранит миллионы документов, другой - несколько сотен, а продукт требует одинаково честного ответа обоим. Команда видит хороший recall на общей выборке, приемлемую p95-задержку и считает задачу закрытой. Затем небольшой клиент не находит собственный договор, хотя этот договор лежит в базе и его эмбеддинг почти совпадает с запросом.
Причина обычно не в модели эмбеддингов. ANN-поиск строит ограниченный набор кандидатов в общем пространстве, а фильтр по арендатору, правам, региону или статусу резко уменьшает допустимый набор. Если нужный сегмент редок, кандидатный бюджет уходит на соседей из чужих сегментов. После фильтрации выдача становится короткой или нерелевантной. Это надо проверять нагрузочным тестом как отдельное свойство системы, а не надеяться, что хороший глобальный recall покроет все случаи.
Recall надо считать внутри допустимого множества
Для запроса с tenant_id эталонный ответ - не глобальные k ближайших векторов с последующим отсевом. Эталонный ответ - k ближайших среди документов, которые проходят тот же tenant_id и все остальные обязательные условия. Иначе вы измеряете другую задачу.
Пусть запрос q принадлежит арендатору t, а F(t, q) - документы, разрешенные фильтром. Точный эталон выглядит так:
truth(q) = top_k по distance(q, d), где d принадлежит F(t, q)
Приближенный запрос возвращает ann(q). Тогда recall@k для конкретного запроса надо считать как пересечение идентификаторов, а не как сходство текстов или оценку человека:
recall@k(q) = |truth(q) ∩ ann(q)| / k
Если в допустимом множестве меньше k документов, знаменатель должен быть размером эталонной выдачи. Это мелочь только на бумаге. В реальной мультиарендной системе маленькие и новые арендаторы часто имеют меньше документов, чем запрошенный k, а неверная формула делает пустую выдачу похожей на нормальную.
Нужно также различать две неисправности. Первая - система вернула k документов, но пропустила близкие допустимые документы. Это потеря ранжирования. Вторая - система вернула меньше k документов, хотя в разрешенном сегменте их достаточно. Это потеря наполнения выдачи. Пользователь замечает вторую раньше, потому что интерфейс внезапно показывает два результата вместо десяти. Для инженера обе неисправности важны, но лечатся они не всегда одинаково.
В документации pgvector это сформулировано прямо: при приближенном индексе фильтрация применяется после сканирования индекса, поэтому ограниченный hnsw.ef_search может не дать нужное число строк. В качестве примера документация приводит фильтр с селективностью 10%: при стандартном ef_search = 40 в среднем останется около четырех подходящих строк. Это не баг PostgreSQL и не исключительный случай, а ожидаемая математика ограниченного кандидата.
Средний арендатор скрывает длинный хвост
Распределение документов между арендаторами почти никогда не бывает ровным. Один банк загружает историю обращений за годы, SaaS-клиент добавляет десятки тысяч карточек, небольшой отдел приносит пару сотен регламентов. Если вы берете тысячу запросов пропорционально трафику, крупные сегменты съедают эксперимент. Общая цифра recall окажется красивой даже тогда, когда редкие арендаторы систематически проигрывают.
Разрезайте результаты минимум по двум независимым шкалам:
- размер допустимого сегмента после фильтра;
- селективность фильтра относительно всего индекса.
Это не одно и то же. Арендатор с 10 000 документами может быть очень мал в индексе на миллиард векторов. И наоборот, фильтр по роли может оставить 500 документов, но эти документы могут быть распределены среди всех арендаторов. Для ANN-индекса важны оба обстоятельства: сколько приемлемых объектов существует и как редко они встречаются при обходе структуры.
Практичная сетка корзин выглядит так: меньше 100 допустимых векторов, 100-1 000, 1 000-10 000, 10 000-100 000 и больше 100 000. Для селективности заведите отдельные корзины: меньше 0,01%, 0,01-0,1%, 0,1-1%, 1-10% и выше 10%. Границы не священные. Их задача в том, чтобы на графике не исчезал редкий режим работы.
Не смешивайте размер арендатора с размером его корпуса до фильтра. У арендатора может быть 50 000 документов, но запрос с document_type = policy, language = kk и active = true оставит 60. Именно эти 60 определяют сложность конкретного поиска. В тестовой таблице храните как исходный размер tenant, так и количество строк, прошедших полный фильтр для каждого запроса.
Есть еще одна неприятная деталь. Малые арендаторы часто имеют хуже сформулированные запросы, меньше повторных поисков и более разнородные документы. Нельзя списывать их плохой recall на фильтрацию, не проверив эталонные ближайшие тексты вручную. Но и обратная ошибка встречается постоянно: команда улучшает промпт или меняет embedding-модель, когда ANN-поиск просто не дошел до правильных кандидатов.
Нагрузочный тест должен воспроизводить неравенство, а не среднее
Случайно распределить по арендаторам одинаковое число векторов - значит убрать саму проблему из теста. Нужен корпус с длинным хвостом: несколько очень крупных арендаторов, заметная группа средних и много маленьких. Синтетическая генерация допустима для объема и перекоса, но запросы и векторы должны быть похожи на ваш рабочий материал. Случайные независимые векторы почти не образуют сложных соседств, поэтому они плохо выявляют потери качества.
Соберите три набора запросов. Первый состоит из запросов, у которых в нужном сегменте есть очевидные близкие документы. Он измеряет технический recall. Второй содержит пограничные случаи с похожими документами у разных арендаторов. Он ловит ошибки, когда фильтр применили не там или забыли вовсе. Третий повторяет реальную смесь условий: тип документа, статус, язык, дата, права доступа и tenant_id.
Для каждого запроса сохраните неизменяемый паспорт:
{
"query_id": "q-1842",
"tenant_id": "tenant-small-17",
"k": 10,
"filter": {
"tenant_id": "tenant-small-17",
"document_type": "policy",
"active": true
},
"eligible_count": 74,
"tenant_count": 623,
"global_count": 18000000
}
Поле eligible_count нельзя вычислять по результату ANN-запроса. Его получает отдельный точный прогон или заранее построенный эталон. Если вы записали туда число фактически возвращенных документов, тест начнет подтверждать собственную ошибку.
Нагрузка тоже должна быть неравной. Крупные арендаторы создают большую часть трафика, но тест обязан принудительно дать достаточное число наблюдений каждой редкой корзине. Иначе доверительный интервал для маленьких сегментов будет слишком широким, а регрессия пройдет незамеченной. В отчете держите две сводки: взвешенную по реальному трафику и невзвешенную, где каждый класс сегментов имеет одинаковый вес. Первая отвечает за общую нагрузку. Вторая показывает, кого система тихо обделяет.
Не измеряйте только одиночные запросы. Конкурентная нагрузка меняет попадание индекса и данных в память, очередь на CPU, поведение кэша и хвост задержки. Запускайте хотя бы несколько уровней параллелизма, включая тот, при котором сервис почти достигает рабочей загрузки. Затем повторяйте точный эталон офлайн, не на том же перегретом пути обслуживания. Цель эталона - правильность, а не скорость.
Постфильтр часто возвращает красивую, но неполную выдачу
Самая распространенная архитектурная ошибка проста: сначала выполнить ANN-поиск по всем векторам с k = 10, затем отфильтровать десять найденных объектов по tenant_id. При селективном фильтре такая схема не может гарантировать десять результатов. Если восемь кандидатов принадлежат другим арендаторам, вы покажете два, даже если внутри разрешенного сегмента есть тысячи подходящих документов.
Инженеры иногда пытаются исправить это увеличением внешнего k, например берут 200 кандидатов и после фильтра оставляют десять. Это может быть приемлемым временным решением при умеренной селективности, но его нельзя считать свойством качества. Для сегмента с долей 0,01% даже 200 кандидатов часто не дадут ни одного документа. А при росте k вы переносите стоимость в сеть, память, реранкер и прикладной код.
OpenSearch четко разделяет фильтрацию внутри k-NN-запроса и фильтрацию после поиска. Документация предупреждает, что post-filter при ограничительном условии способен вернуть существенно меньше k результатов, тогда как efficient k-NN filtering применяет фильтр во время векторного поиска и стремится вернуть k, если в индексе вообще есть k подходящих документов.
Но формулировка "фильтр внутри поиска" не означает автоматический идеальный recall. Реализация может выбирать между обходом ANN-структуры и точной обработкой отфильтрованного подмножества, ограничивать бюджет обхода или менять стратегию по оценке селективности. Поэтому в отчете фиксируйте семантику конкретного движка и конкретный синтаксис запроса. Один и тот же фильтр, помещенный внутрь или снаружи векторного условия, может менять не только задержку, но и набор результатов.
Проверяйте это простым инвариантом: если eligible_count >= k, то количество возвращенных после всех обязательных фильтров строк должно быть k. Нарушение инварианта не всегда означает низкий recall, но всегда требует объяснения. Либо вы сознательно поставили лимит на поиск, либо движок применил фильтр после слишком короткого отбора кандидатов, либо в запросе есть ошибка.
Редкий арендатор требует отдельной стратегии поиска
Один общий HNSW-индекс удобен в эксплуатации, но он не обязан быть лучшим путем для каждого сегмента. Когда допустимое множество велико, HNSW обычно дает нужный компромисс между задержкой и полнотой. Когда после фильтра остается несколько десятков или сотен векторов, точное вычисление расстояния по этому множеству может быть быстрее, дешевле и предсказуемее, чем глубокий обход глобального графа.
Не выбирайте стратегию по числу документов во всей таблице. Выбирайте ее по ожидаемому размеру набора после фильтра и по целевой задержке. Условная политика может выглядеть так:
- при очень малом
eligible_countвыполнять точный поиск по фильтрованному сегменту; - при среднем размере сегмента увеличивать кандидатный бюджет или использовать фильтрацию во время ANN-обхода;
- при крупном сегменте идти в общий ANN-индекс с обычной настройкой;
- при стабильных крупных арендаторах оценить партицию или отдельный индекс.
Порог между этими режимами нельзя взять из чужого бенчмарка. Он зависит от размерности эмбеддинга, метрики, количества CPU, памяти, дисковой подсистемы, состава фильтров и очереди запросов. Его находят графиком, где по оси X лежит eligible_count, а по оси Y - p95 и recall@k для нескольких стратегий. Точка переключения должна находиться там, где точный путь перестает укладываться в ваш бюджет задержки или ANN начинает уверенно давать заданную полноту.
Пагинация по результатам не лечит эту проблему. Если первый ANN-поиск не нашел нужные объекты, страница номер два не сделает их ближе. Аналогично, reranking не вернет документ, которого не было в кандидате. Реранкер улучшает порядок кандидатов, а фильтрованный ANN определяет, удалось ли вообще доставить кандидатов к нему.
Разделение по арендаторам тоже не бесплатное. Миллионы отдельных индексов дают метаданные, фоновые операции, перекос потребления памяти и тяжелую схему обновлений. Обычно разумнее отделять только крупных или юридически изолированных арендаторов, а длинный хвост обслуживать общим индексом с явным режимом для редких сегментов. Полезен не лозунг "один индекс" или "индекс на каждого", а таблица решения с измеренными границами.
Настройки индекса нужно подбирать по худшей корзине
Настройка HNSW по глобальному recall почти гарантированно выбирает слишком малый поисковый бюджет для редких фильтров. В pgvector параметр hnsw.ef_search ограничивает размер динамического списка кандидатов. Документация рекомендует увеличивать его при фильтрации, а начиная с версии 0.8.0 предлагает iterative index scans: движок продолжает сканирование, пока не соберет достаточно строк или не достигнет установленного лимита по числу просмотренных кортежей либо по памяти.
Итеративное сканирование полезно, но не отменяет бюджет. Если поставить слишком маленький hnsw.max_scan_tuples, редкий арендатор останется без результатов. Если поставить слишком высокий лимит без защиты, один узкий фильтр может занять CPU и ухудшить p99 для остальных. Поэтому тестируйте не один ef_search, а связку параметров: начальный бюджет, максимальное расширение, лимит памяти и допустимый хвост задержки.
Для IVFFlat похожая логика относится к числу проверяемых списков. Мало probes дает быстрый, но неполный поиск. Много probes поднимает полноту, но приближает запрос к дорогому сканированию. У IVFFlat есть дополнительная ловушка: число списков влияет на то, насколько равномерно данные распределяются по кластерам. Индекс, созданный на небольшом объеме с агрессивным числом списков, может давать плохую полноту сам по себе, еще до фильтра.
Проверяйте конфигурации матрицей, а не ручным подбором на одном запросе. Для каждой пары параметров записывайте:
| Сегмент | Recall@10 | Неполная выдача | p95 | p99 | Стоимость кандидатов |
|---|---|---|---|---|---|
| Крупный, фильтр 10% | |||||
| Средний, фильтр 1% | |||||
| Малый, фильтр 0,1% | |||||
| Очень малый, фильтр <0,01% |
Последний столбец может быть числом рассмотренных векторов, посещенных узлов, ef_search, числом проб или другой метрикой, которую дает ваш движок. Без него сложно отличить удачную настройку от настройки, которая просто выкупила качество неприемлемой стоимостью.
Не принимайте настройку, если она проходит среднюю метрику, но не проходит заранее названный минимум для редкой корзины. Иногда бизнес действительно допускает другой SLO для бесплатного тарифа или архивного поиска. Тогда зафиксируйте это как правило продукта и покажите пользователю понятное поведение. Молчаливо выдавать два результата вместо десяти для небольшого арендатора - не политика, а дефект.
Точный эталон должен жить рядом с приближенным тестом
Точный поиск не обязан обслуживать продакшен-трафик, но обязан существовать в тестовой системе. Без него вы не узнаете, что потеряли. Сравнение одной ANN-настройки с другой показывает только, какая из них чаще соглашается с соседней настройкой.
В PostgreSQL с pgvector можно собрать эталон и тестовый запрос в одной транзакции. Важно отключить использование ANN-индекса только для эталонного прогона, а не для всего нагрузочного стенда.
BEGIN;
SET LOCAL enable_indexscan = off;
SET LOCAL enable_bitmapscan = off;
SELECT id
FROM chunks
WHERE tenant_id = :tenant_id
AND document_type = :document_type
AND active = true
ORDER BY embedding <=> :query_embedding
LIMIT 10;
COMMIT;
Затем отдельно запускайте рабочий вариант с индексом и сохраняйте два упорядоченных списка идентификаторов. pgvector прямо рекомендует мониторить recall сравнением приближенного поиска с точным и показывает отключение index scan для такого контроля.
Для HNSW-эксперимента рабочий запрос можно оформить так:
BEGIN;
SET LOCAL hnsw.ef_search = 80;
SET LOCAL hnsw.iterative_scan = strict_order;
SET LOCAL hnsw.max_scan_tuples = 20000;
SELECT id
FROM chunks
WHERE tenant_id = :tenant_id
AND document_type = :document_type
AND active = true
ORDER BY embedding <=> :query_embedding
LIMIT 10;
COMMIT;
Не копируйте эти числа в продакшен как рецепт. Они нужны, чтобы показать форму эксперимента. Для каждой строки результата сохраняйте query_id, параметры поиска, список exact IDs, список ANN IDs, длительность, число возвращенных строк и корзины сегмента. После этого можно посчитать recall@10, hit rate первого места, долю неполных выдач и задержки без повторного запуска всего теста.
Проверьте, что точный запрос действительно точный. Планировщик может выбрать неожиданный путь, а фильтр может оказаться примененным иначе, чем вы предполагаете. Смотрите EXPLAIN (ANALYZE, BUFFERS) на нескольких запросах из каждой корзины. Особенно внимательно смотрите на фильтры, спрятанные в join, подзапросе или функции контроля доступа. Семантика запроса важнее красивого названия индекса.
Ошибки в схеме данных маскируются под проблему ANN
Мультиарендный поиск часто соединяет таблицу векторов с основной таблицей документов, таблицей разрешений и таблицей организаций. Если tenant_id лежит только в основной таблице, фильтр может примениться после векторного сканирования и join. Это не всегда неверно, но вы должны увидеть это в плане и замерить последствия. Когда правило изоляции критично, хранить нужные для поиска неизменяемые фильтры рядом с вектором обычно проще и надежнее для анализа.
Не дублируйте все подряд. Дублируйте поля, которые одновременно выполняют три условия: участвуют в каждом поисковом ограничении, меняются контролируемо и могут быть проверены при записи. Для tenant_id это часто разумно. Для сложного набора прав с тысячами групп дублирование может оказаться источником рассинхронизации. В таком случае лучше построить явный набор разрешенных идентификаторов и отдельно проверить, как движок исполняет этот вариант.
Еще одна ловушка - soft delete. Документ уже скрыт из продукта, но его вектор остается в индексе до фоновой уборки. Поиск тратит кандидаты на мертвые строки, а узкий фильтр отбрасывает их в конце. В отчете разделяйте случаи, где ANN не нашел живые документы, и случаи, где он нашел удаленные или недоступные. Иначе команда поднимет ef_search, хотя сначала надо исправить жизненный цикл данных.
Проверьте инварианты записи: каждый вектор должен получить tenant_id, версию прав и статус документа до того, как станет доступен поиску. При асинхронной индексации возможна короткая рассинхронизация, но она должна быть измерима и ограничена. Нельзя называть потерей recall ситуацию, когда нужный документ еще не проиндексирован. Это отдельная метрика свежести индекса.
SLO должен запрещать тихий провал малых сегментов
Для фильтрованного поиска одной цели вроде "recall@10 не ниже 0,95" мало. Она не говорит, на каких запросах и ценой какой задержки достигнут результат. Задайте требования по корзинам и отдельно задайте ограничение на неполную выдачу.
Пример формулировки, которую можно обсуждать с командой: для запросов, где допустимых документов не меньше десяти, сервис возвращает десять документов; recall@10 по каждой поддерживаемой корзине селективности не опускается ниже согласованного порога; p99 не выходит за лимит при рабочей конкурентности. Если очень маленькие сегменты обслуживаются точным путем, это надо отметить в метриках, а не смешивать с ANN.
Следите за изменением распределения арендаторов. Настройка, которая работала при нескольких крупных клиентах, может перестать работать после миграции большого архива или появления множества новых небольших команд. Пересчитывайте профиль сегментов при крупных загрузках, смене embedding-модели, перестроении индекса и изменении фильтров доступа.
AI Router может быть точкой, где команда фиксирует модель, параметры запроса и маршрут LLM для воспроизводимого RAG-теста, но полноту фильтрованного векторного поиска все равно нужно измерять в вашем хранилище и на ваших правилах доступа. Маршрутизация модели не исправит кандидаты, которых поисковый слой не вернул.
Самый полезный первый прогон не требует сложного стенда. Возьмите несколько десятков реальных запросов от крупных, средних и маленьких арендаторов. Постройте для них точный top-k внутри полного фильтра. Затем сравните его с текущим ANN-путем, разложите ошибки по eligible_count и селективности. Если график резко проваливается в редких сегментах, не спорьте о среднем recall. Меняйте путь поиска, структуру данных или бюджет индекса там, где пользователь уже получает плохой ответ.
Часто задаваемые вопросы
Что такое фильтрованный векторный поиск?
Это поиск ближайших векторов с обязательным условием по метаданным, например tenant_id, региону, роли пользователя или сроку действия документа. Условие меняет не только набор допустимых результатов, но и вероятность того, что ANN-индекс вообще доберется до этих результатов.
Почему общий recall не показывает проблему с арендаторами?
Глобальная метрика смешивает запросы крупных и мелких арендаторов. Если большой арендатор дает почти все запросы и документы, высокий общий recall может скрыть ситуацию, где маленькие арендаторы регулярно получают пустой или слабый top-k.
Почему редкие арендаторы теряют recall сильнее крупных?
Малый арендатор часто занимает доли процента в общем корпусе. При поиске сначала по общему ANN-графу его документы редко попадают в ограниченный пул кандидатов, а фильтр отбрасывает найденные чужие документы уже после этого.
Как правильно считать recall для tenant_id?
Нет, если вы считаете эталон внутри того же фильтра. Для каждого запроса нужно сначала получить точный top-k среди документов, которые действительно принадлежат нужному арендатору и проходят остальные условия, а уже затем сравнивать его с ANN-выдачей.
Какие сегменты нужны в нагрузочном тесте?
Начните с доли документов, прошедших фильтр, числа документов арендатора и требуемого k. Затем измерьте recall и p95 latency отдельно для каждой корзины, потому что одинаковая селективность при 40 и при 40 000 документах ведет себя по-разному.
Поможет ли увеличение ef_search для маленького арендатора?
Увеличение ef_search, num_candidates или probes помогает только пока индекс может найти достаточно кандидатов в нужном сегменте. Если арендатор совсем мал, рост бюджета быстро превращает поиск в дорогой обход общего индекса, и точный поиск по сегменту часто разумнее.
Когда точный поиск лучше HNSW?
Выберите точный поиск, если после фильтра остаются сотни или несколько тысяч векторов и запрос не слишком чувствителен к задержке. Он дает понятную полноту и часто обходится дешевле, чем агрессивная настройка общего ANN-индекса.
Нужно ли делать отдельный индекс для каждого арендатора?
Партиционирование по tenant_id оправдано, когда у вас ограниченное число крупных и стабильно активных арендаторов. Для миллионов мелких арендаторов отдельная партиция или индекс на каждого обычно создают больше операционной работы, чем выигрыша.
Можно ли проверять фильтрацию на случайных векторах?
Нельзя честно тестировать только на случайных векторах. Нужны реальные или похожие на реальные эмбеддинги, распределение размеров арендаторов с длинным хвостом, настоящие комбинации фильтров и запросы, где релевантные документы действительно существуют.
Какие метрики ставить в мониторинг фильтрованного поиска?
Следите за recall@k по корзинам tenant_size и filter_selectivity, долей запросов с неполной выдачей, p95 и p99 задержки, а также за стоимостью кандидатов или посещенных узлов. Один средний показатель по всему трафику для этой задачи почти бесполезен.