ip / ivanpleshkov.dev
EN

02 Блог

TurboQuant в Qdrant: трюки и идеи реализации

· qdrant ·quantization ·turboquant ·vector-search ·ann

1. Введение

TurboQuant — алгоритм скалярной квантизации векторов от Google Research, опубликованный в 2026 году. На бумаге он сжимает float32-векторы в 8× практически без потерь в качестве поиска ближайших соседей и при этом не требует обучать никакие per-dataset codebook’и. Звучит как серьёзная заявка, и я потратил несколько месяцев на её реализацию в Qdrant — со всеми расширениями, без которых «алгоритм из статьи» в production не выживает. Результат зарелизен в Qdrant 1.18.

К оригинальному TurboQuant я добавил несколько расширений. Часть придумал сам по ходу работы, часть встречалась в других реализациях (в первую очередь в llama-turbo-quant и в интерактивном объяснении Arkar Min Aung) и хорошо легли на специфику Qdrant. Я не делаю вид, что всё придумал — но постараюсь объяснить, почему каждое из этих расширений хорошо подходит к Qdrant как production-grade векторной базе.

Небольшая ремарка про код. Все сниппеты в посте — на Python, для наглядности и чтобы идею можно было воспринять без знания Rust. Реальная имплементация в Qdrant — это Rust с серьёзными SIMD-оптимизациями под AVX2 / AVX-512 / NEON; про них подробно и со вкусом рассказывает мой коллега Jojii в своём посте (TODO: ссылка). Этот пост — про идеи и алгоритмические решения; пост Jojii — про то, как заставить эти идеи бежать на железе с максимальной скоростью.

2. Цифры recall

Чтобы честно измерить, что именно дают мои расширения над оригинальным алгоритмом, я написал Python-реализацию своей версии TurboQuant (читабельный референс, лежит в отдельном репо turboquant-qdrant-showcase) и прогнал её рядом с vanilla TurboQuant из открытого pip-пакета turboquant 0.2.0 — первой публичной имплементации алгоритма от Google. Сравнение algorithm-vs-algorithm: три конфигурации на одном и том же brute-force бенчмарке.

Конфигурации:

  • vanilla-MSEturboquant.TurboQuantMSE, paper Algorithm 1 (MSE-вариант).
  • vanilla-IPturboquant.TurboQuantIP, paper Algorithm 2 (PROD-вариант с QJL-битом).
  • qdrant — моя имплементация со всеми расширениями, разобранными в секции про реализацию дальше в посте.

Данные: 6 публичных датасетов, специально подобранных так, чтобы покрыть оба сценария — эмбеддинги, у которых есть выраженная анизотропия (instruction-tuned / contrastive-trained, без Matryoshka-регуляризации), и эмбеддинги, у которых её почти нет (Matryoshka-trained или post-hoc decorrelated). По 100K векторов в корпусе и 1000 запросов на датасет, корпус сэмплируется однородно через детерминированный shuffle. На датасетах, исходно крупнее 100K (gte-multilingual-ads — 1M; wiki-cohere — мультиязычный, ~5M полный — беру en-конфиг), беру первые 100K после shuffle — это совпадает с типичным production-размером сегмента в Qdrant. В ячейках — recall@10 относительно brute-force cosine ground truth. Названия датасетов во всех таблицах ниже кликабельны и ведут на страницу датасета на HuggingFace. Таблицу можно воспроизвести командой python benchmark.py из репозитория turboquant-qdrant-showcase.

Размер бенчмарка (100K) — не случайный: в Qdrant данные нарезаны на сегменты, и моя калибровка по построению применяется per-segment, независимо. Типичный production-сегмент — тех же ~100K порядков, что и бенчмарк. То есть я тестирую ровно тот scale, на котором калибровка реально работает в production.

4 бита на координату

Датасетvanilla-MSEvanilla-IPqdrant
arxiv-instr0.8150.7740.954
gte-mul-ads0.7910.7400.967
gemini-0010.8410.7990.952
openai-3-l0.9410.9200.970
openai-3-s0.9380.9160.969
wiki-cohere0.9370.9200.973

2 бита на координату

Датасетvanilla-MSEvanilla-IPqdrant
arxiv-instr0.6960.6270.840
gte-mul-ads0.6840.7320.885
gemini-0010.7300.6330.840
openai-3-l0.8780.8080.901
openai-3-s0.8770.8060.901
wiki-cohere0.8830.8270.914

1 бит на координату

Датасетvanilla-MSEqdrant
arxiv-instr0.6090.686
gte-mul-ads0.7200.786
gemini-0010.6150.702
openai-3-l0.8000.806
openai-3-s0.7980.807
wiki-cohere0.8190.840

(vanilla-IP при 1 бите вырождается: PROD отделяет 1 бит на QJL, и на codebook остаётся ноль битов. Чтобы сравнить честно, IP нужен минимум 2-битный бюджет — поэтому в этой таблице его нет.)

Дальше в посте я разбираю по одному трюку на секцию, откуда именно эти проценты в колонке qdrant берутся. Но прежде чем туда нырять — короткий бэкграунд про сам алгоритм TurboQuant и про то, почему я выбрал именно MSE-вариант. Сравнение моей TurboQuant-имплементации с другими методами квантизации в Qdrant (SQ, BQ, и f32-baseline по recall) живёт отдельно и используется в маркетинговых материалах.


3. Что такое TurboQuant и почему я выбрал MSE-вариант

Цель TurboQuant — сжать D-мерный float-вектор до 1–4 бит на координату с минимальной потерей точности при поиске ближайших соседей и без обучения per-dataset codebook’а (arXiv:2504.19874).

В этом посте я не буду подробно пересказывать статью — на это есть существенно более качественное объяснение, чем я могу предложить: интерактивный визуальный разбор. Если вы не знакомы с алгоритмом, потратьте 20 минут на этот ресурс — вы сразу всё поймёте.

Но в двух словах MSE-версия алгоритма выглядит так. Важный момент, который надо проговорить с самого начала: алгоритм в оригинале сформулирован для единичной сферы — то есть для cosine-метрики, когда все вектора нормированы на единичную длину. На этом предположении держится вся математика: при равномерном распределении точек на сфере и случайном повороте каждая координата ведёт себя как ~N(0, 1/D) — одинаково для всех векторов в датасете, что и позволяет применить единый Lloyd-Max codebook ко всем без исключения координатам всех без исключения векторов.

