Привет, я Салават Динмухаметов, senior ML-engineer в команде рекомендаций Авито. В статье расскажу, как мы изменили систему рекомендаций на главной странице.
Одна из важных частей этой системы — коллаборативная модель avitofm. Раньше она обновлялась раз в 6 часов. Проблема в том, что товары из хороших объявлений разбирают быстрее: они не успевали оказаться в рекомендациях.
Мы решили ускорить обучение — и за несколько итераций сделали neartime-архитектуру, которая обновляется каждые 20 минут. Это повысило качество, бизнес-метрики и сэкономило память.
Статья будет полезна ML-инженерам и тимлидам, которые строят рекомендательные системы и упираются в свежесть: когда модель даёт нормальное качество, но узнаёт о новых айтемах слишком поздно.

Как устроены рекомендации на главной Авито
На Авито 72 млн пользователей в месяц и 240 млн активных объявлений. Половину всех просмотров и около 30% контактов продавцам приносят рекомендации их товаров на главной.
Страница решает две задачи:
1️⃣ Exploitation — показываем пользователю максимально релевантные товары. Мы знаем его интент и предлагаем то, с чем он, скорее всего, будет взаимодействовать. Так растим business value.
2️⃣ Exploration — разбавляем выдачу товарами, которые могут привлечь внимание. Например, если человек интересуется автомобилями, можем подкинуть ему запчасти. Так растим разнообразие ассортимента (diversity), новизну товаров для юзера (novelty) и попадание в неочевидные интересы (serendipity), а заодно больше узнаём о пользователе.
Архитектура рекомендаций стандартная
Весь каталог из 240 млн айтемов показать нельзя, поэтому сначала отбираем небольшой сет кандидатов.
Источники кандидатов — назовём их движками — делятся на две группы:
-
user-online— user2item-similars, graph, user2vec, crosscat -
offline— там живёт модельcollab, она жеavitofm.
Дальше кандидаты идут в ранжирующую модель, потом блендятся по категориям, к ним применяются бизнес-правила — и получается выдача.

Дальше речь пойдёт про последний, оффлайновый источник — модель avitofm. У неё есть особенность, из которой вырос весь этот рассказ: она обучается раз в 6 часов.
Почему в 6 часов — это долго
Хорошее объявление на Авито живёт недолго. Если товар интересный и цена адекватная, он быстро улетает. Пик CTR приходится на первые часы, дальше кривая падает.
Поэтому у нас появилась гипотеза: что будет, если попробовать ускорить обучение модели и успеть подхватить этот пик?

Как устроена avitofm и почему её нельзя просто выкинуть
У этой модели три роли:
1️⃣ Recall главной. Это один из крупнейших источников кандидатов для ленты.
2️⃣ Кандидатогенератор u2i-sim. Похожие товары ищем на эмбеддингах от collab.
3️⃣ Фича ранкера. Для каждого объявления и пользователя дополнительно получаем признак collab_score, которую используем в модели ранжирования. Он стабильно держится в топ-10 SHAP.
Сама модель устроена просто. Есть таблица эмбеддингов для айтемов и таблица для пользователей. Мы хотим, чтобы они жили в одном пространстве: тогда векторы можно перемножить, получить скор, отранжировать по нему, взять топ-K и отдать дальше.
Обучаемся только на кликстриме, никаких дополнительных данных не используем. Позитив — взвешенный сигнал по действиям пользователя, негативы — sampled, с поправкой на популярность. Скалярное произведение user_emb · item_emb идёт в CrossEntropyLoss. Пользователя инициализируем как среднее эмбеддингов тех айтемов, с которыми он взаимодействовал. Для нового айтема берём контентный вектор, а предсказание — это поиск топ-K похожих айтемов.
Минусы: работа по чанкам
В Авито 52 категории айтемов и 85 регионов — для каждого пересечения категория×регион мы обучаем свою модель.
Почему так? Во-первых, исторически сложилось. Во-вторых, раньше не было мощных GPU, а такая модель при разбивке по чанкам влезает в GPU целиком. К тому же чанки между собой отличаются: в каких-то категориях больше товаров и взаимодействий между продавцами и покупателями, в других — меньше, и это нужно учитывать отдельными параметрами.
Отсюда и минусы:
🔴 Слишком много моделей. 52×85=4420. Каждую нужно обучать, поддерживать и следить, что они не разъехались. Такие данные не просто агрегировать.
🔴 Дубли эмбеддингов. Пользователь может взаимодействовать сразу в нескольких категориях: например, смотреть недвижимость в Москве и Петербурге. В чанках появляются одинаковые эмбеддинги.
🔴 GPU не работают на полную. Раньше разбивка по чанкам была плюсом. А теперь, когда используем GPU с большим объёмом памяти — наоборот, потому что они не используются полностью.
Первый шаг: дообучаемся только на свежем
Самый простой baseline, который приходит в голову: оставить архитектуру как есть, но дообучаться на лету. Так появилась инкрементальная модель avitofm.
В ней те же векторы и та же близость — avitofm никуда не девается: продолжает учиться раз в 6 часов и отдаёт «опорные» эмбеддинги как стартовую точку. Поверх основной модели мы докидываем инкрементальную, которая подучивает эти векторы на свежих событиях, а не с нуля. Новые товары и новых пользователей инициализируем, всё остальное не трогаем.
Ключевой приём в том, что новый товар получает вектор из контента, а новый пользователь — среднее его товаров. В таблице эмбеддингов мы обновляем только затронутые строки: доли процента таблицы вместо всей, как в батче.

