Перейти к содержанию

Ранжирование ленты новостей -- компоненты

~4 минуты чтения

Предварительно: Определение задачи

Архитектура ленты новостей -- это конвейер из 5 компонентов, каждый с жёстким latency budget. Candidate Generation за 50 мс фильтрует 10 000+ постов до ~1000, Feature Extraction за 30 мс собирает 500--1000 фичей из 5 хранилищ, Ranking Model за 20 мс прогоняет multi-task DNN, Post-Ranking за 10 мс применяет diversity и business rules. Суммарно: весь pipeline за <200 мс при 500K QPS. На масштабе Meta это означает параллельную работу тысяч Triton Inference серверов и Redis-кластеров с <5 мс latency на чтение фичей.

High-Level Architecture

graph TD
    A["Content Sources<br/>Friends, Pages, Groups, Ads"] --> B["Candidate Generation<br/>~1000 candidates per user"]
    B --> C["Feature Extraction<br/>User, Content, Context features"]
    C --> D["Ranking Model<br/>Multi-task: P(like), P(comment), P(share), P(hide)"]
    D --> E["Post-Ranking<br/>Diversity, Freshness, Business Rules, Ads"]
    E --> F["Feed Delivery<br/>Top-N items, infinite scroll"]

    style D fill:#e8eaf6,stroke:#3f51b5
    style E fill:#fff3e0,stroke:#ff9800

Component Details

1. Candidate Generation

class CandidateGenerator:
    """
    Retrieves ~1000 candidates from millions of possible items
    """

    def __init__(self):
        self.sources = {
            "friends": FriendPostSource(),
            "groups": GroupPostSource(),
            "pages": PagePostSource(),
            "recommendations": RecommendationSource(),
            "ads": AdCandidateSource(),
        }

    def generate(self, user_id: str, limit: int = 1000) -> list[Candidate]:
        # Parallel retrieval from all sources
        candidates = []
        with ThreadPoolExecutor() as executor:
            futures = {
                name: executor.submit(source.fetch, user_id)
                for name, source in self.sources.items()
            }
            for name, future in futures.items():
                candidates.extend(future.result())

        # Deduplicate and apply hard filters
        candidates = self.deduplicate(candidates)
        candidates = self.apply_hard_filters(candidates, user_id)

        return candidates[:limit]

    def apply_hard_filters(self, candidates, user_id):
        """Remove blocked users, seen posts, policy violations"""
        blocked = self.get_blocked_users(user_id)
        seen = self.get_recently_seen(user_id)
        return [
            c for c in candidates
            if c.author_id not in blocked
            and c.post_id not in seen
            and not c.is_policy_violation
        ]

2. Feature Extraction

class FeatureExtractor:
    """
    Extract features for ranking model
    """

    def extract(self, user_id: str, candidates: list[Candidate]) -> list[FeatureVector]:
        user_features = self.get_user_features(user_id)
        context_features = self.get_context_features(user_id)

        feature_vectors = []
        for candidate in candidates:
            content_features = self.get_content_features(candidate)
            interaction_features = self.get_interaction_features(user_id, candidate)

            feature_vectors.append(FeatureVector(
                user=user_features,
                content=content_features,
                interaction=interaction_features,
                context=context_features,
            ))

        return feature_vectors
Feature Group Examples Storage
User age, country, interests, engagement history Redis (user profile)
Content type, age, author, text embeddings, image features Feature Store
Interaction user-author affinity, past likes on similar content Graph DB / Precomputed
Context time of day, device, session depth, network speed Request context
Social friends who liked, mutual connections with author Social Graph

3. Ranking Model

class MultiTaskRankingModel:
    """
    Multi-task model predicting engagement probabilities
    """

    def __init__(self):
        # Shared bottom layers
        self.shared_layers = nn.Sequential(
            nn.Linear(FEATURE_DIM, 512),
            nn.ReLU(),
            nn.Linear(512, 256),
            nn.ReLU(),
        )

        # Task-specific towers
        self.towers = {
            "like": TaskTower(256, 1),
            "comment": TaskTower(256, 1),
            "share": TaskTower(256, 1),
            "click": TaskTower(256, 1),
            "hide": TaskTower(256, 1),      # negative signal
            "report": TaskTower(256, 1),    # negative signal
        }

    def forward(self, features):
        shared = self.shared_layers(features)
        predictions = {
            task: torch.sigmoid(tower(shared))
            for task, tower in self.towers.items()
        }
        return predictions

    def compute_ranking_score(self, predictions: dict) -> float:
        """
        Weighted combination of task predictions
        """
        weights = {
            "like": 1.0,
            "comment": 5.0,    # comments more valuable
            "share": 10.0,     # shares most valuable
            "click": 0.5,
            "hide": -50.0,     # strong negative
            "report": -100.0,  # strongest negative
        }

        score = sum(
            weights[task] * predictions[task]
            for task in weights
        )
        return score

Model Evolution:

Generation Architecture Latency
V1 Logistic Regression <1ms
V2 GBDT (XGBoost) ~5ms
V3 Deep Neural Network ~10ms
V4 Multi-task DNN (MMoE) ~15ms
V5 Transformer-based (current) ~20ms

4. Post-Ranking

class PostRanker:
    """
    Apply business rules and diversity after ML ranking
    """

    def rerank(self, ranked_items: list[RankedItem]) -> list[RankedItem]:
        # Diversity: no more than 2 consecutive posts from same author
        items = self.apply_diversity(ranked_items)

        # Freshness boost: recent posts get score bonus
        items = self.apply_freshness_boost(items)

        # Content type mixing: alternate photos, videos, text
        items = self.mix_content_types(items)

        # Ad injection: insert ads at positions 3, 8, 15, ...
        items = self.inject_ads(items)

        # Policy: ensure no adjacent sensitive content
        items = self.apply_policy_rules(items)

        return items

    def apply_diversity(self, items):
        result = []
        author_streak = defaultdict(int)

        for item in items:
            if author_streak[item.author_id] >= 2:
                continue  # skip, too many from same author
            result.append(item)
            author_streak[item.author_id] += 1

            # Reset streaks for other authors
            for a in author_streak:
                if a != item.author_id:
                    author_streak[a] = 0

        return result

5. Serving Infrastructure

graph LR
    A["App Server<br/>(API)"] --> B["Candidate<br/>Generator"]
    B --> C["Feature<br/>Store"]
    C --> D["Model<br/>Serving"]
    B --> E["Social Graph<br/>(Neo4j)"]
    C --> F["Redis<br/>(features)"]
    D --> G["Triton /<br/>TF Serve"]

    style A fill:#e8eaf6,stroke:#3f51b5
    style D fill:#e8eaf6,stroke:#3f51b5
    style E fill:#f3e5f5,stroke:#9c27b0
    style F fill:#fff3e0,stroke:#ef6c00
    style G fill:#e8f5e9,stroke:#4caf50
Component Technology SLA
Feature Store Redis Cluster (hot), Hive (cold) <5ms read
Model Serving Triton Inference Server <20ms p99
Candidate Store Cassandra / ScyllaDB <10ms
Social Graph Neo4j / TAO (Meta) <5ms
Message Queue Kafka (engagement events) Async

Latency Budget

Этап Бюджет
Candidate generation 50 мс
Feature extraction 30 мс
Model inference 20 мс
Post-ranking 10 мс
Serialization 10 мс
Network overhead 80 мс
Итого <200 мс

Типичные заблуждения

Заблуждение: можно ранжировать все посты одной моделью

При 10 000+ кандидатов прогонять полную DNN-модель для каждого -- это 10K x 20 мс = 200 секунд. Поэтому обязателен этап Candidate Generation, который легковесными фильтрами (социальный граф, recency) сужает пул до ~1000. Без этого этапа latency budget невыполним.

Заблуждение: multi-task модель всегда лучше отдельных моделей

Multi-task (MMoE, PLE) работает лучше только при наличии shared structure между задачами. Если P(like) и P(share) имеют очень разные feature distributions, shared bottom layers могут вредить -- явление negative transfer. На практике Facebook использует expert gating (MMoE) чтобы каждая задача сама выбирала какие shared layers использовать. Слепое объединение всех задач в одну модель может дать -3--5% по отдельным метрикам.

Заблуждение: Post-Ranking -- это просто фильтрация

Post-Ranking -- это не фильтр, а критический компонент для user experience. Без diversity enforcement пользователь может получить 10 постов от одного автора подряд. Без ad injection нет монетизации. Без content type mixing -- только текст или только видео. На масштабе Instagram post-ranking правила влияют на 15--20% финального ordering.


Интервью

Объясните, почему используется multi-stage pipeline, а не одна модель?

❌ "Потому что так делают большие компании"

✅ "Latency constraint. При 2B DAU и 500K QPS весь pipeline должен уложиться в <200 мс. Полная DNN-модель стоит ~20 мс на инференс. Для 10K+ кандидатов это 200 секунд -- невозможно. Поэтому Candidate Generation за 50 мс с лёгкими heuristics (социальный граф, recency, hard filters) сужает пул до ~1000. Затем Feature Extraction за 30 мс готовит фичи из Redis/Feature Store, и только потом 1000 кандидатов проходят через DNN за 20 мс. Каждый stage -- trade-off между precision и latency."

Как бы вы добавили рекламу в ленту?

❌ "Вставляем рекламу на фиксированные позиции -- каждый 5-й пост"

✅ "Комбинированный подход: (1) отдельная ad ranking система с собственным CTR prediction, (2) auction-based выбор рекламы с eCPM scoring, (3) ad slots на позициях 3, 8, 15 с адаптивным spacing в зависимости от session depth, (4) relevance matching к окружающему органическому контенту, (5) frequency caps -- не больше 3 показов одного рекламодателя в день. Ключевое: ad quality score должен учитывать user experience -- навязчивая реклама увеличивает hide rate и снижает retention."