Сами шаги:

  1. Поворот. Применяем псевдослучайное ортогональное преобразование к вектору. Это «размазывает» точки на сфере равномерно по координатам, и каждая координата начинает себя вести как гауссова с дисперсией 1/D.
  2. Скалярная квантизация по Lloyd-Max. Каждую координату независимо квантизуем в 1, 2 или 4 бита по предпосчитанному Lloyd-Max codebook’у — это классический алгоритм из теории квантизации (Lloyd, 1957; Max, 1960), который для заданной плотности распределения итеративно находит 2^b центроид и границ между ними, минимизирующих среднеквадратичную ошибку реконструкции. Для гауссова источника он даёт известные таблицы: для 1 бита это пара ±0.798, для 4 бит — 16 центроидов от −2.733 до 2.733. Если хочется потрогать алгоритм руками — в интерактивном разборе есть отдельный раздел с виджетом, который показывает Lloyd-Max сходимость пошагово; здесь важно только то, что эти центроиды — единственная универсальная таблица в коде, и больше per-dataset codebook нигде нет.

Хранится только b·D бит индексов центроид и ничего больше — никаких per-vector scale, zero-point или сохранённых длин. Скоринг между квантизованным вектором и непрерывным запросом, прошедшим через тот же поворот, — это просто dot-product между запросом и центроидами.

Эта элегантность держится строго в рамках cosine-метрики. В Qdrant же пользователи приходят с L2 и ненормированным dot-product тоже, и для продукта это принципиально важная часть. Как я аккуратно расширяю алгоритм за пределы единичной сферы (и где у этого расширения есть честная цена) — отдельный сюжет, разберу ниже в секции про метрики.

Асимметричный и симметричный скоринг в Qdrant

Прежде чем переходить к выбору варианта, нужно проговорить одну вещь, на которой держится всё остальное: в Qdrant query-вектор не квантуется методом TurboQuant.

Когда пользователь делает поиск, оригинальный float32-запрос всё равно живёт в памяти на время одного query — он пришёл из API, и хранить его как есть бесплатно по дисковой памяти и почти бесплатно по RAM. Поэтому скоринг для пользовательского поиска идёт по асимметричной схеме: к запросу применяется только тот же поворот, что и к хранимым векторам, а дальше — обычный dot-product между непрерывным запросом и центроидами хранимого квантизованного вектора. Это сильно улучшает recall по сравнению с симметричной схемой (квантизованный-против-квантизованного), потому что весь шум квантизации остаётся только в хранимом векторе, а не удваивается.

Но в Qdrant есть и другие сценарии, где обе стороны скоринга обязательно квантизованные. При построении HNSW, при merge сегментов, при внутренней кластеризации, при rescoring внутри индекса нельзя таскать оригинальные float32-векторы — это свело бы на нет весь смысл квантизации. Для этих случаев в Qdrant отдельная ветка скоринга, работающая на двух квантизованных представлениях.

Получается, в Qdrant два равноправных пути скоринга — асимметричный (для пользовательского поиска) и симметричный (для внутренних операций), — и они должны делить один общий артефакт на диске. Этот факт определяет почти всё, что идёт дальше: и выбор варианта TurboQuant, и почти все мои трюки активно используют то, что во время поиска запрос остаётся непрерывным.

Почему MSE, а не PROD

В оригинальной статье есть два варианта алгоритма: TurboQuant-MSE (то, что выше) и TurboQuant-prod. Второй — это та же MSE-конструкция, но один из бит уходит не на Lloyd-Max codebook, а на бит знака случайной QJL-проекции. Зачем? Lloyd-Max центроиды по построению лежат ближе к нулю, чем оригинальные координаты, поэтому каждый dot-product на восстановленном векторе систематически усыхает (в 2/π раз для 1 бита, ≈0.88 для 2 бит, ≈0.97 для 3 бит). QJL-бит компенсирует этот bias за счёт случайного скетча, добавляя несмещённость ценой увеличения дисперсии.

Для Qdrant я целенаправленно выбрал MSE-вариант. Тому есть три причины, в порядке важности.

Во-первых, у Qdrant два равноправных пути скоринга, и они должны делить один codebook. Асимметричный путь (полный запрос против хранимого квантованного вектора) — это то, что видит пользователь во время поиска. Симметричный путь (квантованное против квантованного) — это то, что работает при построении HNSW, при merge сегментов, при rescoring и при внутренней кластеризации. TurboQuant-prod по построению асимметричен: вывод математики QJL опирается на полный запрос, без него знаковый бит не даёт сигнала. То есть на симметричном пути PROD-вариант просто не работает — пришлось бы поддерживать два независимых артефакта. MSE даёт один набор кодов, который аккуратно ложится в обе схемы: ⟨c[idx_x], c[idx_y]⟩ для симметричного, ⟨q, c[idx_x]⟩ для асимметричного. Один артефакт, два пути, ноль special case’ов.

Во-вторых, цена скоринга. QJL требует применять случайную (или Hadamard-приближённую) проекцию к каждому запросу перед тем, как комбинировать его с хранимыми битами. Это лишний O(D log D) проход поверх уже существующего поворота. MSE-путь — это «достать центроиду по индексу, накопить dot-product» — буквально несколько операций на одно скоринг-сравнение. Для онлайн-БД, скорящей миллионы кандидатов в секунду на шард, эта разница принципиальна.

В-третьих, бит-бюджет. Оба варианта асимптотически выходят на Шенноновский предел D ≈ 4^(−b), но TurboQuant-prod делит свои биты — b−1 идёт в codebook, 1 в QJL — поэтому при одинаковом суммарном бюджете подложечный MSE-codebook у PROD всегда на 1 бит беднее. Тратить весь бюджет на точность кодбука выгоднее: на bits=2 codebook уже даёт ≈0.88 fidelity, на 4 бит — ≈0.998, что и есть рабочий диапазон ANN.

