RecSys · учебник
Тренажёр Виджеты Повторение О проекте Все главы ← Обзор Скоринг →

Дополнение · X-алгоритм · страница 4 из 11

Retrieval: две башни, хеши и semantic IDs

Как из 28 миллионов постов за считанные миллисекунды достать несколько сотен кандидатов. Разбираем двухбашенную модель Phoenix: почему у пользователя нет собственного эмбеддинга, как пост представляется без словаря идентификаторов, что такое semantic ID и чем обучение здесь отличается от того, как двухбашенные модели учат обычно.

Коротко

  • Пользователь — это его история. Обучаемого эмбеддинга пользователя в модели нет вообще (use_user_embedding = False). Есть трансформер над историей взаимодействий плюс один токен с грубыми профильными признаками.
  • Пост — это его semantic ID и хеши автора. Обучаемого эмбеддинга поста тоже нет (use_post_embedding = False). Пост кодируется шестью уровнями остаточного квантования по 256 кодов, полученными из мультимодального эмбеддинга.
  • Хеши, а не словарь. Каждый идентификатор превращается в две строки таблицы двумя разными хеш-функциями. Словаря не существует, новый айтем представим сразу.
  • Индекс кандидатов лежит внутри чекпоинта. На каждом сохранении башня кандидатов прогоняется по корпусу, и получившиеся 28.7 млн векторов пишутся в тот же файл. Сервинг ничего не индексирует при старте.
  • Обучение — sampled softmax с двумя видами негативов и LogQ-коррекцией, ровно как в главе «LogQ-коррекция», только с масштабом коррекции 2.0 и раздельными скоростями обучения для эмбеддингов и остальной сети.

Карта страницы

Башня пользователя история до 1023 событий + 1 токен профиля события истории страна, язык, возраст… трансформер, 8 слоёв, d = 1024 вектор пользователя, ‖·‖ = 1 Башня кандидата никаких обучаемых ID поста semantic ID, 6 × 256 хеши автора (×2) MLP + нормировка вектор поста, ‖·‖ = 1 скалярное произведение = косинус, т.к. оба вектора нормированы Индекс: 28 672 000 векторов внутри чекпоинта поиск топ-K по скалярному произведению → сотни кандидатов
Обе башни считаются независимо; пересекаются они только в скалярном произведении. Именно это и позволяет заранее посчитать индекс.

1. Зачем вообще две башни

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

Корпус кандидатов — 28 672 000 постов (константа max_posts в конфиге). На запрос надо отдать несколько сотен. Если бы модель считала совместную функцию \(f(\text{пользователь}, \text{пост})\), пришлось бы вызвать её 28 миллионов раз — это невозможно ни за какое разумное время.

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

$$ s(u, i) \;=\; \langle \mathbf{q}_u,\ \mathbf{p}_i \rangle. $$

Плата известна: башни не видят друг друга, поэтому никакие «перекрёстные» признаки — совпадение языка пользователя и поста, время с момента подписки на автора — модель выразить не может. Выигрыш тоже известен: \(\mathbf{p}_i\) для всех постов считаются заранее, а на запросе остаётся посчитать \(\mathbf{q}_u\) один раз и найти ближайших соседей.

Деталь, которую обычно опускают: нормируются обе стороны

В коде есть функция с говорящим именем:

def _l2_normalize_candidates(embeddings: jax.Array) -> jax.Array:

Фрагмент из recsys_two_tower_model.py · код X, Apache 2.0, коммит 28e414f

То есть векторы приводятся к единичной длине, и скалярное произведение превращается в косинус. Мы разбирали это в главе «Косинус и температура» и щупали руками в виджете мер похожести: норма вектора в подобных моделях выучивает популярность, и косинус её выбрасывает.

Здесь у решения есть и второй, чисто инженерный смысл. Поиск ближайших соседей по скалярному произведению на нормированных векторах эквивалентен поиску по евклидову расстоянию, потому что \(\|a-b\|^2 = 2 - 2\langle a,b\rangle\). А это значит, что можно брать любую библиотеку приближённого поиска, не заботясь о том, поддерживает ли она максимизацию скалярного произведения — задача сводится к обычному поиску ближайших.

2. У пользователя нет эмбеддинга

Самое неожиданное решение в этой модели, и в конфиге оно записано прямо:

"use_user_embedding": False,
"use_post_embedding": False,

Фрагмент из xrecsys_two_tower.py · код X, Apache 2.0, коммит 28e414f

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

Почему это радикально важно

