Data engineering
Векторный поиск: треугольник recall–latency–память за RAG
На демо RAG отвечал идеально. В проде поддержка говорит, что бот «не находит» документы, которые очевидно есть. Ничего не падает — каждый запрос возвращает свои десять чанков, ранжированных, быстро. Подвох: команда выкатила HNSW-индекс, но так и не подняла hnsw.ef_search с дефолтных 40, а потом прикрутила фильтр WHERE tenant_id = ?. Recall тихо упал до ~30%. Система уверенно возвращала не те десять строк, и ни один лог, ни метрика, ни исключение об этом не сказали.
Эмбеддинги — это точки; поиск — это расстояние
Модель эмбеддингов превращает текст (или картинку) в вектор фиксированной длины — 768 измерений у многих открытых моделей, 1536 у OpenAI text-embedding-3-small, 3072 у большой. Семантически близкие входы оказываются рядом в этом пространстве, поэтому «найти релевантные документы» превращается в «найти ближайшие векторы к вектору запроса». Близость — это метрика: косинусная схожесть (угол), скалярное/inner product или L2 (евклидово) расстояние. Для нормализованных эмбеддингов косинус и dot ранжируют одинаково, поэтому inner product используют как более дешёвый эквивалент.
Наивный ответ — точный k-nearest-neighbor: сравнить запрос с каждым хранимым вектором и взять top k. Это O(N·d) на запрос — при 10M векторов × 1536 измерений это ~15 миллиардов умножений-сложений на один поиск. Нормально для 50k строк, фатально для 50M. Поэтому brute-force ORDER BY embedding <-> $1 LIMIT 10 без индекса корректен, но не выкатываем дальше пары сотен тысяч строк.
ANN: меняем recall на скорость
Approximate nearest neighbor (ANN) индексы отказываются от гарантии найти истинный top-k в обмен на сублинейный поиск. Метрика качества — recall@k: из истинного top-10 сколько индекс реально вернул? Recall 1.0 — идеально; 0.9 — один из каждых десяти «лучших» результатов потерян. Инсайт сеньора: recall — это регулятор настройки, а не фиксированное свойство, и, что критично, запрос на recall 0.3 выглядит идентично запросу на recall 1.0: то же число строк, тот же профиль latency, то же отсутствие ошибок. Поймать это можно только измеряя recall против точного бейзлайна.
Доминирующий индекс — HNSW (Hierarchical Navigable Small World). Он строит слоистый граф: разреженные дальние связи наверху для быстрых прыжков по пространству, плотные короткие связи внизу для точности. Запрос входит сверху, жадно идёт к вектору запроса и спускается. Три ручки управляют треугольником:
M— связей на узел (дефолт pgvector 16). Выше M → лучше recall и больше, плотнее граф. Сам граф HNSW занимает 2–5× размера сырых векторов в RAM, и M — главный рычаг на это.ef_construction— размер пула кандидатов при построении (дефолт 64). Выше → лучше качество графа и recall, но время сборки растёт примерно линейно; 400 против 200 — примерно в 2× дольше.ef_search— размер пула кандидатов в момент запроса (дефолт 40 в pgvector). Это runtime-регулятор recall/latency. Подъём со 100 до 500 может сдвинуть recall с ~85% до ~98% ценой роста с ~1мс до ~5мс на запрос.
| Индекс | Память | Recall | Сборка / записи | Выбирай, когда |
|---|---|---|---|---|
HNSW | Высокая (2–5× сырых) | Наивысший, легче добиться | Медленная сборка, дешёвые инкрементальные вставки | Дефолт для RAG; данные меняются; recall важен |
IVFFlat | ~1× сырых | Хороший, дрейфует при изменениях | Быстрая сборка, нужны данные заранее | Большой статичный набор, RAM в обрез |
IVF-PQ | Наименьшая (сжатие ~7×) | Ниже (~70%), восстановим | Сборка + обучение codebook | 100M+ векторов не влезают в RAM |
IVF и IVF-PQ: кластеризуем, потом квантуем
Другое семейство — IVF (inverted file). Он k-means-кластеризует все векторы в lists бакетов; запрос сканирует только probes ближайших бакетов, а не всё. В pgvector это ivfflat с lists при сборке и ivfflat.probes в запросе (дефолт 1 — слишком мало для хорошего recall). Память IVF близка к 1× сырых векторов, но есть острый край: кластеры фиксированы при сборке, поэтому по мере вставки новых данных центроиды устаревают и recall дрейфует, пока не переиндексируешь. У HNSW такого дрейфа нет — вставки в граф константной стоимости и держат качество.
IVF-PQ добавляет product quantization: разбивает каждый вектор на под-векторы и заменяет каждый id ближайшего центроида из маленького codebook, сжимая 1536-мерный float32-вектор (6 КБ) до пары десятков байт. NVIDIA показала 360 ГиБ FP32-векторов, сжатых до 54 ГиБ, удерживая >95% recall на миллиарде векторов. Цена — потеря recall из-за ошибки квантования: IVF-PQ сам по себе часто оказывается около 70% от recall HNSW и требует rerank-шага (пересчитать top-кандидатов по полноточным расстояниям), чтобы восстановить. PQ — это инструмент, когда 100M+ векторов просто не влезают в RAM, а не дефолт.
Почему это работает
«Проклятие размерности» — вот почему это вообще сложно. В 1536 измерениях почти каждая пара случайных точек примерно равноудалена — расстояния концентрируются, и контраст между ближайшим соседом и сотым становится тонким. Именно это делает recall хрупким: чуть слишком жадный обход графа или слишком грубый квантователь не отличат настоящего соседа от near-miss, а приближённые методы отказываются от гарантии именно потому, что точное разделение стоит O(N·d).
Фильтрация и hybrid search: где ломается прод
Две продовые ловушки. Первая — фильтрация по метаданным плохо сочетается с ANN. pgvector делает post-filtering: обходит граф HNSW на ef_search кандидатов, потом применяет твой WHERE tenant_id = ?. Если фильтр оставляет 1% строк, а ef_search равен 40, можно остаться почти ни с чем — граф навигировал по крошечному, почти разорванному подграфу, и recall рушится. pgvector 0.8.0 добавил hnsw.iterative_scan (strict_order / relaxed_order), чтобы продолжать вытягивать кандидатов, пока фильтр не удовлетворён, что возвращает latency ради потерянного recall.
Вторая — чистый векторный поиск пропускает точные совпадения. Запрос по коду ошибки, SKU или имени функции — это лексическая потребность, а эмбеддинги размазывают её в «семантически близкое» — поэтому panic: nil pointer может оказаться ниже смутно связанного абзаца. Фикс — hybrid search: запускать BM25 (ключевые слова) и вектор параллельно и сливать через Reciprocal Rank Fusion (RRF = Σ 1/(k + rank), k≈60), который объединяет по позиции в ранге, так что две несовместимые шкалы оценок никогда не сравниваются напрямую. Команды сообщают о прыжке с ~65–78% до ~91% recall@10 за счёт добавления keyword-ноги.
RAG-сервис над ~5M чанков получает непрерывную загрузку, требует высокого recall и имеет запас RAM. Выбери стратегию индекса.
Твой поиск на HNSW возвращает десять ранжированных строк за 2мс, без ошибок — но ответы слегка неверны. Что проверить первым?
Пользователи ищут точную строку ошибки 'ECONNREFUSED', и чистый векторный поиск хоронит совпадающий документ. Лучший фикс?
Расставь, как запрос HNSW находит ближайших соседей и как ты бы его настраивал:
- 1 Эмбеддишь запрос в то же векторное пространство, что и хранимые документы
- 2 Входишь в граф HNSW на верхнем разреженном слое для быстрых дальних прыжков
- 3 Жадно идёшь к вектору запроса, спускаясь в более плотные слои
- 4 Собираешь ef_search кандидатов на нижнем слое и возвращаешь top k по расстоянию
- 5 Меряешь recall@k против точного бейзлайна; поднимаешь ef_search, если recall низкий
- 01Почему ANN-индекс с низким recall так опасен в проде и как его реально обнаружить?
- 02Когда тянуться к IVF-PQ вместо HNSW и какую цену принимаешь?
Векторный поиск превращает «найти релевантный контент» в «найти ближайшие эмбеддинги», ранжированные по косинусу, dot или L2-расстоянию. Точный kNN корректен, но O(N·d), поэтому дальше пары сотен тысяч строк переходишь на approximate nearest neighbor и принимаешь обмен recall на скорость. HNSW — дефолт: слоистый граф, настраиваемый M и ef_construction при сборке и ef_search в запросе, стоящий 2–5× сырых векторов в RAM, но дающий наивысший recall с наименьшим дрейфом при изменениях данных. IVF кластеризует, а IVF-PQ сжимает — последний для корпусов, слишком больших для RAM, ценой recall (≈70% от HNSW), который восстанавливаешь reranking. Дисциплина сеньора — это треугольник recall, latency и памяти, и одна привычка важнее всех: падения recall тихие — то же число строк и latency — поэтому меряешь recall@k против точного бейзлайна, поднимаешь ef_search осознанно, следишь за селективными фильтрами по метаданным, которые post-filtering уничтожает (используй iterative scan), и добавляешь hybrid BM25 + вектор с rank fusion, когда точные совпадения важны.