Честно про trade-off. Оценка скалярного произведения через MSE имеет известный детерминированный shrinkage на низких битностях. На bits=1 теряется ≈36% от каждого dot-product, на bits=2 — ≈12%. PROD-вариант несмещённый по построению и теоретически лучше ведёт себя на adversarial-входах и в задачах, где важно абсолютное значение score (а не только ранжирование). Для Qdrant это приемлемо, потому что (а) я работаю при b ≥ 2, где shrinkage — это однородный скаляр, не влияющий на ранжирование, (б) в реальном пайплайне всегда есть rescoring top-K на оригинальных векторах, который поглощает остаточный bias до того, как он дойдёт до пользователя.

4. Особенности реализации в Qdrant

Дальше пойду по конкретным трюкам и решениям из моей реализации, в порядке от самого простого к самому содержательному:

Откуда именно проценты в главной таблице recall берутся — разбирается ниже, по одному трюку на секцию.


5. Трюк 1. Ренормализация — bias-correction без QJL

Самый простой из трюков, и самый «перевыполняющий» по эффекту. Сразу проговорю: этот приём придумал не я. Лично я впервые увидел его в llama-turbo-quant, он же встречается и в других реализациях TurboQuant. Я взял этот приём у сообщества и применил в Qdrant — но не упомянуть его было бы нечестно, потому что именно он закрывает большую часть разрыва между vanilla и моей имплементацией.

Что он делает. Когда декодируется сохранённый вектор, получается не оригинальный X, а его аппроксимация через ближайшие центроиды. Из-за того, что центроиды Lloyd-Max лежат ближе к нулю, чем оригинал, у декодированного вектора L2-длина систематически меньше — он смещён по амплитуде. Это и есть тот самый shrinkage, который PROD-вариант исправляет через QJL.

Я делаю проще, без QJL. На этапе квантизации дополнительно сохраняю измеренную L2-длину центроидного вектора cn = ‖c[idx]‖. Эта длина — постоянная цена в 4 байта на вектор, которая окупается с лихвой.

При скоринге сохранённое отношение l2 / cn используется как scaling factor, заменяющий наивное 1/√D. То есть когда нужно восстановить настоящий dot-product из «нормализованного» raw_dot, я делю не на детерминированное √D (которое было бы правильным, если бы шума квантизации не было), а на фактически измеренную длину центроидного вектора. Это убирает per-vector shrinkage точно, без случайных проекций.

Сколько это даёт

Сравним в чистом виде: vanilla-MSE против моей имплементации с одной только ренормализацией, без других расширений. Цифры — recall@10 при 4 битах:

Датасетvanilla-MSE+ ренормализацияΔ, pp
arxiv-instr0.8150.937+12.2
gte-mul-ads0.7910.944+15.3
gemini-0010.8410.937+9.6
openai-3-l0.9410.969+2.8
openai-3-s0.9380.967+2.9
wiki-cohere0.9370.972+3.5

Важное уточнение: в этой таблице + ренормализация — это моя имплементация только с ренормализацией, без всех остальных трюков из этого поста. Чистый изолированный эффект приёма из llama-turbo-quant. Полная имплементация (со всеми трюками) даёт ещё больше — её цифры в главной таблице recall выше.

Надо обратить внимание на разрыв: на anisotropic-датасетах ренормализация одна делает +9–15pp, на изотропных — +3pp. Этот эффект коррелирует с анизотропией данных не случайно (на anisotropic-датасетах вариация cn между векторами больше, и per-vector коррекция работает заметнее), но детально это отдельная тема.

На 1-битной квантизации ренормализация почти ничего не даёт: при двух центроидах ±c норма центроидного вектора cn = c·√D одинакова для всех векторов независимо от индексов — per-vector коррекция вырождается в константный масштаб, который не меняет ранжирования. Поэтому весь выигрыш ренормализации сидит на 2 и 4 битах.


6. Трюк 2. Чистое, обратимое, не персистентное вращение

Здесь, я считаю, наиболее интересная инженерная конструкция — поэтому начну с козырей. Моё вращение является:

  1. Чистой функцией. Нет никакого глобального состояния — поворот детерминирован относительно своих аргументов и константных захардкоженных seed’ов.
  2. Не персистентным. Я не сохраняю матрицу вращения ни на диск, ни в metadata. Вращение полностью описывается размерностью + константами в коде.
  3. Полностью обратимым. Есть обратный поворот, который возвращает оригинал точно (до численной ошибки), и он тоже чистый.

Hadamard как приближение случайного поворота

Я использую, как и многие другие имплементации quant-алгоритмов, преобразование Адамара. Это известный приём: Walsh-Hadamard Transform (WHT) аппроксимирует случайный ортогональный поворот за O(D log D) вместо O(D²). В этом нет ничего уникального.

Но WHT работает только на размерностях, кратных степени двойки. Реальные эмбеддинги — 384, 768, 1024, 1536, 3072 — иногда укладываются, иногда нет.

Декомпозиция на блоки степеней двойки

Дополнять до ближайшей степени двойки — расточительно (для 1025 пришлось бы дополнять до 2048). Поэтому я раскладываю размерность на сумму степеней двойки жадно: для D=300 это [256, 32, 8, 4]. Каждый блок проходит свой собственный WHT с нормировкой 1/√(block_size).

Почему одного раунда мало

WHT на блоках в лоб даёт только частичную декорреляцию — если энергия исходно сконцентрирована в одном блоке, она там и останется. Чтобы как следует размешать координаты между блоками, нужна пермутация, перемешивающая позиции, и за ней ещё один WHT. И так несколько раундов.

В моей реализации — 3 раунда (permute + WHT) после первоначального WHT, итого 4 применения WHT и 3 применения пермутации. Этого эмпирически достаточно, чтобы добиться качественного перераспределения на всех датасетах, что я тестировал — на сильно неоднородном входе stddev по координатам после поворота падает в ~500 раз по отношению к исходному.

Проблемы с обычной случайной пермутацией

Если делать random permutation в лоб — нужно где-то хранить массив индексов размера D. Это:

  • O(D) память на каждую пермутацию (а у меня их 3).
  • Этот массив надо персистить в metadata — иначе после рестарта нельзя будет декодировать вектора.
  • Чтобы получить inverse, нужно либо построить и хранить ещё один массив, либо проходить вперёд каждый раз, что дорого.