Что это дало:
+6% Recall@K — офлайн-прирост качества.
+1% контактов — по результатам A/B-теста.
Свежесть: часы → минуты. Новые объявления попадают в выдачу почти сразу после публикации.
Но появился минус, который съел часть выигрыша: в проде теперь две модели. Прошлая, батчевая, требовала много GPU. К ней добавилась инкрементальная — она, правда, может спокойно инференситься на CPU, потому что дообучать нужно немного.
Итог: двойная поддержка, ×2 ресурсов и — главное — сложнее проводить A/B. Если захочется покатить что-то ещё, ресурсы понадобятся уже под четыре модели.
Neartime: одна модель вместо двух
Следующий шаг — убрать дубль и оставить одну near-real-time модель на категорию.
Берём исходную модель, забираем её эмбеддинги как опорные и дообучаемся по батчам, которые приходят раз в N минут. Минимум, на который мы вышли, — 20 минут: именно с таким шагом получается обрабатывать каждый батч. avitofm при этом остаётся, но трогаем её раз в сутки — только ради стартовой точки, дальше модель автономна.
Полностью выпилить avitofm не получится: это одна из самых первых моделей в Авито, её предсказания используются в куче других продуктов как базовый источник кандидатов. Зато её можно оставить как базу для инициализации векторов.

Как всё устроено. Кликстрим лежит в ceph. Отдельный CPU-под prep нарезает его на окна по 20 минут и кладёт их в очередь queue:work. N GPU-воркеров смотрят в эту очередь, составляют расписание и разбирают самое старое необработанное окно. После обучения воркер отдаёт рекомендации в Redis, откуда сервис складывает снапшот затронутых эмбеддингов обратно в хранилище. Если какое-то окно не поднялось или воркер упал, остальные это подхватят.

Всё это крутится в Авифлоу — нашей платформе оркестрации ML-пайплайнов. Она держит расписание, GPU-аллокацию, авто-рестарты, логи и метрики, и в ней же зарегистрированы сами пайплайны.
При нарезке окон важны две вещи:
Watermark — читаем данные ровно с момента последнего обработанного окна. Это существенно, потому что надо обновлять основную модель, когда она обучается. Для этого накапливается окно, которое потом пересчитается.
Дедупликация по ключу (user, item, event, time) — читаем из Kafka, где случаются пересылки, поэтому оставляем только уникальные события.

Сколько времени на это уходит:
-
~10 минут копим окно из Kafka
-
~4 минуты идёт обучение
-
~2 минуты — расчёт top-K с гео-маской
-
~3 минуты — выгрузка в Redis
-
~1 минута — ожидание в очереди.
С учётом grace и очереди укладываемся в p99 «событие → рекомендация» ≤ 25 минут с запасом. И это на все 52 категории сразу.
Инициализация векторов. avitofm обучается раз в сутки ночью. Пока она учится, окна копятся в очереди — но происходит это ночью, поэтому очередь разгребается быстро: достаточно добавить пару воркеров.
Дальше из чанковой модели нужно собрать единую. На bootstrap мы объединяем id всех чанков и усредняем дубли — получается одна модель категории. Здесь же сама собой решается проблема дубликатов векторов из батч-чанков. А чтобы догнать новую базу, мы сбрасываем события с последнего обучения и заново перебучаемся с того момента, когда началось обучение avitofm.
Что пришлось придумать, чтобы это поехало
Одна модель на категорию вместо тысяч. От чанков «категория × гео» хотелось уйти: одно дело инференсить 52 модели, другое — четыре с лишним тысячи.
Мы попробовали самое простое — обучать одну модель на категорию, полностью избавившись от разбиения по гео. В офлайне это ничего не просадило. А локальность вернули на инференсе: применяем гео-маску, то есть берём только те айтемы, которые находятся в одном гео с пользователем, и ищем ближайших соседей уже внутри нужного региона.
SparseAdam на крупных категориях. Большие категории вроде авто держать в памяти можно, но они очень большие. Dense Adam обновляет все моменты — O(N·dim), включая ложные апдейты неактивных строк. SparseAdam обновляет только те строки, которые мы действительно затронули, — O(nnz·dim).

