ip / ivanpleshkov.dev
EN

02 Блог

Полиномиальный автоэнкодер

· embeddings ·pca ·autoencoder ·matryoshka ·retrieval

Самый прямой способ сжать эмбеддинг (если не считать квантизации) — посчитать PCA на корпусе и оставить top-d собственных направлений. Это работает, но PCA — линейная проекция, а эмбеддинги нейросеток на сфере структурно нелинейны (известный cone effect у трансформеров). Часть дисперсии остаётся в нелинейном хвосте, который линейный декодер принципиально не достаёт.

Этот пост — про closed-form способ добавить квадратичный декодер поверх PCA, чтобы захватить часть этого нелинейного хвоста. Encoder остаётся обычным PCA. Decoder — полиномиальный лифт степени 2 плюс Ridge OLS (обычная линейная регрессия с L2-регуляризацией), тоже closed-form. Никакого SGD, никаких эпох, никакого подбора гиперпараметров. Один np.linalg.solve поверх корпусной статистики.

Сама конструкция — не моя. «PCA-encoder + квадратичный decoder + least-squares fit» встречается в литературе по динамическим системам под названием quadratic manifold (см. Jain 2017, Geelen-Willcox 2022/2023, Schwerdtner-Peherstorfer 2024+ — подробнее в §9). Сам я наткнулся на эти работы уже после того, как прогнал эксперименты и думал, что конструкция новая. В современном ML полиномиальный лифт встречается нечасто, и этот пост — заметка о том, что в соседней дисциплине есть полезный приём, который переносится на retrieval.

Конкретный результат. BEIR/FiQA, mxbai-embed-large-v1 (1024d), бюджет 512 байт на вектор. Метрика — NDCG@10 (Normalized Discounted Cumulative Gain в топ-10, стандартная retrieval-метрика для качества ранжирования; диапазон [0, 1], чем выше тем лучше):

методNDCG@10Δ vs raw 1024d
raw 1024d (4096 байт)0.4533
PCA top-2560.4175-3.58 п.п.
PCA-whitened top-2560.3892-6.41 п.п.
PCA-half-whitened top-2560.4311-2.22 п.п.
poly-AE 256d0.4445-0.88 п.п.
matryoshka top-2560.4040-4.93 п.п.

PCA даёт 4× сжатие per-vector памяти при -3.58 п.п. NDCG. Квадратичный декодер поверх PCA вытаскивает +1.34 п.п. над best-PCA-baseline (half-whitened, +2.70 п.п. над vanilla PCA) — почти полностью закрывает gap до raw, при том же байт-бюджете. Matryoshka в таблице как ещё один знакомый baseline (здесь проседает сильнее PCA — это известный побочный результат, не главный тезис поста; см. сноску в §3 и §4).

Замерено на четырёх моделях (nomic-v1.5, mxbai-large, bge-base, e5-base). Прирост poly-AE над PCA-half-whitened — от −0.59 до +2.10 п.п. при d=128 и от +0.57 до +2.65 п.п. при d=256. Полная таблица в §3.

Полная реализация — ~150 строк numpy, MIT, репозиторий github.com/IvanPleshkov/poly-autoencoder. Eval-скрипт против BEIR — beir_eval.py в том же репо. Воспроизводится за 30–40 минут на M-серии MacBook (энкодинг корпуса 10–15 мин, Ridge solve при d=256 — около 15 мин).

Содержание:

1. Что с чем сравниваем