Всё это нарушает три моих свойства (чистота, не-персистентность, обратимость). Хотелось бы уметь по чуть-чуть, без всего этого.

Мой способ: reversible LCG + Fisher-Yates

Решение — детерминированный Fisher-Yates shuffle, прокручиваемый из обратимого LCG. Распакуем по слоям.

Fisher-Yates — это классический алгоритм равномерного перемешивания массива на месте. Идём с конца к началу: на шаге i (от n − 1 до 1) берётся «случайное» число j ∈ [0, i] и arr[i] меняется местами с arr[j]. На выходе — каждая из n! возможных перестановок имеет равную вероятность. Алгоритм линейный по времени, O(1) по дополнительной памяти и тратит ровно одно случайное число на шаг.

Как сделать Fisher-Yates обратимым? Очевидное наблюдение: swap — это операция, обратная самой себе. Значит, если знать ту же последовательность чисел j и применить swap’ы в обратном порядке, то пермутация развернётся обратно в оригинал.

Самый прямой путь — сохранить эти числа где-нибудь. Но это O(n) памяти на пермутацию — те самые O(D) индексов, которых изначально и хотелось избежать. Тупик.

Лучше иметь источник случайных чисел, способный восстанавливать свою последовательность в обратном порядке. Тогда сохранять ничего не надо: обратный обход просит у источника те же j, что выдавались на прямом, только в обратном порядке.

Самый наивный «обратимый» источник — это детерминированная функция от индекса: j_i = hash(seed, i) mod (i + 1). Обратимость тривиальна — на шаге i снова считается hash(seed, i), получается то же число. Работает корректно, но Qdrant заточен под максимальную производительность, а crypto-grade хеш на каждом шаге — это десятки наносекунд работы; на сегментах в сотни тысяч векторов чистая стоимость поворота начинает чувствоваться.

Поэтому я использую более элегантную и быструю конструкцию — reversible LCG.

LCG (Linear Congruential Generator) — это простейший псевдослучайный генератор, известный со времён первых компьютеров. Состояние — одно целое число, шаг вперёд: state = (A · state + C) mod 2^64 для константных A и C (я использую известную пару от Knuth для MMIX). Один шаг — одно умножение и одно сложение, работает мгновенно. По качеству распределения LCG, конечно, проигрывает современным cryptographic-grade RNG, но для задачи «дать псевдослучайный индекс для Fisher-Yates» этого с лихвой хватает — здесь не криптография, а перемешивание координат.

Ключевой трюк: уравнение шага можно алгебраически развернуть. Если state' = (A · state + C) mod 2^64, то state = A_inv · (state' − C) mod 2^64, где A_inv — модулярная инверсия A по модулю 2^64. Эту инверсию я посчитал один раз и записал константой в код. В итоге обратный шаг LCG такой же дешёвый, как прямой — то же одно умножение и одно вычитание. На Python это выглядит так:

A      = 6364136223846793005    # Knuth MMIX multiplier
C      = 1442695040888963407
A_INV  = 13877824140714322085   # A * A_INV ≡ 1  (mod 2^64)
MASK64 = (1 << 64) - 1

def lcg_step(state):
    return (A * state + C) & MASK64

def lcg_step_back(state):
    return (A_INV * (state - C)) & MASK64

# Обратный шаг — точная инверсия прямого:
assert lcg_step_back(lcg_step(42)) == 42

Дальше Fisher-Yates на этом LCG: проход вперёд даёт пермутацию, проход назад её отменяет. На каждом шаге используются верхние 32 бита состояния (об этом — ниже):

def shuffle(arr, seed):
    """Fisher-Yates на LCG. Возвращает end_state, чтобы можно было
    воспроизвести inverse только по нему."""
    state = seed
    for i in range(len(arr) - 1, 0, -1):
        state = lcg_step(state)
        j = (state >> 32) % (i + 1)
        arr[i], arr[j] = arr[j], arr[i]
    return state

def unshuffle(arr, end_state):
    """Шагаем LCG назад, отменяем swap'ы в обратном порядке."""
    state = end_state
    for i in range(1, len(arr)):
        j = (state >> 32) % (i + 1)
        arr[i], arr[j] = arr[j], arr[i]
        state = lcg_step_back(state)

В итоге вся пермутация полностью описывается тройкой (seed, count, end_state) — три 64-битных целых независимо от размерности. Сами seed’ы у меня захардкожены — их три (по одному на раунд) и менять их нельзя, потому что они вшиты в encoding каждого квантизованного вектора.

Маленькая деталь, на которой я поймал баг: брать остаток от LCG-выхода надо из верхних 32 битов, а не из нижних. Нижние биты LCG имеют короткие периоды и дают вырожденные пермутации — для count=4 мой изначальный код покрывал только 12 из 24 возможных перестановок вместо всех. Поэтому в коде выше — (state >> 32) % (i + 1), а не state % (i + 1).

В сумме: O(1) состояние на любую размерность, обратимость бесплатно, никакой персистентности.


7. Трюк 3. Компенсация анизотропии

Это самая содержательная часть моей имплементации, и правильный заход к ней — через проблему.

Проблема: алгоритм работает для изотропных данных

TurboQuant теоретически элегантен строго на изотропных данных — на векторах, равномерно распределённых по единичной сфере. Это и было исходное предположение Google в статье: при равномерности на сфере и случайном повороте каждая координата ведёт себя как ~N(0, 1/D), и один и тот же Lloyd-Max codebook оказывается оптимальным для каждой координаты каждого вектора в датасете.

Слово «изотропный» страшно звучит, но в одном предложении это значит «нет выделенного направления — точки распределены вокруг центра одинаково во все стороны». На картинке проще:

Слева: изотропные данные. Справа: один пример того, как могут выглядеть анизотропные данные.

тяни мышью чтобы повернуть

С реальными эмбеддингами трансформеров проблема в том, что они анизотропны: внутри них есть выделенные направления с большой дисперсией («spike directions») и направления, по которым почти ничего не происходит. Поворот размазывает эту анизотропию по координатам, но не убирает её — после поворота у каждой координаты своя эмпирическая дисперсия, не равная универсальной 1/D.

