Article

2026-08-11-襷の系譜-选手关系标签化与算分方案

个人项目

襷の系譜 选手关系标签化与算分方案

日期:2026-08-11

1. 目标

本文档用于沉淀 选手关系 下一步迭代中的三件事:

  1. 关系标签体系
  2. 关系缓存 payload / schema
  3. 第一版算分方案

本次重点不是直接改前端表现,而是先把“标签语义”和“关系信号结构”定下来。

2. 本次结论

2.1 信息类标签

信息类标签先定为:

  1. 同出身地
  2. 中学同队 · X年
  3. 同中学经历
  4. 高中同队 · X年
  5. 同高中经历
  6. 大学同队 · X年
  7. 同大学经历
  8. 同实业团经历

说明:

  1. 同队 · X年 只用于 中学 / 高中 / 大学
  2. 同校经历 表示双方都曾在同一组织,不要求时间重叠
  3. 同实业团经历 第一版只保留经历类标签,不计算重叠年数

2.2 比赛类标签

比赛类标签先定为:

  1. 多次对决
  2. 长期交手
  3. 跨阶段交手

说明:

  1. 标签对用户展示时保持稳定语义
  2. 标签背后保留数值字段,用于排序和后续算分
  3. 不把功能状态做成标签,例如“可查看详细对战”

3. 信息类标签的模型边界

3.1 为什么 teamOverlapYears 值得加入

当前 Membership 已经具备较稳定的时间边界:

  1. junior_high_schoolhigh_schooluniversity 三类组织的起止日期完整度较高
  2. 学籍边界天然适合按年表达
  3. PlayerRelationCache 本身是派生 JSON 缓存,扩字段成本低

因此:

  1. 中学同队 · X年
  2. 高中同队 · X年
  3. 大学同队 · X年

都适合直接建立在现有 Membership 事实之上。

3.2 为什么实业团不做 X年

当前 corporate_team 的时间窗口数据明显不如学籍阶段稳定:

  1. 入队和离队时间缺失较多
  2. 移籍和过渡期更复杂
  3. 强行输出 X年 容易制造伪精确

因此第一版约束为:

  1. 同实业团经历 可以展示
  2. 不计算 实业团同队 · X年
  3. 不把实业团纳入 teamOverlapYears

4. 比赛类标签的制定依据

比赛类标签需要避免两个问题:

  1. 某个标签覆盖过大,几乎人人都有
  2. 某个标签覆盖过小,几乎没有展示价值

因此本次先基于线上 PlayerRelationCache 全量关系边做阈值观察。

样本口径:

  1. 全量关系边:90,486
  2. 每条关系边来自 PlayerRelationCache.payload.topRelations
  3. matchupYearCount 通过双方共同出现的 Race 去重年份统计得到

4.1 观察结果

关键分布如下:

  1. matchupCount >= 311,667
  2. matchupCount >= 46,132
  3. matchupCount >= 53,221
  4. matchupYearCount >= 220,893
  5. matchupYearCount >= 36,791
  6. matchupYearCount >= 42,163
  7. stageCount >= 24,435
  8. stageCount >= 383

4.2 第一版阈值选择

为了让三个比赛类标签覆盖率相对接近,第一版建议:

  1. 多次对决matchupCount >= 4
  2. 长期交手matchupYearCount >= 3
  3. 跨阶段交手stageCount >= 2

对应覆盖率大致为:

  1. 多次对决:约 6.8%
  2. 长期交手:约 7.5%
  3. 跨阶段交手:约 4.9%

这组阈值的好处是:

  1. 不会像 >= 2年 那样过宽
  2. 也不会像 stageCount >= 3 那样过窄
  3. 三个标签既有重叠,也不会完全变成同一个标签

5. Schema

本次不新建正式业务表,继续沿用 PlayerRelationCache.payload 作为派生关系结构。

建议下一版 relation payload 至少扩展为:

type RelationStageKey =
  | "junior_high_school"
  | "high_school"
  | "university"
  | "corporate_team";

type PlayerRelationContextV2 = {
  sameHometown: boolean;
  sharedOrigins: {
    juniorHighSchool: boolean;
    highSchool: boolean;
    university: boolean;
    corporateTeam: boolean;
  };
  teamOverlapYears: {
    juniorHighSchool?: number;
    highSchool?: number;
    university?: number;
  };
};

type PlayerRelationMatchupSignals = {
  matchupCount: number;
  matchupYearCount: number;
  stageCount: number;
  ekidenCount: number;
  latestMatchAt: string | null;
};

type PlayerRelationLabels = {
  info: Array<
    | "same_hometown"
    | "same_junior_high_team"
    | "same_junior_high_origin"
    | "same_high_school_team"
    | "same_high_school_origin"
    | "same_university_team"
    | "same_university_origin"
    | "same_corporate_team_origin"
  >;
  matchup: Array<
    | "frequent_matchup"
    | "long_term_matchup"
    | "cross_stage_matchup"
  >;
};

