pgvector: почему HNSW с фильтром возвращает меньше строк, чем LIMIT
В базе знаний лежат документы нескольких клиентов. Вы ищете пять ближайших фрагментов для одного клиента: WHERE tenant_id = 42 ORDER BY embedding <-> $1 LIMIT 5. Документы у клиента есть, но запрос иногда возвращает два фрагмента. Для RAG это заметно сразу: модель получает мало контекста и может не увидеть нужную инструкцию, хотя она записана в базе.
Где пропадают строки
HNSW быстро находит приблизительных соседей во всём индексе. При таком плане PostgreSQL применяет обычный фильтр tenant_id = 42 к кандидатам, которые индекс уже выдал. Если среди первых кандидатов мало документов нужного клиента, после фильтра остаётся меньше пяти. LIMIT 5 ограничивает максимум строк, но сам по себе не заставляет обычный проход HNSW искать дальше. Этим случай отличается от точного поиска по отфильтрованным строкам.
Сначала проверьте две вещи. Посчитайте SELECT count(*) FROM documents WHERE tenant_id = 42: если там три строки, получить пять невозможно. Затем выполните EXPLAIN (ANALYZE, BUFFERS) для того же поискового запроса и найдите Index Scan по HNSW и Rows Removed by Filter. Так вы отделите эффект постфильтрации от ошибок в самих данных. Планировщик вправе выбрать другой план, поэтому объяснение ниже относится к случаю, когда реально используется приблизительный индекс.
Полезно сохранить два результата одного и того же вопроса: быстрый поиск с индексом и эталонный точный поиск только среди документов клиента. Сравнивать надо идентификаторы фрагментов, а не только длину списков. Иначе можно «исправить» число строк до пяти, но продолжать отдавать модели нерелевантный контекст. Для диагностики зафиксируйте в журнале tenant_id, идентификатор запроса, количество возвращённых строк, время SQL и ID найденных фрагментов. Текст чужих документов в такой журнал писать не нужно.
Разрешите индексу продолжать поиск
Начиная с pgvector 0.8.0, итеративный проход продолжает брать кандидатов после фильтра, пока не соберёт достаточно строк или не упрётся в лимиты обхода. Для сеанса можно выбрать strict_order, когда нужен порядок по расстоянию:
BEGIN;
SET LOCAL hnsw.iterative_scan = strict_order;
SELECT id, source_url, embedding <-> '[0.2,0.4,0.6]'::vector AS distance
FROM documents
WHERE tenant_id = 42
ORDER BY embedding <-> '[0.2,0.4,0.6]'::vector
LIMIT 5;
COMMIT;
Здесь SET LOCAL действует только в транзакции, что полезно при пуле соединений: настройка одного поиска не переедет в чужой запрос. Размерность литерала должна совпадать с размерностью вашего embedding; в примере показан трёхмерный вектор для читаемости. Запрос вернёт не больше пяти строк клиента 42 и сохранит id и source_url для следующего шага RAG — цитирования найденных документов.
Если при наличии пяти документов строк всё ещё мало, сравните план и время до и после настройки. Ограничение hnsw.max_scan_tuples останавливает дополнительный поиск; увеличение бюджета может улучшить полноту ценой задержки. Если изменение этого параметра не помогает, проверьте доступную память через hnsw.scan_mem_multiplier. Не повышайте оба значения вслепую: замерьте количество найденных строк и p95 на запросах с реальными фильтрами.
Второй режим, relaxed_order, допускает небольшое нарушение порядка по расстоянию и может дать лучшую полноту. Если он нужен ради числа кандидатов, а передача в модель требует строгой сортировки найденного набора, поместите внутренний поиск в materialized CTE и отсортируйте его снаружи по вычисленному расстоянию. Это упорядочит уже найденные строки; потерянные индексом соседи от такой сортировки не появятся. Начните со strict_order, если пять корректно упорядоченных фрагментов находятся за приемлемое время.
Когда фильтр стоит вынести из общего графа
Если у клиента очень мало документов, точный поиск по строкам клиента может оказаться проще и полнее. Индекс B-tree по tenant_id помогает выбрать эти строки, а затем вычислить расстояние и отсортировать их. Проверять этот вариант удобно сравнением с результатом без HNSW на представительном наборе. Если значений фильтра немного и запросы к ним частые, частичный HNSW-индекс для одного значения может исключить чужие документы из самого графа. Для большого числа изолированных клиентов рассмотрите секционирование. Эти варианты зависят от распределения данных; решайте по планам и задержке, а не по одному удачному запросу.
Пять строк после настройки не означают, что найдены пять истинно ближайших: HNSW остаётся приблизительным. Для контроля качества сравните идентификаторы с точной выдачей на выборке запросов и считайте Recall@5. Здесь же пригодится разбор сравнения ANN с точным поиском: при другой базе принцип проверки тот же. Если в выдаче мало фрагментов, проверьте также сколько релевантных кандидатов вообще дошло до реранкера.
В RAG лучше отделить диагностику поиска от качества формулировки ответа. Для нескольких реальных вопросов заранее отметьте фрагменты, где содержится ответ. Затем проверьте: прошли ли они фильтр клиента, появились ли в первых пяти по точному поиску, дошли ли до ответа после HNSW и реранжирования. Так станет ясно, где менять настройку: в SQL, в количестве кандидатов для реранкера или уже в инструкциях модели. Без этой цепочки легко «лечить» ответ промптом, когда нужный документ даже не попал в контекст.
В платном курсе Vector DB & RAG Developer вы строите поиск с фильтрами, сравниваете индексы и измеряете Recall@K и nDCG, затем подключаете выдачу к RAG-боту с цитатами и API. В программе практика на Qdrant, Weaviate и FAISS; приём с hnsw.iterative_scan выше относится именно к pgvector, а метод проверки полноты переносится на другие движки. Откройте программу курса, если хотите отработать весь путь от индекса до ответа, который опирается на найденные документы.
Продолжить на практике
- Vector DB & RAG Developer ↗
Практический курс по векторным базам данных и семантическому поиску. Вы развернёте Qdrant, Weaviate и FAISS, научитесь индексировать миллионы эмбеддингов, строить k-NN-поиск с фильтрами, объединять его с BM-25 и создавать RAG-ботов, которые отвечают на вопросы на основе ваших данных.
