Фичи и ранжирование
~18 мин

Multi-stage ранжирование

Candidate generation → pre-ranking → ranking → re-ranking. Архитектура production-пайплайна.

Multi-Stage Ranking — как за 200 мс выбрать 10 из 50 миллионов

Загрузка интерактивного виджета...

У тебя 50 миллионов товаров в каталоге. Показать нужно 10. Самая точная модель ранжирования тратит 1 мс на один айтем — значит, прогнать через неё весь каталог займёт 14 часов. Пользователь столько ждать не будет.

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

Решение — каскад моделей: каждый следующий этап точнее предыдущего, но обрабатывает меньше кандидатов. Лёгкая модель из 50M оставляет 1000, модель потяжелее — 200, тяжёлая — 50, бизнес-правила финализируют 10. Это и есть multi-stage ranking — архитектурный паттерн, без которого ни одна рекомендательная система не работает в продакшне.

Большая картина: четыре этапа каскада

Любой production-пайплайн рекомендаций состоит из четырёх этапов. Каждый — компромисс между скоростью и качеством:

1. Candidate Generation (50M → 1000, ~10 мс). Самый быстрый этап. Из всего каталога отбираем ~1000 потенциально интересных кандидатов. Модели лёгкие — Two-Tower, ANN-поиск. Главное требование: recall — не пропустить релевантные айтемы. 2. Pre-Ranking (1000 → 200, ~10 мс). Лёгкая модель (GBDT с базовыми фичами) быстро отсеивает явно слабых кандидатов. Не везде есть — нужен, когда основная модель слишком дорогая для 1000 штук. 3. Ranking (200 → 50, ~50 мс). Главная модель. Тяжёлый бустинг или нейросеть с десятками признаков, включая cross-features (user × item). Здесь происходит основная «интеллектуальная» работа. 4. Re-Ranking (50 → 10, ~5 мс). Финальная доработка: diversity, бизнес-правила, freshness, deduplication. Часто не ML, а набор правил поверх скоров.

Пайплайн: Candidate Generation → Pre-Ranking → Ranking → Re-Ranking
Каждый этап точнее предыдущего, но обрабатывает на порядок меньше кандидатов

Аналогия: ты ищешь квартиру в Москве. Шаг 1 — фильтры на ЦИАН: район, цена, площадь (из 100 000 → 500 объявлений за секунду). Шаг 2 — быстро пролистываешь фотки, отсеиваешь явно плохие (500 → 50). Шаг 3 — внимательно читаешь описание, проверяешь карту, метро, отзывы (50 → 10). Шаг 4 — «эту не буду, потому что рядом стройка; эту подниму, потому что заезд завтра» (бизнес-правила и контекст).

Candidate Generation: 50 миллионов → 1000 за 10 мс

Задача первого этапа — recall: из всего каталога вытащить ~1000 кандидатов так, чтобы среди них точно были релевантные. Если нужный айтем не попал в эту тысячу — никакое ранжирование его не спасёт. Recall на этапе генерации кандидатов — самая критичная метрика всего пайплайна.

Основной инструмент — Two-Tower модель + ANN-индекс. User-tower кодирует пользователя в вектор, item-tower — каждый айтем. Все item-эмбеддинги хранятся в ANN-индексе (FAISS, ScaNN, HNSW). При запросе: считаем user-эмбеддинг (1 forward pass), ищем ближайших соседей в индексе (~10 мс на 50M векторов). Подробнее об архитектуре — в ноде Two-Tower.

Но одного канала мало. В продакшне candidate generation — это несколько источников, работающих параллельно: • Two-Tower — персональные рекомендации на основе истории пользователя • Item-based CF — «похожие на последние просмотренные» (хорошо для сессионных рекомендаций) • Популярное — бейзлайн для холодного старта, новых пользователей • Тренды — айтемы, набирающие популярность прямо сейчас Каждый канал возвращает свой список кандидатов. Результаты объединяются, дедуплицируются и передаются дальше.

import asyncio

async def multi_source_retrieval(user_id: str, n: int = 1000) -> list[str]:
    """Параллельный запуск нескольких каналов candidate generation."""
    # Все каналы стартуют одновременно — укладываемся в бюджет
    two_tower, item_cf, popular = await asyncio.gather(
        two_tower_retrieve(user_id, n=500),    # персональные
        item_cf_retrieve(user_id, n=300),       # похожие на последние
        popular_retrieve(n=200),                # популярное (cold start)
    )

    # Объединяем со взвешенными скорами
    merged: dict[str, float] = {}
    for item_id, score in two_tower:
        merged[item_id] = merged.get(item_id, 0) + score * 0.5
    for item_id, score in item_cf:
        merged[item_id] = merged.get(item_id, 0) + score * 0.3
    for item_id, score in popular:
        merged[item_id] = merged.get(item_id, 0) + score * 0.2

    # Топ-N по объединённому скору
    top_n = sorted(merged, key=merged.get, reverse=True)[:n]
    return top_n