type PlayerRelationEntryV2 = {
  relatedPersonId: string;
  context: PlayerRelationContextV2;
  matchupSignals: PlayerRelationMatchupSignals;
  labels: PlayerRelationLabels;
  hasHeadToHeadDetail: boolean;
  latestCompetitionEditionId: string | null;
  latestCompetitionName: string | null;
  score?: number;
  scoreBreakdown?: Record<string, number>;
};

type PlayerRelationCachePayloadV2 = {
  personId: string;
  generatedAt: string;
  topRelations: PlayerRelationEntryV2[];
};

6. 标签生成口径

6.1 信息类标签

  1. same_hometown
    • 双方 hometown 相同
  2. same_junior_high_team
    • 同一中学且时间窗口重叠
  3. same_junior_high_origin
    • 同一中学经历,但不要求重叠
  4. same_high_school_team
    • 同一高中且时间窗口重叠
  5. same_high_school_origin
    • 同一高中经历,但不要求重叠
  6. same_university_team
    • 同一大学且时间窗口重叠
  7. same_university_origin
    • 同一大学经历,但不要求重叠
  8. same_corporate_team_origin
    • 同一实业团经历,不要求重叠

6.2 比赛类标签

  1. frequent_matchup
    • matchupCount >= 4
  2. long_term_matchup
    • matchupYearCount >= 3
  3. cross_stage_matchup
    • stageCount >= 2

7. 前端展示文案

信息类:

  1. 同出身地
  2. 中学同队 · 1年
  3. 中学同队 · 3年
  4. 同中学经历
  5. 高中同队 · 3年
  6. 同高中经历
  7. 大学同队 · 4年
  8. 同大学经历
  9. 同实业团经历

比赛类:

  1. 多次对决
  2. 长期交手
  3. 跨阶段交手

7.1 关系网络页面命名

关系的独立页面统一命名为 关系网络

多语言文案建议如下:

  1. ja: 関係ネットワーク
  2. zh: 关系网络
  3. zh-Hant: 關係網絡
  4. en: Relationship Network
  5. ko: 관계 네트워크

8. 算分 V1

本次先固定一个 可解释、可调参、偏保守 的第一版参数。

原则:

  1. 标签负责语义
  2. 数值字段负责强弱
  3. 同队/同校背景比赛交手强度 都参与排序
  4. 第一版先采用人工可理解的启发式方案,不引入动态稀缺性权重

第一版总公式:

finalScore = infoScore + matchupScore + recencyBonus

8.1 信息类分

sameHometown = +3

sameJuniorHighOrigin = +5
sameHighSchoolOrigin = +6
sameUniversityOrigin = +5
sameCorporateTeamOrigin = +4

sameJuniorHighTeam = 8 + 2 * juniorHighOverlapYears
sameHighSchoolTeam = 10 + 2 * highSchoolOverlapYears
sameUniversityTeam = 9 + 1.5 * universityOverlapYears

口径说明:

  1. 同队 明显高于 同校经历
  2. 同高中 / 同大学同队 是最强的信息类信号
  3. 同出身地 只做轻辅助
  4. overlapYears 进入信息类分,但不让年数无限放大

例子:

  1. 大学同队 · 4年 = 9 + 1.5 * 4 = 15
  2. 高中同队 · 3年 = 10 + 2 * 3 = 16
  3. 同大学经历 = 5

8.2 比赛类分

matchupScore =
  6 * ln(1 + matchupCount)
  + 5 * ln(1 + matchupYearCount)
  + 4 * max(stageCount - 1, 0)

口径说明:

  1. matchupCount 表示对决强度
  2. matchupYearCount 表示持续性
  3. stageCount 表示关系跨度
  4. matchupCountmatchupYearCount 使用对数压缩,避免 10次对决3次对决 形成过度碾压

8.3 最近交手修正

latestMatchAt within 2 years => +2
latestMatchAt within 4 years => +1
older or null => +0

口径说明:

  1. recencyBonus 只做轻修正
  2. 不能让“最近交手”压过“长期强关系”
  3. latestMatchAt 缺失时不扣分,也不加分

8.4 为什么这一版参数这样定

本次参数是结合当前线上关系网分布定下的保守版本。

已确认的现状包括:

  1. 全量关系边中,大多数只是普通同场出现
  2. matchupCount = 1 的关系非常多
  3. sharedTeamStagessharedUniversitysharedHighSchool 都属于相对稀缺但语义较强的信号
  4. latestMatchAt 为空的关系也不少,因此最近交手不能给太高权重

因此第一版采取:

  1. 信息类信号使用固定分层权重
  2. 比赛类信号使用对数压缩
  3. 时间只做轻量修正

