---
title: "SimClusters 站外候选源"
description: "SimClusters 把关注图聚成约 14.5 万个社区，用社区向量表示用户与帖子；home-mixer 以用户最近互动过的帖子为种子，经 SimClusters ANN 召回相似的站外帖"
---

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

# SimClusters 站外候选源

## 这一页回答什么

SimClusters 是 For You 的站外候选源之一，与 [Phoenix 召回](https://x-algorithm.vergego.ai/concepts/phoenix-retrieval/) 并列。本页回答四个问题：社区（cluster）如何构建；用户和帖子如何表示为社区向量；home-mixer 的 `SimclustersSource` 如何调用 SimClusters ANN 召回帖子；`SimclustersSource` 与 `OONNsfwSimclustersFilter` 是什么关系。

`simclusters/` 目录约有 3.5 万行 Scala 代码，大部分是离线批处理作业和流式作业。本页只说明 For You 用到的主干部分。

## 核心结论

1. **SimClusters 于 2026-08-13 随上游更新开源**。README 把它列为“在召回阶段与 Thunder、Phoenix 召回一起调用的站外候选源”（`README.md:70`）。home-mixer 侧的 `SimclustersSource` 默认开启（`home-mixer/params/param.rs:48-53`）。
2. **社区来自关注图**。离线作业每 7 天运行一次。它选取约 2000 万个“被足够多活跃用户关注”的账号，按关注者相似度用 Metropolis-Hastings 采样聚成社区，得到每个账号的 KnownFor 向量（`simclusters/simclusters_v2/scalding/update_known_for/UpdateKnownFor20M145K2020.scala:35-116`）。模型版本名 `20M_145K_2020` 表示 2000 万账号、14.5 万个社区。
3. **用户向量 InterestedIn = 用户关注、点赞过的账号的 KnownFor 加权和**。InterestedIn 同样每 7 天离线更新一次（`simclusters/simclusters_v2/scalding/InterestedInFromKnownFor.scala:25-35`）。
4. **帖子向量实时更新**。每当有人点赞一条帖子，流式作业就把点赞者的 InterestedIn 向量按 8 小时半衰期累加到这条帖子上。作业同时维护“每个社区当前得分最高的帖子”索引（`simclusters/simclusters_v2/summingbird/storm/TweetJob.scala:59-210`；`summingbird/common/Configs.scala:39`）。
5. **召回方式是“由帖子找帖子”**。home-mixer 取用户最近显式或隐式互动过的帖子（90 天内）作为种子，并为每个种子请求一次 SimClusters ANN。ANN 取种子帖向量中得分最高的 50 个社区，合并这些社区的热门帖，再按近似余弦打分（`home-mixer/sources/simclusters_source.rs:25-36`、`313-334`）。
6. **home-mixer 侧再次筛选**：它只保留余弦分大于 0.5 的结果，把各种子的结果轮流交错并去重，总数截断到 800（`simclusters_source.rs:35`、`26`、`336-359`）。候选帖龄上限为 48 小时（`param.rs:753-758`）。
7. **`OONNsfwSimclustersFilter` 只作用于这一路候选**：来自 SimClusters、作者被标为成人内容、且用户未关注该作者的帖子，在打分前被移除（`home-mixer/filters/oon_nsfw_simclusters_filter.rs:9-27`）。这个过滤器的开关就是 `EnableSimclustersSource`。

## 主干数据流

```mermaid
flowchart TD
    FG[("关注图<br/>账号间关注者余弦相似度")] -->|"每 7 天 · MH 采样聚类"| KF["KnownFor<br/>账号 → 社区"]
    UG[("用户关注 / 点赞图")] --> II["InterestedIn<br/>用户 → 社区"]
    KF -->|"每 7 天"| II
    FAV[("实时点赞事件")] --> TJ["TweetJob 流式作业<br/>点赞者 InterestedIn 累加到帖子"]
    II --> TJ
    TJ --> TE[("帖子 → 社区向量<br/>8 小时半衰期")]
    TJ --> CT[("社区 → 热门帖索引")]
    TE --> ANN["SimClusters ANN 服务<br/>近似余弦"]
    CT --> ANN
    SEED["你最近互动过的帖子"] --> SRC["home-mixer<br/>SimclustersSource"]
    SRC -->|"每个种子一次查询"| ANN
    ANN -->|"相似帖 + 余弦分"| SRC
    SRC --> PIPE["过滤 · 打分流水线"]
```

## 构建社区：KnownFor

`UpdateKnownFor20M145K2020` 是一个按周调度的 Scalding 作业。它首次运行于 2020-10-04，增量周期为 7 天（`UpdateKnownFor20M145K2020.scala:35-39`）。主要参数如下（启动参数默认值，`UpdateKnownFor20M145K2020.scala:57-65`）：

| 参数 | 默认值 | 含义 |
|------|------:|------|
| `minActiveFollowers` | 400 | 账号至少需要这么多活跃关注者，才参与聚类 |
| `topK` | 20000000 | 参与聚类的账号数上限 |
| `maxNeighbors` | 400 | 每个账号在相似图中保留的邻居数 |
| `maxEpochs` | 3 | 聚类迭代轮数 |
| `wtCoeff` | 10.0 | 聚类目标中的权重系数 |

输入是账号之间的“关注者余弦相似度”图（`FollowingsCosineSimilaritiesManhattanSource`），上一版 KnownFor 作为初始值（`UpdateKnownFor20M145K2020.scala:67-91`）。聚类使用 `com.twitter.sbf` 的 `MHAlgorithm`：它在稀疏二值分配矩阵上执行 Metropolis-Hastings 采样，使同一社区内的账号彼此相似（`UpdateKnownForSBFRunner.scala:455-467`）。聚类结束后，作业用启发式方法给每个账号对所属社区打分，写成 KnownFor 数据集，并把与上一版的差异通过邮件发给维护组（`UpdateKnownForSBFRunner.scala:500-561`；`UpdateKnownFor20M145K2020.scala:92-112`）。

`com.twitter.sbf` 库本身不在仓库中，源码未包含。

## 用户向量：InterestedIn

`InterestedInFromKnownFor20M145K2020` 每 7 天运行一次，回看 30 天的数据（`InterestedInFromKnownFor.scala:25-41`）。它读取归一化的用户-用户图（用户关注了谁、给谁点过赞），把每条边所指向账号的 KnownFor 向量按边权累加到该用户上（`InterestedInFromKnownFor.scala:204-260`）。结果按多种口径保存分数，包括 `favScore`、`logFavScore`、按 100 天半衰期衰减的点赞分等。作业再对分数做归一化，并只保留得分最高的社区（`InterestedInFromKnownFor.scala:294-508`）。

两者的区别是：KnownFor 回答“这个账号属于哪些社区”，InterestedIn 回答“这个用户关心哪些社区”。

## 帖子向量：实时累加

`TweetJob`（`simclusters/simclusters_v2/summingbird/storm/TweetJob.scala`）是一个 Summingbird/Storm 流式作业，按以下步骤执行：

1. 只接收服务端点赞事件 `ServerTweetFav`，排除作者给自己的点赞，并排除太旧的帖子（`TweetJob.scala:59-72`、`295-299`）。“太旧”的阈值是 3 天（`Configs.scala:69`）。
2. 关联点赞者的 InterestedIn，取其中得分最高的社区，并按 `favScoreThresholdForUserInterest` 过滤。`20M_145K_2020` 版本的阈值是 0.3（`TweetJob.scala:74-113`；`Configs.scala:27-32`）。
3. 按社区把分数累加到帖子的社区分上，分数按 8 小时半衰期衰减（`Configs.scala:39`）。
4. 同时维护两份索引：帖子 → 得分最高的社区，社区 → 得分最高的帖子（`TweetJob.scala:134-210`）。容量常量见 `Configs.scala:51-55`（每个社区 1600 条帖子，每条帖子 400 个社区）。
5. 同时写出每条帖子点赞向量的平方范数，供召回时归一化使用（`TweetJob.scala:160-182`）。

帖子向量有两种形态，用途不同：

- `LogFavBasedTweet`：随时间衰减的当前向量，用于构建“社区 → 热门帖”索引，代表**被召回的一方**。
- `LogFavLongestL2EmbeddingTweet`：持久化作业保留的“L2 范数最大时”的那一版向量（`summingbird/common/Monoids.scala:193-208`），只为点赞数不少于 8 的帖子生成（`summingbird/storm/PersistentTweetJob.scala:23`、`54`）。帖子热度衰减后，它仍保留最强的信号，因此用作**查询种子**。

## 召回服务：SimClusters ANN

`simclusters/simclustersann/` 是一个 Finatra 服务，对外提供 `getTweetCandidates`。一次查询按以下步骤执行（`simclustersann/candidate_source/SimClustersANNCandidateSource.scala:27-96`）：

1. 读取种子的 SimClusters 向量。读不到时，返回空结果。
2. 截取向量中得分最高的 `maxScanClusters` 个社区。
3. 批量读取这些社区的热门帖，以及每条帖子的点赞平方范数。社区热门帖索引外层有 15 分钟的 memcache（`simclustersann/modules/ClusterTweetIndexProviderModule.scala`）。
4. 交给 `ApproximateCosineSimilarity` 打分。

近似余弦的计算方式如下（`simclustersann/candidate_source/ApproximateCosineSimilarity.scala:48-132`）：

```
对每个扫描到的社区 c、该社区热门帖里的帖子 t（最多 maxTopTweetsPerCluster 条）：
    分子[t]  += tweet_c(t) · source_c
    范数²[t] += tweet_c(t)²
COSINE_SIMILARITY:  score(t) = 分子[t] / ‖source‖₂ / sqrt(范数²[t])
```

“近似”体现在两处：服务只看种子最强的若干社区；候选帖的范数只用这些社区中的分量估计。打分前，服务排除种子帖本身，以及不在 `[最小帖龄, 最大帖龄]` 时间窗内的帖子（用 snowflake id 判断，`ApproximateCosineSimilarity.scala:57-91`）。最后，服务按 `minScore` 过滤、降序排列，并截断到 `min(maxNumResults, 1000)` 条（`ApproximateCosineSimilarity.scala:128-131`）。

`simclusters/simclusters_v2/candidate_source/` 下另有一套 Scala 侧的候选源封装（`SimClustersANNCandidateSource`、`HeavyRanker` 等）。home-mixer 不经过这些封装，而是用 Rust 客户端直接调用 ANN 服务。

## home-mixer 的调用方式

### 启用条件

`SimclustersSource::enable()`（`home-mixer/sources/simclusters_source.rs:215-220`）要求以下条件同时成立：

- `EnableSimclustersSource` 为真，默认值为 `true`（`home-mixer/params/param.rs:48-53`）；
- 请求不是“只看关注”请求（`in_network_only`）；
- 请求不是缓存命中请求；
- 查询中有帖子互动信号（显式或隐式，`simclusters_source.rs:277-285`）。

### 种子选择

`post_signal_ids()` 合并显式和隐式互动信号，按互动时间倒序排列，并按帖子 id 去重（`simclusters_source.rs:287-311`）。之后，它只保留发布时间不超过 90 天的帖子作为种子（`MAX_SEED_AGE`，`simclusters_source.rs:36`、`223-228`）。查询水合阶段写入这些信号，见 [home-mixer 编排层](https://x-algorithm.vergego.ai/concepts/home-mixer-orchestration/)。`SidSource` 也复用同一组种子函数（`home-mixer/sources/sid_source.rs:18`）。

### 查询参数

`SimclustersSource` 为每个种子构造一个 ANN 查询（`simclusters_source.rs:313-334`），常量定义在 `simclusters_source.rs:25-36`：

| 常量 | 值 | 含义 |
|------|------:|------|
| `SOURCE_EMBEDDING_TYPE` | `LOG_FAV_LONGEST_L2_EMBEDDING_TWEET` | 种子帖使用持久化的最强向量 |
| `CANDIDATE_EMBEDDING_TYPE` | `LOG_FAV_BASED_TWEET` | 候选帖使用实时衰减向量 |
| `MODEL_VERSION` | `MODEL_20M_145K_2020` | 社区模型版本 |
| `ANN_MAX_SCAN_CLUSTERS` | 50 | 每个种子扫描的社区数 |
| `ANN_MAX_TOP_POSTS_PER_CLUSTER` | 800 | 每个社区最多读取的热门帖数 |
| `ANN_MAX_NUM_RESULTS` | 200 | 每个种子最多返回的条数 |
| `ANN_MIN_SCORE` | 0.0 | 服务端最低分 |
| `ANN_MIN_POST_CANDIDATE_AGE_HOURS` | 0 | 候选的最小帖龄 |
| `ann_algorithm` | `COSINE_SIMILARITY` | 打分算法 |
| `POST_ANN_MIN_SCORE` | 0.5 | home-mixer 侧的最低余弦分 |
| `MAX_SANN_CANDIDATES` | 10000 | 所有种子合计的候选预算 |
| `MAX_RESULTS` | 800 | 本候选源的最终返回上限 |

`SimclustersMaxCandidateAgeHours` 控制候选的最大帖龄，默认值为 48（`param.rs:753-758`），与过滤阶段 48 小时的 `AgeFilter` 一致。

### 两级缓存

`candidates_by_seed()`（`simclusters_source.rs:64-142`）对每个种子依次查询：

1. **进程内缓存**：使用 moka，容量 200 万条，TTL 600 秒，键为 `(种子 id, 最大帖龄)`（`simclusters_source.rs:56-59`）。
2. **共享 memcache**：键前缀为 `HOME:SANN:v1:`，请求超时 50 ms，不重试，TTL 600 秒（`home-mixer/clients/simclusters_ann_cache_client.rs:15-26`）。
3. **ANN 服务**：两级缓存都未命中时才调用，超时 600 ms（`home-mixer/clients/simclusters_ann_client.rs:20`）。结果先按 0.5 过滤，再写回两级缓存；写共享缓存在后台任务中执行（`simclusters_source.rs:109-136`、`144-173`）。

任何一个种子的 ANN 调用失败时，整个 `source()` 返回错误，这一路候选为空（`simclusters_source.rs:138-141`）。按 [candidate-pipeline 框架](https://x-algorithm.vergego.ai/concepts/candidate-pipeline-framework/) 的约定，单个候选源失败不影响其他候选源。

### 合并与补全

1. 每个种子的结果再按 0.5 过滤，并截断到 `10000 / 种子数`（至少 1）条（`simclusters_source.rs:233`、`239-249`）。
2. `interleave_by_post_id()` 轮流从各种子的队列中取一条，跳过已出现的帖子，形成交错序列（`simclusters_source.rs:336-359`）。排在前面的种子（最近互动的帖子）先输出。
3. 截断到 800 条，标记 `served_type = ForYouSimclusters`，并记录 ANN 分和位置（`simclusters_source.rs:251-267`）。
4. 候选源内部直接调用 `CoreDataCandidateHydrator` 补全帖子核心数据，并丢弃作者 id 为 0 的帖子（即取不到核心数据的帖子）（`simclusters_source.rs:269-273`）。

装配位置：`SimclustersSource` 是内层流水线的第 4 个候选源，排在 Thunder、热门帖、TweetMixer 之后（`home-mixer/candidate_pipeline/phoenix_candidate_pipeline.rs:370-390`）。流水线并行调用各候选源，顺序只影响合并时的排列。

## 与 OONNsfwSimclustersFilter 的关系

```rust
// home-mixer/filters/oon_nsfw_simclusters_filter.rs:9-27（节选）
fn enable(&self, query: &ScoredPostsQuery) -> bool {
    query.params.get(EnableSimclustersSource)
}
// 移除条件
c.served_type == Some(ServedType::ForYouSimclusters)
    && c.in_network == Some(false)
    && c.nsfw_author == Some(true)
```

- **只作用于 SimClusters 这一路**：判断条件的第一项就是 `served_type == ForYouSimclusters`。这个过滤器不影响其他候选源中成人内容作者的帖子；这些帖子由其他过滤器和 [可见性过滤](https://x-algorithm.vergego.ai/concepts/visibility-filtering/) 处理。
- **只作用于站外帖**：用户关注了作者时，即使作者被标为成人内容，这个过滤器也不移除其帖子。
- **依赖水合结果**：`InNetworkCandidateHydrator` 写入 `in_network`，作者水合（`home-mixer/candidate_hydrators/gizmoduck_hydrator.rs:50`）写入 `nsfw_author`。
- **开关与候选源绑定**：过滤器的 `enable()` 读取的就是 `EnableSimclustersSource`。关闭候选源时，过滤器也随之关闭。
- **位置**：在打分前的过滤序列中，它紧跟 `OONRetweetReplyFilter`（`phoenix_candidate_pipeline.rs:428-439`）。完整顺序见 [过滤流水线](https://x-algorithm.vergego.ai/concepts/filtering-pipeline/)。

设计原因（推测）：SimClusters 按“点赞者所在社区”传播帖子。成人内容账号的帖子容易在相关社区中获得高分，从而被推给从未关注过这类账号的用户。这个过滤器专门封堵这条路径。

## 设计决策

| 决策 | 理由 |
|------|------|
| 社区来自关注图，而不是内容 | 关注关系稳定、覆盖面大；社区天然对应“一群人共同关心的事物” |
| 账号与用户向量按周离线更新，帖子向量实时更新 | 社区结构变化慢，帖子热度变化快；分开更新可以兼顾成本与新鲜度 |
| 种子使用“L2 最大时”的持久向量 | 种子帖可能已发布多天，实时向量已经衰减；持久向量保留了它信息量最大的时刻 |
| 由帖子找帖子，而不是用用户向量直接召回 | 以用户刚互动过的具体帖子为起点，结果更贴近当下兴趣，也更容易解释 |
| home-mixer 再加 0.5 的最低分 | ANN 服务端最低分是 0.0；只靠服务端会带回大量弱相关帖子 |
| 两级缓存 + 按种子缓存 | 热门帖子会成为大量用户的共同种子；按种子缓存可以被多人复用 |
| 专门的站外成人内容过滤器 | 社区扩散会把这类内容推到未关注的用户面前，需要在这一路单独拦截 |

## 源码锚点

- `simclusters/simclusters_v2/scalding/update_known_for/UpdateKnownFor20M145K2020.scala:35-116` —— KnownFor 周作业与参数
- `simclusters/simclusters_v2/scalding/update_known_for/UpdateKnownForSBFRunner.scala:455-561` —— MH 聚类与社区打分
- `simclusters/simclusters_v2/scalding/InterestedInFromKnownFor.scala:25-260` —— InterestedIn 计算
- `simclusters/simclusters_v2/summingbird/storm/TweetJob.scala:59-210` —— 帖子向量与社区热门帖索引
- `simclusters/simclusters_v2/summingbird/common/Configs.scala:27-69` —— 半衰期、阈值、容量常量
- `simclusters/simclustersann/candidate_source/SimClustersANNCandidateSource.scala:27-96` —— ANN 查询流程
- `simclusters/simclustersann/candidate_source/ApproximateCosineSimilarity.scala:48-132` —— 近似余弦
- `home-mixer/sources/simclusters_source.rs:25-359` —— home-mixer 侧的种子、查询、缓存与合并
- `home-mixer/filters/oon_nsfw_simclusters_filter.rs:9-27` —— 站外成人内容过滤
- `home-mixer/params/param.rs:48-53`、`753-758` —— 开关与帖龄上限

## 相关页面

- [Phoenix 召回：双塔检索架构](https://x-algorithm.vergego.ai/concepts/phoenix-retrieval/) —— 并列的另一路站外召回
- [Thunder 站内帖子内存库](https://x-algorithm.vergego.ai/concepts/thunder-in-network-store/) —— 站内候选源
- [过滤流水线](https://x-algorithm.vergego.ai/concepts/filtering-pipeline/) —— `OONNsfwSimclustersFilter` 所在的过滤序列
- [可见性过滤](https://x-algorithm.vergego.ai/concepts/visibility-filtering/) —— 成人内容等标签的统一处理
- [home-mixer 编排层](https://x-algorithm.vergego.ai/concepts/home-mixer-orchestration/) —— 候选源的装配与互动信号的水合
- [candidate-pipeline 框架与七个组件 trait](https://x-algorithm.vergego.ai/concepts/candidate-pipeline-framework/) —— `Source` trait 与并行调用
- [系统架构总览](https://x-algorithm.vergego.ai/concepts/system-architecture/) —— 各候选源在系统中的位置

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