Lloyd-Max codebook рассчитан строго на N(0, 1/D). Если реальные координаты имеют другое распределение — биты тратятся впустую: часть центроидов оказывается в области, где точек почти нет, а в области с большой плотностью разрешения не хватает. Вот реальный пример — распределение одной координаты после поворота на датасете dbpedia 100K — оно явно не попадает в разметку Lloyd-Max:

Распределение значений 122-й координаты после Hadamard-поворота на dbpedia-100K — гистограмма с тяжёлыми хвостами, не совпадающая с N(0, 1/D)

TurboQuant на сферически-равномерных данных доказывает, что после поворота координаты распределены как N(0, 1/D). Но это доказательство не работает для произвольных эмбеддингов — там получается что-то похожее, но не то же самое. И на квантильных границах codebook’а (где разница между «попал в правильный bin» и «не попал» определяет recall) это «не то же самое» начинает чувствоваться очень больно.

Решение: сегментация + per-coord shift+scale

В Qdrant есть архитектурная фора. В отличие от оригинального TurboQuant, который по дизайну динамический и не предполагает никакого анализа датасета, данные в Qdrant нарезаны на сегменты — обозримые куски ~100K векторов, к которым можно применить отдельный анализ. И вот что я делаю: после поворота и нормировки длины — первый pass по сегменту, для каждой координаты оцениваются shift и scale, и применяется покоординатное преобразование, которое подгоняет реальное распределение под тот участок, где Lloyd-Max codebook действительно умеет точно квантовать:

X⁺ᵢ = (Xᵢ + shiftᵢ) · scaleᵢ

После этой подгонки распределение каждой координаты ложится на Lloyd-Max codebook ровно, и центроиды используются равномерно. Та же 122-я координата dbpedia-100K, что была сверху, после shift+scale выглядит так:

Та же 122-я координата dbpedia-100K после применения shift+scale — гистограмма аккуратно ложится в разметку Lloyd-Max codebook'а

Какие именно статистики оцениваются (и почему не «просто mean+stddev») — отдельный вопрос, ему посвящён трюк 4 про P-Square. Пока для понимания идеи компенсации анизотропии достаточно того, что есть пара чисел (shift, scale) на каждую координату, и они применяются так, как написано выше.

Почему это бесплатно при скоринге

Вот тут включается асимметричность скоринга, про которую шла речь выше. Хранимый вектор квантованный, а запрос — нет. Поэтому компенсацию можно полностью переложить на сторону запроса:

Пусть M = -shift, D' = 1/scale (диагональная матрица). Хранимый вектор после shift+scale: X⁺ = (X − M) ⊘ D'. Тогда

⟨Q, X⟩ = ⟨Q, X⁺ ⊙ D' + M⟩
       = ⟨Q ⊙ D', X⁺⟩ + ⟨Q, M⟩

Повёрнутый запрос предумножается покоординатно на D', и к итоговому скору добавляется скалярная поправка qm = ⟨Q, M⟩. Формула скоринга не меняется вообще — это по-прежнему одна uniform-weight dot-сумма, просто запрос пришёл уже масштабированный. Сама поправка qm — это один float, который добавляется к raw_dot перед применением scaling-формул для конкретной метрики.

То есть: за счёт того, что запрос и так был высокоточный, на него можно переложить всю математическую нагрузку компенсации анизотропии. Хранимый вектор получает больше эффективной точности в той же битности (центроиды используются равномерно), а запрос принимает на себя дополнительную точечную дисперсию — у него её хватает, потому что он float32.

Сколько это даёт

Сравним мою имплементацию без компенсации анизотропии (то есть только ренормализация из трюка 1 + моё вращение из трюка 2) против полной имплементации (с shift+scale). Чистый изолированный эффект компенсации анизотропии:

На anisotropic-датасетах

Датасет1b без1b сΔ2b без2b сΔ4b без4b сΔ
arxiv-instr0.6020.686+8.40.7920.840+4.80.9370.954+1.7
gte-mul-ads0.7220.786+6.40.8400.885+4.50.9440.967+2.3
gemini-0010.6140.702+8.80.7950.840+4.50.9370.952+1.5

На близких к изотропным датасетах

Датасет1b без1b сΔ2b без2b сΔ4b без4b сΔ
openai-3-l0.8010.806+0.50.8970.901+0.40.9690.970+0.1
openai-3-s0.7970.807+1.00.8960.901+0.50.9670.969+0.2
wiki-cohere0.8200.840+2.00.9070.914+0.70.9720.973+0.1

(«без» / «с» = без / с компенсацией анизотропии. Δ — прирост recall в percentage points от включения компенсации.)

Картина такая: метод сильно улучшает recall на anisotropic-эмбеддингах (особенно на низких битностях, где margin меньше всего) и практически не делает ничего на изотропных. Это не баг и не недоработка — это в точности ожидаемое поведение, и важно, что он никогда не делает хуже. Поэтому в production он включён по умолчанию: если данные пользователя анизотропные, он бесплатно даст им заметный прирост recall’а; если изотропные — он аккуратно сводится к identity и не вносит шума.

Почему он не делает хуже

Ключевое свойство моей формулы оценки (shift, scale) (детали в трюке 4 про P-Square) — для идеально N(0, 1/D)-данных она вырождается в identity. Конкретно: shift = 0, scale = 1. Я оцениваю не mean/stddev (которые дают «по-разному настроенный» Lloyd-Max codebook на гауссовых данных и слегка ломают вещь), а квантили на тех же вероятностных уровнях, на которых лежат границы codebook’а. Если данные действительно гауссовы, эмпирические квантили совпадают с теоретическими, формула выдаёт (0, 1), и калибровка ничего не делает — буквально не вносит ни одного бита изменения в encoded вектор.

Поэтому таблица выше: на изотропных данных Δ ≈ 0 не из-за того, что калибровка «случайно ничего не сломала», а из-за того, что она по построению не должна ничего делать на изотропных данных. Это математическая гарантия, а не эмпирическое везение.

Симметричный скоринг: компенсация намеренно не применяется