Вспомним глава «Обучаемые эмбеддинги», где мы противопоставляли обучаемые эмбеддинги и контентные представления. Обучаемый эмбеддинг пользователя — это меморизация: модель запоминает конкретного человека. У этого есть три беды, и все три критичны для ленты соцсети.

  1. Новый пользователь получает случайный вектор. Пока он не наберёт истории, его эмбеддинг — шум, и рекомендации соответствующие. Здесь же новый пользователь с первого же действия представлен этим действием: история из одного события уже даёт осмысленный вектор.
  2. Вектор устаревает. Обучаемый эмбеддинг отражает пользователя на момент последнего обучения. Если вы вчера увлеклись новой темой, таблица об этом узнает только после переобучения. История же обновляется в реальном времени: сделали действие — следующий запрос уже считает вектор с ним.
  3. Таблица на сотни миллионов строк. Её надо хранить, обновлять и синхронизировать между репликами сервинга.

Отказ от эмбеддинга пользователя переводит модель из трансдуктивной в индуктивную: она умеет считать вектор для человека, которого никогда не видела, по одним лишь его действиям. Это ровно то, что мы называли индуктивным смещением — модель обязана обобщать, а не запоминать.

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

Один любопытный параметр обучения
"empty_history_user_dropout_rate": 0.1,

Фрагмент из xrecsys_two_tower.py · код X, Apache 2.0, коммит 28e414f

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

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

3. Хеш-эмбеддинги: как обойтись без словаря

Автор поста, пользователь, IP — всё это категориальные признаки с огромным числом значений. Классический подход — словарь «значение → индекс строки» — здесь не работает: словарь надо строить, хранить, пересобирать, а новые значения появляются каждую секунду.

Вместо словаря используется универсальное хеширование. Функция в коде выглядит так:

raw = (ids[i] * scales[j] + biases[j]) % modulus

Фрагмент из recsys_embedding.py · код X, Apache 2.0, коммит 28e414f

то есть \(h_j(\text{id}) = \bigl((\text{id} \cdot a_j + b_j) \bmod M\bigr) \bmod m\), где \(a_j, b_j\) — фиксированные константы, \(M\) — большое простое, \(m\) — число строк таблицы. И ключевое: таких функций две.

СущностьХеш-функцийМножители \(a_j\)Модуль \(M\)
пользователь2196 742 702 и 1 852 108 2662 859 568 897
пост22 161 410 491 и 1 754 358 8322 361 375 383
автор2371 965 780 и 328 930 218631 860 353
id автора 1 883 042 771 h₁ = (id·a₁ + b₁) mod M → строка 7 341 h₂ = (id·a₂ + b₂) mod M → строка 2 908 одна общая таблица эмбеддингов смещения делят её на зоны: users, items, authors две строки → конкатенация Чтобы два автора стали для модели неразличимы, они должны столкнуться в обеих таблицах сразу. При вероятности коллизии p в одной это даёт p² — квадратичное улучшение за двукратную память.
Идентификатор превращается в две строки одной общей таблицы; зоны пользователей, постов и авторов разделены смещениями.

Это ровно та схема, которую мы разбирали в главе «Unified Embedding» под названием Unified Embedding, и она же стоит за виджетом хеширования: вероятность того, что два значения окажутся неразличимы, равна не \(p\), а \(p^2\). При загрузке таблицы, скажем, в 5% это даёт падение с 5% до 0.25%.

Чего хеширование не лечит

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

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

4. Semantic ID: пост без идентификатора

Самая интересная часть. Раньше пост представлялся хешами своего идентификатора. Сейчас — semantic ID, и в конфиге это записано так:

"use_post_embedding": False,
"use_post_sid": True,
"sid_num_levels": 6,
"sid_codebook_size": 256,
"sid_embed_dim": 1024,

Фрагмент из xrecsys_two_tower.py · код X, Apache 2.0, коммит 28e414f

Как получается такой код

Механизм — остаточное квантование, ровно то, что мы разбирали в главе «Generative retrieval и semantic IDs». Берётся мультимодальный эмбеддинг поста (текст плюс изображение) размерности 1024 и кодируется шестью уровнями:

  1. Первый кодбук из 256 векторов. Находим ближайший — это первый токен \(c_1\).
  2. Вычитаем его из исходного вектора. Остаётся остаток — то, что первый уровень не объяснил.
  3. Второй кодбук из 256 векторов, но уже для остатков. Ближайший — токен \(c_2\).
  4. И так шесть раз.

Пост превращается в кортеж из шести чисел, каждое от 0 до 255: например, \((37,\ 210,\ 4,\ 118,\ 250,\ 91)\). Всего различимых кодов — \(256^6 \approx 2.8 \cdot 10^{14}\), с огромным запасом на любой каталог.

