01 Постановка задачи
Нужно рекомендовать пользователю фильмы/сериалы из каталога ~290 000 единиц так, чтобы (а) выдача была персональной и объяснимой, (б) запрос рекомендаций укладывался в десятки миллисекунд, (в) система масштабировалась и переобучалась без простоя.
Классический полносвязный DNN, принимающий пару (пользователь, фильм), для этого не годится: он требует прогона нейросети по всему каталогу на каждый запрос. Решение — Two-Tower (двухбашенная) архитектура, отделяющая офлайн-вычисление от онлайн-поиска.
02 Архитектура Two-Tower
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) |
| Любимые жанры | 32 | one-hot топ-32 жанров; 1 если в топ-3 пользователя |
| Любимые страны | 10 | one-hot топ-10 стран; 1 если в топ-5 пользователя |
| Семантика | 768 | усреднённый semantic_vector избранных фильмов |
Item Features (≈817 признаков)
| Блок | Размер | Содержание |
|---|---|---|
| Тип | 3 | one-hot (MOVIE / SERIES / SHOW) |
| Год / длит. / возраст / рейтинг KP | 4 | нормализованные скаляры |
| Жанры / страны | 32 + 10 | one-hot присутствия |
| Семантика | 768 | semantic_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:
| Шаг | Команда | Что делает |
|---|---|---|
| 1 | generate-embeddings | 768-векторы для новых фильмов через Infinity (пропускает уже обработанные → можно прерывать) |
| 2 | build-curated-clusters | кураторская классика + рекурсивный обход франшиз (movie_relation) → global-curated-clusters.json |
| 3 | generate-fake-users | синтетические «цифровые психотипы» с реалистичными цепочками просмотров (любят франшизы целиком) |
| 4 | build-movielens-users | «тёплый» прайор на реальных оценках MovieLens 25M (потоковое чтение 662 МБ CSV, маппинг по imdb_id) |
| 5 | export-data | сборка профилей + бинарная нормализованная выборка training-data.bin |
| 6 | train | обучение Two-Tower (in-batch sampled-softmax) → user_tower/, item_tower/ |
| 7 | cache-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;
- In-batch sampled-softmax: негативы — это остальные фильмы того же батча, а не диск с заранее насэмплированными негативами. Позитивы пишутся на диск, негативы рождаются в рантайме — отсюда компактная выборка (только score ≥ 0.6).
- 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); // вычитаем по столбцам
- 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 соблюдён буквально.
- 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])); }
- Сознательный отказ от BatchNormalization в кастомном loop: его moving-статистики не обновлялись бы корректно и разошлись бы с инференсом. Регуляризация — dropout + L2 на весах + L2-норма выходов. Это тонкое понимание того, как слои ведут себя в train vs inference.
- Взвешенная кросс-энтропия (вес = непрерывный лейбл,
rating/5.0для MovieLens, квантованный для своих/фейков):-Σ w·logP / Σ w. - Переиспользуемые JS-буферы под батч (gather по индексам) — без аллокаций в горячем цикле.
Уровень: это код, который пишет ML-инженер, читавший первоисточники, а не туториалы.
06 Инференс на бэкенде
core-api-grpc/.../recommendation.service.ts загружает только User Tower при старте (onModuleInit), с многоуровневым фолбэком загрузки весов (file:// → ручной IO-handler → tfjs-node/tfjs). Дальше — getUserProfile → model.predict → 64-мерный вектор → двухстадийный HNSW-поиск в pgvector (подробно в backend.md §6).
Явный фидбэк (SubmitRecommendationFeedback → ml_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.csv | 435 МБ | tag-genome (потенциал для будущих признаков) |
movielens-users.jsonl | 340 МБ | синтезированные пользователи-прайор |
training-data.bin | 671 МБ | бинарная обучающая выборка |
Стриминг с константной памятью
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(MovieLenslinks.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, магия + версия + плотные секции):
Тонкости, выдающие системное мышление низкого уровня:
- Структура файла = структура алгоритма. Индексные пары — ровно то, что нужно для 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), расширить тестовое покрытие и декомпозировать несколько крупных сервисов.