Эмбеддинги и обучение представлений
В предыдущих статьях трека архитектуры отличались тем, какую структуру данных они предполагают: решётку у свёрточных сетей, последовательность у рекуррентных и трансформеров, произвольный граф у GNN. Но у всех у них есть общая внутренняя валюта: вектор фиксированной размерности, в который свёрнут объект.
Этот вектор и есть эмбеддинг. Он же — то, ради чего половину моделей вообще обучают: классификационная «голова» часто выбрасывается, а в прод уезжает именно энкодер. Поиск, рекомендации, дедупликация, антифрод, RAG, zero-shot классификация, кластеризация логов — это всё одна и та же техника: превратить объект в точку и заменить смысловую близость геометрической.
Эта статья — про то, откуда берутся «хорошие» точки, почему косинус между ними иногда ничего не значит, и что ломается, когда векторов становится сто миллионов.
1. Зачем: от one-hot к плотному вектору
Начнём с самого честного представления категориального объекта — one-hot. Словарь из $V$ слов, слово $i$ — вектор из нулей с единицей на позиции $i$. Такое представление корректно и не врёт, но у него три фатальных свойства:
- Размерность равна размеру словаря. Первый линейный слой на словаре в 300 000 слов и скрытом размере 512 — это 153 млн параметров только на вход.
- Все объекты равноудалены. $\langle e_{\text{кот}}, e_{\text{кошка}}\rangle = 0$, ровно как и $\langle e_{\text{кот}}, e_{\text{вертолёт}}\rangle = 0$. Модель обязана выучить всё про «кошку» заново, даже если про «кота» она уже всё знает.
- Нет обобщения на редкое. Слово, встретившееся дважды, получит два обновления градиента — и останется шумом.
Эмбеддинг решает всё три пункта одним ходом: сопоставим каждому объекту плотный вектор $e \in \mathbb{R}^d$, $d \ll V$, и будем учить его вместе с моделью. Формально это просто матрица $E \in \mathbb{R}^{V \times d}$, а «получить эмбеддинг» — взять строку:
$$e_i = E^\top ,\text{onehot}(i) = E[i, :]$$
Умножение one-hot на матрицу — это lookup, и никакой матричной арифметики на инференсе не происходит. Но важно понимать, что эмбеддинг-слой — это обычный линейный слой, просто с разреженным входом; градиент по нему приходит только в те строки, которые встретились в батче.
Ключевой сдвиг мышления: сходство становится геометрией. Вместо правил «если товар из категории X и бренд Y» мы получаем непрерывное пространство, где вопрос «что похоже на это?» решается арифметикой, а не логикой. За это платим интерпретируемостью — про то, как её частично возвращают, есть отдельная статья про интерпретируемость.
Скалярное произведение, косинус и длина
Три метрики, которые постоянно путают:
| Метрика | Формула | Что кодирует | Когда брать |
|---|---|---|---|
| Скалярное | $\langle u, v\rangle$ | смысл × «величина» | ранжирование, где популярность = хорошо |
| Косинус | $\frac{\langle u,v\rangle}{|u||v|}$ | только направление | семантическое сходство |
| Евклид $L_2$ | $|u - v|_ 2$ | и то, и другое | кластеризация, если норма осмысленна |
Для нормированных векторов они эквивалентны с точностью до монотонного преобразования:
$|u - v|^2 = 2 - 2\cos(u,v)$. Отсюда практическое правило: если вы всё равно нормируете
на выходе энкодера, выбор между IP и L2 в векторной БД ни на что не влияет — а если
не нормируете, влияет драматически, потому что длина вектора у частотных объектов обычно
больше, и dot product начнёт систематически поднимать популярное.
2. Таксономия: чем вообще можно учить представление
Эмбеддинг никогда не учится «сам по себе» — он всегда побочный продукт какой-то задачи. Выбор этой задачи (pretext task) определяет, какие свойства попадут в геометрию.
Дальше разберём три опорные точки этой карты — word2vec как минимальный работающий пример, контрастное обучение как современный мейнстрим и не-контрастные методы как ответ на его главную проблему.
3. word2vec: минимальная модель, объясняющая почти всё
Mikolov et al., 2013 поставили задачу так: слово характеризуется своим окружением (дистрибутивная гипотеза Фёрса: «you shall know a word by the company it keeps»). Возьмём корпус, для каждого слова $w$ и каждого слова $c$ из окна вокруг него будем максимизировать вероятность «$c$ встречается рядом с $w$».
Полный softmax по словарю $p(c \mid w) = \frac{\exp\langle v_w, u_c\rangle}{\sum_{c’} \exp\langle v_w, u_{c’}\rangle}$ стоит $O(V)$ на каждую пару — при $V = 10^6$ это неприемлемо. Отсюда negative sampling: вместо нормировки по словарю решаем бинарную задачу «эта пара настоящая или подсунутая»:
$$\mathcal{L} = -\log \sigma(\langle v_w, u_c\rangle) - \sum_{k=1}^{K} \mathbb{E}_ {c_k \sim P_n} \log \sigma(-\langle v_w, u_{c_k}\rangle)$$
где $P_n(c) \propto f(c)^{3/4}$ — сглаженное униграммное распределение (степень 3 $/4$ подобрана эмпирически: она поднимает редкие слова и придавливает служебные).
Здесь уже видны все идеи современного contrastive learning: позитивная пара, $K$ негативов, сигмоида/софтмакс поверх скалярного произведения, распределение негативов как отдельная ручка качества. Разница с SimCLR — только в том, что позитив берётся не из окна, а из аугментации.
import numpy as np
rng = np.random.default_rng(0)
def sgnos_step(W_in, W_out, center, context, neg_ids, lr=0.025):
"""Один шаг skip-gram with negative sampling.
W_in : (V, d) — «входные» векторы (то, что уедет в прод как эмбеддинги)
W_out : (V, d) — «выходные» векторы контекста (обычно выбрасываются)
Сложность: O((K + 1) * d) на пару — не зависит от размера словаря V.
"""
v = W_in[center] # (d,)
ids = np.concatenate(([context], neg_ids))
labels = np.zeros(len(ids)); labels[0] = 1.0
u = W_out[ids] # (K+1, d)
score = u @ v # (K+1,)
pred = 1.0 / (1.0 + np.exp(-score)) # сигмоида
err = pred - labels # dL/dscore
grad_v = err @ u # (d,) — вклад всех негативов и позитива
grad_u = np.outer(err, v) # (K+1, d)
W_in[center] -= lr * grad_v # разреженное обновление: одна строка
W_out[ids] -= lr * grad_u # K+1 строк
return float(-np.log(pred[0] + 1e-9) - np.log(1 - pred[1:] + 1e-9).sum())
V, d, K = 5000, 64, 5
W_in = (rng.random((V, d)) - 0.5) / d # инициализация масштаба 1/d
W_out = np.zeros((V, d)) # выходные — нулями, как в оригинале
loss = sgnos_step(W_in, W_out, center=17, context=42, neg_ids=rng.integers(0, V, K))
Три детали, которые отличают работающую реализацию от нерабочей:
- Два набора векторов. «Быть словом» и «быть контекстом» — разные роли. Если использовать одну матрицу, модель начнёт максимизировать $\langle v_w, v_w \rangle$ на самосовпадениях и схлопнется.
- Subsampling частотных слов. Слово выбрасывается из корпуса с вероятностью $1 - \sqrt{t / f(w)}$, $t \approx 10^{-5}$. Без этого 90% пар — это «и», «в», «на».
- Динамическое окно. Реальный размер окна сэмплируется от 1 до $L$ — это неявно взвешивает близкие слова сильнее.
Почему это вообще работает: связь с факторизацией матрицы
Levy и Goldberg, 2014 доказали красивый факт: SGNS с $K$ негативами неявно факторизует матрицу сдвинутой поточечной взаимной информации:
$$\langle v_w, u_c \rangle \approx \text{PMI}(w, c) - \log K, \qquad \text{PMI}(w,c) = \log\frac{p(w,c)}{p(w)p(c)}$$
То есть нейросетевой word2vec — это стохастический способ приблизить SVD от PMI-матрицы,
не строя её целиком ($V \times V$ не влезет в память). Это объясняет и знаменитую
«векторную арифметику» $\text{king} - \text{man} + \text{woman} \approx \text{queen}$:
разности векторов приближают отношения логарифмов условных вероятностей, то есть
аналогии линейны в пространстве логарифмов совместных частот. Эффект реален, но
слабее, чем в популярных пересказах: обычно из ответа явно исключают три исходных слова,
иначе ближайшим оказывается сам king.
Что из этого живо сегодня
Сам word2vec на текстах вытеснен контекстными моделями: у него один вектор на слово, поэтому «замок» на двери и «замок» на холме склеиваются в бессмысленное среднее. Но рецепт «последовательность → скип-грамм → эмбеддинги» переехал в другие домены и там процветает:
- item2vec / prod2vec — сессия пользователя как «предложение», товары как «слова».
Работает из коробки на любом
gensim, отлично ловит комплементарность. - node2vec, DeepWalk — случайные блуждания по графу как корпус (см. GNN).
- Эмбеддинги категориальных признаков в табличных моделях — прямая замена one-hot
для колонок с высокой кардинальностью (
city_id,merchant_id).
4. Контрастное обучение: InfoNCE как рабочая лошадь
Общая постановка: есть якорь $x$, его позитив $x^+$ (тот же объект в другом виде) и набор негативов $x^-_ 1, \dots, x^-_ N$. Хотим, чтобы энкодер $f_\theta$ приблизил якорь к позитиву и оттолкнул от негативов. Стандартный лосс — InfoNCE (он же NT-Xent):
$$\mathcal{L}_ {\text{InfoNCE}} = -\log \frac{\exp(\text{sim}(z, z^+)/\tau)}{\exp(\text{sim}(z, z^+)/\tau) + \sum_{i=1}^{N}\exp(\text{sim}(z, z^-_ i)/\tau)}$$
где $z = f_\theta(x)$ нормирован, $\text{sim}$ — косинус, $\tau$ — температура. Это в точности кросс-энтропия задачи «выбери свой позитив среди $N+1$ кандидатов».
Три вещи, которые нужно понимать про эту формулу:
Температура $\tau$ управляет жёсткостью. При $\tau \to 0$ градиент концентрируется на самом близком негативе (обучение только по «сложным» примерам, риск нестабильности), при больших $\tau$ — размазывается по всем и модель перестаёт различать оттенки. Практический диапазон 0.05–0.1; в CLIP $\tau$ — обучаемый параметр с клиппингом.
InfoNCE — нижняя граница взаимной информации между видами объекта: $I(z; z^+) \ge \log N - \mathcal{L}$ (van den Oord et al., 2018). Отсюда логика «больше негативов — лучше граница», хотя на практике выигрыш быстро насыщается и определяется скорее их качеством.
Лосс раскладывается на alignment и uniformity (Wang, Isola, 2020): первый член тянет позитивные пары вместе, второй разгоняет всё остальное равномерно по гиперсфере. Именно этот второй член спасает от коллапса — вырожденного решения «выдавать всем одну константу».
crop, blur, dropout, перефраз"] X --> A2["Аугментация t2"] A1 --> E1["Энкодер f_theta"] A2 --> E2["Энкодер f_theta
те же веса"] E1 --> H1["Projection head g
MLP, 2-3 слоя"] E2 --> H2["Projection head g
общие веса с H1"] H1 --> N1["L2-нормировка -> z1"] H2 --> N2["L2-нормировка -> z2"] N1 --> S["Матрица сходства B x B
sim / tau"] N2 --> S S --> L["InfoNCE: диагональ = позитивы,
остальное = негативы"] L --> G["Градиент в f и g"] G -.->|"в прод уходит только f"| E1 subgraph TRAP["Где ломается"] T1["Слабая аугментация:
задача решается по цвету"] T2["Ложные негативы:
тот же класс в батче"] T3["Малый батч:
мало негативов"] end S --- TRAP
Реализация InfoNCE с in-batch негативами короче, чем кажется — весь батч служит негативами друг для друга:
import torch
import torch.nn.functional as F
def info_nce(z1: torch.Tensor, z2: torch.Tensor, tau: float = 0.07) -> torch.Tensor:
"""Симметричный NT-Xent для двух видов одного батча.
z1, z2: (B, d) — выходы энкодера ДО нормировки.
Память: O(B^2), время: O(B^2 * d). Отсюда предел на размер батча.
"""
z1 = F.normalize(z1, dim=-1)
z2 = F.normalize(z2, dim=-1)
logits = z1 @ z2.T / tau # (B, B): [i, j] = sim(x_i вид1, x_j вид2)
labels = torch.arange(z1.size(0), device=z1.device) # позитив — на диагонали
# Симметризация: и «текст ищет картинку», и «картинка ищет текст»
return 0.5 * (F.cross_entropy(logits, labels)
+ F.cross_entropy(logits.T, labels))
class TwoTower(torch.nn.Module):
"""Bi-encoder: два независимых энкодера с общим пространством выхода."""
def __init__(self, q_enc, d_enc, dim=256):
super().__init__()
self.q_enc, self.d_enc = q_enc, d_enc
self.q_proj = torch.nn.Linear(q_enc.out_dim, dim)
self.d_proj = torch.nn.Linear(d_enc.out_dim, dim)
# Обучаемая температура в лог-пространстве — трюк из CLIP
self.log_tau = torch.nn.Parameter(torch.tensor(2.6593)) # 1/0.07
def forward(self, queries, docs):
q = F.normalize(self.q_proj(self.q_enc(queries)), dim=-1)
d = F.normalize(self.d_proj(self.d_enc(docs)), dim=-1)
scale = self.log_tau.exp().clamp(max=100.0) # без clamp разъезжается
return q @ d.T * scale
Негативы решают всё
Качество retrieval-модели определяется не архитектурой, а тем, с чем именно вы контрастируете. Иерархия по силе:
- In-batch negatives — бесплатны, но случайный документ из корпуса почти всегда тривиально далёк. Модель быстро выучивает поверхностные признаки (тема, язык, длина).
- BM25-негативы — лексически похожие, но нерелевантные документы. Дешёвый способ заставить модель выучить семантику поверх совпадения слов.
- Hard negatives из самой модели — прогнать текущую версию по корпусу и взять топ-k нерелевантных. Так работает ANCE и большинство современных ретриверов.
- Denoised hard negatives — те же кандидаты, отфильтрованные cross-encoder-ом: без фильтра в «негативы» попадают настоящие позитивы, которых нет в разметке, и модель получает прямо противоположный сигнал.
Инженерные приёмы, которые расширяют число негативов, не расширяя память:
- Gradient cache / GradCache — считаем эмбеддинги без графа, потом пересчитываем по частям; позволяет батч 16k на одной карте.
- Momentum queue (MoCo) — очередь эмбеддингов от медленно обновляемого энкодера-копии.
- Cross-device negatives —
all_gatherэмбеддингов со всех GPU: батч 8×1024 вместо 1024.
5. Не-контрастные методы: как жить без негативов
Негативы — источник главной боли: ложные негативы отравляют сигнал, а на маленьком батче их просто мало. Отсюда семейство методов, где негативов нет вообще, а коллапс предотвращается архитектурно.
- BYOL (Grill et al., 2020): онлайн-сеть предсказывает выход target-сети (EMA-копии себя же). Асимметрия предиктора + стоп-градиент делают константное решение неустойчивой точкой.
- SimSiam (Chen, He, 2020): то же без EMA —
показано, что критичен именно
stop_gradient. - Barlow Twins / VICReg: вместо отталкивания примеров — декорреляция координат. Диагональ кросс-корреляционной матрицы двух видов гонится к 1, недиагональ — к 0. Коллапс невозможен, потому что тогда матрица вырождается.
- DINO / DINOv2 (Caron et al., 2021): самодистилляция с центрированием и sharpening; даёт признаки, которые сегментируют объекты без разметки.
Отдельная ветка — masked modelling: BERT для текста, MAE для картинок. Здесь
представление учится из задачи «восстанови выброшенное». Важный практический нюанс:
[CLS] из ванильного BERT — плохой sentence-эмбеддинг (без дообучения он хуже
усреднённого GloVe), потому что задача MLM не требует, чтобы один вектор описывал всё
предложение. Это классическая ошибка на собеседованиях и в проде.
6. Мультимодальность: CLIP и общее пространство
CLIP (Radford et al., 2021) берёт ту же InfoNCE, но позитивной парой делает картинку и её подпись из интернета. Два энкодера, общее пространство, батч 32 768 — и на выходе получается модель, которая классифицирует объекты, которых не было в разметке: достаточно закодировать фразы «фото кота», «фото собаки» и выбрать ближайшую.
Это главное практическое следствие обучения представлений: классификатор становится данными, а не кодом. Добавить класс — значит добавить строку текста, а не переобучить сеть.
Что важно знать при использовании:
- Модальный разрыв (modality gap). Векторы картинок и текстов в CLIP лежат в двух разделённых конусах — косинус картинка↔текст систематически ниже, чем картинка↔картинка. Абсолютные пороги нельзя переносить между парами модальностей, только ранжирование.
- SigLIP (Zhai et al., 2023) заменяет softmax на
попарную сигмоиду: не нужен глобальный
all_gather, работает на маленьких батчах. - Тексты в CLIP короткие (77 токенов) и «подписочного» стиля — на длинных описаниях качество разваливается.
7. Текстовые эмбеддинги на практике
Современный стек текстового поиска почти всегда двухступенчатый, и путаница между bi-encoder и cross-encoder — самая частая архитектурная ошибка.
уже посчитаны офлайн EMB-->>API: q, размерность 768 par Гибридный отбор API->>ANN: top-100 по косинусу ANN-->>API: кандидаты + скоры and API->>BM: top-100 по BM25 BM-->>API: кандидаты + скоры end API->>API: слияние RRF, дедуп, фильтры доступа API->>CE: 50 пар (query, doc) целиком Note over CE: полный self-attention по паре,
дорого, но точно CE-->>API: переранжированный список API-->>U: топ-10
| Bi-encoder | Cross-encoder | |
|---|---|---|
| Вход | документ и запрос отдельно | пара целиком |
| Стоимость запроса | 1 форвард + ANN, $O(\log N)$ | $k$ форвардов, $O(k)$ |
| Индексируемость | да, векторы считаются офлайн | нет |
| Качество (NDCG) | базовое | +5–15 пунктов |
| Роль | отбор кандидатов | переранжирование |
Практические свойства современных text-embedding моделей:
- Инструкции в запросе. У E5, BGE, GTE и подобных модели обучены на префиксах
(
query: .../passage: ...). Забыть префикс — потерять несколько пунктов NDCG на ровном месте, при этом ошибка молчаливая. - Matryoshka Representation Learning (Kusupati et al., 2022): лосс считается сразу на вложенных префиксах ($d = 768, 512, 256, 128, 64$), поэтому вектор можно обрезать до нужной длины без переобучения. Это ровно то, что позволяет держать 256-мерный индекс для отбора и 1536-мерный — для точного дораунжирования.
- Пулинг. Mean pooling с учётом маски — разумный дефолт; для decoder-only моделей берут последний токен. Смешивать пулинги между индексом и запросом нельзя.
- MTEB (Muennighoff et al., 2022) — стандартный бенчмарк, но лидерборд переобучен: модели верхних строк часто тренировались на тестовых доменах. Всегда проверяйте на своих данных.
import torch
import torch.nn.functional as F
from transformers import AutoTokenizer, AutoModel
MODEL = "intfloat/multilingual-e5-base"
tok = AutoTokenizer.from_pretrained(MODEL)
model = AutoModel.from_pretrained(MODEL).eval()
def mean_pool(last_hidden: torch.Tensor, mask: torch.Tensor) -> torch.Tensor:
"""Среднее по НЕ-паддинговым токенам. Забыть маску — классическая ошибка:
при батче с разной длиной паддинг размывает короткие тексты."""
mask = mask.unsqueeze(-1).to(last_hidden.dtype)
return (last_hidden * mask).sum(dim=1) / mask.sum(dim=1).clamp(min=1e-9)
@torch.inference_mode()
def encode(texts: list[str], prefix: str, dim: int | None = None) -> torch.Tensor:
"""prefix — 'query: ' или 'passage: ', это часть контракта модели.
dim — усечение по Matryoshka (только для моделей, обученных с MRL)."""
batch = tok([prefix + t for t in texts], padding=True,
truncation=True, max_length=512, return_tensors="pt")
out = model(**batch).last_hidden_state
emb = mean_pool(out, batch["attention_mask"])
if dim is not None:
emb = emb[:, :dim] # усечение ДО нормировки
return F.normalize(emb, dim=-1) # нормировка ПОСЛЕ усечения
docs = encode(["Подписку можно отменить в разделе «Профиль».",
"Курьер доставит заказ в течение двух дней."], "passage: ")
q = encode(["как отключить платную подписку"], "query: ")
print((q @ docs.T).squeeze(0)) # -> первый документ заметно ближе
Изотропия и почему косинус иногда врёт
Эмбеддинги языковых моделей страдают анизотропией: все векторы занимают узкий конус, и косинус между случайными предложениями оказывается 0.85–0.95 (Ethayarajh, 2019; Gao et al., ICLR 2019 — «representation degeneration»). Причина в частотном дисбалансе: редкие токены отталкиваются в общую область, и вся частотная структура доминирует над семантической.
Что с этим делают:
- Контрастное дообучение (SimCSE, E5) — самое эффективное: uniformity-член напрямую распрямляет конус.
- Whitening / all-but-the-top — постобработка: вычесть среднее и убрать первые главные компоненты. Быстро и без обучения, но ломает совместимость между версиями индекса.
- Центрирование по домену — вычесть средний вектор корпуса перед сравнением. Часто даёт +2–4 пункта recall почти бесплатно.
Следствие для практики: абсолютное значение косинуса не имеет универсального смысла. Порог «считаем дубликатами при cos > 0.9» нужно калибровать на своей модели и своих данных, а при смене модели — калибровать заново.
8. Эксплуатация: ANN-поиск и жизнь индекса
Точный поиск ближайших соседей — это $O(N \cdot d)$ на запрос. Для 100 млн векторов по 768 float32 это 300 ГБ памяти и сотни миллисекунд на брутфорс — неприемлемо. Поэтому в проде живёт приблизительный поиск (ANN), где мы явно разменяли recall на скорость.
| Индекс | Идея | Память | Recall/скорость | Когда брать |
|---|---|---|---|---|
| Flat (brute force) | всё как есть | $N d \cdot 4$ Б | recall = 1, медленно | до ~1 млн, как эталон |
| IVF-Flat | кластеризация, поиск в nprobe ячейках |
как flat + центроиды | хорошо | средний масштаб |
| IVF-PQ | + квантизация остатков | в 10–50 раз меньше | recall падает, нужен rerank | сотни млн на CPU |
| HNSW | многослойный граф соседства | $Nd\cdot4 + N M \cdot 8$ Б | лучший латентность/recall | дефолт для онлайн-поиска |
| ScaNN | anisotropic quantization | компактно | SOTA на больших | Google-стек, batch |
import numpy as np, hnswlib
d, N = 256, 1_000_000
data = np.random.rand(N, d).astype(np.float32)
data /= np.linalg.norm(data, axis=1, keepdims=True) # нормируем -> cosine == ip
index = hnswlib.Index(space="ip", dim=d)
# M — число рёбер на узел (память и качество), ef_construction — тщательность сборки
index.init_index(max_elements=N, ef_construction=200, M=32)
index.add_items(data, np.arange(N)) # O(N log N * M * d), параллелится по потокам
index.set_ef(128) # ручка на запросе: recall vs RPS
labels, dists = index.knn_query(data[:5], k=10)
# Замер recall против точного поиска на выборке запросов — обязательный шаг,
# иначе вы не знаете, что теряете. Считать на 1-5 тыс. запросов достаточно.
def recall_at_k(index, data, queries_idx, k=10):
q = data[queries_idx]
exact = np.argsort(-(q @ data.T), axis=1)[:, :k] # эталон, дорого
approx, _ = index.knn_query(q, k=k)
hits = [len(set(a) & set(e)) for a, e in zip(approx, exact)]
return float(np.mean(hits) / k)
Что ломается в проде (и почти всё это — не про модель):
- Рассинхрон версий. Половина индекса построена моделью v1, половина — v2. Векторы из разных пространств несравнимы, но поиск не падает — он тихо возвращает мусор. Лечение: версия модели в имени коллекции и жёсткая проверка на записи.
- Дрейф данных. Появились новые товары/термины, распределение уехало, recall падает на месяцы незаметно. Нужен офлайн-мониторинг: доля запросов с нулевым кликом, средний скор топ-1, KL между распределениями скоров сейчас и на бейзлайне.
- Удаления. В HNSW нет настоящего удаления — только tombstone; после 20–30% удалённых граф деградирует и нужен полный rebuild.
- Фильтры + ANN. Пост-фильтрация после top-k убивает recall, если фильтр селективный (запросили 100 кандидатов, после фильтра «только в наличии» осталось 3). Нужна pre-фильтрация на уровне индекса или отдельные партиции.
- Cold start. Новый объект без взаимодействий не имеет обученного эмбеддинга — спасают контентные признаки как fallback.
Ключевая мысль этой диаграммы: смена версии энкодера = полный пересчёт всего корпуса. Это не деплой сервиса на пять минут, а батч-джоб на часы и деньги. Планируйте это заранее: держите пайплайн пересчёта идемпотентным, храните исходные тексты, считайте стоимость одного полного reindex до того, как индекс вырастет до сотни миллионов.
9. Как измерять качество представлений
Лосс контрастного обучения почти ничего не говорит о полезности эмбеддингов — как и в GAN. Нужны внешние измерения.
Linear probing. Заморозить энкодер, обучить поверх один линейный слой на целевую задачу. Метрика показывает, насколько информация линейно доступна — это и есть рабочее определение «хорошего представления».
k-NN probe. Ещё честнее и без обучения: классифицировать объект голосованием $k$ ближайших соседей в трейне. Не даёт модели «дообучить» ничего и хорошо ловит коллапс.
Retrieval-метрики. Recall@k, MRR, NDCG@k на золотом наборе пар. Единственные, что коррелируют с продуктовым результатом в поиске.
Alignment и uniformity — диагностика геометрии, независимая от задачи:
import torch, torch.nn.functional as F
def alignment(z1, z2, alpha=2):
"""Насколько близки позитивные пары. Меньше — лучше."""
return (F.normalize(z1, dim=-1) - F.normalize(z2, dim=-1)).norm(dim=1).pow(alpha).mean()
def uniformity(z, t=2):
"""Насколько равномерно точки покрывают сферу. Меньше — лучше (ближе к -4)."""
z = F.normalize(z, dim=-1)
sq = torch.pdist(z, p=2).pow(2)
return sq.mul(-t).exp().mean().log()
def isotropy_ratio(z):
"""Отношение min/max сингулярного значения ковариации: 1 = идеальная изотропия,
~0 = вырожденный конус. Считать на выборке 10-50 тыс. векторов."""
z = z - z.mean(dim=0, keepdim=True)
s = torch.linalg.svdvals(z)
return (s.min() / s.max()).item()
Здоровая динамика при обучении: alignment падает, uniformity тоже падает (в минус). Если alignment улучшается, а uniformity растёт к нулю — вы едете в коллапс, и никакой рост «точности» на валидации это не компенсирует.
Осторожно с t-SNE/UMAP. Это инструменты для генерации гипотез, а не доказательства. Расстояния между кластерами на картинке UMAP не имеют интерпретации, а «красивые кластеры» появляются даже на случайных данных при неудачных гиперпараметрах — см. Wattenberg et al., «How to Use t-SNE Effectively».
10. Типичные ошибки
- Использовать
[CLS]необученного BERT как эмбеддинг предложения. Нужен SimCSE/E5/BGE — модель, дообученная контрастивно именно под sentence-similarity. - Забыть нормировку или смешать метрики. Индекс на
L2, запросы считаются как косинус — результаты выглядят правдоподобно и при этом неверны. - Разные препроцессинги при индексации и запросе. Разный пулинг, разная длина
усечения, отсутствие префикса
query:. Тихая деградация без единой ошибки в логах. - Утечка через аугментацию. Позитивная пара, различающаяся артефактом (JPEG-качество, таймстемп в тексте) — модель выучит артефакт и покажет отличный лосс.
- Ложные негативы. В батче с товарами одной категории почти все «негативы» — на самом деле хорошие замены. Отсюда дедупликация внутри батча и фильтрация кандидатов cross-encoder-ом.
- Сравнивать косинусы между разными моделями или версиями. Разные пространства — разные шкалы. Пороги, откалиброванные на v1, для v2 бессмысленны.
- Эмбеддинг как замена бизнес-правилам. Векторный поиск не понимает «в наличии», «в моём регионе», «не 18+». Фильтры остаются обязательными и должны применяться до или внутри индекса.
- Слепая вера в MTEB. Модель на первой строчке лидерборда может уступать на вашем домене модели с сороковой — и быть в пять раз дороже на инференсе.
- Слишком большая размерность по умолчанию. 1536-мерные векторы на 50 млн объектов — 300 ГБ. Matryoshka-усечение до 256 обычно теряет 1–2% recall и экономит в шесть раз.
- Игнорировать приватность. Эмбеддинг — это лоссовое, но обратимое представление: из вектора предложения текст восстанавливается с высокой точностью (Morris et al., 2023). Векторная БД с персональными данными требует того же режима защиты, что и исходный текст.
11. Где это применяется в проде
- Поиск и RAG. Гибрид BM25 + плотный ретривер + cross-encoder — стандарт индустрии. Чисто векторный поиск проигрывает на точных совпадениях (артикулы, имена, коды ошибок), чисто лексический — на перефразировках. Подробнее про инференс-часть — в следующей статье.
- Рекомендации. Two-tower: башня пользователя считается онлайн, башня item — офлайн, ANN отбирает 500 кандидатов, тяжёлый ранкер сортирует. Так устроены YouTube, Pinterest (PinSage), большинство маркетплейсов.
- Дедупликация и entity resolution. Слияние карточек товаров, схлопывание дублей тикетов, поиск повторных обращений.
- Антифрод. Эмбеддинг сессии/устройства/поведения; аномалия = вектор далеко от всех кластеров или подозрительно близко к известной фрод-группе.
- Zero-shot модерация. Эмбеддинги CLIP + прототипы запрещённых категорий; добавить новую категорию — значит добавить описание, а не переобучить модель.
- Мониторинг и логи. Кластеризация текстов ошибок для автоматической группировки инцидентов вместо регулярок.
- Признаки для классических моделей. Эмбеддинг как фича в градиентном бустинге — часто самый быстрый способ получить прирост, не трогая основную модель (см. трек машинного обучения).
12. Мини-итог
- Эмбеддинг — это способ заменить смысловое сходство геометрическим. Всё остальное в статье — следствия этого решения.
- Представление всегда наследует свойства задачи, на которой обучалось. «Хорошего эмбеддинга вообще» не существует: вектор, отличный для поиска, может быть плох для кластеризации по тональности.
- Контрастное обучение (InfoNCE) — универсальный рецепт: alignment притягивает позитивы, uniformity разгоняет остальных. Качество определяется негативами и аугментациями, а не архитектурой.
- Не-контрастные методы (BYOL, VICReg, DINO) убирают негативы, заменяя их асимметрией или декорреляцией координат.
- Косинус не абсолютен: анизотропия, модальный разрыв и смена версии модели делают пороги непереносимыми. Калибруйте на своих данных.
- Прод-часть — это не модель, а жизненный цикл индекса: версионирование, пересчёт, дрейф, фильтры, recall-мониторинг. Ошибки здесь тихие и дорогие.
Источники
- Mikolov et al. Efficient Estimation of Word Representations in Vector Space (2013) — word2vec.
- Mikolov et al. Distributed Representations of Words and Phrases (2013) — negative sampling, subsampling.
- Levy, Goldberg. Neural Word Embedding as Implicit Matrix Factorization (2014).
- Pennington et al. GloVe (2014).
- van den Oord et al. Representation Learning with Contrastive Predictive Coding (2018) — InfoNCE.
- Chen et al. SimCLR (2020); He et al. MoCo (2019).
- Wang, Isola. Understanding Contrastive Representation Learning through Alignment and Uniformity (2020).
- Gao et al. SimCSE (2021) — dropout как аугментация для текста.
- Radford et al. Learning Transferable Visual Models From Natural Language Supervision (2021) — CLIP.
- Caron et al. Emerging Properties in Self-Supervised Vision Transformers (2021) — DINO.
- Kusupati et al. Matryoshka Representation Learning (2022).
- Karpukhin et al. Dense Passage Retrieval (2020) — in-batch и BM25-негативы.
- Malkov, Yashunin. Efficient and robust approximate nearest neighbor search using HNSW (2016).
- Johnson et al. Billion-scale similarity search with GPUs (2017) — FAISS.
- FAISS wiki: guidelines to choose an index — практический выбор индекса.
- Sentence-Transformers docs — референсные рецепты обучения и инференса.
- Muennighoff et al. MTEB: Massive Text Embedding Benchmark (2022).
Что дальше
Мы получили энкодер, который превращает объекты в векторы, и индекс, который ищет по ним за логарифм. Осталась самая приземлённая часть: сделать так, чтобы всё это укладывалось в бюджет по латентности и памяти — квантизация весов, дистилляция в модель поменьше, батчинг запросов и сервинг под нагрузкой. Об этом — в статье Инференс и деплой: квантизация, дистилляция, сервинг.