---
title: "候选选择与成型"
description: "选帖过程：TopK 选 50 条 → 选后水合与过滤 → 截断到 35 条 → 外层 BlenderSelector 组装成信息流"
---

> 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.

# 候选选择与成型

## 这一页回答什么

本页回答：候选打完分之后，系统怎样从几百到几千条带分候选里选出最终展示的几十条。本页说明流水线的选择阶段与之后的成型步骤。打分见 [打分与排序](https://x-algorithm.vergego.ai/concepts/scoring-and-ranking/)。

## 核心结论

1. **选择 = 排序 + 截断**。`Selector` trait 的默认 `select()` 按分降序排序，再截断到 `size()`（`candidate-pipeline/selector.rs:52-66`）。
2. **内层先选 50 条，最后留 35 条**。`TopKScoreSelector` 取 `TOP_K_CANDIDATES_TO_SELECT` = 50 条（`home-mixer/params/config.rs:17`）。选后水合与过滤完成后，框架截断到 `RESULT_SIZE` = 35 条（`config.rs:18`）。两个值都是开源默认值。
3. **视频轮播从落选候选里再补选**。视频轮播开启时，`TopKScoreSelector` 从未入选的候选中按轮播分数补选最多 30 条视频（`home-mixer/selectors/top_k_score_selector.rs:18-25`）。这些视频只进轮播模块，不进主信息流。
4. **外层 `BlenderSelector` 不按分排序，只做组装**：帖子与广告混排后，依次插入 Prompt、Who to Follow、视频轮播、PushToHome、Frame、FeedSurvey（`home-mixer/selectors/blender_selector.rs:40-106`）。
5. **框架不丢弃落选候选**：落选候选进入 `non_selected`，与截断掉的候选一起交给 side effect（`candidate-pipeline/candidate_pipeline.rs:141-170`）。

## Selector：排序 + 截断的骨架

`Selector` trait（`candidate-pipeline/selector.rs:24-107`）把选择定义成可复用的骨架：

```rust
// candidate-pipeline/selector.rs:52-66
fn select(&self, _query: &Q, candidates: Vec<C>) -> SelectResult<C> {
    let mut sorted = self.sort(candidates);
    if let Some(limit) = self.size() {
        let non_selected = sorted.split_off(limit.min(sorted.len()));
        SelectResult { selected: sorted, non_selected }
    } else {
        SelectResult { selected: sorted, non_selected: vec![] }
    }
}
```

- `sort()`（`:70-78`）：按 `score()` 降序排序。`partial_cmp` 失败（NaN）时，两条候选视为相等。
- `score()`（`:68`）：业务必须实现，返回用于排序的分数。
- `size()`（`:80-82`）：默认 `None`，不截断。
- `enable()`（`:29-31`）：默认 true。返回 false 时框架跳过选择，全部候选进入 `selected`（`candidate_pipeline.rs:416-431`）。
- `run()`（`:40-50`）在调用 `select()` 前后记录组件统计与 `selected_count`、`non_selected_count`。

业务代码至少要实现 `score()`。`TopKScoreSelector` 与 `BlenderSelector` 都重写了 `select()`。

## TopKScoreSelector：内层选择器

```rust
// home-mixer/selectors/top_k_score_selector.rs:10-38(节选)
fn select(&self, query: &ScoredPostsQuery, candidates: Vec<PostCandidate>) -> SelectResult<PostCandidate> {
    let mut selected = self.sort(candidates);
    let mut non_selected =
        selected.split_off(params::TOP_K_CANDIDATES_TO_SELECT.min(selected.len()));
    if video_carousel::is_enabled(query) {
        selected.extend(video_carousel::take_videos_by_carousel_score(
            &mut non_selected,
            query.params.get(VideoCarouselExtraCandidates),
            &CarouselScoring::from_query(query),
            &query.seen_carousel_video_ids,
        ));
    }
    SelectResult { selected, non_selected }
}

fn score(&self, candidate: &PostCandidate) -> f64 {
    candidate.score.unwrap_or(f64::NEG_INFINITY)
}
```

- 排序依据是 `candidate.score`。在 2e2295b 的装配里，打分链最后写入 `score`：`VMRanker` 先写入加权分与冷启动调整后的分数，再用 vm-ranker 服务返回的分数覆盖（`home-mixer/scorers/vm_ranker.rs:93-136`）。两个页级重排打分器默认关闭。细节见 [打分与排序](https://x-algorithm.vergego.ai/concepts/scoring-and-ranking/) 与 [VM Ranker](https://x-algorithm.vergego.ai/concepts/vm-ranker/)。
- 没有分数的候选取负无穷，排到最后。
- 视频轮播补选（`util/video_carousel.rs:189-206`）：从 `non_selected` 中挑出有视频、不是回复、有 `video_open_score`、轮播里没看过的候选，按轮播分数取最多 `VideoCarouselExtraCandidates` 条（默认 30，`params/param.rs:1343-1348`），并标记 `video_carousel_extra = true`。轮播开关 `EnableVideoCarousel` 默认 false（`params/param.rs:1217-1222`）。

## 选完之后：水合 → 过滤 → 截断 → finalize

选择之后，框架执行四步（`candidate-pipeline/candidate_pipeline.rs:146-160`）：

```rust
// candidate-pipeline/candidate_pipeline.rs:146-160
let post_selection_hydrated_candidates = self
    .hydrate_post_selection(&hydrated_query, selected_candidates)
    .await;
let (mut final_candidates, post_selection_filtered_candidates) =
    self.filter_post_selection(&hydrated_query, post_selection_hydrated_candidates);
filtered_candidates.extend(post_selection_filtered_candidates);
let truncated_candidates = final_candidates.split_off(
    self.result_size_for(&hydrated_query).min(final_candidates.len()),
);
non_selected_candidates.extend(truncated_candidates);
self.finalize(&hydrated_query, &mut final_candidates);
```

| 步骤 | 内层内容 | 为什么放在选择之后 |
|------|----------|--------------------|
| 选后水合 | 10 个水合器：可见性、品牌安全、帖子类型指标、关注者回复头像、互关 Jaccard、话题反馈上下文、AI 趋势反馈上下文、视频媒体、视频嵌入、祖先视频（`phoenix_candidate_pipeline.rs:485-509`） | 可见性与品牌安全查询成本高，只对会展示的候选执行 |
| 选后过滤 | 5 个过滤器：`VFFilter`、`AncillaryVFFilter`、`AncestorVideoFilter`、`DedupConversationFilter`、`VideoCarouselFilter`（`:511-517`） | 这些过滤器依赖选后水合的数据。会话去重要在接近最终的集合上执行。详见 [过滤流水线](https://x-algorithm.vergego.ai/concepts/filtering-pipeline/) |
| 截断 | `result_size_for(query)`，默认等于 `result_size()` = 35 | 选后过滤删掉一部分候选后，再截断到最终条数 |
| finalize | 默认空实现 | 留给业务代码做最后一次调整 |

`result_size_for` 是新增钩子（`candidate_pipeline.rs:100-102`），允许流水线按请求决定结果条数。2e2295b 中没有流水线重写它。

### 为什么有两个数量上限

```mermaid
flowchart LR
    A[打分后的全部候选] -->|"TopKScoreSelector 取 50"| B[入选候选]
    A -.->|"轮播开启时补选至多 30 条视频"| B
    B --> C[选后水合]
    C --> D["选后过滤<br/>删不可见、去会话重复、移出轮播视频"]
    D -->|"截断到 35"| E[内层输出]
```

如果一开始只选 35 条，选后过滤删掉不可见或重复的帖子后，条数会不够。因此选择器先多选 15 条作余量。

## BlenderSelector：外层选择器与最终成型

外层 [ForYouCandidatePipeline](https://x-algorithm.vergego.ai/concepts/home-mixer-orchestration/) 的选择器是 `BlenderSelector`。它的候选是 `FeedItem`，`score()` 恒为 0.0（`blender_selector.rs:108-110`）。帖子顺序在内层已经确定。`select()` 的步骤（`:40-106`）：

1. `partition_feed_items` 按类型分桶：帖子、广告、Who to Follow、Prompt、PushToHome、Frame、FeedSurvey、视频轮播（`:225-257`）。
2. 存在 Who to Follow 模块时丢弃视频轮播（`:55`、`:157-162`）。
3. 按 `AdsBlenderType` 选广告混排器，混排帖子与广告。被丢弃的广告写入 `query.ad_drops`（`:61-82`）。开源默认值是 `multi_risk`（`params/param.rs:957-962`），见 [广告混排](https://x-algorithm.vergego.ai/concepts/ads-blending/)。
4. 依次插入其他内容：

| 内容 | 位置 | 源码 |
|------|------|------|
| Prompt | 列表最前，`position` 记为 `PROMPTS_POSITION` = 0 | `:113-123`、`params/config.rs:31` |
| Who to Follow | 第 6 位（下标 5） | `:125-137`、`config.rs:29` |
| 视频轮播 | `VideoCarouselPosition`，默认第 6 位 | `:139-155`、`param.rs:1229-1234` |
| PushToHome | 钉在下标 0 | `:164-175` |
| Frame | 由 `frames::plan` 规划的槽位 | `:177-198` |
| FeedSurvey | 第 12 位 | `:200-212`、`config.rs:32` |

5. 统计被混排丢掉的帖子数与未投放的广告，构造 `non_selected`（`:95-100`、`selectors/mod.rs:14-40`）。

外层框架截断到 `FOR_YOU_MAX_RESULT_SIZE` = 48（`config.rs:21-22`）之后，还执行一道选后过滤 `AdAdjacentServedFilter`。该过滤器开源默认关闭（`param.rs:1021-1026`）。

## 完整选择链

```mermaid
flowchart TB
    S[打分后的候选] --> T["TopKScoreSelector<br/>按 score 降序取 50"]
    T --> SEL[入选候选]
    T --> NS[落选 → non_selected]
    NS -.->|"轮播开启时补选视频"| SEL
    SEL --> PH["选后水合 ×10"]
    PH --> PF["选后过滤 ×5"]
    PF --> TR["截断到 35"]
    TR --> POSTS[内层输出:帖子 + 可选视频轮播]
    POSTS --> BL["外层 BlenderSelector<br/>帖子与广告混排,再插入模块"]
    BL --> FEED([For You 信息流,至多 48 项])
```

## 设计决策

| 决策 | 理由 |
|------|------|
| `Selector` trait 提供默认 `select()` | 排序与截断是通用逻辑，业务通常只填 `score()` |
| 内层按最终分截断 | 帖子顺序只由打分链决定，行为可预期 |
| 先选 50、最后留 35 | 给选后过滤留余量 |
| 轮播视频从落选候选补选 | 轮播不挤占主信息流的 50 个名额 |
| 昂贵水合放在选择之后 | 只对会展示的候选查询可见性与品牌安全 |
| 外层只组装、不排序 | 相关性排序与广告、模块编排是两件事 |

## FAQ

**Q：落选的候选去哪了？**
A：落选候选进入 `SelectResult.non_selected`，截断掉的候选也并入 `non_selected`。它们随 `SideEffectInput` 交给 side effect。例如，Redis 候选缓存把选中与落选候选一起写入，供下次翻页复用（`side_effects/redis_post_candidate_cache_side_effect.rs:49-60`）。

**Q：Who to Follow 与视频轮播会同时出现吗？**
A：不会。`BlenderSelector` 在 Who to Follow 模块存在时丢弃视频轮播（`blender_selector.rs:157-162`）。

## 源码锚点

- `candidate-pipeline/selector.rs:24-107` —— `Selector` trait 与默认 `select()`
- `candidate-pipeline/candidate_pipeline.rs:141-160` —— 选择、选后水合与过滤、截断
- `home-mixer/selectors/top_k_score_selector.rs:9-39` —— `TopKScoreSelector`
- `home-mixer/util/video_carousel.rs:189-206` —— 轮播补选
- `home-mixer/selectors/blender_selector.rs:39-212` —— `BlenderSelector`
- `home-mixer/params/config.rs:17-32` —— 选择数量与模块位置常量

## 相关页面

- [打分与排序](https://x-algorithm.vergego.ai/concepts/scoring-and-ranking/) —— 候选的最终分怎么来
- [VM Ranker](https://x-algorithm.vergego.ai/concepts/vm-ranker/) —— 写入最终 `score` 的重排服务
- [过滤流水线](https://x-algorithm.vergego.ai/concepts/filtering-pipeline/) —— 选后过滤器细节
- [广告混排](https://x-algorithm.vergego.ai/concepts/ads-blending/) —— `BlenderSelector` 的广告混排
- [CandidatePipeline trait](https://x-algorithm.vergego.ai/entities/candidate-pipeline/) —— `execute()` 的阶段编排
- [candidate-pipeline 框架](https://x-algorithm.vergego.ai/concepts/candidate-pipeline-framework/) —— `Selector` trait 在框架中的位置
- [home-mixer 编排层](https://x-algorithm.vergego.ai/concepts/home-mixer-orchestration/) —— 内外两层流水线
- [帖子是怎么被选中的](https://x-algorithm.vergego.ai/guide/how-posts-are-picked/) —— 本页的白话版

Source: https://x-algorithm.vergego.ai/concepts/candidate-selection/index.mdx