Остаточное квантование: каждый уровень кодирует то, что не объяснил предыдущий эмбеддинг поста, d = 1024 уровень 1 256 кодов → c₁ остаток уровень 2 256 кодов → c₂ остаток …до уровня 6 остаток → 0 semantic ID (37, 210, 4, 118, 250, 91) Общий префикс = близость (37, 210, 4, …) — про космос (37, 210, 9, …) — тоже космос (37, 88, …) — другая тема (140, …) — совсем далеко Чем длиннее совпадающий префикс, тем ближе посты по смыслу. Всего различимых кодов: 256⁶ ≈ 2.8 · 10¹⁴. Хранить надо не таблицу на весь каталог, а 6 × 256 = 1536 обучаемых векторов — по одному на код каждого уровня.
Шесть уровней по 256 кодов. Посты на близкие темы получают общий префикс — это не побочный эффект, а весь смысл конструкции.
Что это даёт и почему это лучше хеша поста

Сравним три способа представить пост.

  1. Обучаемый эмбеддинг по ID. Таблица на весь каталог, новый пост — случайный вектор до переобучения. Для ленты, где пост живёт часы, бесполезно.
  2. Хеши идентификатора. Таблица фиксированного размера, новый пост представим сразу — но представление произвольное. Два поста про космос получают несвязанные строки таблицы, и всё, что модель о них знает, она должна выучить отдельно для каждого.
  3. Semantic ID. Код выводится из содержимого. Новый пост получает код немедленно после публикации, прогнав его через мультимодальный энкодер и кодбуки. И — главное — этот код осмысленно связан с кодами похожих постов.

Третий пункт стоит проговорить. Если модель научилась на тысячах постов с префиксом (37, 210), то новый пост с тем же префиксом наследует это знание бесплатно. Именно это в README называют compositional generalization: пост, которого модель никогда не видела, представим не случайно, а как комбинация уже понятных ей частей.

Практически это решает холодный старт айтема — задачу, с которой матричная факторизация не справляется в принципе. Покрутить механику остаточного квантования руками можно в виджете «Квантизация» из каталога.

Ещё две строчки конфига стоят пояснения:

5. Обучение: sampled softmax с двумя видами негативов

Здесь мы встречаем в живом коде почти всё, что выводили в главе «Двухбашенные модели». Разберём по частям.

Что считается позитивом

Наивный ответ — «взаимодействие». Реальный оказывается строже:

valid_positive_mask = (
    has_positive_actions
    & ~has_hard_negative_actions
    & ~has_soft_negative_actions
    & ad_mask_candidates
    & candidate_padding_mask[:, :C]
)

Фрагмент из recsys_two_tower_model.py · код X, Apache 2.0, коммит 28e414f

Позитивом считается пост, на котором произошло положительное действие и не произошло ни одного отрицательного. Причём отрицательные разделены на два сорта:

СортДействия
Позитивныелайк — и только он
Жёсткие негативныежалоба, «не интересно», «показывать меньше», отписка от автора, блокировка, скрытие, «не релевантно»
Мягкие негативныене задержался на посте

Обратите внимание на первую строку: в базовой конфигурации retrieval обучается на лайках, и больше ни на чём. Не на репостах, не на кликах, не на времени просмотра. Это осознанное сужение: retrieval должен грубо очертить область интересов, а тонкие различия — работа ранжирования.

Зачем вычитать негативы из позитивов

Пост, который человек лайкнул, а потом пожаловался — что это? Формально позитив. Для модели — мусор: она получит сигнал «такое надо доставать», а на самом деле такое доставать не надо.

Мы обсуждали это в главе «Проблемы implicit-данных» в разделе про проблемы implicit-данных: клик не равен удовлетворённости. Здесь ту же мысль довели до правила: событие засчитывается позитивом, только если ничего не пошло не так.

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

Два вида негативов

Негативы берутся из двух источников одновременно, и это отличается от того, что обычно рассказывают:

ИсточникСколькоОткуда берётся
In-batchвесь батчПозитивы других примеров того же батча. Бесплатно: их эмбеддинги уже посчитаны
Глобальные64 на примерОтдельно засэмплированные посты из корпуса, посчитанные специально

Зачем оба, если in-batch бесплатны? Потому что у них разное распределение, и мы это подробно разбирали в главе «Сэмплирование негативов».

В конфиге, кстати, видно интересное распределение ролей: num_negatives_per_example: 0 при num_global_negatives_per_example: 64. То есть «локальных» дополнительных негативов нет вовсе — работают in-batch плюс 64 глобальных.

LogQ-коррекция — в живом коде

