ML · Two-Tower рекомендации

ML — ai-recommendation

Детальный разбор системы рекомендаций PrimeTime. Обзор всей системы — в about.md; серверный инференс — в backend.md.

01 Постановка задачи

Нужно рекомендовать пользователю фильмы/сериалы из каталога ~290 000 единиц так, чтобы (а) выдача была персональной и объяснимой, (б) запрос рекомендаций укладывался в десятки миллисекунд, (в) система масштабировалась и переобучалась без простоя.

Классический полносвязный DNN, принимающий пару (пользователь, фильм), для этого не годится: он требует прогона нейросети по всему каталогу на каждый запрос. Решение — Two-Tower (двухбашенная) архитектура, отделяющая офлайн-вычисление от онлайн-поиска.

02 Архитектура Two-Tower

[ История, жанры, страны, семантика ] [ Год, длит., рейтинг, жанры, семантика ] │ │ ▼ ▼ ┌─────────────────┐ ┌─────────────────┐ │ User Tower │ │ Item Tower │ │ (онлайн в gRPC) │ │(офлайн → в БД) │ └────────┬────────┘ └────────┬────────┘ ▼ вектор пользователя (64) ▼ вектор фильма (64) └──────────────┬───────────────────────────┘ ▼ Косинусное сходство (pgvector <=>)

User Tower (userDim → 128 relu → dropout → 64 linear) считается онлайн на бэкенде. Item Tower (itemDim → 512 → 128 → 64 linear) считается офлайн один раз и кэшируется в колонку item_tower_vector vector(64) PostgreSQL.

Главное преимущество: при запросе рекомендаций бэкенду нужно лишь прогнать профиль пользователя через лёгкую User Tower и сделать один kNN-запрос по предрассчитанным векторам фильмов (HNSW-индекс pgvector). Никакого инференса по каталогу в рантайме.

03 Формирование признаков

User Features (≈814 признаков)

Собираются динамически в RecommendationService.getUserProfile():

БлокРазмерСодержание
Статистика4кол-во просмотров (÷100), избранного (÷50), средний рейтинг (÷10), средний год (норм. 1920–2025)
Любимые жанры32one-hot топ-32 жанров; 1 если в топ-3 пользователя
Любимые страны10one-hot топ-10 стран; 1 если в топ-5 пользователя
Семантика768усреднённый semantic_vector избранных фильмов

Item Features (≈817 признаков)

БлокРазмерСодержание
Тип3one-hot (MOVIE / SERIES / SHOW)
Год / длит. / возраст / рейтинг KP4нормализованные скаляры
Жанры / страны32 + 10one-hot присутствия
Семантика768semantic_vector описания фильма

Семантический вектор (768d)

Текст фильма (название, слоган, факты, режиссёры, актёры) кодируется моделью Alibaba-NLP/gte-multilingual-base (768d, контекст 8192, сильная поддержка русского) через локальный Infinity Embedding Inference Server (контейнер :1919). Векторы хранятся в semantic_vector vector(768). Выбор модели обоснован в docs/search-models.md (сравнение с e5-small, BGE-M3, Qwen3, Nomic).

Ключевой раздел

04 7-шаговый пайплайн обучения

Весь процесс «от пустой базы до рекомендаций» — детерминированный и отказоустойчивый. Команды из ai-recommendation:

ШагКомандаЧто делает
1generate-embeddings768-векторы для новых фильмов через Infinity (пропускает уже обработанные → можно прерывать)
2build-curated-clustersкураторская классика + рекурсивный обход франшиз (movie_relation) → global-curated-clusters.json
3generate-fake-usersсинтетические «цифровые психотипы» с реалистичными цепочками просмотров (любят франшизы целиком)
4build-movielens-users«тёплый» прайор на реальных оценках MovieLens 25M (потоковое чтение 662 МБ CSV, маппинг по imdb_id)
5export-dataсборка профилей + бинарная нормализованная выборка training-data.bin
6trainобучение Two-Tower (in-batch sampled-softmax) → user_tower/, item_tower/
7cache-item-towerпрогон всех фильмов через Item Tower → item_tower_vector в PG

