Skip to content

SimClusters 站外候选源

SimClusters 把关注图聚成约 14.5 万个社区,用社区向量表示用户与帖子;home-mixer 以用户最近互动过的帖子为种子,经 SimClusters ANN 召回相似的站外帖

Updated

这一页回答什么

SimClusters 是 For You 的站外候选源之一,与 Phoenix 召回 并列。本页回答四个问题:社区(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。

主干数据流

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 编排层。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 框架 的约定,单个候选源失败不影响其他候选源。

合并与补全

  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 的关系

// 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 —— 开关与帖龄上限

相关页面

Type to search…

↑↓ navigate↵ selectEsc close