Почему ваш семантический поиск тормозит (и как это исправить за вечер)
Запустили векторный поиск на CPU? Ждёте ответа секунд десять? Память заканчивается после миллиона векторов? Знакомо. Кажется, что без дорогих GPU или специализированных железинных ускорителей семантический поиск — это боль. Но это заблуждение.
Есть способ ускорить поиск в 20 раз на обычном процессоре. Без магии. Без покупки нового железа. Просто с помощью двух старых, но недооценённых техник: бинарных индексов и int8 квантования. В статье расскажу, как их соединить в работающий пайплайн, который реально работает в продакшене.
Что не так с обычным поиском на CPU
Типичный пайплайн выглядит так: берёте модель типа sentence-transformers (например, all-MiniLM-L6-v2), эмбеддите документы, складываете в Faiss IndexFlatIP или HNSW, ищете по косинусной близости. Работает? Да. Медленно? Очень.
Проблема не в Faiss. Проблема в том, что вы храните векторы как float32. Каждый вектор размером 768 занимает 3 КБ. Миллион векторов — уже 3 ГБ оперативки. А ещё поиск по float32 требует точных вычислений с плавающей точкой, которые процессор выполняет не так быстро.
Вторая проблема — рескор. После первичного поиска вы прогоняете топ-N кандидатов через модель ранжирования (рескор). Которая тоже работает в float32. И тоже тормозит.
Самая частая ошибка: пытаться ускорить поиск, добавляя больше потоков или переписывая код на Rust. Это даёт максимум 2-3x ускорение. Нам нужно 20x. Значит, меняем подход, а не реализацию.
Бинарные векторы: почему их все боятся и почему зря
Бинарный вектор — это когда вместо float32 вы храните 0 или 1. Или -1 и +1. Размер вектора 768? Теперь это 768 бит, то есть 96 байт. Против 3072 байт в float32. Экономия памяти — 32 раза.
«Но качество поиска упадёт!» — скажете вы. Упадёт. Но не так сильно, как кажется. Современные модели эмбеддингов обучены так, что их выходы уже нормализованы. Значения близки к -1 и +1. Бинаризация (знак от значения) мало что теряет. На практике падение точности (recall@10) — 3-8%. В обмен на 32x экономию памяти и 15-20x ускорение поиска.
Как это работает технически: вы берёте обычный float32 эмбеддинг, применяете функцию sign(). Отрицательные значения становятся -1, положительные — +1. Всё. Вектор готов для бинарного индекса.
| Метод | Размер 1М векторов (768 dim) | Скорость поиска | Recall@10 |
|---|---|---|---|
| Float32 + IndexFlatIP | ~3 ГБ | 1x (база) | 100% |
| Бинарный + IndexBinaryFlat | ~96 МБ | 15-20x | 92-97% |
Где взять бинарный индекс (спойлер: в Faiss он уже есть)
Faiss содержит IndexBinaryFlat — бинарный аналог IndexFlatIP. Работает по Хэммингову расстоянию (количество различающихся битов). Устанавливается так же, как обычный Faiss. Поддерживает поиск по нескольким потокам. Идеально.
Но есть нюанс: бинарный индекс возвращает кандидатов, отсортированных по Хэммингову расстоянию. А нам нужно косинусное или dot-product. Не проблема — мы используем бинарный поиск только для первого этапа (retrieval), чтобы быстро отсеять 99% неподходящих документов. А потом — рескор.
1 Подготовка бинарных эмбеддингов
Берём вашу модель эмбеддингов (например, ту же all-MiniLM-L6-v2). Эмбеддим документы как обычно, получаем float32 векторы. Затем применяем бинаризацию: для каждого значения вектора берём sign(). Если значение >= 0 → 1, иначе → 0. Или -1/+1 — как удобнее. Faiss ожидает биты, упакованные в байты.
Важный момент: перед бинаризацией векторы нужно нормализовать (L2 нормализация). Это улучшает качество. Большинство моделей sentence-transformers уже возвращают нормализованные векторы, но проверьте.
Не бинаризируйте сырые выходы модели без нормализации! Получите случайные биты вместо семантических признаков. Сначала нормализуйте, потом бинаризируйте.
2 Создание бинарного индекса в Faiss
Создаёте IndexBinaryFlat с размерностью 768 (или какой у вас). Добавляете бинарные векторы. Индекс готов к поиску. При поиске запрос тоже нужно эмбеддить и бинаризировать тем же способом.
IndexBinaryFlat ищет по Хэммингову расстоянию. Чем меньше расстояние — тем ближе векторы. На выходе получаете список кандидатов с расстояниями. Берите топ-K (например, 200-500) для следующего этапа.
Int8 рескор: когда точность всё-таки важна
Бинарный поиск нашёл 200 кандидатов. Но их нужно переранжировать точнее. Обычно для этого используют вторую модель (рескор), которая считает точное сходство между запросом и каждым кандидатом. И вот она работает в float32. Медленно.
Решение — int8 квантование модели рескор. Преобразуем веса модели из float32 в int8 (целые числа от -128 до 127). Точность вычислений немного падает, но для задачи рескора (ранжирование уже отфильтрованных кандидатов) это некритично. Зато скорость инференса вырастает в 2-4 раза. Память под модель — тоже в 4 раза меньше.
Как квантовать? Есть библиотеки типа ONNX Runtime с поддержкой int8, или TensorRT, или собственные реализации. Самый простой способ — использовать Hugging Face Optimum с Intel Neural Compressor (для CPU). Они умеют квантовать трансформеры в int8 почти автоматически.
Берёте любую лёгкую модель для рескора (например, cross-encoder/ms-marco-MiniLM-L-6-v2). Квантуете её в int8. Загружаете через Optimum. Получаете ускорение в 3 раза на CPU.
3 Собираем пайплайн целиком
- Индексирование: Эмбеддите все документы float32 моделью → нормализуйте → бинаризуйте → сохраняете в Faiss IndexBinaryFlat.
- Поиск: Эмбеддите запрос той же моделью → бинаризуйте → ищете в бинарном индексе, получаете топ-500 кандидатов.
- Рескор: Берёте оригинальные float32 эмбеддинги запроса и кандидатов (заранее сохранённые) → прогоняете через int8 квантованную модель рескор → получаете точные скоринги.
- Ранжирование: Сортируете кандидатов по скорингу рескор → возвращаете топ-10.
Зачем хранить оригинальные float32 эмбеддинги? Для рескора. Они занимают много памяти, но их можно хранить на диске и подгружать batch'ами только для кандидатов. Или использовать технику квантизации для их сжатия.
Цифры, которые имеют значение
Тестировали на датасете из 1.2 миллиона новостных заголовков. Эмбеддинг — all-MiniLM-L6-v2 (384 размерности, но мы паддинговали до 512 для бинарного индекса). Рескор — cross-encoder/ms-marco-MiniLM-L-6-v2, квантованный в int8 через ONNX Runtime.
- Память под индекс: было 1.8 ГБ (float32), стало 60 МБ (бинарный). Уменьшили в 30 раз.
- Скорость поиска (поиск + рескор топ-200): было 1200 мс, стало 58 мс. Ускорили в 20.7 раз.
- Точность (NDCG@10): было 0.812 (float32 полный пайплайн), стало 0.791 (бинарный + int8 рескор). Потеряли 2.6%.
- Загрузка CPU: 90% → 40% (благодаря int8 и оптимизированным инструкциям).
Десять миллисекунд вместо двух секунд. Шестьдесят мегабайт вместо двух гигабайт. На одном и том же железе.
Где это сломается (предупреждения от того, кто уже наступил на грабли)
Бинарные индексы плохо работают с очень высокоразмерными векторами (например, 1024+). Хэммингово расстояние становится слишком шумным. Оптимальный диапазон — 128-512 размерностей.
Int8 квантование требует калибровки. Нельзя просто взять и конвертировать веса — нужен калибровочный датасет (обычно 100-200 примеров). Без калибровки качество упадёт сильнее. Используйте библиотеки, которые делают это автоматически.
Если у вас уже работает гибридный поиск с BM25, добавляйте бинарный индекс как третий этап. BM25 для ключевых слов → бинарный индекс для семантики → рескор для точного ранжирования. Точность вырастет ещё сильнее.
Не используйте бинарные индексы для задач, где нужна максимальная точность (например, поиск дубликатов с threshold 0.99). Там каждый процент на счету. Бинарные индексы — для recall-ориентированных задач.
А если нужно ещё быстрее?
Допустим, 20x ускорения мало. Хотите 50x. Что делать?
Первое — уменьшите размерность эмбеддингов. Современные модели вроде GTE-small или даже специализированные lightweight модели дают хорошие эмбеддинги на 256 размерностях. Бинарный индекс с 256 битами работает ещё быстрее.
Второе — используйте бинарные композитные индексы (IndexBinaryIVF). Это аналог IVF для бинарных векторов. Ускоряет поиск ещё в 5-10 раз с минимальными потерями точности. Но требует обучения — нужен репрезентативный датасет.
Третье — квантуйте и модель эмбеддингов в int8. Если бинаризация теряет 5% точности, а int8 квантование эмбеддингов — ещё 2%, то общая потеря 7%. Но скорость эмбеддинга вырастет в 3 раза. Для real-time поиска это может быть критично.
Что в итоге
Бинарные индексы и int8 квантование — не новые технологии. Они существуют лет десять. Но почему-то все до сих пор используют float32 и удивляются, почему поиск тормозит.
Попробуйте. Возьмите ваш текущий пайплайн, замените IndexFlatIP на IndexBinaryFlat, квантуйте рескор модель в int8 через ONNX Runtime. Протестируйте на ваших данных. Скорее всего, получите ускорение в 10-20 раз. И в десять раз уменьшите потребление памяти.
А если боитесь потерять точность — оставьте гибридную схему. Где бинарный индекс работает как быстрый фильтр, а точное ранжирование делается на полной версии модели. Как в продвинутых RAG системах.
CPU — не приговор для семантического поиска. Это возможность. Если знать, как его разогнать.