Привет, Хабр! Меня зовут Андрей Белов, я работаю в команде стриминг-технологий AI VK. Сегодня хочу рассказать, как мы построили систему, которая в реальном времени обрабатывает сотни тысяч событий в секунду и превращает их в персонализированные рекомендации для десятков миллионов пользователей. Благодаря этому люди встречают больше интересной для себя информации, а авторам легче находить свою аудиторию. Поговорим про ProfileStream (часть Discovery-платформы) — нашу систему для расчёта пользовательских эмбеддингов, про то, как мы боролись с терабайтами трафика, и какие простые, но изящные инженерные решения помогли нам справиться с нагрузкой.

Коллаборативная фильтрация: магия без магии

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

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

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

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

Матричная факторизация: как из истории получить векторы

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

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

У нас в компании для этого используется ALS-разложение. Но это детали, главное — для расчёта эмбеддинга пользователя достаточно знать, с какими элементами он взаимодействовал, и иметь эмбеддинги этих элементов. Никакой магии, чистая математика.

Эмбеддингов может быть много, десятки и сотни. Мы учитываем разные типы контента (видео отдельно, статьи отдельно), разные типы событий (лайки, клики, время просмотра), разные экраны приложения. Каждый эмбеддинг несёт в себе только часть информации о пользователе, но вместе они дают полную картину.

ProfileStream: считаем векторы на лету

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

Проиллюстрирую на числах, они впечатляют. Мы обслуживаем миллионы активных пользователей в день. На вход прилетает 800 тысяч событий в секунду из различных источников.

Считает всё это кластер из 100 тысяч ядер, который оснащён 30 терабайтами оперативной памяти. Напомню, что один эмбеддинг несёт только часть информации, поэтому в каждом случае мы считаем сотни эмбеддингов. В ответ на каждое действие пользователя проделываем огромную работу.

Всё это крутится под капотом главной страницы ВКонтакте, ленты VK Видео и VK Клипы — везде, где вы видите персонализированные рекомендации. И работать должно быстро, стабильно и без сбоев.

Масштабирование: как не утонуть в данных

Система не была бы высоконагруженной, если бы мы не умели масштабироваться. Давайте посмотрим, из каких кубиков она состоит и как нам удаётся обрабатывать такие объёмы.

В основе лежат YTsaurus — динамические таблицы, которые работают как key-value хранилище. Таблицу разбиваем на множество независимых таблетов, каждый обслуживает свой диапазон ключей. Таблеты могут жить на разных машинах в разных ЦОДах, получается распределённое хранилище.

Ключевой компонент — решардер. Название незамысловатое, но роль критически важная. Он принимает поток событий и разбивает его на 2048 шардов (число выбрано с запасом). Каждый пользователь всегда попадает в один и тот же шард, как партиции в Kafka.

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

Битва за трафик: история пользователя

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

Логика, вроде бы, простая: получили новое событие из журнала, прочитали историю пользователя, добавили событие в конец, записали обратно. Но давайте посчитаем трафик. На вход приходит 800 тысяч событий в секунду, они редуцируются примерно до 50 тысяч уникальных пользователей. Если история одного пользователя занимает мегабайт, то мы генерируем 50 гигабайтов в секунду на чтение и столько же на запись. Сетевики за такое спасибо не скажут. Но это ещё не всё: YTsaurus реплицируют данные между ЦОДами. Наши 50 гигабайтов легко превращаются в 150 гигабайтов дорогого cross-DC трафика. Надо что-то делать.

Слоистый подход: делим и властвуем

Решение оказалось изящным: мы разбили историю на три слоя: fast delta (маленькая часть), delta (средняя) и full history (полная история). У каждого слоя свои пороги.

Когда приходит новое событие, мы читаем только fast delta — это всего 1 % истории, первые 100 событий. Дописываем туда новое событие и сохраняем обратно. Когда fast delta переполняется, объединяем её с delta. Когда переполняется delta — объединяем с full history.

Формулы приводить не буду (обещал же), но на практике, поигравшись с порогами, мы снизили трафик на два порядка: с десятков гигабайтов до сотен мегабайтов в секунду. На графиках это выглядит просто великолепно: резкий обвал трафика после внедрения.

Кеширование: когда обычных подходов недостаточно

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

В обычных системах всё просто: не нашли в кеше — сходили в базу, положили в кеш. Но как поддерживать его согласованность? Можно ставить TTL или обновлять по внешнему триггеру, но при расчёте эмбеддингов нам критически важна актуальность — нельзя потерять свежие сигналы.

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

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

SSD в помощь: файловый кеш

Остаются 40 % пользователей, которые всё равно создают существенную нагрузку. Многие на этом останавливаются и ставят Redis в качестве кеша второго уровня. Но мы вспомнили про SSD — их много, они относительно дешёвые и не генерируют cross-DC трафик.

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

Файловый кеш не такой шустрый, как in-memory. Он не хранит часть fast delta и не обновляется из потока — приходится делать pull из базы, сравнивая timestamp. Прогревается файловый кеш часа четыре, но когда выходит на крейсерскую скорость, то обеспечивает 90 % попаданий сверх memory-кеша.

В итоге мы сократили трафик на чтение с 50 гигабайтов до 2 гигабайтов в секунду. Неплохо.

Сжатие истории: выжимаем максимум

А что если сжать саму историю? Типовое событие содержит enum-ы, timestamp-ы, ID элементов. В первой итерации мы просто положили всё в Protobuf и прогнали через LZ4 — получили экономию всего 10 %.

Задумались, как добиться большего. Enum-ы хоть и разреженные, но повторяются часто. Timestamp-ы можно кодировать дельтами от базового значения. ID элементов уникальные, но на 50 тысяч событий будет только 10 тысяч уникальных — можно группировать.

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

Квантование эмбеддингов: когда точность не критична

Кроме истории нужны эмбеддинги элементов. С ними сложнее: нельзя положить в файловый кеш (данные локализованы по пользователям, а не по элементам), нельзя эффективно сжать (значения почти случайные), но они генерируют даже больше трафика, чем история.

Зато эмбеддинги мало весят, и простой кеш по частотным элементам покрывает 70 % запросов. Также мы применили квантование: заменили float на byte.

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

Теряем в точности? Да. Критично для метрик? A/Б-тесты показали, что нет. Зато в кеш влезает в четыре раза больше эмбеддингов, и трафик снижается пропорционально.

От 50 гигабайтов до сотен мегабайтов

Подведём итоги нашего путешествия по оптимизациям. Начали с 50 гигабайтов в секунду трафика на чтение и запись. Применили слоистое хранение — трафик упал на два порядка. Добавили умное кеширование с самообновлением из потока событий, подключили SSD для холодных данных — сжали историю в четыре раза. Заквантовали эмбеддинги.

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

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

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

Ещё больше практических кейсов и новостей индустрии разбираем в Telegram-канале AI VK Hub.

Комментарии (2)


  1. hypermachine
    07.08.2025 08:34

    Рекомендации ВК очень редко попадают в мои интересы, а умная лента мало того что убила охваты в пабликах (что привело к массовым миграциям на ТГ), так ещё и мешает мне видеть полную картину, помещая в какой-то информационный пузырь.


  1. IlyaStroynov
    07.08.2025 08:34

    ВК видео еще худо-бедно использую, чтобы новинки быстро найти, когда лень торрент качать, лента же в ВК - помойка, мусор из рекламы и левых рекомендаций