Сильные инженерные детали пайплайна:

  • MovieLens-прайор даёт модели реальные оценки ещё до накопления собственных свайпов (cold-start пользователя/системы). Python не требуется, в БД ничего не пишется.
  • Бинарный формат training-data.bin (training-codec.ts): таблица юзеров + таблица фильмов (каждый вектор float32 ровно один раз) + список взаимодействий по индексам (userIdx, itemIdx, weight). На порядки компактнее JSON (нет дублирования векторов), грузится целиком в RAM. Под кодек есть тест (training-codec.test.ts).
  • Keyset-пагинация в cache-item-tower (по item_tower_vector IS NULL, батчи по 1000): скрипт можно прерывать и дозапускать — мгновенно продолжит с места остановки.
  • Идемпотентность на каждом шаге (обрабатываются только новые/пустые записи).
Ключевой раздел

05 Обучение: in-batch sampled-softmax + logQ-коррекция

train.ts — самый технически продвинутый файл всего проекта. Это не «model.fit()», а кастомный train-loop уровня индустриальных рекомендеров (YouTube/Netflix).

Ключевые решения и их обоснование

const NUM_EPOCHS = 8;
const BATCH_SIZE = 2048;        // больше батч → больше in-batch негативов
const LEARNING_RATE = 0.0015;
const TEMPERATURE = 0.05;        // шарпинг косинусных логитов: cos∈[-1,1] → масштаб ±20
const USE_LOGQ_CORRECTION = true;
  1. In-batch sampled-softmax: негативы — это остальные фильмы того же батча, а не диск с заранее насэмплированными негативами. Позитивы пишутся на диск, негативы рождаются в рантайме — отсюда компактная выборка (только score ≥ 0.6).
  2. logQ-коррекция семплинг-байеса (из статьи YouTube «Sampling-Bias-Corrected Neural Modeling»): из логитов вычитается log P(item), чтобы популярные фильмы не доминировали как «лёгкие негативы».
function computeLogQ(data: TrainingData): Float32Array {
  // частоты фильмов среди позитивов → log P(item)
}
// ...
if (USE_LOGQ_CORRECTION) logits = tf.sub(logits, qRow);  // вычитаем по столбцам
  1. L2-нормализация эмбеддингов → косинус ровно той геометрии, что на серве:
uE = tf.div(uE, tf.norm(uE, 'euclidean', 1, true));
iE = tf.div(iE, tf.norm(iE, 'euclidean', 1, true));
let logits = tf.div(tf.matMul(uE, iE, false, true), tf.scalar(TEMPERATURE)); // [B,B]

Это критично: модель учится в том же косинусном пространстве, в котором потом ищет pgvector (<=>). Train/serve consistency соблюдён буквально.

  1. Sparse-маскирование дублей через scatterND вместо плотной B×B-маски:
// Если фильм повторяется в батче — гасим его как «чужой» негатив (-1e9),
// координаты собираем за O(B) через Map (плотный B×B JS-цикл был горячей точкой).
if (dupIdx) {
  logits = tf.add(logits, tf.scatterND(
    tf.tensor2d(dupIdx, [numDup, 2], 'int32'),
    tf.fill([numDup], -1e9), [B, B]));
}
  1. Сознательный отказ от BatchNormalization в кастомном loop: его moving-статистики не обновлялись бы корректно и разошлись бы с инференсом. Регуляризация — dropout + L2 на весах + L2-норма выходов. Это тонкое понимание того, как слои ведут себя в train vs inference.
  2. Взвешенная кросс-энтропия (вес = непрерывный лейбл, rating/5.0 для MovieLens, квантованный для своих/фейков): -Σ w·logP / Σ w.
  3. Переиспользуемые JS-буферы под батч (gather по индексам) — без аллокаций в горячем цикле.

Уровень: это код, который пишет ML-инженер, читавший первоисточники, а не туториалы.

06 Инференс на бэкенде