Выше всё хорошо ложится для асимметричного пути: запрос непрерывный, и компенсация (D'-предумножение и поправка qm) бесплатно встраивается в существующий скоринг-цикл без потери точности. Но в Qdrant есть и второй путь — симметричный скоринг, когда обе стороны квантизованные (HNSW build, merge сегментов, внутренние сравнения). Здесь история сильно отличается, и важно проговорить, что я делаю по-другому и почему.

Чисто формально, та же алгебра, что в асимметричном случае, прекрасно раскрывается и для двух квантизованных векторов:

⟨X_a, X_b⟩ = Σ X⁺_a_i · X⁺_b_i · D'_i² + xm_a + xm_b − ⟨M, M⟩

Здесь X⁺ — это закодированный вектор (после shift+scale), D'_i² = 1/scale_i² — per-coord вес, а xm = ⟨X, M⟩ — скалярная поправка, которую можно сохранить рядом с квантизованным вектором при кодировании. Казалось бы, можно просто применять эту формулу и получать «честный» ⟨X_a, X_b⟩ для всех внутренних путей.

На практике это ломает recall. И вот почему.

В асимметричном пути per-coord вес D' живёт на стороне непрерывного запроса. Шум квантизации сидит только в X⁺ (одна сторона), и при суммировании произведений вида Q_i · D'_i · c_i ошибки шума усредняются по координатам, а большой D'_i в одной координате уравновешивается соседями. Шум усреднён, и амплификация безболезненна.

В симметричном пути обе стороны — квантизованные, и шум сидит и в c1, и в c2. Ключевая операция — c1_i · c2_i · D'_i². Тут уже произведение двух шумов умножается на D'², а D'_i² = 1/stddev_i² имеет огромный динамический диапазон: на координатах с маленькой исходной дисперсией D'² может быть в десятки и сотни раз больше, чем на координатах с большой. Эффективно получается взвешенная сумма, где несколько «шумных» координат с большим весом доминируют над всем остальным сигналом — и итоговый score становится практически случайным относительно истинной близости.

Поэтому в симметричном пути с компенсацией анизотропии я не применяю ни per-coord weighting D'², ни скалярные поправки xm + xm − ⟨M, M⟩. Считается простой uniform-weight dot между двумя квантизованными представлениями — то есть ровно та же формула скоринга, что и без компенсации. Никаких дополнительных операций, никакой ветки «если есть калибровка, то…» при скоринге.

Что при этом фактически считается? Поскольку хранимые центроиды аппроксимируют X⁺ (закодированный вектор после shift+scale), uniform-weight dot даёт приближение ⟨X⁺_a, X⁺_b⟩ — близость двух векторов в EC-пространстве, а не в оригинальном. Это другая метрика, но она полностью согласована: HNSW-граф, который строится через симметричный скоринг, оптимизируется под близость в этом же EC-пространстве, и кандидаты, найденные обходом графа, в нём же близки к запросу. Финальный top-K, который видит пользователь, всё равно скорится через асимметричный путь — где компенсация применяется честно.

1-битная метрика: запрос приходится расширять до 12 бит

На 1-битном хранении при включённой компенсации анизотропии есть отдельный нюанс — без него на этой битности recall заметно проседает.

Сначала контекст. На 1-битном хранении codebook — это просто две точки ±c, где c = sqrt(2/π) ≈ 0.798 (Lloyd-Max для N(0, 1)), то есть индекс в codebook совпадает со знаковым битом хранимой координаты. Хранимый вектор — плотная упаковка знаков (8 dim в байт), а запрос квантуется в знаковые целые BITS бит (по умолчанию 8) и комбинируется с битами данных в финальный dot. Конкретный способ того, как это эффективно вычисляется на CPU — тема отдельного поста про SIMD-оптимизации; здесь важно одно: разрядность запроса — это свободный параметр, который можно крутить без изменения формата хранилища.

Ключевой момент: на 1-битном хранилище сторона данных имеет минимальное возможное разрешение — один знаковый бит. Это значит, что точность асимметричного скоринга упирается в сторону запроса. На 2 и 4 битах данные сами несут достаточно разрешения, и шум 8-битного запроса теряется в шуме квантизации центроид. На 1 бите всё не так: каждое скалярное произведение в координате фактически равно q_i · sign(x_i), и любая ошибка в q_i идёт напрямую в финальный score без амортизации шумом данных.

Дальше включается специфика компенсации анизотропии. В обычном режиме (без shift+scale) после Hadamard-поворота координаты запроса распределены довольно равномерно, и диапазона int8 [-127, 127] хватает с запасом. Но при включённой компенсации повёрнутый запрос предумножается покоординатно на D' = 1/scale. Per-coord scale_i в реальных эмбеддингах сильно различаются — это ровно та анизотропия, ради которой и делается shift+scale. После предумножения распределение query-координат становится резко неоднородным: координаты, которым соответствует большая дисперсия в данных (scale_i большой → D'_i маленький), сжимаются почти к нулю; координаты с малой дисперсией наоборот раздуваются.

Когда дальше запрос нормируется как q_scale = 127 / max(|q|), координаты из «маленькой» группы получают целые значения вроде 0, ±1, ±2 — а это уже не сигнал, а шум округления. Скоринг на этих координатах работает фактически вхолостую: их вклад в финальный dot близок к нулю, хотя в оригинальном непрерывном запросе они имели какую-то осмысленную (пускай и небольшую) информацию.

Решение — расширить квантизацию запроса с 8 до 12 бит на случай «компенсация + 1 бит на хранилище». Тогда q_scale = 2047 / max(|q|), и даже сжатые координаты получают по ~16 уровней разрешения. Замеры показывают: 8 бит запроса даёт relative error до ~2%, 12 бит — до ~0.2%, то есть в 10 раз меньше scoring error на той же входной паре.

Цена. Сторона запроса занимает на 50% больше памяти. Но query precomputation делается один раз на поисковый запрос и переиспользуется для миллионов кандидатов в шарде — в пересчёте на одно скоринг-сравнение эта добавка размазывается до бесплатной. Сторона данных не меняется вообще: хранилище по-прежнему 1 бит на dim. Формула скоринга тоже не меняется по структуре — разрядность запроса входит в неё как параметр.

Получается типичный для моих трюков рисунок: качество чинится только на стороне запроса, без изменения хранилища и без изменения формулы скоринга — за счёт того, что запрос при скоринге и так непрерывный, а его разрядность — это параметр, который можно свободно крутить.


8. Трюк 4. P-Square квантили вместо mean+std