Четыре линии в каждом замере:

  • raw — полный эмбеддинг, без сжатия. Потолок качества и максимум байт на вектор. Это «дорогой» вариант, который даёт честный ceiling.
  • matryoshkaembedding[:d] плюс L2-нормировка. На моделях с matryoshka-обучением (nomic, mxbai в нашей выборке) — валидный matryoshka-вектор. На моделях без matryoshka-обучения (bge-base, e5-base) это тест на то, что произойдёт при наивной нарезке не-MRL-модели — сценарий, в который реально попадают пользователи bge / e5 / custom-fine-tuned эмбеддингов.
  • PCA — top-d собственных направлений ковариации, посчитанной по корпусу. Векторы хранятся в d-мерной PCA-системе координат. В таблицах сравниваем три варианта взвешивания осей: vanilla (cosine на сырых PCA-координатах), whitened (на p / sqrt(eigvals) — каждая ось весит одинаково), half-whitened (на p / eigvals^(1/4) — компромисс между двумя крайностями).
  • poly-AE — наш метод. Кодируем PCA-проекцией в p ∈ ℝ^d, декодируем квадратичным полиномом обратно в полный D-мерный , retrieve по .

Все четыре метода при выбранной d хранят 2d байт на вектор (fp16 координаты).

2. Постановка эксперимента

BEIR — стандартный набор retrieval-датасетов (SciFact, FiQA, NFCorpus, TREC-COVID и др.). Метрика — NDCG@10. Корпус и запросы кодируются выбранной моделью, потом по cosine similarity берутся top-10 для каждого запроса, NDCG считается против размеченного qrels.

Для PCA и poly-AE «фит» делается трансдуктивно: считаем статистики прямо на корпусе, который собираемся компрессировать. Запросы при этом никогда не участвуют в фите — они приходят на вход уже фиксированному энкодеру/декодеру. Это совпадает с production-сценарием: оператор индекса один раз считает PCA + Ridge на своих данных, потом обслуживает поток запросов.

Для основных замеров берём FiQA — 57K документов, 648 запросов, 1706 qrels.

3. Главная таблица — четыре модели

NDCG@10 на FiQA при бюджете 256 fp16 (512 байт/вектор) и 128 fp16 (256 байт):

МодельDdrawmatryoshka†PCAPCA-wPCA-h-wpoly-AE
nomic-embed-text-v1.57681280.37460.31900.32730.32270.34390.3380
nomic-embed-text-v1.57682560.37460.35080.36700.32980.36160.3673
mxbai-embed-large-v110241280.45330.35050.36910.38760.40020.4125
mxbai-embed-large-v110242560.45330.40400.41750.38920.43110.4445
bge-base-en-v1.5*7681280.40620.29140.32680.32160.34430.3653
bge-base-en-v1.5*7682560.40620.35740.36850.33570.36970.3962
e5-base-v2*7681280.39870.24980.30620.32640.33580.3315
e5-base-v2*7682560.39870.33330.36080.33160.36600.3848

† Колонка «matryoshka» — это embedding[:d] плюс L2-нормировка. На nomic и mxbai это валидный matryoshka-вектор. На моделях, помеченных * (bge-base, e5-base), модель не тренировали под matryoshka, и slice здесь — это тест на то, что произойдёт при наивной нарезке не-MRL-модели. Это сценарий, в который попадают пользователи bge-семейства, e5, custom-fine-tuned моделей — и мы здесь его честно замеряем.

Что отсюда видно:

  1. Poly-AE впереди vanilla PCA на всех восьми ячейках (+1.07 до +4.34 п.п. при d=128, +0.03 до +2.77 п.п. при d=256). Над более жёстким baseline’ом — half-whitened PCA — разница меньше: −0.59 до +2.10 п.п. при d=128, +0.57 до +2.65 п.п. при d=256. Где квадратичный декодер реально помогает и при каком d — разбираем в далее.
  2. При d=256 poly теряет 0.7–1.4 п.п. NDCG vs raw 768/1024 на всех четырёх моделях. 4× сжатие per-vector памяти при потере меньше полутора пунктов — основная цифра поста.
  3. На моделях без матрёшка-обучения колонка matryoshka проседает сильнее PCA — до -15 п.п. NDCG при d=128. Это побочное наблюдение: в посте мы сравниваем PCA и poly-AE, а не PCA с матрёшкой. Если цифры matryoshka в таблице кажутся странными — короткий комментарий в §4.

4. Где квадратичный декодер реально помогает