И вот то, ради чего стоило сюда добираться. In-batch негативы смещены к популярному, и без поправки модель выучит «популярное значит плохое». В коде поправка есть:

def _apply_logq_correction(
    local_batch, local_neg, local_batch_correction, global_batch_correction
):
    if use_in_batch_negatives:
        local_batch_correction = local_batch_correction.reshape((1, -1))
    local_batch += local_batch_correction
    if N > 0:
        local_neg += global_batch_correction.reshape((1, -1))
    return local_batch, local_neg

Фрагмент из recsys_two_tower_model.py · код X, Apache 2.0, коммит 28e414f

Обратите внимание на две вещи. Первая: поправка прибавляется к логитам — это ровно \(s - \log Q\) из вывода LogQ-коррекции, с точностью до знака, зашитого в то, как считается сама поправка. Вторая: поправки две разные — своя для in-batch негативов и своя для глобальных, потому что распределения у них разные, и одной коррекцией их не выправить.

Масштаб коррекции — отдельный параметр:

logq_correction_scale: float = 2.0

Фрагмент из recsys_two_tower_model.py · код X, Apache 2.0, коммит 28e414f

То есть поправка применяется не как \(s - \log Q\), а как \(s - \lambda \log Q\) с \(\lambda = 2\). Теория говорит, что несмещённая оценка получается при \(\lambda = 1\); значение 2 означает сознательную перекоррекцию — популярное давится сильнее, чем требует математика.

Почему перекоррекция — это разумно

Теоретическое \(\lambda = 1\) делает retrieval несмещённым относительно распределения, которое породило лог. Но лог порождён предыдущей версией системы, которая сама предпочитала популярное. То есть даже несмещённая относительно лога модель воспроизводит популярностное смещение, унаследованное от логирующей политики.

Это тот самый feedback loop. Перекоррекция \(\lambda = 2\) — грубый, но практичный способ его придавить: платим точностью оценки ради того, чтобы retrieval активнее лез в хвост. А поскольку после retrieval работает ранжирование, которое популярное всё равно поднимет, риск невелик.

Посмотреть, что делает коррекция и что бывает без неё, можно в виджете LogQ: там две модели учатся на одном потоке батчей, и без коррекции скор сходится к \(\log p - \log Q\).

Две скорости обучения

"learning_rate": 2e-3,
"emb_learning_rate": 0.1,

Фрагмент из xrecsys_two_tower.py · код X, Apache 2.0, коммит 28e414f

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

6. Индекс: сервинг ничего не считает

Как эти эмбеддинги попадают в поиск? Ответ в README неожиданный и красивый: индекс лежит внутри чекпоинта.

На каждом сохранении модели тренер прогоняет башню кандидатов по всему корпусу и записывает получившиеся 28.7 миллиона векторов в тот же файл, что и веса. Сервинг загружает чекпоинт — и индекс уже там.

Что это решает
  1. Согласованность. Веса башни и индекс всегда из одного момента обучения. Классическая беда раздельного хранения — индекс, посчитанный старой версией башни, а вектор пользователя новой; скалярное произведение между ними бессмысленно. Здесь такое невозможно по построению.
  2. Простота выката. Один артефакт вместо двух с их синхронизацией. Откат модели автоматически откатывает индекс.
  3. Скорость старта. Ничего не индексируется при загрузке — 28.7 миллиона прогонов башни это часы.

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

7. Конфигурация продовой модели одним экраном

ПараметрЗначениеЧто означает
history_seq_len1023Сколько последних событий пользователя видит башня
candidate_seq_len64Сколько кандидатов в обучающем примере
num_global_negatives_per_example64Глобальных негативов на пример
num_layers8Слоёв трансформера в башне пользователя
emb_size, emb_table_width1024Размерность представлений
query_heads / kv_heads16 / 4Grouped-query attention: на четыре головы запросов одна голова ключей
max_posts28 672 000Размер индексируемого корпуса
attn_logit_cap80.0Ограничение логитов внимания — защита от расходимости
effective_sequence_len513Средняя длина после упаковки последовательностей
num_candidate_heads2Две головы кандидатов: home и immersive

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

Grouped-query attention в двух словах

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

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

Частые ошибки и подводные камни