В предыдущем трюке shift+scale применялся на каждую координату без уточнения, как именно их оценивать. Можно было пойти простым путём: пройтись по сегменту и посчитать per-coord mean и stddev, считая, что после поворота данные нормально распределены — пускай и с произвольным mean и дисперсией. На большинстве реальных датасетов это близко к правде, но не идеально: даже после Hadamard-поворота гауссовость восстанавливается асимптотически, и на конечной размерности (особенно на низких bit-widths, где границы codebook’а очень чувствительны к хвостам) эта аппроксимация течёт.

Хуже того, Qdrant — production-grade БД, и нельзя полагаться на «обычно нормально». Что если на проде окажется датасет с тяжёлыми хвостами, бимодальный, или специально подобранный adversarial? Калибровка должна выдавать предсказуемое качество без знания о датасете.

Поэтому вместо mean/stddev я использую dataset-agnostic подход — алгоритм P-Square (Jain & Chlamtac, 1985). Это онлайн-оценка квантилей с константной памятью, не делающая никаких предположений о распределении. Эстиматор держит компактный набор «маркеров» (я использую 7 — обобщение оригинального P-Square с 5 маркерами, дающее лучшую точность в хвостах), которые двигаются с каждой новой точкой так, чтобы средний маркер сходился к нужному квантилю. Под капотом — параболическая интерполяция позиций маркеров с фоллбэком на линейную, когда параболическая выходит за пределы соседей. Состояние эстиматора — несколько чисел, обновление формально O(1) на точку.

Как используется результат

Эстиматор не один на сегмент, а по одному на каждую координату — итого dim независимых P-Square инстансов, считающих симметричный per-coord интервал [q_lo, q_hi]. Уровни квантилей выбираются хитро: пусть c_outer — самая крайняя центроида кодбука Lloyd-Max (≈0.798 для 1 бита, ≈2.733 для 4 бит). Для идеально N(0, 1) данных вероятностная масса в интервале [-c_outer, c_outer] равна 2·Φ(c_outer) − 1, где Φ — стандартная нормальная CDF. Поэтому я запрашиваю у P-Square per-coord квантили ровно на уровнях 1 − Φ(c_outer) и Φ(c_outer) — то есть на тех вероятностных позициях, на которых лежат края codebook’а в эталонной модели.

Дальше из эмпирических q_lo, q_hi собираются shift и scale:

shift = -(q_lo + q_hi) / 2           # recenter
scale =  2 · c_outer / (q_hi - q_lo) # stretch to [-c_outer, c_outer]

Ключевое свойство, ради которого этот вид калибровки и выбран: для идеально N(0, 1) данных q_lo == -c_outer и q_hi == +c_outer, поэтому shift == 0 и scale == 1 — калибровка становится identity и не делает ничего лишнего, не вносит шума на уже хорошо распределённых данных. Это и есть то самое математически-гарантированное свойство «не делает хуже», на которое опиралось обсуждение в трюке 3. Для анизотропных данных формула аккуратно растягивает per-coord эмпирический «хвостовой» интервал на тот участок, который codebook реально умеет точно квантовать. Никакой привязки к гауссовой модели — калибровка делается в той точке распределения, где находится граница codebook’а, и эта точка может находиться где угодно в реальных данных.

Стриминг через резервуар вместо all-stream P-Square

Изначально я заводил один P-Square на координату и пушил туда каждое значение из потока — буквально применяя алгоритм по его прямому назначению. Идея простая, но на production-нагрузке оказалась слишком дорогой. Формально P-Square — это O(1) на push, но за этим O(1) скрывается ~50 ns параболической интерполяции, проверок и движения маркеров. На сегменте в 100k+ векторов × 1k+ координат суммарная работа калибровки начинала доминировать над всей остальной квантизацией — настолько, что pre-pass становился самой дорогой стадией.

Поэтому стриминговую и оценочную фазы я развёл:

  1. На стриминге — резервуарная выборка per-coord (классический Vitter’s Algorithm R). Push в резервуар — это ~5 ns: один RNG draw, один branch, один возможный store. На порядок дешевле P-Square push, и на реальных данных эта разница окупается с лихвой.
  2. После того, как поток закончился — по содержимому каждого резервуара (параллельно по координатам) гоняется настоящий P-Square. Размер резервуара R фиксирован — ~4–8 тысяч в зависимости от того, насколько глубокий хвостовой квантиль нужен (для bits=1 анкер сидит на p≈0.79 и хватает R≈4096; для bits=4 анкер на p≈0.997 и нужен R≈8192) — и на этой ограниченной выборке P-Square уже не дорогой.

Корректность: резервуарная выборка по конструкции даёт равномерную i.i.d. выборку из всего потока — каждое из N значений имеет одинаковую вероятность R/N оказаться в резервуаре. P-Square — сходящийся алгоритм на любой равномерной выборке из распределения, что и даёт резервуар. Маркеры дополнительно сглаживают шум хвостовых порядковых статистик: брать просто sorted[k] на R≈8k и квантиле p=0.997 даёт сильно дрожащий ответ — P-Square с параболической интерполяцией на той же выборке существенно стабильнее.

Хорошее свойство: при N < R резервуар вмещает весь поток в исходном порядке, и новая схема выдаёт бит-в-бит тот же ответ, что и старая «P-Square по всему потоку». На малых сегментах ничего не теряется и ничего не меняется.

Сам стрим устроен как producer/consumer pipeline. Producer на отдельном потоке тянет векторы из входа в переиспользуемые chunk-буферы (capacity ~4k векторов, аллоцированы один раз и переиспользуются), rayon-параллельно прогоняет каждый вектор через preprocess (Hadamard rotation + length rescale) прямо в тот же буфер. Consumer (главный поток) забирает заполненный блок и rayon-параллельно сливает его в per-coord резервуары — каждый поток получает свою координату и пушит её колонку. Producer и consumer работают одновременно: пока consumer сливает один блок, producer уже наполняет следующий.

Память — это уже не бесплатно

Резервуар хранит R f64-чисел на каждую координату, итого 8 · R · D байт на время pre-pass’а. Для типичной размерности D=1024 при R=8192 это ~67 МБ, для D=3072 — ~200 МБ. Плюс пара chunk-буферов (~32 МБ при D=1024). После того как калибровка завершилась, всё это освобождается — финальный квантизованный артефакт на диске остаётся таким же компактным, как раньше (b·D бит индексов + extras).