Pre-Ranking: 1000 → 200 за 10 мс

Pre-ranking нужен, когда основная модель ранжирования слишком дорогая для 1000 кандидатов. Если ranking-модель тратит 0.5 мс на айтем — 1000 × 0.5 мс = 500 мс, это за пределами бюджета. Pre-ranking сокращает список до 200, и ranking укладывается в 100 мс.

Модель должна быть лёгкой, но умнее, чем candidate generation. Типичные варианты: • GBDT с базовыми фичами — категория айтема, популярность, CTR за последнюю неделю, возраст пользователя. Десяток признаков, неглубокие деревья. CatBoost/LightGBM на 1000 примеров отрабатывают за миллисекунды. • Маленькая нейросеть — 2-3 слоя, без тяжёлых cross-features. Быстрее батч-инференса тяжёлой модели. • Distilled model — «ученик», обученный повторять скоры основной ranking-модели, но с меньшей архитектурой.

Ключевое отличие от candidate generation: pre-ranker видит больше фичей (не только эмбеддинги, но и статистику по айтему), но меньше, чем полноценный ranker. Ключевое отличие от ranking: pre-ranker не вычисляет дорогие cross-features (user × item кросс-статистики, real-time агрегаты).

Ranking: 200 → 50 — главная модель

Ranking — это сердце пайплайна. Модель работает только с 200 кандидатами, поэтому может себе позволить тяжёлые вычисления: десятки и сотни признаков, cross-features, real-time статистику.

Что делает ranking-модель сильнее pre-ranker: • Cross-features (user × item) — «сколько раз этот юзер кликал на товары этой категории за последний час», «средняя оценка этого айтема среди пользователей с похожим профилем». Эти фичи дорого считать, но они дают основной прирост качества. • Эмбеддинги из нескольких моделей — Two-Tower эмбеддинг + BERT-эмбеддинг описания + visual-эмбеддинг картинки. • Контекстные фичи — время суток, день недели, устройство, геолокация. • Real-time агрегаты — CTR айтема за последний час, кликстрим юзера за текущую сессию.

Типичные модели: • Градиентный бустинг (CatBoost, XGBoost) — рабочая лошадка. 100-500 фичей, обучается за минуты, инференс быстрый. Покрывает 80% кейсов. • Deep & Cross Network, DeepFM — нейросети, которые автоматически находят полезные кросс-фичи. Хорошо, когда ручной feature engineering не масштабируется. • Multi-task модели — одновременно предсказывают click, add-to-cart, purchase. Финальный скор = взвешенная комбинация: score = w₁·P(click) + w₂·P(cart) + w₃·P(buy).

import numpy as np

def build_ranking_features(user: dict, item: dict, context: dict) -> np.ndarray:
    """Признаки для ranking-модели — дорогие, но точные."""
    features = []

    # User features
    features.append(user['age'])
    features.append(user['days_since_registration'])
    features.append(user['total_purchases_30d'])

    # Item features
    features.append(item['price'])
    features.append(item['avg_rating'])
    features.append(item['views_last_24h'])

    # Cross-features (user × item) — главный источник качества
    features.append(user['clicks_in_category'].get(item['category'], 0))
    features.append(user['purchases_in_brand'].get(item['brand'], 0))
    features.append(item['ctr_in_user_segment'].get(user['segment'], 0))

    # Context
    features.append(context['hour_of_day'])
    features.append(context['is_weekend'])
    features.append(context['device_type'])

    return np.array(features)

# Ranking: предсказываем P(click) × P(purchase)
# scores = ranker.predict(features_matrix)  # 200 items × 12 features
# top_50 = np.argsort(scores)[::-1][:50]

Re-Ranking: бизнес-логика и diversity

Ranking-модель оптимизирует метрику (CTR, conversion) — но бизнесу нужно больше. Если в топ-10 попали 10 похожих кроссовок — пользователь подумает, что у магазина ничего нет, и уйдёт. Re-ranking решает проблемы, которые ML-модель не видит.