这一版希望满足的排序直觉是:

  1. 大学同队 4年 + 也经常交手 应明显靠前
  2. 只是同大学经历 不应压过真实的长期 rival
  3. 10次对决 应高于 3次对决
  4. 跨阶段交手 应有稳定辨识度
  5. 同出身地 不能单独把关系顶上去

9. 页面与开放规则

9.1 页面结构

选手关系第一版拆成两层:

  1. 选手主页关系板块
  2. 关系网络 独立页面

两层共用同一套关系结果、同一套展示阈值、同一套排序逻辑。

区别仅在于:

  1. 选手主页关系板块只显示 Top 6
  2. 关系网络 页面显示全部达标关系并分页

9.2 明星选手门槛

是否开放 关系网络 页面,按选手自身比赛条数判断。

第一版门槛定为:

  1. raceResults >= 8

线上数量参考:

  1. raceResults >= 51780
  2. raceResults >= 81060
  3. raceResults >= 10838
  4. raceResults >= 15484

采用 >= 8 的原因:

  1. >= 5 更收敛,不会把完整关系页开放得过宽
  2. >= 10 更保守地保留中量级选手
  3. 第一版约 1060 人可开放,规模较合适

9.3 非明星选手的关系板块

是否展示选手主页中的关系板块,与是否是明星选手分开判断。

规则:

  1. 非明星选手如果存在达标关系,主页仍显示关系板块
  2. 明星选手如果达标关系数为 0,主页不显示关系板块
  3. 明星选手门槛只决定是否开放 关系网络 页面,不决定主页关系板块是否出现

9.4 关系展示阈值

关系是否进入展示集合,按 pair 级 rawScore 判断。

第一版展示阈值定为:

  1. rawScore >= 15

观察结论:

  1. rawScore >= 10 过宽,很多中低比赛量选手会留下过多弱关系
  2. rawScore >= 20 过窄,很多本应有关系感知的选手会掉成 0-2
  3. rawScore >= 15 更适合作为第一版展示分界线

因此:

  1. 主页关系板块从 rawScore >= 15 的集合中取前 6
  2. 关系网络 页面显示全部 rawScore >= 15 的关系

9.5 排序规则

第一版只保留一种排序:

  1. 关系分降序

内部排序键固定为:

  1. rawScore DESC
  2. matchupCount DESC
  3. latestMatchAt DESC
  4. relatedPersonId ASC

9.6 分数展示规则

对外展示两套分值:

  1. rawScore
    • 内部使用
    • 用于阈值判断和排序
  2. displayScore
    • 前台使用
    • 映射到 0-100
    • 保留 1 位小数

规则:

  1. 主页关系板块不显示分数
  2. 关系网络 页面右侧显示 displayScore
  3. 展示阈值仍以 rawScore >= 15 判定

9.7 标签展示规则

主页关系板块:

  1. 每条关系最多显示 3 个标签
  2. 不显示分数

关系网络 页面:

  1. 标签全展示
  2. 不额外写解释句
  3. 标签顺序按对当前关系贡献最大的标签优先
  4. 右侧显示 displayScore

10. 缓存与数据来源策略

10.1 主页关系板块

主页关系板块继续使用 PlayerRelationCache

原因:

  1. 主页访问频率更高
  2. 主页只需要稳定读取 Top 6
  3. 关系板块不应引入额外在线计算抖动

10.2 关系网络页面

第一版采用与主页一致的数据源策略:

  1. PlayerRelationCache 不再只缓存 Top 12
  2. PlayerRelationCache 需要扩展为缓存完整达标关系集合
  3. 关系网络 页面与主页共用同一份缓存结果

不采用:

  1. 主页读缓存、独立页面在线重算完整结果
  2. SQL 先裸排序、页面层再补标签

原因:

  1. 标签、signals、score 应在进入页面前就成为完整关系对象
  2. 主页与独立页面应共用统一口径
  3. 后续分页、筛选、解释和调试都更稳定

10.3 分页

关系网络 页面第一版使用分页展示全部达标关系。

建议:

  1. 每页 24
  2. 先使用普通分页即可
  3. 先不额外设置很小的总量上限

11. 当前建议的实现顺序

  1. 扩展 RelationStageKey,纳入 junior_high_school
  2. 扩展 PlayerRelationCache.payload 结构
  3. PlayerRelationCacheTop 12 扩展为完整达标关系集合缓存
  4. 落地信息类标签和比赛类标签生成
  5. rawScoredisplayScorescoreBreakdown
  6. 新增 关系网络 页面与分页
  7. 调整选手页关系板块展示规则与入口文案

12. 结论

本次先定下的不是最终分数,而是:

  1. 哪些标签要存在
  2. 哪些数值信号要保留
  3. 比赛类标签阈值为什么这样定
  4. 缓存 payload 下一版该长什么样
  5. 关系网络 页面如何开放与展示
  6. 主页和独立页面如何共用同一套关系对象

这能保证后续即使继续调算分规则,也不会反复推翻标签语义层。