Сладкая боль рекоммендеров
Вы когда-нибудь пытались скормить нейронке и текст описания товара, и картинку, и историю покупок за последний год, и ещё погоду на улице, а потом выкатить это в прод на тысячу RPS? Если да — вы знаете эту смесь восторга и желания поджечь кластер. Если нет — добро пожаловать в клуб, где один лишний эмбеддинг превращает ваш latency-бюджет в горстку пепла.
Обычные векторные базы данных (FAISS, pgvector) тут бессильны — они дают k кандидатов, но не понимают мультимодального контекста и не фильтруют уже показанное. А простой ANN-поиск без дополнительных этапов превращает рекомендации в кашу из повторяющихся товаров. Поэтому умные ребята уже давно собирают многостадийные пайплайны: генерация кандидатов -> лёгкая фильтрация -> тяжёлый реранкинг с контекстом.
Я покажу, как собрать такую систему на Amazon EKS, используя Kubeflow для обучения, Bloom-фильтры для дедупликации, Redis для кэширования признаков и Triton Inference Server для инференса. Без воды, только код и грабли.
Предупреждение: вся архитектура рассчитана на нагрузку от 10K уникальных пользователей в день. Если у вас стартап на трёх котах — не надо так. Начните с SageMaker и одного инстанса.
Архитектура: четыре этапа вместо одного
Типичная ошибка новичка — скормить все признаки в одну гигантскую модель и надеяться, что она сама разберётся. Работает, пока датасет маленький. Как только появляются сотни миллионов взаимодействий — модель начинает кодировать шум, а инференс превращается в пытку.
Вот как выглядит правильная архитектура на май 2026 года:
| Этап | Задача | Инструмент |
|---|---|---|
| 1. Candidate Generation | Быстрый поиск 500–1000 кандидатов из миллиона | Two-Tower ANN + FAISS |
| 2. Bloom Filter & Cache | Убрать повторы, подгрузить кэшированные эмбеддинги | RedisBloom + Redis Cluster |
| 3. Contextual Retrieval | Вытащить контекстные признаки (погода, время, устройство) | Feature Store (Feast on EKS) |
| 4. Heavy Ranking | Deep FM или трансформер с контекстом, топ-10 | Triton Inference Server |
1 Поднимаем EKS и Kubeflow
Без Kubernetes тут никуда — нам нужны горизонтальное масштабирование, управление GPU и бесшовный деплой пайплайнов. Используйте EKS 1.30 (на май 2026 это стабильная версия).
Установка Kubeflow 1.9 через официальный манифест:
# Создаём кластер с Karpenter для автоскейлинга GPU
eksctl create cluster --name recsys-cluster --region eu-west-1 \
--nodegroup-name gpu --node-type g5.2xlarge --nodes 1 \
--managed --spot
# Устанавливаем Kubeflow
wget https://github.com/kubeflow/manifests/archive/v1.9.0.tar.gz
tar -xzf v1.9.0.tar.gz
cd manifests-1.9.0
while IFS= read -r dir; do
kubectl apply -k "$dir"
done < kustomize-cluster-stack.txtВажный нюанс: не ставьте Kubeflow до настройки сетевых политик и IAM. Иначе через пару дней проснётесь с баном за подозрительный трафик от подов. Лучше сразу привяжите IRSA к сервис-аккаунтам.
2 Two-Tower модель: кандидаты без тормозов
Генерация кандидатов — это fast path. Берём две башни: одна для пользователя (все его признаки + контекст), другая для товара (текст, картинка, категория). Обучаем с контрастной потерей (Sampled Softmax) на парах (user, item).
Показываю код PyTorch с мультимодальным энкодером товара:
import torch
import torch.nn as nn
from transformers import AutoModel, ViTModel
class ItemTower(nn.Module):
def __init__(self, text_model='bert-base-uncased', img_model='google/vit-base-patch16-384'):
super().__init__()
self.text_encoder = AutoModel.from_pretrained(text_model)
self.image_encoder = ViTModel.from_pretrained(img_model)
self.fusion = nn.Linear(768 + 768, 256) # проекция в единое пространство
def forward(self, text_ids, text_mask, image_pixels):
text_emb = self.text_encoder(text_ids, attention_mask=text_mask).pooler_output
img_emb = self.image_encoder(image_pixels).pooler_output
combined = torch.cat([text_emb, img_emb], dim=-1)
return self.fusion(combined)
class UserTower(nn.Module):
def __init__(self, num_features=64):
super().__init__()
self.mlp = nn.Sequential(
nn.Linear(num_features, 128),
nn.ReLU(),
nn.Linear(128, 256)
)
def forward(self, x):
return self.mlp(x)После обучения сохраняем эмбеддинги товаров в FAISS (индекс IVF_PQ для скорости) и деплоим башни в Triton. Зачем Triton? Он умеет батчить запросы и разворачивать несколько версий модели — идеально для A/B тестов.
Кстати, если вы думаете, что контрастное обучение — это серебряная пуля, почитайте фреймворк оценки AI-агентов от Amazon. Там показано, как правильно мерить качество рекомендаций без сдвига.
3 Bloom-фильтры: как не показывать одно и то же дважды
Классический подход — хранить в Redis список ID просмотренных товаров для каждого пользователя. Но когда пользователь посмотрел 10 000 товаров, доставать и проверять список — O(N) и убивает latency. Тут на сцену выходят Bloom-фильтры.
Bloom-фильтр — это вероятностная структура данных, которая с вероятностью ~1% скажет «возможно, был» (ложноположительное срабатывание) и никогда не ошибается в обратную сторону. Выделяете для каждого пользователя фильтр размером, скажем, 10 Кбит — он занимает 10 КБ и проверяется за O(k) хешей.
Устанавливаем RedisBloom модуль на кластер Redis:
helm repo add redislabs https://redislabs.github.io/redisbloom
helm install redis-bloom redislabs/redisbloom --set cluster.enabled=trueТеперь при каждом просмотре товара добавляем ID в Bloom-фильтр пользователя. На этапе кандидатов проверяем: если фильтр говорит «нет» — товар точно новый. Если «да» — с вероятностью 99% показывали, пропускаем. Ошибочное исключение 1% кандидатов дешевле, чем повторная демонстрация.
Ошибка, которую я видел раз сто: используют один Bloom-фильтр на все товары. Не делайте так. Фильтр должен быть per user и динамически расширяться через Scaling Bloom Filters, иначе через месяц ложно-положительные срабатывания съедят половину кандидатов.
4 Feature Caching: куда сохранять эмбеддинги и контекст
Вычисление эмбеддинга товара на лету при каждом запросе — убить latency. Правильно: гоняем батч-джоб на Kubeflow, который раз в 6 часов пересчитывает эмбеддинги для всех активных товаров и складывает их в Redis. Пользовательские эмбеддинги считать сложнее — они зависят от последних действий. Тут помогает Feast с online store (Redis).
Пример конфигурации feature view для товара:
feature_view:
- name: item_embeddings
ttl: 3600 # час жизни
entities: [item_id]
features:
- name: embedding
dtype: FLOAT_LIST
batch_source:
path: s3://recsys/embeddings/items/
timestamp_field: created_atПри запросе рекомендаций мы сначала дёргаем Redis за кэшем пользователя и товаров. Если кэш пуст (холодный старт) — используем fallback на предвычисленные средние. Проблема холодного старта очень подробно разобрана в статье как Ozon отказался от ANN в пользу Query Prediction. Там показано, что иногда лучше вообще не использовать эмбеддинги для новых пользователей, а предсказывать запрос.
5 Contextual Ranking на Triton
После того как мы отфильтровали кандидатов через Bloom и подгрузили фичи, остаётся 50–200 товаров. Теперь запускаем тяжёлую ranking-модель — DeepFM или небольшой трансформер. Эта модель получает на вход конкатенацию: user_embedding, item_embeddings, контекст (время суток, день недели, тип устройства, гео).
Деплоим модель в Triton с поддержкой динамического батчинга:
tritonserver --model-repository=/models \
--backend-config=python,shm-default-byte-size=67108864Чтобы обеспечить SLA под 50 мс, ставим Horizontal Pod Autoscaler по CPU и GPU памяти. Когда нагрузка скачет (например, чёрная пятница), Karpenter добавляет ноды с инстансами p4d за пару минут.
Однажды я забыл включить --cuda-memory-pool-size и Triton упал OOM на первом же батче из 1024 запросов. Не повторяйте.
Сводим всё в пайплайн Kubeflow
Всё перечисленное — не отдельные микросервисы, а этапы одного пайплайна. Kubeflow позволяет оркестровать обучение two-tower, обновление FAISS индекса, пересчёт эмбеддингов, проверку качества (AUC, MRR) и деплой новой версии в Triton. Пример DAG:
from kfp import dsl
def recsys_pipeline(image: str):
train = dsl.ContainerOp(name='train-two-tower', image=image).set_gpu_limit(1)
push_index = dsl.ContainerOp(name='build-faiss', image=image).after(train)
deploy_triton = dsl.ContainerOp(name='deploy-to-triton', image=image).after(push_index)
dsl.Condition(deploy_triton.success, 'production').after(deploy_triton)Если хотите подробностей про CI/CD для ML на EKS — гляньте гайд по автоматизации сбора доказательств для аудита на Bedrock. Там похожие принципы, только для compliance.
Шесть граблей, на которые наступит каждый
- Размер Bloom-фильтра: 10 Кбит на пользователя — мало для активной аудитории. Формула: m = - (n ln p) / (ln 2)^2. Если хотите p=1% при n=10000, берите 95 Кбит.
- Кэш без TTL: Эмбеддинги устаревают — товары раскупают, пользователи меняют интересы. Ставьте TTL 1 час для user_embedding и 6 часов для item.
- Размер батча в Triton: Не делайте батч больше 128, если модели не Quark-квантованы. Latency скачет до 200 мс.
- Не учитывать положение пользователя: Если у вас гео-зависимые рекомендации (ресторан, доставка), обычный ANN не поможет. Нужен Multi-Stage Retrieval с геофильтром.
- Отсутствие мониторинга дрейфа: Контекст меняется — сегодня жара, завтра холод. Система должна пересчитывать эмбеддинги не по расписанию, а по триггерам (Feature Drift Detector).
- Забыть про cold start для товаров: Новый товар без эмбеддинга. Решение — Content-based фильтрация через CLIP или Sentence-BERT. Тема разобрана в гайде по виртуальной примерке на Nova Canvas и OpenSearch.
Хватит на сегодня — запускайте
Вся описанная архитектура работает на EKS уже у нескольких клиентов, с которыми я работал. Не верьте, что рекомендательные системы — это магия. Это инженерная задача: правильно разложить на стадии, прикрутить кэши и фильтры, не забыть про контекст. Bloom-фильтры, Redis, Triton — всё это проверено годами. Но главное — никогда не пытайтесь объединить всё в одну модель. Доверьтесь многостадийному пайплайну, и ваши 100 мс latency станут реальностью.
Кстати, скоро мы увидим, как рекомендательные системы превратятся в агентные системы — они сами будут собирать контекст с веб-страниц, API погоды и даже из почты пользователя. Amazon уже делает такие прототипы на Bedrock. Если интересно — почитайте архитектуру Agentic AI на Amazon Bedrock. Тот же принцип, только агенты вместо башен.
А теперь идите и настройте свой EKS. Код из статьи можно взять за основу — я специально показал самые важные куски, а не копипасту из документации. Если наступите на грабли, напишите в комментариях, я подскажу.