xAI社が公開したX(旧Twitter)の推薦アルゴリズムを技術的に深掘り。候補分離アーキテクチャ、Grok Transformerの活用、19種類のエンゲージメント予測、重み設計の哲学まで紹介します。
xAI社が公開したX(旧Twitter)の「For You」タイムラインアルゴリズムのコードを詳細に解析しました。この記事では、推薦システムとして革新的な「候補分離アーキテクチャ」、Grok Transformerの活用方法、そして19種類のエンゲージメント予測を組み合わせたスコアリングロジックについて、技術者向けに深く解説します。
はじめに:なぜXのアルゴリズムは特別なのか
2025年、xAI社はXの推薦アルゴリズムをGitHubで公開しました(https://github.com/xai-org/x-algorithm)。このリポジトリには、数億ユーザーの「For You」タイムラインを支えるコア実装が含まれています。
一般的な推薦システムと大きく異なるのは、候補が互いに影響しない設計です。通常、推薦システムは「候補A、B、Cを並べて比較してランク付け」しますが、Xは各候補を独立してスコアリングします。これにより、スコアのキャッシュが可能になり、バッチ構成に依存しない一貫性が保たれます。
リポジトリの基本情報
- 言語構成: Rust 62.9% + Python 37.1%
- ライセンス: Apache 2.0
- 主要モジュール: home-mixer(オーケストレーション)、thunder(In-network投稿ストア)、phoenix(ML検索・ランキング)、candidate-pipeline(汎用フレームワーク)
アーキテクチャ概要:ThunderとPhoenixの2系統統合
Xのタイムラインは、2つの異なる情報源を統合しています。
graph TB
User[ユーザーリクエスト] --> HomeMixer[Home Mixer<br/>統合レイヤー]
HomeMixer --> Thunder[Thunder<br/>In-Network]
HomeMixer --> Phoenix[Phoenix<br/>Out-of-Network]
Thunder --> ThunderStore[(インメモリストア<br/>2日分の投稿)]
Phoenix --> TwoTower[Two-Tower Retrieval<br/>ML検索]
ThunderStore --> Candidates1[約1000件の候補]
TwoTower --> Candidates2[約1000件の候補]
Candidates1 --> Merge[候補統合<br/>約2000件]
Candidates2 --> Merge
Merge --> Filters[13種類のフィルター]
Filters --> Scorers[4段階のスコアリング]
Scorers --> TopK[Top-K選択<br/>約50件]
TopK --> Timeline[For Youタイムライン]Thunder(In-Network)
役割: フォロー中のユーザーの投稿を高速に提供
pub struct PostStore {
// 完全な投稿データ
posts: DashMap<PostId, LightPost>,
// ユーザーごとのタイムライン(軽量参照)
original_posts_by_user: DashMap<UserId, VecDeque<TinyPost>>,
secondary_posts_by_user: DashMap<UserId, VecDeque<TinyPost>>,
video_posts_by_user: DashMap<UserId, VecDeque<TinyPost>>,
}
// 軽量参照(16バイト)
struct TinyPost {
post_id: u64,
created_at: i64, // Snowflake timestamp
}
特徴:
- 完全インメモリ: DBアクセスなしでサブミリ秒応答
- 2日分保持: 古い投稿は自動削除
- DashMap使用: 並行読み書き可能なハッシュマップ
Phoenix(Out-of-Network)
役割: 未フォローのユーザーの投稿をML検索で発見
Two-Tower Retrievalを使用して、数百万の候補から約1000件に絞り込みます。
class PhoenixRetrievalModel:
def encode_user(self, batch, embeddings):
# User Tower: Transformerでユーザー履歴をエンコード
user_emb = self.user_embedding(batch)
history_embs = self.history_embedding(batch)
# Grok Transformer適用
transformer_out = self.grok_transformer(
concat([user_emb, history_embs]),
mask=None
)
# User表現取得(L2正規化)
user_repr = normalize(transformer_out[:, 0, :])
return user_repr
def retrieve(self, user_repr, corpus_embeddings, top_k=1000):
# ドット積類似度でTop-K取得
scores = user_repr @ corpus_embeddings.T
top_k_indices = jax.lax.top_k(scores, k=top_k)[1]
return top_k_indices
候補分離アーキテクチャ:推薦システムの常識を覆す設計
通常の推薦システムとの違い
従来の推薦システム:
候補A、B、Cを一緒にTransformerに入力
→ A、B、Cが互いに影響し合う
→ バッチ構成が変わるとスコアも変わる
→ キャッシュ不可
Xの候補分離アーキテクチャ:
候補Aは「ユーザー、履歴」のみ見る
候補Bは「ユーザー、履歴」のみ見る
候補Cは「ユーザー、履歴」のみ見る
→ お互いに影響しない
→ バッチ構成に依存しない
→ スコアキャッシュ可能
アテンションマスクによる実装
候補分離は、Transformerのアテンションマスクで実現されます。
def make_recsys_attn_mask(
user_size: int, # 1 (user embedding)
history_size: int, # S (履歴投稿数)
candidate_size: int, # C (候補投稿数)
) -> Array:
"""
マスク構造:
User History(1..S) Candidates(1..C)
User ○ ○ ○
Hist(1) ○ ○ ×
Hist(S) ○ ○ ×
Cand(1) ○ ○ ○ (自分のみ)
Cand(C) ○ ○ ○ (自分のみ)
候補は:
- User embeddingに注意できる
- 履歴投稿に注意できる
- 自分自身に注意できる
- 他の候補に注意できない ← 候補分離の核心
"""
total_size = user_size + history_size + candidate_size
mask = jnp.ones((total_size, total_size))
# 候補同士のアテンションをブロック
cand_start = user_size + history_size
for i in range(candidate_size):
for j in range(candidate_size):
if i != j:
mask[cand_start + i, cand_start + j] = 0
return mask
候補分離のメリット
- スコアの一貫性: 同じユーザー・候補なら、他の候補の有無に関わらず同じスコア
- キャッシュ可能: 候補ごとのスコアを事前計算して保存できる
- A/Bテスト公平性: アルゴリズム変更の影響を正確に測定できる
- スケーラビリティ: 候補を個別に評価できるため、並列化しやすい
Grok Transformerの技術詳細
XのアルゴリズムはGrok-1からTransformer技術を移植しています。
アーキテクチャ
class Transformer(hk.Module):
def __init__(self, config):
self.num_layers = config.num_layers # 2
self.emb_size = config.emb_size # 128
self.layers = [DecoderLayer(config) for _ in range(num_layers)]
def __call__(self, embeddings, mask):
x = embeddings # [B, T, D]
for layer in self.layers:
x = layer(x, mask)
return x
class DecoderLayer(hk.Module):
def __call__(self, x, mask):
# 1. Pre-norm + Multi-Head Attention + Residual
normed = RMSNorm(x)
attn_out = MHABlock(normed, mask)
x = x + attn_out
# 2. Pre-norm + FFN + Residual
normed = RMSNorm(x)
ffn_out = DenseBlock(normed)
x = x + ffn_out
return x
Grok-1からの移植要素
| 技術 | 説明 |
|---|---|
| RoPE | Rotary Position Embedding(位置エンコーディング) |
| GQA | Grouped Query Attention(KVヘッド数 < Qヘッド数) |
| SwiGLU | Activation関数(Swish × GLU) |
| RMSNorm | Root Mean Square Layer Normalization |
| Pre-normalization | アテンション/FFNの前に正規化 |
スコアリングロジック:19種類のエンゲージメント予測
マルチアクション予測
Xのモデルは、1つの候補に対して19種類のエンゲージメント確率を同時に予測します。
actions = [
"favorite", # いいね
"reply", # リプライ
"retweet", # リツイート
"quote", # 引用RT
"photo_expand", # 画像拡大
"click", # クリック
"profile_click", # プロフィールクリック
"video_quality_view", # 動画再生
"share", # シェア
"share_via_dm", # DM共有
"share_via_copy_link", # リンクコピー
"dwell", # 滞在
"quoted_click", # 引用先クリック
"follow_author", # フォロー
"not_interested", # 興味なし
"block_author", # ブロック
"mute_author", # ミュート
"report", # 報告
"dwell_time", # 滞在時間(連続値)
]
重み設計の哲学
| アクション | 重み | 理由 |
|---|---|---|
| フォロー | +12 | 新しいフォローは最強のポジティブシグナル |
| DM共有 | +10 | 個人的な共有は高価値 |
| リプライ | +9 | 会話が生まれるのは重要 |
| いいね | +2 | 片手間でできるため軽い |
| リツイート | +1 | ボタン1つなので軽い |
| 通報 | -500 | 最悪のシグナル |
| ブロック | -200 | 強い拒絶 |
| ミュート | -100 | 中程度の拒絶 |
| 興味なし | -50 | 軽い拒絶 |
ネガティブシグナルの重みが桁違いに大きい理由
ポジティブシグナルの合計が+30程度なのに対し、ネガティブシグナルは-500まであります。これは「嫌われたら終わり」という設計思想を表しています。
Xが最適化しているのは単なるエンゲージメント最大化ではなく、「ユーザーが嫌がらないレベルで、有意義な会話を生む投稿を優先」することです。
ランキング調整:多様性とIn/Out-of-Network
著者多様性スコア
同じ著者の投稿が連続すると、スコアが減衰します。
// AuthorDiversityScorer
DIVERSITY_DECAY = 0.5; // 50%減衰
DIVERSITY_FLOOR = 0.2; // 最低20%維持
// 減衰の推移:
// 1回目: 100% (初出)
// 2回目: 60%
// 3回目: 40%
// 4回目: 30%
// 5回目以降: 20%
Out-of-Network候補のペナルティ
未フォローの投稿には、最終的に0.75倍のペナルティが適用されます。
// OONScorer
In-network候補 (score=100) → 100 (変更なし)
Out-of-network候補 (score=100) → 75 (25%減少)
これにより、「知らない人のバズツイート」より「フォロー中の普通の投稿」が優先される仕組みになっています。
実装技術の詳細
Rust + Pythonのハイブリッド構成
| 言語 | 担当領域 | 理由 |
|---|---|---|
| Rust (62.9%) | パイプライン、サーバー、ストア | 高速、並行性、メモリ安全性 |
| Python (37.1%) | ML推論、モデル定義 | JAX、NumPy、ML生態系 |
Bloomフィルターによる既読管理
// PreviouslySeenPostsFilter
BloomFilter {
size: 10000,
hash_count: 3,
false_positive_rate: ~1%
}
特性:
- 偽陽性あり: 見てないのに「見た」と判定される可能性が約1%
- 偽陰性なし: 見たものを「見てない」と判定することはない
- 超軽量: 10000エントリでも数KBのメモリ
なぜBloom Filterを使うのか
完璧な既読管理(ハッシュセット)だと、数万の投稿IDを保持する必要があります。Bloom Filterなら偽陽性1%を許容して、メモリを大幅に削減できます。
設計思想の考察
なぜ候補分離なのか
- スコアの一貫性: 同じ候補なら、他の候補の有無に関わらず同じスコア
- キャッシュ戦略: 候補ごとにスコアを事前計算できる
- A/Bテスト: アルゴリズム変更の影響を正確に測定
- スケーラビリティ: 候補を個別評価→並列化しやすい
Xが最適化している価値
Xは単なる「エンゲージメント最大化」ではなく、**「ユーザーが嫌がらないレベルで、有意義な会話を生む投稿を優先」**しています。
証拠:
- リプライの重み(+9)が、いいね(+2)より大幅に高い → 会話重視
- ブロック(-200)、通報(-500)の重みが巨大 → 嫌われたら即死
- Out-of-Network候補のペナルティ(0.75倍) → 安心感重視
まとめ
X Algorithmの核心は、以下の3つの革新にあります:
- 候補分離アーキテクチャ - 他の推薦システムと一線を画す設計
- Grok Transformerの活用 - Two-TowerとRankingでの使い分け
- 重み設計の哲学 - ネガティブシグナルを極端に重視
これらの技術により、Xは高速(サブ100ms)かつ高品質なタイムライン生成を実現しています。
技術者として特に参考になるのは:
- アテンションマスクによる候補分離の実装方法
- マルチアクション予測(19種類同時)の学習戦略
- Rust + Pythonのハイブリッド構成
- インメモリストアとBloom Filterの組み合わせ
このアルゴリズムは、推薦システムの設計において「キャッシュ可能性」「一貫性」「スケーラビリティ」を重視する場合の良いお手本となるでしょう。