core-api-grpc/.../recommendation.service.ts загружает только User Tower при старте (onModuleInit), с многоуровневым фолбэком загрузки весов (file:// → ручной IO-handler → tfjs-node/tfjs). Дальше — getUserProfilemodel.predict → 64-мерный вектор → двухстадийный HNSW-поиск в pgvector (подробно в backend.md §6).

Явный фидбэк (SubmitRecommendationFeedbackml_user_to_movie: LIKE / DISLIKE / NEUTRAL) мгновенно (а) исключает фильм из будущих выдач, (б) попадает в следующий export-data для дообучения. Обновление ленты — не чаще раза в 24ч, с фоновым триггером при опустошении.

07 Эволюция модели (видна по докам)

Проект прошёл путь: старый DNN на 19 признакахTwo-Tower с e5-small (384d)Two-Tower с gte-multilingual-base (768d) + in-batch sampled-softmax + MovieLens-прайор.

Это видно по слоям документации (README-RECOMMENDATIONS.md упоминает e5-small/384, актуальный README.md — gte/768 и sampled-softmax). Итерации с обоснованием каждого перехода — признак исследовательской зрелости.

Отдельный документ update-hit.md проектирует следующий шаг: переход с «факта наличия в истории» на богатые признаки из watchSession (среднее время просмотра, процент досмотра, возвраты, броски) — система явно продолжает развиваться.

Ключевой раздел

08 Инженерия больших данных (MovieLens 25M)

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

Объёмы (реальные файлы в ai-recommendation/data/)

ФайлРазмерНазначение
ratings.csv (MovieLens 25M)678 МБ25 млн реальных оценок
genome-scores.csv435 МБtag-genome (потенциал для будущих признаков)
movielens-users.jsonl340 МБсинтезированные пользователи-прайор
training-data.bin671 МБбинарная обучающая выборка

Стриминг с константной памятью

build-movielens-users.ts читает 678 МБ ratings.csv построчно (readline), никогда не загружая файл в RAM. Ключевая оптимизация — эксплуатация того, что ratings.csv отсортирован по userId: пользователи группируются «на лету», в памяти всегда максимум один пользователь:

for await (const line of rl) {
  const userId = parseInt(line.slice(0, c1), 10);
  if (userId !== currentUserId) {        // сменился юзер → финализируем предыдущего
    if (currentUserId !== -1) await flushUser();
    currentUserId = userId;
    currentRatings = new Map();          // O(1) памяти: один юзер за раз
  }
  const kp = movieIdToKp.get(movieId);   // мэтч MovieLens → свой каталог по imdb_id
  if (kp === undefined) continue;         // несматченное выбрасываем
  if (prev === undefined || rating > prev) currentRatings.set(kp, rating);
}

Дополнительные инженерные детали:

  • Ручной indexOf-split вместо CSV-парсера на числовых колонках — быстрее на десятках миллионов строк.
  • Backpressure: ws.write() возвращает false → ждём 'drain' перед продолжением (не переполняем буфер записи).
  • Маппинг датасетов по imdb_id (MovieLens links.csv → свой каталог) с логированием процента совпадения — наблюдаемость качества джойна.
  • Умный кап оценок, сохраняющий крайности: при лимите на пользователя в выборке приоритетно остаются сильные лайки/дизлайки (≥4.0 / ≤2.5) — именно они дают модели реальный негатив, а не «середняк».
  • Прогресс каждые 2M строк — операбельность долгого прогона.
  • Идемпотентность по дизайну: скрипт ничего не пишет в БД, только эмитит .jsonl; Шаг 5 подхватывает его, если файл есть.

Это полноценная data-engineering-задача (constant-memory streaming over GB-scale files), решённая на чистом Node без Python и без загрузки в память.

Ключевой раздел

Собственный бинарный формат выборки (training-codec.ts)

Отдельная инженерная жемчужина — самописный бинарный контейнер .bin для обучающей выборки. Это сильнейшее доказательство того, что код написан под задачу, а не взят готовым: формат спроектирован ровно под нужды in-batch sampled-softmax.

Проблема, которую он решает (прямо в комментарии автора): JSON дублировал 814-/817-мерные векторы в каждой строке взаимодействия → десятки гигабайт. Решение — нормализация, как в БД: каждый вектор юзера/фильма хранится один раз, а взаимодействия — это просто пары индексов (userIdx, itemIdx) + вес.

Раскладка файла (little-endian, магия + версия + плотные секции):

Header (32 байта): magic "PTTD" | version | userDim | itemDim | numUsers | numItems | numInteractions | flags Секции подряд: userTable float32[numUsers·userDim] ← каждый вектор ОДИН раз itemTable float32[numItems·itemDim] interUser uint32[numInteractions] ← взаимодействия = индексы interItem uint32[numInteractions] interWeight float32[numInteractions]

Тонкости, выдающие системное мышление низкого уровня:

  • Структура файла = структура алгоритма. Индексные пары — ровно то, что нужно для lookup-а по индексу в sampled-softmax. Формат данных проектировался под форму вычисления, а не наоборот.
  • Дедуп фильмов с ленивым вычислением фич: internItem(key, computeVec) считает 817-мерный вектор фильма только при первом появлении ключа.
  • Корректность выравнивания при чтении: секции читаются через buf.buffer.slice() (копия → гарантированно выровненный ArrayBuffer), потому что сырой subarray-view мог бы иметь невыровненный byteOffset и сломать конструктор Float32Array. Это знание уровня работы с памятью, а не «прочитал JSON».
  • Валидация на чтении: проверка magic "PTTD" и версии формата → обнаружение повреждённого/чужого файла.
  • Стримовая запись с backpressure (ожидание 'drain').
  • Покрыт тестом (training-codec.test.ts) — и это один из немногих, но точечно поставленных тестов на самую критичную часть.

readTrainingData отдаёт типизированные Float32Array/Uint32Array, которые train-loop напрямую нарезает по индексам в батчи — без парсинга и аллокаций в горячем цикле.

09 Оценка качества ML-части

Сильные стороны

  • Грамотный выбор архитектуры (Two-Tower) под конкретные требования по латентности и масштабу — с чётким разделением офлайн/онлайн.
  • Train/serve consistency: обучение в косинусной геометрии, идентичной pgvector.
  • Продвинутый кастомный train-loop (sampled-softmax, logQ, температура, sparse-маски) — уровень индустриальных рекомендеров.
  • Отказоустойчивый, идемпотентный, прерываемый пайплайн (keyset-пагинация, бинарный кодек, пропуск обработанного).
  • Решение cold-start через MovieLens-прайор и синтетических пользователей-франшизников.
  • Инженерия больших данных: constant-memory стриминг 678 МБ CSV, маппинг датасетов по imdb_id, бинарный кодек выборки на 671 МБ — без Python и без загрузки в RAM.
  • Объяснимость на уровне продукта (полки с причиной — см. backend §7).
  • Собственная embedding-инфраструктура (Infinity) с обоснованным выбором модели.

Замечания

  • Нет офлайн-метрик качества (recall@k / nDCG) в коде — оценка, по-видимому, эмпирическая. Для дипломной защиты стоило бы добавить измеримые метрики и A/B.
  • Negative sampling ограничен in-batch — для очень больших каталогов иногда добавляют hard-negatives mining (точка роста).
  • Тесты есть только на кодек и загрузку башни.

Уровень ML-исполнителя — Senior. Аргументация: знание и применение современных рекомендательных техник из первоисточников, строгий train/serve-консистентный дизайн, production-grade пайплайн данных end-to-end и собственная inference-инфраструктура.

10 Итоговый вердикт по всему проекту

PrimeTime — это production-grade система, написанная одним инженером уровня Senior, демонстрирующая редкое сочетание широты (full-stack + ML + инфраструктура) и глубины (профилирование БД, distributed-паттерны, ML из первоисточников). Для дипломной работы это значительно выше типичного уровня: фактически это самостоятельный коммерческий продукт.

Главные точки роста к защите: вынести секреты из репозитория, добавить измеримые ML-метрики (recall@k/nDCG, A/B), расширить тестовое покрытие и декомпозировать несколько крупных сервисов.