Diversity — одна из главных задач. Алгоритм MMR (Maximal Marginal Relevance): на каждом шаге выбираем айтем, который и релевантен (высокий скор от ranker), и непохож на уже выбранные: score_mmr = λ · relevance(item) − (1−λ) · max_similarity(item, selected) При λ=1 — чистая релевантность, при λ=0 — максимальное разнообразие. Обычно λ ≈ 0.7.

Что ещё делает re-ranking: • Бизнес-правила — промо-товары поднимаются наверх, 18+ контент фильтруется для детских аккаунтов, товары не в наличии скрываются • Freshness — если всё время показывать одно и то же, пользователь заскучает. Новые айтемы получают бонус к скору • Deduplication — убираем дубли (один товар от разных продавцов, один фильм в разных подборках) • Exploration — 5-10% слотов отдаём «исследовательным» рекомендациям — айтемам, про которые система мало знает. Это helps собрать данные и не застрять в «пузыре» • Fairness — равномерное представление продавцов/авторов, чтобы не монополизировать выдачу

def mmr_rerank(
    items: list[str],
    scores: dict[str, float],
    similarity: callable,
    k: int = 10,
    lambd: float = 0.7,
) -> list[str]:
    """Maximal Marginal Relevance — баланс релевантности и diversity."""
    selected = []
    remaining = set(items)

    for _ in range(k):
        best_item, best_score = None, -float('inf')
        for item in remaining:
            relevance = scores[item]
            # Максимальное сходство с уже выбранными
            max_sim = max(
                (similarity(item, s) for s in selected),
                default=0.0,
            )
            mmr = lambd * relevance - (1 - lambd) * max_sim
            if mmr > best_score:
                best_item, best_score = item, mmr
        selected.append(best_item)
        remaining.remove(best_item)

    return selected

Latency budget: 200 мс на всё

Весь пайплайн должен уложиться в 100-200 мс (p99). Это жёсткое ограничение: каждые 100 мс задержки в e-commerce снижают конверсию на ~1%. Latency budget — распределение этого времени между этапами.

Бюджет задержки рекомендательного пайплайна
Типичное распределение latency budget. Ranking — самый дорогой этап, но работает с малым числом кандидатов

Типичное распределение для бюджета в 150 мс: | Этап | Время | Что ограничивает | |---|---|---| | Candidate generation | ~15 мс | ANN-поиск, параллельные каналы | | Pre-ranking | ~10 мс | Лёгкая модель, батч-инференс | | Feature computation | ~30 мс | Запросы к feature store (Redis/кэш) | | Ranking | ~50 мс | Тяжёлая модель, 200 кандидатов | | Re-ranking | ~5 мс | Правила, MMR | | Network + serialization | ~15 мс | gRPC, protobuf | | Итого | ~125 мс | Запас ~25 мс на джиттер |

Как укладываться в бюджет: • Параллельность — каналы candidate generation запускаются одновременно (asyncio.gather), feature computation идёт параллельно с pre-ranking • Кэширование — item-эмбеддинги, офлайн-фичи, user-профили хранятся в Redis/Memcached. Обновление — батчами каждые 5-15 минут • Батч-инференс — не по одному айтему, а пачкой. GPU особенно выигрывает от батчей • Таймауты — если канал candidate generation не ответил за 20 мс, идём без него. Graceful degradation важнее, чем ожидание

Полный pipeline в коде

from dataclasses import dataclass
import time

@dataclass
class PipelineStats:
    stage: str
    items_in: int
    items_out: int
    latency_ms: float