PCA — линейный baseline. Квадратичный декодер добавляет ту самую нелинейную часть, которой линейный не достаёт (механика — в §5). Насколько это реально помогает на retrieval, и при каком d?

Прирост poly-AE по моделям и d. Левые две колонки — над vanilla PCA, правые — над half-whitened PCA (более жёсткий, apples-to-apples baseline, потому что внутри poly-AE координаты p тоже whiten’ятся, см. §6):

Модельpoly над PCA, d=128poly над PCA-½w, d=128poly над PCA, d=256poly над PCA-½w, d=256
nomic-v1.5+1.07 п.п.−0.59 п.п.+0.03 п.п.+0.57 п.п.
mxbai-large+4.34 п.п.+1.23 п.п.+2.70 п.п.+1.34 п.п.
bge-base+3.85 п.п.+2.10 п.п.+2.77 п.п.+2.65 п.п.
e5-base-v2+2.53 п.п.−0.43 п.п.+2.40 п.п.+1.88 п.п.

Картина:

  1. При d=128 (8× сжатие) poly над vanilla PCA стабильно +1–4 п.п. Это режим, где линейный декодер заметно роняет дисперсию в нелинейный хвост, и квадратичная коррекция её вытаскивает. Sweet spot для метода против vanilla PCA. Над PCA-half-whitened картина смешанная — на nomic/e5 poly проигрывает (−0.59/−0.43 п.п.), на mxbai/bge выигрывает (+1.23/+2.10).

  2. При d=256 (4× сжатие) разница над vanilla PCA переменчивая — на mxbai/bge/e5 стабильные +2.4–2.8 п.п., на nomic ноль (+0.03). Возможная причина: nomic тщательно тренировался с multi-slice contrastive loss, его latent более изотропный. Над PCA-half-whitened poly везде впереди (+0.57 до +2.65 п.п.) — на этом d квадратичная коррекция остаётся осмысленной даже после whitening’а.

  3. Анизотропия → больший прирост. Чем сильнее cone effect, тем больше дисперсии лежит в нелинейном хвосте, который не достаёт даже whitened PCA, а poly — достаёт. Это та самая геометрия, которую разбираем в §5.

Замечание про матрёшку в таблице

В §3 видно, что matryoshka column на не-MRL моделях (bge, e5) проседает сильнее PCA — то есть на случайной не-MRL модели наивная нарезка работает хуже линейной проекции на корпус. Это известный результат — вопрос «MRL vs PCA на retrieval» обсуждался независимо от этого поста, см. Matryoshka-Adaptor 2024, SMEC «Rethinking MRL» 2025, CoRECT 2025, и YouTube-видео «Is PCA enough?». В этом посте мы сравниваем PCA и poly-AE; matryoshka в §3-таблице как третий ориентир.

Где poly-AE не подходит

PCA-фит на корпусе — обязательная часть метода. Это значит poly-AE не работает там, где корпуса под рукой нет:

  • multi-tenant SaaS: одна модель, тысячи клиентов с разными корпусами, считать PCA для каждого — operational headache;
  • streaming-индексы: статистика плывёт со временем, PCA придётся переобучать;
  • edge-инференс: телефон, браузер, embedded — не хочется таскать серверную PCA-матрицу клиента.

В этих сценариях нужна модель с матрёшка-обучением и embedding[:d], poly-AE здесь не альтернатива — ему тоже нужна корпусная статистика.

Практический takeaway

В operator-fit-сценарии (фиксированный корпус, оператор фитит сжатие один раз) у вас два рабочих режима:

  • d=256 даёт 4× сжатие при -0.7 до -1.4 п.п. NDCG vs raw. Poly над PCA-half-whitened — от +0.57 (nomic) до +2.65 п.п. (bge). Стабильно впереди best-PCA-baseline на любой модели.
  • d=128 даёт 8× сжатие. Poly над vanilla PCA +1–4 п.п. на любой модели. Над PCA-half-whitened картина расколота: на mxbai/bge выигрывает (+1.23/+2.10 п.п.), на nomic/e5 проигрывает (−0.59/−0.43 п.п.) — т.е. на хорошо MRL-обученных моделях half-whitened PCA сама берёт ту же нелинейность дешевле.