Бюджет не зависит от размера датасета — только от dim и R. Если pre-pass упирается в память, можно уменьшить R ценой роста дисперсии оценки квантиля (на хвостовых уровнях типа p≈0.997 это плохо ощущается раньше всего) или вообще отключить компенсацию для данной коллекции.

Тестовые распределения

P-Square покрыт прицельным набором распределений с очень разным поведением хвостов: uniform, N(0, 1), Poisson(λ=2) (асимметричное, не-нормальное), Student-T с 2 степенями свободы (heavy tails, формально бесконечная дисперсия). На всех — относительная ошибка оценки квантиля укладывается в 5–10%. Это и есть главный смысл трюка: поведение калибровки перестаёт зависеть от того, насколько датасет похож на нормальное распределение — и это именно та гарантия, которая нужна production-БД, не знающей заранее, какие эмбеддинги в неё прилетят.


9. Метрики: L2 и ненормированный dot-product

Возвращаемся к ограничению, зафиксированному в начале поста: TurboQuant в оригинале живёт строго на единичной сфере — для cosine-метрики и нормированных эмбеддингов. Это ограничение алгоритма, и в обмен на него вы получаете нулевой overhead на хранение (только b·D бит индексов, ничего больше). Но Qdrant — production-БД, и для неё неприемлемо сказать пользователю «умеем только cosine». L2 — самая популярная метрика после cosine, и ненормированный dot-product тоже встречается, особенно в production-эмбеддингах с обученным масштабом.

Поэтому я аккуратно расширяю алгоритм за пределы сферы. Цена — +4 байта на вектор на сохранённую длину (L2-норму). На фоне b·D бит индексов это микроскопическая надбавка, но именно она открывает дверь к двум новым метрикам.

Ненормированный dot-product. L2-длина оригинального вектора ‖v‖ сохраняется рядом с квантизованным представлением. Внутри хранилище работает с нормированным до √D представлением (это моё расширение по сравнению с оригиналом — любой вектор превращается в «эквивалент точки на сфере» простой нормировкой), но при скоринге raw_dot умножается обратно на ‖v‖ — это восстанавливает абсолютный масштаб. Для запроса делается то же самое: ‖q‖ хранится в precomputed-структуре. Никаких ограничений на нормировку входа.

L2-метрика. L2-расстояние раскрывается через классическое тождество:

‖q − v‖² = ‖q‖² + ‖v‖² − 2⟨q, v⟩

⟨q, v⟩ уже умеем считать (по предыдущему пункту), а ‖q‖² и ‖v‖² сохранены. Складываем — получаем L2 без какой-либо потери точности относительно того, что даёт основная схема скоринга. То есть L2 стоит ровно столько же, сколько dot-product — потому что вся специфика L2 сводится к одному скалярному вычислению из уже имеющихся данных.

А что с L1?

К сожалению, для L1 трюков нет, и это не лень — поворот не сохраняет L1-норму. Любая ортогональная матрица сохраняет L2 (и косинус, потому что косинус — это нормированный dot-product), но в общем случае ‖Rx‖₁ ≠ ‖x‖₁. То есть вся математика, на которой держится быстрый скоринг через rotated quantized representation, опирается на L2-инвариантность поворота — а для L1 эта подложка просто не работает: разница между двумя векторами после поворота может иметь совершенно другую L1-норму, чем была у исходных.

Это не мешает использовать MSE TurboQuant, если у вас L1 в качестве метрики — но скоринг здесь идёт через полную деквантизацию: центроиды достаются по индексам, длина восстанавливается, применяется обратный поворот, возвращаемся в оригинальное пространство, и уже там считается честный L1. Корректно, но сильно бьёт по производительности и теряет почти весь performance-выигрыш квантизации (обратный поворот за O(D log D) на каждое скоринг-сравнение — катастрофа). Поэтому L1 в TQ — это аварийный fallback, который я не рекомендую в production-нагрузке: если у вас L1, лучше посмотреть на другие способы квантизации в Qdrant, которые с L1 совместимы по дизайну.


10. Заключение

TurboQuant — красивый алгоритм, и MSE-вариант особенно хорошо ложится на специфику векторной БД. Но между «алгоритм из статьи» и «production-grade имплементация в Qdrant» есть пропасть, которую я попытался закрыть несколькими расширениями:

  • Ренормализация через хранимый centroid-norm убирает quantization shrinkage без QJL и без удвоения сложности скоринга. Простой приём из llama-turbo-quant, который в одиночку даёт +9–15pp recall на anisotropic-датасетах при 4 битах.
  • Hadamard rotation на тройном (permute + WHT) с reversible-LCG-пермутациями — полностью чистая, обратимая, не персистентная — даёт качество близко к случайному ортогональному вращению при O(D log D) сложности и нулевой metadata.
  • Компенсация анизотропии через per-coord shift+scale, переложенный на сторону запроса — бесплатно по compute, заметно по recall на анизотропных эмбеддингах. На датасетах, где анизотропии нет, она по построению вырождается в identity и не вносит шума.
  • P-Square калибровка через эмпирические per-coord квантили, привязанные к границам codebook’а — на идеально-нормальных данных она identity, на любых других даёт корректный shift+scale без предположений о распределении.
  • Wide query для 1-битных центроидов в режиме компенсации анизотропии восстанавливает recall, который иначе терялся на rounding-шуме в нижней части 8-битного диапазона.
  • L2 и ненормированный dot вытащены за пределы единичной сферы, на которой формально живёт алгоритм, ценой +4 байта на вектор для хранимой длины. L1 расширению не поддаётся (поворот не сохраняет L1) — и это честное ограничение, в production с L1 я не рекомендую TQ.

Имплементация открыта и лежит в репозитории Qdrant — production-вариант на Rust в lib/quantization/src/turboquant/, а максимально читабельный Python-референс (с тем же набором алгоритмических трюков, без SIMD-обвески) — в отдельном репо turboquant-qdrant-showcase, оттуда же можно воспроизвести таблицу recall’а одной командой. Если что-то непонятно или интересно — пишите, обсудим.