На чём спотыкаются
  • «Двухбашенная модель — это про эмбеддинги пользователей и айтемов». Здесь ни того, ни другого нет как обучаемых таблиц. Пользователь — это трансформер над историей, пост — это его semantic ID. Двухбашенность — про раздельность вычисления, а не про то, что представления обязаны быть обучаемыми строками.
  • Путать semantic ID и хеш. Хеш произволен: у похожих постов несвязанные строки. Semantic ID выводится из содержимого: у похожих постов общий префикс. Это разница между «уникальным именем» и «адресом в пространстве смыслов».
  • Думать, что позитив — это любое взаимодействие. Позитив здесь — лайк при отсутствии любого негативного действия, включая «не задержался».
  • Считать LogQ-коррекцию точной. Масштаб 2.0 вместо теоретической 1.0 — сознательная перекоррекция против унаследованного популярностного смещения, а не ошибка.
  • Забывать, что индекс обновляется только при сохранении модели. Свежие посты в нём отсутствуют, и за них отвечают другие источники.
  • Считать, что «нет эмбеддинга пользователя» = «нет персонализации». Персонализация полная, просто она построена на действиях, а не на запомненном идентификаторе. И она работает мгновенно для новичка — в отличие от таблицы.

Вопросы с собеседований

Почему в двухбашенной модели отказались от обучаемого эмбеддинга пользователя?

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

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

Что такое semantic ID и чем он лучше хеша идентификатора?

Это код из остаточного квантования: мультимодальный эмбеддинг поста кодируется шестью уровнями по 256 кодов. На каждом уровне берётся ближайший вектор кодбука, вычитается, остаток кодируется следующим уровнем.

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

Зачем нужны и in-batch, и глобальные негативы, если in-batch бесплатны?

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

Использовать только in-batch — значит учить модель различать популярное между собой и не знать ничего про хвост. Только глобальные — значит получить слишком лёгкие негативы и слабый градиент. В этом конфиге работают in-batch плюс 64 глобальных на пример, и для каждого вида своя LogQ-поправка.

Зачем LogQ-коррекция и почему её масштаб равен двум?

In-batch негативы приходят из распределения популярности \(Q\). Без поправки sampled softmax сходится к \(\log p - \log Q\), то есть модель систематически занижает популярное. Коррекция вычитает \(\log Q\) из логита и возвращает \(\log p\).

Масштаб 2.0 вместо теоретического 1.0 — это перекоррекция. Мотив: несмещённость достигается относительно распределения, породившего лог, а лог порождён предыдущей версией системы, которая сама любила популярное. Перекоррекция грубо давит этот унаследованный перекос и толкает retrieval в хвост; риск невелик, потому что дальше работает ранжирование.

Почему для retrieval позитив — только лайк, а не любое взаимодействие?

Потому что у retrieval и ранжирования разные задачи. Retrieval должен грубо очертить область интересов — здесь важна полнота, а не тонкость. Ранжирование потом разберётся, что внутри этой области лучше.

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

Отдельно отмечу фильтр: позитив засчитывается, только если на том же посте не было негативного действия, включая «не задержался». Это отсекает кликбейт — пост, который лайкнули и тут же пролистнули.

Зачем хранить индекс кандидатов внутри чекпоинта модели?

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

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

Векторы обеих башен нормируются. Что это меняет?

Скалярное произведение становится косинусом. Первое следствие — уходит popularity bias: в ненормированном случае длина вектора выучивает популярность, и скалярное произведение систематически предпочитает популярное независимо от направления.

Второе следствие инженерное: на единичной сфере \(\|a-b\|^2 = 2 - 2\langle a,b\rangle\), то есть поиск максимума скалярного произведения сводится к поиску ближайшего по евклиду. Можно брать любую библиотеку приближённого поиска, не требуя от неё поддержки MIPS.

Цена — теряется сигнал, который несла норма. Отличить надёжный, много раз подтверждённый вектор от шумного вектора холодного айтема после нормировки нечем.

Шпаргалка одним экраном

Задача

28 672 000 постов → сотни кандидатов. Скор обязан раскладываться в \(\langle q_u, p_i\rangle\).

Пользователь

Обучаемого эмбеддинга нет. Трансформер над историей до 1023 событий + токен профиля.

Пост

Обучаемого эмбеддинга нет. Semantic ID 6 × 256 + хеши автора.

Хеши

Две функции вида \((a\cdot id + b) \bmod M\). Неразличимость требует коллизии в обеих: \(p^2\).

Semantic ID

Остаточное квантование. Похожие посты делят префикс — отсюда перенос знания на новые.

Позитив

Лайк и никаких негативных действий, включая «не задержался».

Негативы

In-batch (∝ популярности) плюс 64 глобальных. У каждого вида своя LogQ-поправка.

LogQ

Масштаб 2.0 — перекоррекция против унаследованного смещения к популярному.

Индекс

Считается при сохранении и лежит внутри чекпоинта. Сервинг не индексирует ничего.

Первоисточники