В прошлой статье мы разбирали, почему retrieval в 2026 переехал с энкодеров на декодерные LLM: Qwen3-Embedding, NV-Embed, E5-Mistral эмбеддят лучше BGE, держат 32k контекста и понимают инструкции в промпте.
Проблема простая. Декодерный эмбеддер 7–8B действительно дает качество. Но за это качество вы платите трижды: память (8B в fp16 — не только веса модели, но и жирные векторы в индексе), latency (forward-pass большой модели) и деньги (GPU под нагрузкой или счет за эмбеддинг-API). И если latency лечится инференс-стеком (SGLang, батчинг — тема будущей статьи серии), то стоимость индекса растет линейно с числом документов и не лечится ничем, кроме сжатия векторов
Пример, чтобы почувствовать масштаб. Qwen3-Embedding отдаёт вектор на 1024 измерения (у 8B — до 4096). Возьмём типичный корпус на 100М чанков и вектор 1024-dim в fp32:
100_000_000 × 1024 × 4 байта = 409 ГБ
409 гигабайт только под сырые векторы, без HNSW-графа сверху (а он добавляет еще 30-50%). Это уже не влезает в один узел и стоит ощутимых денег в час. Тот же индекс после агрессивного сжатия — единицы гигабайт. Вопрос не в том, сжимать ли. Вопрос — где точка невозврата по recall
В этой статье разберем все оси сжатия, посмотрим на реальные замеры, где качество деградирует мягко, а где обрывается в пропасть, и вас ждет воспроизводимый скрипт + Colab-ноутбук, чтобы вы прогнали то же самое на своих векторах, если будет интересно.
> Весь код и данные для воспроизведения — в конце статьи. Скрипт compress_experiment.py принимает вашу матрицу эмбеддингов и измеряет все, что ниже, на CPU. Colab-ноутбук делает то же самое на реальной Qwen3-Embedding-0.6B — free-версии T4 хватает
Три оси сжатия (и почему их путают)
Когда говорят «сжать эмбеддер», обычно смешивают три независимые вещи, которые работают на разных уровнях:
1. Дистилляция — сжимаем саму модель. Учим маленькую модель (0.6B) воспроизводить эмбеддинги большой (8B). Уменьшается вес модели, latency инференса и, косвенно, размерность вектора. Это самый дорогой путь: нужен свой датасет и обучение. Дистилляцию я оставлю за скобками — она смыкается с файнтюном эмбеддера, а это достойно отдельных статей
2. Квантизация — сжимаем точность чисел в векторе. Вектор остается той же размерности, но каждое число хранится не в fp32 (4 байта), а в int8 (1 байт), int4 (полбайта) или вообще в 1 бите. Модель не трогаем — сжимаем уже посчитанные векторы. Дешево, обратимо, дает 4–32x
3. MRL-усечение — сжимаем размерность вектора. Матрешка (Matryoshka Representation Learning): режем 1024-мерный вектор до 512/256/128, если модель обучена так, что важное лежит в первых координатах
Ключевой факт, ради которого стоит держать эту тройку в голове: оси 2 и 3 практически не требуют GPU и не трогают модель. Они работают поверх готовой матрицы векторов. GPU нужен ровно один раз — чтобы получить сами эмбеддинги. Все остальное — арифметика на CPU
Отдельно стоит Product Quantization (PQ) — тоже квантизация, но векторная: пространство бьется на подпространства, в каждом строится маленький кодбук. PQ живет на стороне хранилища (Qdrant, FAISS), а не модели. Разберем его вместе с остальными
Как честно мерить деградацию от сжатия
Для начала разберем методологию
Сжатие всегда обменивает качество на память. Чтобы измерить качество, нужен эталон. Собственный размеченный бенчмарк с ручной разметкой релевантности обязателен (но эта тема тоже достойна быть разобранной отдельно) и отвечает он на другой вопрос — насколько хорош эмбеддер вообще. Здесь вопрос другой: насколько сжатие испортило то, что уже работало. И правильный эталон для него — сам несжатый поиск.
Методология стандартная, ее используют ann-benchmarks и сам Qdrant при замере квантизации:
-
Берем несжатые fp32-векторы. Для каждого запроса точным перебором находим top-10 ближайших. Это golden
-
Сжимаем векторы любым методом. Для тех же запросов ищем top-10 уже по сжатым
-
recall@10 = |top10_сжатый ∩ top10_fp32| / 10, усредняем по запросам
recall@10 = 1.0 означает, что сжатие ничего не изменило в выдаче.
recall@10 = 0.7 — в среднем 3 из 10 результатов сжатие потеряло.
Ручная разметка тут не нужна: мы измеряем потерю от сжатия, и fp32-выдача служит корректным нулем отсчета.
def recall_at_k(pred, gold, k=10): hits = sum(len(set(p[:k]) & set(g[:k])) for p, g in zip(pred, gold)) return hits / (len(gold) * k)
Все цифры ниже — прогон Qwen3-Embedding-0.6B (1024-dim) на корпусе из 5500 документов, 500 запросов. Эмбеддинги считает Colab-ноутбук (единственный GPU-шаг), дальше compress_experiment.pyпроводит замеры на CPU. Golden — точный fp32-поиск по полным 1024-мерным векторам.
Квантизация: int8 бесплатен, binary — обрыв
Начнем с поэлементной квантизации. Идея: у каждой координаты вектора есть диапазон [min, max] по корпусу. Разбиваем его на 2^bits уровней и храним номер уровня вместо float.
def q_scalar(C, bits): levels = (1 << bits) - 1 lo, hi = C.min(0), C.max(0) # по каждой размерности отдельно scale = (hi - lo) / levels codes = np.clip(np.round((C - lo) / scale), 0, levels) return codes * scale + lo # деквантованный вектор
Вот что происходит с recall@10 и памятью по шагам сжатия:
|
Метод |
recall@10 |
Байт/вектор |
Сжатие |
ГБ на 1М векторов |
|
fp32 (baseline) |
1.0000 |
4096 |
1x |
4.096 |
|
fp16 |
0.9984 |
2048 |
2x |
2.048 |
|
int8 |
0.9964 |
1024 |
4x |
1.024 |
|
int4 |
0.9478 |
512 |
8x |
0.512 |
|
binary (без rescore) |
0.6696 |
128 |
32x |
0.128 |
fp16 бесплатен. Вдвое меньше памяти, recall не отличить от fp32 (0.9984). Если вы до сих пор храните индекс в fp32 — вы просто платите вдвое ни за что. fp16 стоит включать по умолчанию, как базовую гигиену индекса.
int8 — почти бесплатен и это правильная точка по умолчанию. 4x сжатия, recall 0.9964 — падение в треть процента. Согласуется с тем, что заявляет Qwen: int8-квантизация сохраняет retrieval-качество с пренебрежимой деградацией. 409 ГБ из примера во вступлении превращаются в ~102 ГБ, и почти без последствий.
int4 — осознанный, но щадящий размен. 8x сжатия, recall 0.948 — минус ~5 пунктов. Для многих продовых сценариев это терпимо (особенно если сверху стоит reranker, который переранжирует топ), но включать int4 по умолчанию уже нельзя — сверяйтесь с бенчмарком.
binary в одиночку — катастрофа. 32x сжатия выглядит соблазнительно, но recall рухнул до 0.67. Треть релевантных результатов просто теряется. Если вы включили binary quantization и не сделали ничего больше — вы сломали поиск. И вот тут начинается самое интересное.
Binary + rescoring: как вернуть recall почти бесплатно
Binary quantization оставляет от каждого числа один бит — знак. Вектор на 1024 измерения сжимается с 4096 байт до 128 байт, и поиск идет по расстоянию Хэмминга (XOR + popcount) — это операции на регистрах процессора, дико быстрые. Проблема одна: одного бита на измерение мало, грубый поиск путается.
Решение — oversampling + rescoring. Не берем top-10 по бинарным векторам. Берем top-30 или top-50 (это и есть oversampling), а потом переранжируем этих кандидатов по полным fp32-векторам и оставляем настоящие top-10.
def binary_rescore(Qb, Cb, Qf, Cf, k, oversample): cand = binary_search(Qb, Cb, k * oversample) # грубо, но быстро: по битам out = [] for i in range(len(Qf)): c = cand[i] scores = Cf[c] @ Qf[i] # точно, но только по кандидатам out.append(c[np.argsort(-scores)[:k]]) return out
Фокус в том, что rescoring дешевый: мы считаем точное скалярное произведение не по всей базе, а по 30–50 кандидатам на запрос. Смотрим, как oversampling вытаскивает recall:
|
Конфигурация |
recall@10 |
|
binary без rescore |
0.6696 |
|
binary + rescore ×2 |
0.8598 |
|
binary + rescore ×3 |
0.9248 |
|
binary + rescore ×5 |
0.9666 |
|
binary + rescore ×10 |
0.9912 |
Из recall 0.67 при ×3 oversampling получается 0.925, при ×5 — 0.967, при ×10 — почти fp32 (0.991). Для трех-пятикратной «переборки» кандидатов при 32× сжатии основного индекса качество почти полностью возвращается. Это ровно тот результат, ради которого binary quantization вообще имеет смысл: грубый поиск по битам + точный rerank по полным векторам.
Практика Qdrant это подтверждает: они рекомендуют oversampling в диапазоне 1.5–3x как sweet spot и сообщают, что на высокоразмерных векторах (например, Cohere 4096-dim) binary + rescoring дает recall@50 около 0.98 при 2x oversampling, ускоряя поиск до 40x. Логика та же, что у reranker’а в классическом RAG: дешевый кандидатный отбор, дорогой точный топ.
Здесь же — первый большой антипаттерн.
> Binary quantization без rescoring убивает качество; с rescoring — почти бесплатна. Разница между «сломанным поиском» (0.67) и «продакшеном» (0.93–0.97) — это одна опция rescore=True и oversampling. Если вы видите в бенчмарке, что binary «не работает», в 9 случаях из 10 просто отсутствует rescoring.
И второе, менее очевидное: binary любит высокую размерность. Один бит на измерение — значит, весь сигнал держится на количестве измерений. Чем выше размерность, тем легче переносится бинаризация: на 4096-dim она отъедает заметно меньше, чем на 1024 (хотя rescoring полезен и там). А вот если вы сначала агрессивно усекли вектор по MRL, а потом бинаризовали — вы забрали у binary ровно тот бюджет, на котором он держится.
MRL-усечение: когда матрешка окупается и почему усечение не бесплатно
Matryoshka Representation Learning — прием, при котором модель обучают так, чтобы вектор оставался осмысленным при усечении. Обрезали 1024-мерный вектор до первых 256 координат, перенормировали — и он все еще ищет. Qwen3-Embedding обучена с MRL и поддерживает размерности от 32 до 1024 из коробки.
Стандартный тезис звучит так: «MRL-усечение работает мягко только на MRL-моделях, на обычной модели усечение все ломает». Это почти правда, и свежая работа 2026 года («To MRL or not to MRL», arXiv:2605.16608) уточняет, где именно.
Оказывается, эмбеддинги устойчивы к усечению сами по себе, без всякого MRL — но только до умеренного сокращения (примерно до −70…80% размерности). А вот в зоне тяжелого усечения (сокращение на 80%+, то есть 1024 → 128 и ниже) нематрешечные модели обваливаются, и вот там MRL действительно решает. То есть MRL отвечает за выживание в зоне агрессивного усечения; умеренное сокращение работает и без него.
Проверим на реальном Qwen3. Мы усекали 1024-мерный вектор двумя способами: нативным MRL (первые k координат — так модель и обучена) и после случайной перестановки осей (ломаем MRL-порядок, эмулируя «обычную» модель). recall измеряется относительно полного 1024-мерного поиска:
|
Размерность |
Сокращение |
нативный MRL Qwen3 |
случайный порядок осей |
|
512 |
−50% |
0.7962 |
0.7612 |
|
256 |
−75% |
0.6662 |
0.6250 |
|
128 |
−88% |
0.5610 |
0.4730 |
|
64 |
−94% |
0.4354 |
0.3038 |
|
32 |
−97% |
0.3016 |
0.1588 |
Тут сразу две вещи, и обе важные:
Нативный MRL стабильно бьет случайный порядок, и разрыв растет с глубиной усечения. На −50% это 0.80 против 0.76 (разница небольшая), а на −94% уже 0.44 против 0.30, на −97% — 0.30 против 0.16, почти вдвое. Это ровно то, что предсказывает arXiv:2605.16608: MRL-обучение окупается именно в зоне тяжелого усечения. За это и платят обучением с матрешкой.
Но само усечение — совсем не «бесплатно». Даже нативный MRL на −50% дает recall 0.80 относительно полного вектора: каждый пятый результат в top-10 перетасовался. Это выглядит жестче, чем расхожие «1–2% просадки при 1024→512», и здесь важно понимать разницу в метрике. Расхожая цифра — это nDCG против размеченной релевантности: усеченная модель может достать столь же релевантный документ, просто в другом порядке, и nDCG почти не падает. Наша метрика строже — это пересечение с выдачей полной модели, и она честно показывает, насколько усечение меняет вашу текущую выдачу. Плюс корпус здесь — обычный текст, а не заточенный retrieval-бенчмарк, что тоже добавляет строгости.
Таким образом, MRL-усечение — рабочая ось сжатия, но, в отличие от int8, оно заметно двигает выдачу уже на −50%. Резать стоит, но обязательно сверяясь со своим размеченным бенчмарком: именно он покажет, ушли ли перетасованные документы в нерелевантные или остались одинаково хорошими.
> MRL-обучение окупается тем сильнее, чем глубже вы режете. На умеренном усечении разница между матрешкой и обычной моделью невелика; в зоне тяжелого сжатия она вырастает почти вдвое. Но не путайте «MRL лучше случайного порядка» с «усечение бесплатно» — само по себе усечение двигает выдачу, и цену этого сдвига покажет только ваш размеченный бенчмарк. График выше построен ячейкой 6 Colab-ноутбука на реальном нативном MRL Qwen3
Хранилище: PQ, scalar и binary на стороне Qdrant
До сих пор мы сжимали векторы руками. В продакшене за это отвечает векторная база. Qdrant умеет три вида квантизации прямо на уровне хранилища, и их полезно различать:
Scalar quantization (int8). Тот самый int8 из таблицы выше. Qdrant хранит квантованный индекс в памяти, а оригиналы fp32 — на диске для опционального rescoring. 4x экономии, recall ~0.99, дефолтная рекомендация для большинства задач.
Binary quantization. 32x экономии, обязательно с oversampling и rescore=True (см. раздел выше). Qdrant прямо предупреждает, что binary имеет смысл на высокоразмерных векторах.
Product Quantization. Пространство бьется на m подвекторов, в каждом — кодбук на 256 центроидов, вектор превращается в m байт. Сжатие настраивается числом подвекторов. Замерили PQ на тех же данных:
|
Метод |
recall@10 |
Байт/вектор |
Сжатие |
Поиск, запросов/с |
|
PQ, m=128 |
0.8100 |
128 |
32x |
323 |
|
PQ, m=64 |
0.6926 |
64 |
64x |
726 |
|
binary + rescore ×3 |
0.9248 |
128 (+fp32 для rescore) |
32x* |
11589 |
* Сам бинарный индекс — это 128 байт/вектор (32x). Но для rescoring нужны оригиналы: в замере они держались рядом в int8 (ещё 1024 байт), отсюда 128 + 1024. В Qdrant оригиналы по умолчанию лежат в fp32 на диске — тогда RAM-индекс остается 128 байт, а полные векторы читаются с диска при переранжировании
Тут вылезает неинтуитивная вещь.
Голый PQ на 32x (0.81) сам по себе приличнее голого binary (0.67) — он бьется не по знаку, а по кластерам, и держит больше структуры. Но при одинаковых 32x binary + rescoring бьет PQ и по качеству (0.92 против 0.81), и по скорости — в моем брутфорс-замере PQ на порядок медленнее из-за таблиц расстояний, тогда как Хэмминг на битах летает. Отсюда практическое правило: PQ хорош, когда память критична до последнего байта и вы готовы вложиться в тонкую настройку (число подвекторов, обучение кодбуков, свой rescoring). Если же нужен просто «сжать в 32 раза и не потерять recall» — binary + rescoring проще и обычно выигрывает.
Полная картина по памяти (ГБ на 1М векторов, подписан recall):
И три вещи про Qdrant, которые бьют на проде и которых нет в туториалах:
-
Индекс не пересобирается с нуля — никогда. Qdrant делает инкрементальные апдейты. Квантизацию включают на коллекции, дальше точки добавляются по мере поступления. Полная переиндексация — только по расписанию, если совсем надо
-
Оригиналы для rescoring живут на диске. Binary/scalar экономят RAM, но rescoring читает полные векторы. Если диск медленный, вы съедите выигрыш от сжатия обратно на latency. Держите оригиналы на NVMe
-
Oversampling — настраиваемый компромисс. ×3 почти всегда возвращает recall, но добавляет чтений на rescoring. Под жесткий latency-бюджет подбирайте его по своему бенчмарку, а не по дефолту
Комбинирование: что стекается, а что конфликтует
Оси сжатия независимы, поэтому их хочется стекать: сначала MRL-усечь, потом квантовать. Здесь важно знать, что складывается чисто, а что — конфликтует. Замерим стек «MRL-усечение → квантизация» на тех же данных:
Колонка recall@10 — качество всего стека (усечение + квантизация), колонка База (только MRL) — recall одного лишь усечения, без квантизации сверху. Разница между ними и есть чистый вклад второй оси — сколько отнимает квантизация поверх уже усечённого вектора:
|
Стек |
recall@10 |
База (только MRL) |
Итоговое сжатие |
|
MRL-512 + int8 |
0.7946 |
0.7962 |
~8x |
|
MRL-256 + int8 |
0.6662 |
0.6662 |
~16x |
|
MRL-512 + binary + rescore ×3 |
0.7444 |
0.7444 |
~8x |
|
MRL-256 + binary + rescore ×3 |
0.5830 |
0.6662 |
~16x |
MRL + int8 стекается идеально. Сравните колонки: int8 поверх усечения не отнимает почти ничего (0.7962 → 0.7946, 0.6662 → 0.6656). Две оси бьют по разным избыточностям — MRL убирает лишние измерения, int8 убирает лишнюю точность в оставшихся, — и складываются без интерференции. Вся просадка тут от усечения, не от квантизации.
MRL + binary мешают друг другу. binary держится на количестве измерений, а MRL это количество как раз и урезает. Поэтому binary+rescore поверх усеченного вектора отнимает заметно больше, чем int8: 0.7962 → 0.7444 на 512, 0.6662 → 0.5830 на 256. Чем агрессивнее усечение, тем сильнее конфликт. Если нужна и размерность поменьше, и биты — бинаризуйте на полной размерности, а экономию берите oversampling’ом, не усечением.
Совет по стеку: MRL (до разумного) → int8 → (опционально) binary + rescoring на полной размерности как отдельный индекс. Не пытайтесь усечь и бинаризовать одновременно
Минимальный рецепт сжатия на сегодня
Если собирать индекс под декодерный эмбеддер сейчас, можно пойти так:
Дефолт для 90% задач: int8 (scalar quantization) в Qdrant. 4x экономии, recall ~0.99, включается одной настройкой — базовая гигиена индекса, как fp16 вместо fp32.
Память критична (десятки-сотни миллионов векторов): binary + rescoring, oversampling ×3–×5. 32x экономии основного индекса, recall ~0.92–0.97, оригиналы на NVMe под rescoring. Работает тем лучше, чем выше размерность — на Qwen3-Embedding-8B (до 4096-dim) особенно.
Нужна ещё и меньшая размерность, модель MRL: MRL-усечение до 512 + int8. ~8x, int8 поверх усечения почти бесплатен. Но помните: само усечение до 512 уже двигает выдачу (в нашем замере ~0.80 относительно полного вектора), поэтому эту ось включайте только после проверки на своем размеченном бенчмарке, а не по умолчанию.
Чего не делать: не гонять fp32 в индексе (платите вдвое зря); не включать binary без rescoring (сломаете поиск); не усекать не-MRL-модель ниже −80% (уедете в обрыв); не стекать MRL + binary.
И сквозной принцип, важнее любого из методов: каждый шаг сжатия сверяйте со своим бенчмарком. Точка невозврата зависит от домена, модели и размерности. Мои цифры показывают форму кривой и порядок величин; ваша точка обрыва может отличаться. Прогоните compress_experiment.py на своих векторах — это десять минут, а не согласование бюджета на GPU
Что осталось за кадром
Дистилляция в маленькую модель. Самая мощная ось сжатия — уменьшить саму модель — требует своего датасета и обучения. Это смыкается с файнтюном эмбеддера, которому будут посвящены следующие статьи
Quantization-aware training. Qwen3 включает QAT в тренировочный пайплайн — модель учат так, чтобы она изначально хорошо переживала int8. Если вы файнтюните свой эмбеддер, QAT стоит закладывать сразу, а не квантовать постфактум
Асимметричная квантизация запрос/документ. Документов миллионы, запрос один. Можно жестко сжимать документы и держать запрос в fp32 — часть просадки recall отыгрывается бесплатно
Сжатие декодерного эмбеддера — обязательный этап между тем, что модель хорошо эмбеддит, и тем, что она работает в продакшене за вменяемые деньги. Хорошая новость: почти все сжатие — арифметика на CPU поверх готовых векторов, и точку невозврата по recall можно нащупать за вечер на своем железе, без GPU. int8 бесплатен, binary требует rescoring, MRL требует матрешки, а стекать их надо с умом.
Но у всего этого есть слепое пятно. Все цифры выше меряют деградацию относительно fp32-поиска той же модели. А что если сама модель на вашем домене ищет плохо? Тогда вы аккуратно сохраняете при сжатии ровно тот recall, которого у вас и так нет. Чтобы отличить «сжатие сломало» от «модель и не умела», нужен собственный размеченный бенчмарк — и это единственное место в пайплайне, где нельзя срезать угол.
В следующей статье собираем такой бенчмарк для RAG в узком домене руками: сколько пар нужно, откуда брать запросы, что такое хороший hard negative, и почему публичные метрики вроде MTEB на вашем домене врут.
Ну а если ждать следующей статьи не хочется — я веду телеграм-канал @torch_lab, где выкладываю такие разборы, заметки из практики retrieval и агентов и цифры, которые не дошли до статей: что сработало, что развалилось на проде и почему
Воспроизведение
Весь код — в репозитории: github.com/KuzminaSofia/embedding-compression (compress_experiment.py + Colab-ноутбук). Все цифры в статье — прогон Qwen3-Embedding-0.6B. Воспроизвести можно в два шага
Шаг 1 (GPU, один раз). Colab-ноутбук article1_qwen3_colab.ipynb(открыть в Colab): грузит модель, эмбеддит публичный корпус (+ слот под вашу доменную синтетику), сохраняет qwen3_emb.npy и строит графики.
Runtime → T4 GPU → Run all, ~10–15 минут на free-версии
Шаг 2 (CPU, где угодно). compress_experiment.pyпринимает готовую матрицу эмбеддингов и пересчитывает все таблицы и графики:
# на векторах из Colab (или на своих доменных):python compress_experiment.py --emb qwen3_emb.npy --outdir results# без файла — на синтетическом корпусе, чтобы просто проверить пайплайн:python compress_experiment.py --outdir results
Подставьте свои векторы — и получите свою точку невозврата по recall на своем домене
Источники
-
Matryoshka Representation Learning — arxiv.org/abs/2205.13147
-
To MRL or not to MRL: Text Embeddings are Robust to Truncation Without Matryoshka Embeddings, Except In Heavy Truncation Scenarios (2026) — arxiv.org/abs/2605.16608
-
Qdrant — Quantization guide — qdrant.tech/documentation/guides/quantization
-
Qdrant — Binary Quantization — qdrant.tech/articles/binary-quantization
-
Qdrant — Accuracy Recovery with Rescoring — qdrant.tech/documentation/guides/quantization
-
Qwen3-Embedding (blog + модели) — qwenlm.github.io/blog/qwen3-embedding
-
Qwen3-Embedding-0.6B на HuggingFace — huggingface.co/Qwen/Qwen3-Embedding-0.6B
-
Предыдущая статья серии: Retrieval в 2026 — habr.com/ru/articles/1049872
ссылка на оригинал статьи https://habr.com/ru/articles/1054930/