5. Почему линейная проекция теряет информацию

PCA — это лучшая возможная линейная проекция данных в d-мерное подпространство. Но «лучшая линейная» не значит «достаточная»: если у данных есть нелинейная структура, линейный декодер её принципиально не достаёт.

У transformer-эмбеддингов такая структура есть и хорошо изучена — cone effect. Облако точек концентрировано в узком конусе на сфере и сильно нелинейно структурировано внутри.

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

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

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

Из этого ясно, что хочется. Декодер, который умеет квадратичные комбинации координат — то есть умеет ловить локальную curvature многообразия. Часть информации, которую PCA теряет, мы тогда вытащим обратно.

6. Полиномиальный декодер через линейный лифт

Из §5 ясно, что нужен нелинейный декодер. Прямолинейный путь — обучить нейросетку. Но тогда мы теряем закрытость пайплайна: SGD, learning rate, batch size, сходимость, ранний останов. Хочется иметь нелинейный декодер, который решается одной формулой.

Стандартный приём из теории регрессии: полиномиальный лифт. Берём вектор p и поднимаем его во все мономы степени до 2 — bias, линейные, квадраты и парные произведения. На двумерном примере:

lift([p₁, p₂]) = [1,   p₁, p₂,   p₁², p₁·p₂, p₂²]
                  ↑    ↑   ↑     ↑    ↑      ↑
                bias  линейные   квадратичные

Любая линейная комбинация этих шести компонент = квадратичная функция от исходных p₁, p₂. То есть линейная регрессия по лифту = квадратичная регрессия в исходном пространстве. Никакой нелинейной оптимизации не нужно — обычный Ridge OLS в закрытом виде.

Применяем к нашему случаю. Encoder — top-d PCA (p = (V − V̄) @ Q, p ∈ ℝ^d). Поднимаем p во все мономы до степени 2:

def polynomial_lift(p, degree=2):
    features = [1.0]                              # bias
    features.extend(p)                            # degree 1
    for i in range(len(p)):
        for j in range(i, len(p)):
            features.append(p[i] * p[j])          # degree 2
    return np.array(features)

Размерность лифта — M = (d+1)(d+2)/2. Для d=128 это M = 8385, для d=256M = 33153. Decoder — линейная регрессия от M-мерного лифта обратно к D-мерному исходному вектору. Несмотря на «обычную линейную регрессию», итоговое отображение p → V̂ — квадратичное.

Тренировка decoder’а — np.linalg.solve:

def fit_decoder(P, V, lam=1e-3):
    L = polynomial_lift(P, degree=2)               # (N, M)
    G = L.T @ L + lam * np.trace(L.T @ L) / L.shape[1] * np.eye(L.shape[1])
    W = np.linalg.solve(G, L.T @ V)
    return W

Полиномиальный автоэнкодер собран:

Схема полиномиального автоэнкодера: V → PCA → p → poly lift → L → Ridge OLS → V̂; V − V̂ → V_resid
  • encoder — закрытый линейный (PCA), без обучения,
  • decoder — закрытый квадратичный (Ridge OLS на полиномиальном лифте), обучается одним np.linalg.solve,
  • reconstructionV̂ = polynomial_lift(p) @ W,
  • residualV_resid = V − V̂ (полезен в §8 для квантизации).

Никаких backprop, learning rate, batch size, эпох. На 100K векторах при d=100 — пара минут на CPU. При d=256 — около 15 минут (Ridge решает систему , кубически по M).

Технический штрих с нормировкой

Сырой p после PCA имеет огромную анизотропию по самим координатам (первая ось ~50× по дисперсии, последняя ~0.5×) — это плохо для Ridge: крупные оси доминируют решение, мелкие недополучают веса. Решается покоординатной нормировкой на единичную дисперсию:

