Суть Читай реальный pgvector DDL и запросы — предсказывай поведение recall/latency и выбирай фикс с наибольшим рычагом для HNSW-параметров, операторов расстояния и фильтрованного поиска.
Высота — путь к senior
НольJuniorMiddleSenior
Ты на senior-высоте — в орбите
◷ 14 min
Баги vector search живут в DDL и в запросе, а не в исключении. Читай SQL, предсказывай recall и latency, которые он даёт, затем выбирай фикс, который senior-инженер делает первым.
Цель
Отработай цикл, который запускаешь в каждом recall-инциденте: читай определение индекса и запрос, предсказывай, где утекает recall или взлетает latency, и берись за фикс с наибольшим рычагом.
Сниппет 1 — HNSW-индекс и ручка по умолчанию
CREATE INDEX ON docs USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64);-- путь запроса, на каждый запросSELECT id, body FROM docsORDER BY embedding <=> $1LIMIT 10;
Викторина
Completed
Индекс строится нормально, запросы быстрые, но recall@10 измеряется лишь ~80%. Фикс с наибольшим рычагом?
Heads-up Бо́льший m поднимает recall и память, но требует полной перестройки и всё равно оставляет ef_search на 40. Дешёвый немедленный рычаг — рантайм ef_search, а не дорогая перестройка.
Heads-up <=> это cosine-расстояние, а <-> это L2; индекс построен с vector_cosine_ops, поэтому смена оператора на запросе рассогласует opclass. Разрыв recall от ef_search, а не от оператора.
Heads-up Добавление данных не поднимает recall существующих запросов — оно расширяет пространство поиска. recall@10 задаётся ef_search и качеством графа, а не размером корпуса.
Сниппет 2 — оператор расстояния vs opclass
-- embedding сохранены от модели, обученной на cosine similarityCREATE INDEX ON items USING hnsw (embedding vector_l2_ops);SELECT id FROM itemsORDER BY embedding <=> $1 -- cosine-операторLIMIT 10;
Викторина
Completed
Запрос медленный и recall плохой. Что здесь не так?
Heads-up Операторы сопоставимы по стоимости; медлительность — от того, что индекс непригоден для этого оператора, что вынуждает последовательный скан по каждой строке.
Heads-up pgvector поддерживает cosine (<=>), L2 (<->) и inner product (<#>). Дефект — рассогласование между оператором запроса и opclass индекса, а не отсутствие поддержки.
Heads-up LIMIT не управляет использованием индекса. Индекс пропускается, потому что его opclass не совпадает с оператором расстояния запроса.
Сниппет 3 — IVFFlat probes
CREATE INDEX ON docs USING ivfflat (embedding vector_cosine_ops) WITH (lists = 1000);-- запросSELECT id FROM docs ORDER BY embedding <=> $1 LIMIT 10;-- ivfflat.probes оставлен по умолчанию
Викторина
Completed
При lists = 1000 и probes по умолчанию recall сильно ниже ожиданий. Почему и первый ход?
Heads-up Бо́льший lists даёт более мелкие кластеры и может помочь recall, когда probes поднят; при probes = 1 симптом — скан одной корзины, а не слишком многих.
Heads-up IVFFlat поддерживает cosine через vector_cosine_ops, что и используется. Потеря recall — от probes = 1, сканирующего единственный кластер.
Heads-up Reindex — для дрейфа центроидов со временем, не на каждый запрос. Немедленный фикс — поднять probes, чтобы сканировалось больше кластеров.
Сниппет 4 — измерение recall против ground truth
-- точный ground truth (без индекса, брутфорс)SELECT id FROM docs ORDER BY embedding <=> $1 LIMIT 10; -- с отключённым индексом-- ANN-результатSET hnsw.ef_search = 100;SELECT id FROM docs ORDER BY embedding <=> $1 LIMIT 10; -- с HNSW-- recall@10 = |exact_ids ∩ ann_ids| / 10
Викторина
Completed
Коллега считает recall, сверяя среднее расстояние ANN-результата с фиксированным порогом, а не пересечение с точным топ-10. Почему это неверно?
Heads-up Порог расстояния меряет, насколько близки возвращённые элементы, а не верные ли они. Два разных вектора могут оба быть под порогом, но один истинный сосед, а другой нет.
Heads-up Latency и recall — разные оси. Вся суть в том, что низкий recall невидим по latency, поэтому recall надо мерить напрямую через пересечение ID.
Heads-up Recall меряется сравнением набора ID от ANN с точным ORDER BY-сканом в той же базе; для ground-truth сравнения вызов модели не нужен.
Итог
Каждый recall-инцидент читается в DDL и запросе: ef_search по умолчанию 40 и это рантайм-ручка recall; оператор расстояния запроса должен совпадать с opclass индекса, иначе планировщик падает в полный скан; probes в IVFFlat по умолчанию 1 и сканирует один кластер, пока не поднимешь; а recall@k — это пересечение наборов ID против точного baseline, никогда не порог расстояния. Читай SQL, найди утечку, поверни самую дешёвую ручку первой, затем перемерь recall для подтверждения.