Сжатие декодерных эмбеддеров: как ужать 8B до продакшена без потери recall

от автора

В прошлой статье мы разбирали, почему 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 при замере квантизации:

  1. Берем несжатые fp32-векторы. Для каждого запроса точным перебором находим top-10 ближайших. Это golden

  2. Сжимаем векторы любым методом. Для тех же запросов ищем top-10 уже по сжатым

  3. 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

Скалярное сжатие: точка невозврата по recall

Скалярное сжатие: точка невозврата по recall

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

Binary + rescoring: почти бесплатное восстановление recall

Binary + rescoring: почти бесплатное восстановление recall

Из 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-усечение Qwen3: нативный порядок против случайного

MRL-усечение Qwen3: нативный порядок против случайного

Тут сразу две вещи, и обе важные:

Нативный 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 на своем домене

Источники

ссылка на оригинал статьи https://habr.com/ru/articles/1054930/