这一页回答什么
SimClusters 是 For You 的站外候选源之一,与 Phoenix 召回 并列。本页回答四个问题:社区(cluster)如何构建;用户和帖子如何表示为社区向量;home-mixer 的 SimclustersSource 如何调用 SimClusters ANN 召回帖子;SimclustersSource 与 OONNsfwSimclustersFilter 是什么关系。
simclusters/ 目录约有 3.5 万行 Scala 代码,大部分是离线批处理作业和流式作业。本页只说明 For You 用到的主干部分。
核心结论
- SimClusters 于 2026-08-13 随上游更新开源。README 把它列为“在召回阶段与 Thunder、Phoenix 召回一起调用的站外候选源”(
README.md:70)。home-mixer 侧的SimclustersSource默认开启(home-mixer/params/param.rs:48-53)。 - 社区来自关注图。离线作业每 7 天运行一次。它选取约 2000 万个“被足够多活跃用户关注”的账号,按关注者相似度用 Metropolis-Hastings 采样聚成社区,得到每个账号的 KnownFor 向量(
simclusters/simclusters_v2/scalding/update_known_for/UpdateKnownFor20M145K2020.scala:35-116)。模型版本名20M_145K_2020表示 2000 万账号、14.5 万个社区。 - 用户向量 InterestedIn = 用户关注、点赞过的账号的 KnownFor 加权和。InterestedIn 同样每 7 天离线更新一次(
simclusters/simclusters_v2/scalding/InterestedInFromKnownFor.scala:25-35)。 - 帖子向量实时更新。每当有人点赞一条帖子,流式作业就把点赞者的 InterestedIn 向量按 8 小时半衰期累加到这条帖子上。作业同时维护“每个社区当前得分最高的帖子”索引(
simclusters/simclusters_v2/summingbird/storm/TweetJob.scala:59-210;summingbird/common/Configs.scala:39)。 - 召回方式是“由帖子找帖子”。home-mixer 取用户最近显式或隐式互动过的帖子(90 天内)作为种子,并为每个种子请求一次 SimClusters ANN。ANN 取种子帖向量中得分最高的 50 个社区,合并这些社区的热门帖,再按近似余弦打分(
home-mixer/sources/simclusters_source.rs:25-36、313-334)。 - home-mixer 侧再次筛选:它只保留余弦分大于 0.5 的结果,把各种子的结果轮流交错并去重,总数截断到 800(
simclusters_source.rs:35、26、336-359)。候选帖龄上限为 48 小时(param.rs:753-758)。 OONNsfwSimclustersFilter只作用于这一路候选:来自 SimClusters、作者被标为成人内容、且用户未关注该作者的帖子,在打分前被移除(home-mixer/filters/oon_nsfw_simclusters_filter.rs:9-27)。这个过滤器的开关就是EnableSimclustersSource。
主干数据流
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 流式作业,按以下步骤执行:
- 只接收服务端点赞事件
ServerTweetFav,排除作者给自己的点赞,并排除太旧的帖子(TweetJob.scala:59-72、295-299)。“太旧”的阈值是 3 天(Configs.scala:69)。 - 关联点赞者的 InterestedIn,取其中得分最高的社区,并按
favScoreThresholdForUserInterest过滤。20M_145K_2020版本的阈值是 0.3(TweetJob.scala:74-113;Configs.scala:27-32)。 - 按社区把分数累加到帖子的社区分上,分数按 8 小时半衰期衰减(
Configs.scala:39)。 - 同时维护两份索引:帖子 → 得分最高的社区,社区 → 得分最高的帖子(
TweetJob.scala:134-210)。容量常量见Configs.scala:51-55(每个社区 1600 条帖子,每条帖子 400 个社区)。 - 同时写出每条帖子点赞向量的平方范数,供召回时归一化使用(
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):
- 读取种子的 SimClusters 向量。读不到时,返回空结果。
- 截取向量中得分最高的
maxScanClusters个社区。 - 批量读取这些社区的热门帖,以及每条帖子的点赞平方范数。社区热门帖索引外层有 15 分钟的 memcache(
simclustersann/modules/ClusterTweetIndexProviderModule.scala)。 - 交给
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 编排层。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)对每个种子依次查询:
- 进程内缓存:使用 moka,容量 200 万条,TTL 600 秒,键为
(种子 id, 最大帖龄)(simclusters_source.rs:56-59)。 - 共享 memcache:键前缀为
HOME:SANN:v1:,请求超时 50 ms,不重试,TTL 600 秒(home-mixer/clients/simclusters_ann_cache_client.rs:15-26)。 - 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 框架 的约定,单个候选源失败不影响其他候选源。
合并与补全
- 每个种子的结果再按 0.5 过滤,并截断到
10000 / 种子数(至少 1)条(simclusters_source.rs:233、239-249)。 interleave_by_post_id()轮流从各种子的队列中取一条,跳过已出现的帖子,形成交错序列(simclusters_source.rs:336-359)。排在前面的种子(最近互动的帖子)先输出。- 截断到 800 条,标记
served_type = ForYouSimclusters,并记录 ANN 分和位置(simclusters_source.rs:251-267)。 - 候选源内部直接调用
CoreDataCandidateHydrator补全帖子核心数据,并丢弃作者 id 为 0 的帖子(即取不到核心数据的帖子)(simclusters_source.rs:269-273)。
装配位置:SimclustersSource 是内层流水线的第 4 个候选源,排在 Thunder、热门帖、TweetMixer 之后(home-mixer/candidate_pipeline/phoenix_candidate_pipeline.rs:370-390)。流水线并行调用各候选源,顺序只影响合并时的排列。
与 OONNsfwSimclustersFilter 的关系
// 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。这个过滤器不影响其他候选源中成人内容作者的帖子;这些帖子由其他过滤器和 可见性过滤 处理。 - 只作用于站外帖:用户关注了作者时,即使作者被标为成人内容,这个过滤器也不移除其帖子。
- 依赖水合结果:
InNetworkCandidateHydrator写入in_network,作者水合(home-mixer/candidate_hydrators/gizmoduck_hydrator.rs:50)写入nsfw_author。 - 开关与候选源绑定:过滤器的
enable()读取的就是EnableSimclustersSource。关闭候选源时,过滤器也随之关闭。 - 位置:在打分前的过滤序列中,它紧跟
OONRetweetReplyFilter(phoenix_candidate_pipeline.rs:428-439)。完整顺序见 过滤流水线。
设计原因(推测):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 召回:双塔检索架构 —— 并列的另一路站外召回
- Thunder 站内帖子内存库 —— 站内候选源
- 过滤流水线 ——
OONNsfwSimclustersFilter所在的过滤序列 - 可见性过滤 —— 成人内容等标签的统一处理
- home-mixer 编排层 —— 候选源的装配与互动信号的水合
- candidate-pipeline 框架与七个组件 trait ——
Sourcetrait 与并行调用 - 系统架构总览 —— 各候选源在系统中的位置