Так мы сняли OOM без потери качества. SparseAdam включаем на категориях от 5 ГБ, а на мелких оставили dense — там от sparse страдает качество. На крупной категории потребление памяти снизилось в 3 раза: меньше GPU для инференса — тут тоже выигрыш.
Надёжность. Поды периодически падают: то ресурсы не выделяются, то проблемы приходят из соседних очередей. Поэтому делаем:
-
Снапшоты. Раз в ~4 часа кладём anchor — полный снимок накопленного состояния, а каждые 20 минут — лёгкие delta, только обновлённые строки, они примерно в 100 раз меньше. Упал под — делаем cold-load с последнего снапшота.
-
Перераспределение нагрузки. Если под упал и на нём инференсились, скажем, 20 категорий, остальные воркеры понимают это по растущей очереди в Redis, забирают эти категории себе и полностью восстанавливают модель из хранилища. Саму модель мы не теряем — она воспроизводится на других воркерах.
-
Кэш моделей. Чтобы не таскать веса между RAM и памятью GPU, горячие категории держим на GPU (HOT), тёплые — в RAM (WARM), плюс sticky-привязка к поду. Смотрим в очередь, понимаем, какие категории скоро пойдут в обучение, и подтягиваем их из RAM заранее.
-
Мониторинг дрейфа. Эмбеддинги категории, которую мы долго дообучаем инкрементально, могут сильно разъехаться с тем, что получилось бы при полном обучении модели. На каждом шаге считаем l2-скор эмбеддингов к baseline — если расходится, срабатывает алерт.
В итоге sizing и SLO такие: ~6 ГБ на категорию, ~4 пода A100/H100 на все категории, p99 свежести ≤ 25 минут.
Что получилось и что дальше
Три цифры, ради которых всё затевалось:
6 часов → 20 минут — свежесть айтемов. Новое объявление попадает на главную за 25 минут вместо 6 часов.
+1% контактов — наша главная метрика, проверено A/B.
Что в планах:
Ускорить downloader. Сейчас данные из Kafka попадают к нам не напрямую: отдельный воркер выкачивает кусок, перекладывает на s3, это обрабатывается, и только потом prep нарезает окна. Так исторически сложилось, и это откровенный костыль. Хотим перевести на Spark и обучаться ещё быстрее, чем раз в 20 минут.
Перейти на свою инициализацию. Сейчас раз в сутки обучается avitofm, и на это тоже уходят ресурсы. Хотим оставить пайплайн обучения с нуля только на случай, когда что-то идёт не так.
Добавить информацию с прошлых батчей. Чтобы бороться с забыванием и уменьшить дрейф эмбеддингов, докидываем в обучение информацию из предыдущих прогонов. Сразу это не завелось, но сейчас уже сделано и ждёт своей очереди в A/B.
Продолжить эксперименты с архитектурой. Модель у нас простая: эмбеддинги, которые мы приводим в одно пространство. Место для улучшений есть.
Коротко
👉 Коллаборативная модель avitofm обеспечивает существенную долю рекомендаций на главной Авито, но обучалась батчами раз в 6 часов — а хорошие объявления «улетают» за первые часы.
👉 Первая попытка — инкрементальное дообучение поверх старой модели — сработала по метрикам, но привела к двум параллельным моделям, двойным ресурсам и усложнённому A/B.
👉 Финальная архитектура — одна neartime-модель на категорию: batch-avitofm даёт стартовую точку раз в сутки, а дальше модель дообучается сама каждые 20 минут на потоке из Kafka через пайплайны Авифлоу (на Kubeflow).
👉 Заодно избавились от обучения по чанкам: было 4200+ моделей на категорию×регион, стало 52 — по одной на категорию с гео-маской на инференс, и сократили память на крупных категориях за счёт SparseAdam.
👉 Итог: свежесть объявлений в выдаче — с 6 часов до 20 минут, контакты — плюс 1% по A/B, всё уместилось в 4 GPU-пода на 52 категории.
👉 Дальше — быстрее забирать данные из Kafka, полностью уйти от батч-инициализации и продолжать эксперименты с архитектурой самой модели.
На этом всё. Если у вас есть вопросы или свой опыт ускорения оффлайн-моделей — буду рад обсудить в комментариях.
Кстати, а вы заметили, что с ИИ-агентами приходится принимать гораздо больше решений — и от этого мозг устаёт даже сильнее, чем в доагенсткую эпоху?
Об этом мы недавно спросили DS-инженеров в нашем тг-канале «Доска AI-объявлений» → заходите узнать ответы. Там много и других интересных постов, например, недавно рассказали:
ссылка на оригинал статьи https://habr.com/ru/articles/1086210/