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