class MultiStageRanker:
    """Production multi-stage ranking pipeline."""

    def __init__(self, retriever, pre_ranker, ranker, re_ranker):
        self.retriever = retriever     # Two-Tower + ANN (multi-source)
        self.pre_ranker = pre_ranker   # Light GBDT
        self.ranker = ranker           # Heavy model (CatBoost / deep)
        self.re_ranker = re_ranker     # Diversity + business rules

    def recommend(self, user_id: str, context: dict, k: int = 10):
        stats = []

        # Stage 1: Candidate Generation (50M → 1000)
        t0 = time.monotonic()
        candidates = self.retriever.retrieve(user_id, n=1000)
        stats.append(PipelineStats('retrieval', , len(candidates),
                                   (time.monotonic() - t0) * 1000))

        # Stage 2: Pre-Ranking (1000 → 200)
        t0 = time.monotonic()
        pre_scores = self.pre_ranker.predict(user_id, candidates)
        top_200 = sorted(zip(candidates, pre_scores),
                         key=lambda x: x[1], reverse=True)[:200]
        stats.append(PipelineStats('pre_ranking', len(candidates), 200,
                                   (time.monotonic() - t0) * 1000))

        # Stage 3: Ranking (200 → 50)
        t0 = time.monotonic()
        items_200 = [c for c, _ in top_200]
        features = self.build_features(user_id, items_200, context)
        scores = self.ranker.predict(features)
        top_50 = sorted(zip(items_200, scores),
                        key=lambda x: x[1], reverse=True)[:50]
        stats.append(PipelineStats('ranking', 200, 50,
                                   (time.monotonic() - t0) * 1000))

        # Stage 4: Re-Ranking (50 → k)
        t0 = time.monotonic()
        result = self.re_ranker.rerank([c for c, _ in top_50], k=k)
        stats.append(PipelineStats('re_ranking', 50, k,
                                   (time.monotonic() - t0) * 1000))

        total_ms = sum(s.latency_ms for s in stats)
        # Логируем: retrieval 12ms, pre_ranking 8ms, ranking 45ms, re_ranking 3ms
        return result, stats

🎯 На собеседовании

Junior

Зачем нужен multi-stage? Нельзя прогнать тяжёлую модель по 50M айтемов — слишком долго. Каскад: каждый этап точнее, но обрабатывает меньше кандидатов. Весь пайплайн укладывается в 100-200 мс. • Какие 4 этапа? Candidate generation (50M→1000), pre-ranking (1000→200), ranking (200→50), re-ranking (50→10). Каждый следующий точнее и дороже. • Зачем re-ranking? ML-модель не знает про бизнес-правила, diversity, freshness. 10 одинаковых кроссовок в выдаче — плохой UX, даже если модель считает их лучшими.

Middle

Чем отличается candidate generation от ranking? CG: лёгкая модель (Two-Tower + ANN), покрывает весь каталог, оптимизирует recall. Ranking: тяжёлая модель (CatBoost/DNN), работает с ~200 кандидатами, cross-features, оптимизирует precision/NDCG. • Что такое cross-features и почему они важны? Пересечение признаков user×item: «CTR этого айтема в сегменте этого юзера», «число покупок юзера в категории айтема». Дорого считать, но дают основной прирост качества в ranking. • Multi-source retrieval — зачем? Один канал не покрывает все кейсы. Two-Tower — персональные, item-CF — сессионные, популярное — cold start. Параллельный запуск, дедупликация. • Как обеспечить diversity? MMR: score = λ·relevance − (1−λ)·max_sim(item, selected). Балансирует релевантность и непохожесть.

Senior

Latency budget — как распределить 200 мс? CG ~15мс (ANN), pre-ranking ~10мс (лёгкий GBDT), features ~30мс (feature store), ranking ~50мс (тяжёлая модель), re-ranking ~5мс, сеть ~15мс. Итого ~125мс + запас на джиттер. • Как улучшить recall candidate generation без увеличения latency? Больше каналов (параллельно), distillation из ranking в retrieval, hard negative mining для Two-Tower, periodic offline sweep для обновления ANN-индекса. • Pre-ranking: нужен ли всегда? Нет. Если ranking-модель достаточно быстрая (лёгкий GBDT на 1000 за 30мс) — pre-ranking избыточен. Нужен, когда ranking — тяжёлая DNN или когда кандидатов >1000. • Как бороться с каскадной ошибкой? Если CG пропускает айтем — ranking его не увидит. Решения: метрика recall@1000 для CG, A/B тесты с увеличенным N на CG, exploration slots в re-ranking.

Собираем всё вместе

Multi-stage ranking — это архитектурный паттерн: каскад моделей от самой быстрой (candidate generation, весь каталог) до самой точной (ranking, сотни кандидатов). Каждый этап — компромисс скорость/качество, и у каждого своя метрика: recall для CG, NDCG для ranking, diversity + бизнес-KPI для re-ranking.

Если запомнить одну вещь: самая частая ошибка — улучшить ranking, но забыть про recall candidate generation. Если нужный айтем не попал в тысячу кандидатов, никакая модель ранжирования не поможет. Качество пайплайна определяется его самым слабым звеном.

Этот пайплайн — стандартный вопрос на system design собеседованиях. «Нарисуй архитектуру рекомендательной системы» = нарисуй 4 этапа с указанием моделей, количества кандидатов и latency budget на каждом. Следующий шаг на роадмапе — Sequential RecSys, где разберём, как учитывать последовательность действий пользователя в ранжировании.