p_normed = p / np.sqrt(eigvals)
p = p_normed * (0.9 / np.linalg.norm(p_normed, axis=1).max())

Глобальный множитель 0.9 / max-norm даёт ‖p‖ ≤ 0.9 — численная стабильность при возведении в квадрат. На NDCG не влияет, но численно аккуратнее.

7. Замер на маленьком корпусе и in-sample magic

Один методологический штрих. Когда я гонял первый sanity-check на SciFact (5183 документа, 300 запросов), poly-AE при d=256 показывал NDCG@10 0.6980 vs raw 0.7032 — потеря всего 0.5 п.п. И прирост над PCA там был +1.36 п.п.

На FiQA (57638 документов) тот же эксперимент дал:

  • poly vs raw: -0.73 п.п. (стабильно, как на SciFact);
  • poly над PCA: +0.03 п.п. (вместо +1.36 на SciFact).

Что произошло. На SciFact с d=256 Ridge решает регрессию с M=33153 фичей на N=5183 векторах — серьёзно overdetermined: in-sample V̂_corpus почти точно совпадает с V_corpus, и retrieval по V̂_corpus эквивалентен retrieval’у по полному 768-мерному V_corpus. То есть в SciFact-замере poly-AE раздул разрыв с PCA за счёт того, что просто запомнил корпус в весах Ridge, а не за счёт настоящего обобщения.

Что важно: poly vs raw просадка стабильна (-0.5–0.85 п.п.) на обоих корпусах. А вот в poly vs PCA нужно держать в голове, что на маленьких корпусах (N ≤ 10K при d=256) разница частично mythical. Безопасное правило: N должно быть хотя бы в 5–10 раз больше M = (d+1)(d+2)/2. Для d=256 это N ≳ 200K. Для d=128N ≳ 50K.

В headline-таблице (§3) FiQA с N = 57K достаточен для d=128, на грани для d=256. Цифры для bge на FiQA при d=256 нужно читать с тем же caveat’ом.

8. Сжатие — что делать с residual

Сам по себе автоэнкодер ничего не сжимает в строгом смысле слова. Он перепаковывает вектор в пару (p, V_resid) — латентная координата плюс остаток. Та же информация, в другой форме. Чтобы получить реальное сжатие, residual ещё нужно квантовать.

И вот тут anisotropy-снимающее свойство poly-AE как раз в кассу. На DBpedia-OpenAI при d=100 cond(V_resid) падает с 72 до 3.4 — почти изотропный остаток. Линейный PCA того же d даёт cond=4.21 — заметно менее изотропный.

Google Research недавно опубликовал TurboQuant — квантизатор с фиксированной случайной ротацией, который на изотропных распределениях подходит близко к теоретическому пределу сжатия (Shannon rate-distortion для нормальных). Композиция получается естественная: автоэнкодер снимает анизотропию, TurboQuant эффективно квантует уже изотропный остаток.

V  ──►  encoder (PCA)  ──►  p

        decoder (poly+Ridge)  ──►  V̂

V  −  V̂  ──►  V_resid  ──►  TurboQuant  ──►  компактный код

В Qdrant 1.18 я выпустил модификацию TurboQuant с компенсацией анизотропии. Она даёт примерно такой же recall, что и гибрид «poly-AE + квантизация остатка», но без необходимости хранить poly-AE латентные координаты — то есть экономит p-часть бюджета байт на вектор. Про трюки, которые я применил поверх стандартного TurboQuant — в следующем посте.

9. Откуда метод и где его применяют

С полиномиальным лифтом я знаком ещё со времён работы в ACD/Labs — там в хемоинформатике это типовой инструмент для построения признаков по молекулярной структуре (как вход для downstream-регрессии). Сейчас работаю в Qdrant. Когда делал там поддержку late-interaction retrieval (ColBERT-style multi-vectors с maxsim, v1.10), я вспомнил про этот приём — и так сложилась идея «PCA + квадратичный декодер». Мне казалось, что для нейросетевых эмбеддингов её никто не пробовал.

