---
title: "Phoenix 召回：双塔检索架构"
description: "生产版双塔召回：用户塔 transformer、语义 ID 候选塔、对比学习训练、索引随 checkpoint 一起发布；10-13 起加训“停留达标”头"
---

> Documentation Index
> Fetch the complete documentation index at: https://x-algorithm.vergego.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Phoenix 召回：双塔检索架构

## 这一页回答什么

推荐系统在排序之前，先从千万级全站候选池中筛选出数百至上千条候选帖子。Phoenix 召回阶段采用双塔模型执行粗筛：双塔模型分别将用户历史与候选帖子映射至同一向量空间。用户塔把用户的近期浏览与交互记录编码为单位向量；候选塔把每条候选帖子编码为单位向量。在线检索通过大矩阵乘法计算相似度，并提取 top-K 候选。

本页解析 2026-08 起开源的生产版召回系统，涵盖双塔输入结构、训练目标、索引生成与加载机制以及在线打分流程。类与方法的详细拆解见实体页 [RecsysTwoTowerModel（召回模型）](https://x-algorithm.vergego.ai/entities/recsys-retrieval-model/)；召回索引收录的帖子范围由 [Phoenix 召回索引：rankall 与事件层](https://x-algorithm.vergego.ai/concepts/phoenix-rankall/) 决定。

> 旧版文档基于 `phoenix/recsys_retrieval_model.py` 与 `run_pipeline.py` 的演示代码编写，两份文件现已删除。当前生产召回模型类为 `RecsysTwoTowerModel`（`phoenix/xrex/models/recsys_two_tower_model.py:1610`）。

## 核心结论

1. **双塔分离计算**：系统为每个在线请求计算一次用户塔。系统对全量语料离线预计算候选塔，将结果保存为 `post_embeddings` 表并写入 checkpoint。在线服务启动时直接加载该表（`phoenix/README.md:116-119`、`phoenix/xrex/train/trainer_recsys.py:4195-4235`）。
2. **候选特征解耦帖子 ID 哈希**：生产配置设置 `use_post_embedding=False`，候选塔仅输入“作者哈希 + 语义 ID（SID）”。同主题帖子共享 SID 前缀，新发帖子无需积累交互即可获得有效向量表征（`phoenix/xrex/configs/xrecsys_two_tower.py:463-465`、`:330-332`，`phoenix/README.md:109-115`）。
3. **用户塔不使用用户 ID 嵌入**：系统配置 `use_user_embedding=False`，仅通过交互历史序列和单张画像 token（包含国家、语言等）表征用户（`xrecsys_two_tower.py:463`、`:330`，`phoenix/README.md:101-108`）。
4. **训练采用对比学习**：模型默认将“点赞”作为正样本。负样本包含批次内其他用户的候选帖子，以及每例 64 个全局负样本，同时执行 log-Q 校正。用户主动划走、举报或拉黑的候选帖子同样计入分母（`recsys_two_tower_model.py:1035-1380`）。
5. **2026-10-13 新增“停留达标”预测头**：在 `combined` 与 `home_only` 配置中，系统新增第三个候选头 `ld`，以 `CLIENT_TWEET_RECAP_DWELLED` 为正样本单独训练。在线服务打分公式为 1.0 × 点赞头得分 + 0.75 × `ld` 头得分（`xrecsys_two_tower.py:293-296`、`recsys_two_tower_model.py:2969-2992`）。上游 README 称该指标为“读完整篇”；源码中该头对应停留达标事件，生产环境判定阈值在源码中未包含。
6. **打分机制支持非点积模式**：`combined` 配置启用 4 个用户查询头和 MoL（mixture of logits）打分机制。小门控网络混合多个余弦相似度计算单条候选帖子的得分（`xrecsys_two_tower.py:313-320`、`recsys_two_tower_model.py:710-880`）。旗舰配置 `xrecsys_two_tower` 与 nano 配置仍沿用向量点积。

## 双塔总览

```mermaid
flowchart TB
    subgraph UT["用户塔 · 每请求在线"]
        UF["画像 token"] --> CAT["拼接 用户前缀 与 历史"]
        HH["历史 ×1023<br/>作者哈希+SID+动作+停留+上下文"] --> CAT
        CAT --> TF["Transformer 8 层"]
        TF --> POOL["掩码平均池化<br/>或 4 个查询头"]
        POOL --> N1["L2 归一化"]
    end
    subgraph CT["候选塔 · 离线建索引"]
        CP["作者哈希 ×2 + SID token"] --> PROJ["concat→MLP<br/>或 各自投影再相加"]
        PROJ --> N2["L2 归一化"]
    end
    N2 --> IDX["post_embeddings 表<br/>写进 checkpoint"]
    N1 --> SC["点积 或 MoL 打分"]
    IDX --> SC
    SC --> MASK["按语料类型、可用掩码、话题过滤"]
    MASK --> TOPK["top-K"]
```

## 用户塔

用户塔以 `user_representation` 为计算入口（`recsys_two_tower_model.py:1990-2128`），按以下步骤生成用户表征：

1. **构造输入特征**（`build_user_inputs`，`:1621-1815`）。计算路径分为两类：
   - `feature_prep_enabled=True`（`combined` 系列）：复用排序模型的特征准备流程，但不包含候选片段（`include_candidates=False`，`:1633-1645`）。历史 token 字段构成与排序模型一致，详见 [Phoenix 排序模型](https://x-algorithm.vergego.ai/concepts/phoenix-ranking/)。
   - 其他配置（旗舰 `xrecsys_two_tower`、nano）：执行旧式 `block_user_reduce` / `block_history_reduce`。系统拼接作者哈希、SID、动作类型与产品面标识后执行投影（`:1647-1815`；两函数分别定义于 `recsys_model.py:1673`、`:1947`）。
2. **执行 transformer 编码**：模型复用 `xrex/models/transformer.py`。输入序列不含候选帖子，因此无需引入候选隔离机制（详见 [候选隔离注意力掩码](https://x-algorithm.vergego.ai/concepts/candidate-isolation-masking/)）。训练阶段将多个用户的序列拼接为单行（`use_seqpack=True`），并调用变长注意力算子（`:2001-2028`）。
3. **池化生成用户向量**：
   - 默认模式：计算所有有效位置输出向量的平均值（`:2112-2116`）。
   - 查询头模式（`user_query_heads > 0`，`combined` 配置为 4）：模型学习 4 个查询向量。每个查询向量对完整序列执行一次注意力加权池化。权重中扣除 `gamma × 距最新位置的 token 数 / 16` 作为可学习的时间衰减项（`:2130-2215`，`QUERY_HEAD_RECENCY_SCALE_TOKENS = 16.0` 见 `:71`），输出维度为 `[B, 4, D]`。
4. **L2 归一化**（`:2123-2125`）。

训练阶段配置 `empty_history_user_dropout_rate=0.1`：系统随机清空 10% 用户的交互历史，以此模拟冷启动新用户（`xrecsys_two_tower.py:271`、`:941`）。

## 候选塔

候选塔由 `RecsysCandidateTower` 实现（`recsys_two_tower_model.py:180-331`）。模型为每条候选帖子输入一组 token，包含可选的帖子哈希嵌入、2 个作者哈希嵌入和 1 个 SID token（`_stack_candidate_tokens`，`:1880-1892`）。特征组合包含三种方式：

| 方式 | 何时用 | 做法 |
|------|-------|------|
| concat→MLP | `enable_candidate_tower_linear_proj=True`（旗舰、nano） | 展平后过 `W → 2W → W` 两层，中间 SiLU（`:203-246`） |
| 各自投影再相加 | `feature_prep_enabled=True`（`combined` 系列） | 每个 token 一个 `[W, W]` 投影，结果相加（`:248-320`） |
| 均值池化 | 两者都关 | 直接平均，无参数（`:329-331`） |

两种投影开关互斥（`xrecsys_two_tower.py:645-652`）。每种方式的输出均执行 L2 归一化。

**多头机制**：当 `num_candidate_heads > 1` 时，每个候选头配置独立的投影参数（参数名包含 `_head_{i}` 后缀），同时共享同一个用户向量（`:214-220`、`:1972-1988`）。`combined` 配置包含 3 个预测头：

| 头 | 正样本（开源默认值） | 用于哪些语料 |
|----|------|------|
| `home` | `SERVER_TWEET_FAV`（`recsys_two_tower_model.py:2895-2899`） | 首页语料 `HOME`、`HOME_COLD`、`HOME_HOT` |
| `immersive` | 点赞、回复、引用、转发、视频质量观看、关注作者、收藏、分享（`xrecsys_two_tower.py:283-291`） | 视频沉浸页语料 `IMMERSIVE*` |
| `ld` | `CLIENT_TWEET_RECAP_DWELLED`（`xrecsys_two_tower.py:293`） | 不占语料，作为额外打分槽位 |

系统通过 `head_dataset_mapping` 确定各语料在构建索引时调用的预测头（`xrecsys_two_tower.py:297-304`、`trainer_recsys.py:4207-4228`）。

## 训练目标：对比学习

系统通过 `retrieval_loss` 计算对比学习损失（`recsys_two_tower_model.py:1035-1380`）。训练流程对每个预测头分别计算损失后求和（`:2767-2800`）：

- **正样本定义**：候选集合中包含正样本动作、且不包含任何硬负或软负动作的位置（`positive_and_negative_masks`，`:675-702`）。
- **负样本动作分类**：硬负动作包含举报、不感兴趣、少看此类、取关、拉黑、静音和不相关；软负动作包含未停留 `CLIENT_TWEET_RECAP_NOT_DWELLED`（`:2900-2916`）。
- **分母项构成**：批次内其他用户的候选（in-batch negatives）、每例 `num_global_negatives_per_example=64` 个全局负样本，以及当前用户带有负反馈动作的候选（叠加 `own_negative_logit_offset`，`combined` 配置为 −2.0）（`:1226-1290`，`xrecsys_two_tower.py:262`、`:324`）。
- **温度系数**：模型提供可学习温度参数，初始值为 0.1；`combined` 配置固定为 0.006（`:2741-2750`，`xrecsys_two_tower.py:322`）。
- **log-Q 校正**：负样本得分增加 `log(1/出现次数) × 2.0`，消除高频热门帖子被频繁采样为负样本引入的估计偏差（`:1202-1213`，`logq_correction_scale = 2.0` 见 `:2860`）。
- **批次假负样本屏蔽**：启用 `mask_in_batch_false_negatives=True` 时，若其他用户的候选帖子与当前用户的正样本属于同一条帖子，算法将该样本从分母中移除（`:1250-1260`）。
- **样本数加权**：配置 `positive_count_user_weight_power=0.5`。算法将每个用户的损失乘以“正样本数的 0.5 次方”后归一化，提升高正样本数用户的损失权重（`:1005-1016`、`:1359-1362`）。

### 10-13 的变化：“停留达标”头

开源提交 `e1a0375`（2026-10-13）在 `combined` 基础配置中将候选头数量由 2 个扩充为 3 个，新增 `ld` 预测头并引入权重系数 `w_ld = 0.75`（`xrecsys_two_tower.py:293-296`）。具体机制包含三个环节：

1. `ld` 头仅将 `CLIENT_TWEET_RECAP_DWELLED` 判定为正样本，负样本选取规则与 `home` 头一致（`recsys_two_tower_model.py:2953-2961`）。
2. `ld` 属于“混合头”（`BLEND_HEAD_ORDER = ("ld", "tweet_click")`，`:75-80`）。该头不绑定独立语料，而是在每条索引行追加一段特征向量。索引表单行宽度由 W 扩展为 2W（`num_table_slots = 1 + 混合头数`，`:2981-2983`；拼接逻辑见 `trainer_recsys.py:4230-4235`）。
3. 在线服务分段计算得分后加权汇总：`最终分 = w_fav × 第 1 段得分 + w_ld × 第 2 段得分`。开源默认权重为 `w_fav = 1.0`、`w_ld = 0.75`（`score_over_slots`，`recsys_two_tower_model.py:150-177`；权重定义见 `:2985-2988`）。

适用范围：`ld` 头仅配置于 `xrecsys_two_tower_combined`、`xrecsys_two_tower_combined_gb300` 和 `xrecsys_two_tower_home_only` 三组配置（`xrecsys_two_tower.py:514-541`）。旗舰配置 `xrecsys_two_tower` 与 nano 配置未包含该头（`:439-513`、`:542-580`）。线上生产环境选用何种配置，源码未包含。

生产环境对“停留达标”的具体判定阈值在源码中未包含。合成数据生成代码中将其定义为“产生交互且停留时间 ≥ 10 秒”（`phoenix/reference/world.py:72`、`:337`）。

## 索引：随 checkpoint 一起生成

- 训练器在保存 checkpoint 时，调用候选塔遍历计算全量语料，将向量写入 checkpoint 中的 `post_embeddings` 表（`phoenix/README.md:116-119`、`trainer_recsys.py:4195-4250`）。在线服务进程启动时直接读取该表，不执行在线重算（`phoenix/xrex/inference/model_runner.py:2313-2330`）。
- 索引表容量上限由 `max_posts` 控制：旗舰配置默认值为 10,240,000，`combined` 配置为 28,672,000，nano 配置为 65,536（`xrecsys_two_tower.py:935`、`:329`、`:555`）。
- 语料集合由多种“语料类型”合并而成，每种类型对应一份 parquet 快照文件。例如 `HOME` 对应 `1fav_1day.parquet`，`IMMERSIVE2Day` 对应 `video_2day.parquet`，`EVERGREEN` 对应 `video_4to14day.parquet`（`phoenix/xrex/data/retrieval_dataset.py:290-326`）。快照由 `phoenix-rankall` 离线生成，详见 [Phoenix 召回索引：rankall 与事件层](https://x-algorithm.vergego.ai/concepts/phoenix-rankall/)。
- **冷启动候选分片**：系统将 `HOME` 语料进一步拆分为 `HOME_COLD` 与 `HOME_HOT` 两组切片。发布时间不超过 2 小时、点赞数少于 8 且浏览数少于 500 的帖子划入 `HOME_COLD`，其余帖子划入 `HOME_HOT`（`retrieval_dataset.py:540-570`，`phoenix/xrex/data/cold_pool_filter.py:16`、`:27-46`、`:66-77`，`combined` 配置中 `cold_start_max_age_seconds=7200` 见 `xrecsys_two_tower.py:312`）。编排层 home-mixer 对冷启动候选结果配置独立数量上限 `PhoenixColdStartMaxResults = 200`（`home-mixer/params/param.rs:17-21`）。

## 服务时的打分与过滤

`RecsysTwoTowerModel.forward` 执行在线检索（`recsys_two_tower_model.py:2279-2600`），分为四个步骤：

1. 计算用户向量。
2. 对索引分片执行逐段打分：未启用 MoL 时计算矩阵乘法 `users @ posts.T`；启用 MoL 时通过门控网络融合多个余弦相似度（支持融合 CUDA 算子与 int8 量化索引表）（`:2358-2385`、`:924-1003`）。
3. 执行候选过滤：系统按语料类型、请求自带的候选可用掩码 `eligible_mask` 与话题位图进行过滤，将未通过过滤的候选得分置为 bfloat16 最小值（`:2408-2445`）。
4. 调用自定义算子 `top_k_by_key` 筛选得分最高的 top-K 候选（`:2312-2323`）。

服务进程架构中，Rust 引擎中的 `RecsysRetrievalPredictorServer` 监听 gRPC 接口 `RetrieveTopKCandidates`，Python 侧 `RetrievalModelRunner` 运行上述前向计算（`model_runner.py:4540`、`:5355-5370`，`phoenix/crates/serving/xai-recsys-proto/proto/recsys.proto:18-24`）。模型加载与服务化细节详见 [Phoenix 训练](https://x-algorithm.vergego.ai/concepts/phoenix-training/)。

编排服务 home-mixer 通过 `PhoenixSource` 发起召回请求。请求召回数量以 `PhoenixMaxResults`（开源默认值 1000）为基准，经 `quality_factor::apply` 动态调整（`home-mixer/sources/phoenix_source.rs:129`、`home-mixer/params/param.rs:5-9`）。同目录下另包含通过 SID 前缀检索相似帖子的 `SidSource`，开源默认处于关闭状态（`home-mixer/sources/sid_source.rs`，`EnableSidSource=false` 见 `param.rs:760-764`）。

## 配置速查（开源默认值）

| 项 | `xrecsys_two_tower`（旗舰） | `xrecsys_two_tower_combined` | `xrecsys_two_tower_nano` |
|---|---|---|---|
| `emb_size` / 层数 / 查询头 / KV 头 | 1024 / 8 / 16 / 4 | 1024 / 8 / 16 / 4 | 512 / 4 / 4 / 2 |
| 历史长度 / 候选长度 | 1023 / 64 | 1023 / 64 | 1022 / 64 |
| 候选组合方式 | concat→MLP | 各自投影再相加 | concat→MLP |
| 候选头 | `home` | `home`、`immersive`、`ld` | `home` |
| 用户查询头 / MoL | 0 / 关 | 4 / 开 | 0 / 关 |
| 温度 | 可学习，初值 0.1 | 固定 0.006 | 可学习，初值 0.1 |
| `max_posts` | 10,240,000 | 28,672,000 | 65,536 |
| 帖 ID 哈希词表 / 作者词表 | 1 亿 / 3000 万 | 1 亿 / 3000 万 | 10 万 / 3 万 |

出处：`xrecsys_two_tower.py:251-357`、`:439-580`、`:930-937`。

## 设计决策

| 决策 | 理由 |
|------|------|
| 双塔分离，索引放进 checkpoint | 在线只算用户塔；索引与模型权重同版本，不会错配（`phoenix/README.md:116-119`） |
| 候选用 SID + 作者，不用帖子 ID | 新帖没有互动也能落到同主题帖附近（`phoenix/README.md:109-115`） |
| 用户塔无用户 ID 嵌入 | 用户完全由行为表示，新老用户同一套参数（`phoenix/README.md:101-108`） |
| 自己的负反馈进分母 | 模型被直接推离用户明确拒绝过的内容 |
| `ld` 作为混合头单独成段 | 不改动原有点赞头，服务时用权重调节两种目标的比例 |
| 冷启动单独切片 | 新帖不与热帖在同一池里竞争 top-K。源码未写理由，此为推测 |

## 源码锚点

- `phoenix/xrex/models/recsys_two_tower_model.py:1610` —— `RecsysTwoTowerModel`
- `phoenix/xrex/models/recsys_two_tower_model.py:180-331` —— `RecsysCandidateTower`
- `phoenix/xrex/models/recsys_two_tower_model.py:1990-2215` —— 用户塔与查询头池化
- `phoenix/xrex/models/recsys_two_tower_model.py:1035-1380` —— `retrieval_loss`
- `phoenix/xrex/models/recsys_two_tower_model.py:2279-2600` —— 服务前向与 top-K
- `phoenix/xrex/models/recsys_two_tower_model.py:2854-3140` —— `RecsysTwoTowerModelConfig`
- `phoenix/xrex/configs/xrecsys_two_tower.py:251-580` —— 预设配置
- `phoenix/xrex/train/trainer_recsys.py:4195-4250` —— 生成索引
- `phoenix/xrex/data/retrieval_dataset.py:290-570` —— 语料类型与冷启动切片

## 相关页面

- [RecsysTwoTowerModel（召回模型）](https://x-algorithm.vergego.ai/entities/recsys-retrieval-model/) —— 召回模型代码构件
- [Phoenix 召回索引：rankall 与事件层](https://x-algorithm.vergego.ai/concepts/phoenix-rankall/) —— 哪些帖子进索引
- [Phoenix 排序模型](https://x-algorithm.vergego.ai/concepts/phoenix-ranking/) —— 召回的下游
- [Phoenix 训练](https://x-algorithm.vergego.ai/concepts/phoenix-training/) —— 训练、合成数据与服务加载
- [Transformer 骨架](https://x-algorithm.vergego.ai/concepts/grok-transformer/) —— 用户塔的 transformer
- [哈希嵌入](https://x-algorithm.vergego.ai/concepts/hash-based-embeddings/) —— 作者哈希与统一嵌入表
- [新号与冷启动：算法怎么区别对待新账号](https://x-algorithm.vergego.ai/guide/new-account-cold-start/) —— 冷启动切片的白话解释
- [候选选取](https://x-algorithm.vergego.ai/concepts/candidate-selection/) —— home-mixer 怎样合并各召回源

Source: https://x-algorithm.vergego.ai/concepts/phoenix-retrieval/index.mdx