Когда черновик попал к Sava Kalbachou на ревью, он нашёл конкретные работы, в которых ровно такая же конструкция давно используется — в области численного моделирования физических систем, под названием quadratic manifold:

В этих работах конструкция применяется к снапшотам траектории решения численной модели физической системы. Эмбеддинги — другая геометрия данных, и заранее неочевидно, что quadratic manifold там тоже будет работать; цифры в §3 показывают, что работает.

Почему этой связки, кажется, нет в mainstream ML

Дальше — мои домыслы. У этих сообществ, видимо, мало пересечений: работы по quadratic manifold выходят в инженерных журналах, в ML-исследованиях они редко всплывают.

Ещё в ML-памяти полиномиальные методы в основном ассоциируются с kernel trick’ом (SVM, kernel PCA) — это другая конструкция, и явный полиномиальный лифт как самостоятельный приём после этого почти не упоминается.

И, наверное, autoencoder в современном ML по умолчанию мыслится как нейросеть. Closed-form варианты могут просто не попадать в поле внимания.

То есть это applied-ML работа: приём, разработанный в соседней области, оказался полезен и для эмбеддингов. Содержание поста — перенос конструкции и эмпирическая проверка.

10. Пределы метода

Несколько честных оговорок:

  • Cubic Ridge solve. При d=256 M = 33K, и np.linalg.solve в закрытом виде стоит O(M³) ≈ 5–15 минут на CPU. Для d=384 это уже M = 74K и неподъёмное. Метод комфортен до d ≈ 200–256. Для больших d нужно либо рандомизированные методы (random feature approximation), либо переходить на нейросетевой декодер.
  • Трансдуктивный фит. PCA + Ridge считаются на корпусе, который собираемся компрессировать. Если корпус drift’ит со временем, декомпрессия деградирует. Для статичных индексов это не проблема, для streaming — нужно переобучать.
  • Маленькие N. При N < 5M (то есть N < 200K для d=256) Ridge начинает запоминать корпус, in-sample retrieval улучшается за счёт переобучения, а не за счёт структуры. Контрольная цифра — соотношение poly-vs-PCA: если оно слишком велико, скорее всего overfit.
  • Степень 3 — пока не работает. Лифт степени 3 даёт M = O(d³) — для d=100 это уже 175K фичей. Помещается в память, но Ridge solve становится непрактичным, и переобучение растёт.

11. Что попробовать дальше

Очевидные ветки:

  • Бóльший корпус, более тяжёлые модели. Все замеры в посте — на FiQA с моделями уровня 305–560M params. Имеет смысл прогнать на MS MARCO (8.8M passages) с моделями уровня e5-mistral-7b. Кубический Ridge становится узким местом, нужно random-feature approximation.
  • Степень лифта. При каких d/N степень 3 начинает реально помогать? На каких эмбеддингах есть тензорная структура 3-го порядка, которую мы сейчас теряем?
  • Режим, в котором матрёшку нельзя применить. Multi-tenant SaaS, edge-инференс — там poly-AE без PCA не сработает (нужны корпусные статистики). А вот гибрид: матрёшка в модели + полиномиальный декодер на клиенте — теоретически даёт лучшее из обоих миров.

Код и воспроизводство

Лоадеры BEIR (SciFact, FiQA, NFCorpus, TREC-COVID), энкодеры (nomic-v1.5, mxbai-large, bge-base, e5-base), PCA-init с per-axis std, polynomial lift, Ridge OLS, retrieval с NDCG@10/Recall@10 — всё в github.com/IvanPleshkov/poly-autoencoder.

Минимальный прогон:

git clone https://github.com/IvanPleshkov/poly-autoencoder.git
cd poly-autoencoder
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt pytrec_eval einops
python beir_eval.py --model nomic-embed-text-v1.5 --dataset fiqa --d 128,256

На M-серии MacBook первый прогон скачает модель + датасет, закодирует корпус (5–15 мин в зависимости от модели), и за следующие 5–20 минут выдаст таблицу с NDCG@10 для всех четырёх методов.