recommend-ordering.md 15 KB

videoContentList 推荐列表排序逻辑

接口:POST /contentPlatform/plan/videoContentList 入口:ContentPlatformPlanServiceImpl.getVideoContentList 数据源:content_platform_demand_video(人群需求/优质相似/人群需求-场景)+ content_platform_video(全局热门)

数据组重命名:旧值「先验需求/后验需求/先验需求-场景」→ 新值「人群需求/优质相似/人群需求-场景」(2026-05 改名,本文档已对齐新值)。

适用:当前分支 cooperation_video_candidate_pool_improved_lld_0509(含 commit 2860bdce)。


1. 入参 → 路径分发

title 非空  ───────────►  全站搜索 (getVideoContentListByTitle)
source=prior  ───────────►  单源「粉丝喜欢」(getSingleSourcePage / prior)
source=posterior  ───────►  单源「已发优质相似」(getSingleSourcePage / posterior)
source=hot   ───────────►  单源「全局热门」(getHotSourcePaged)
source 空(默认)  ──────►  四路随机穿插 (getInterleavedPage)

当前前端:priorposterior 两个 tab 已 disabled(提示"功能正在完善中"),生产实际只走「全部」(四路穿插)和「全局热门」单源。


2. 关键常量

常量 含义
DEMAND_CANDIDATE_LIMIT 10000 每个 demand 池最大候选条数
HOT_CANDIDATE_LIMIT 10000 hot 池候选条数
TOP_K_PER_DEMAND 3 demand 池组内取前 K
DEMAND_STRATEGY_PRIOR "人群需求" 人群需求池过滤值(prior,旧名「先验需求」)
DEMAND_STRATEGY_PRIOR_SCENE "人群需求-场景" 场景池过滤值(旧名「先验需求-场景」)
DEMAND_STRATEGY_POSTERIOR "优质相似" 优质相似池过滤值(posterior,旧名「后验需求」)
PRIOR_PREMIUM_DIMENSION "传播的头部" prior 池 dimension 强过滤
CHANNEL_NAME_GZH "公众号合作-即转-稳定" 公众号入口对应 channel_name 强过滤值
CHANNEL_NAME_QW "群/企微合作-稳定" 企微入口对应 channel_name 强过滤值
PRIOR_GROUP_KEEP_RATIO 0.5 prior 池"特征组"按 total_rov 分位保留比例
POSTERIOR_FILTER_ABS_LIKE "绝对高效率%" posterior 池 A 段 demand_filter_sort_strategy LIKE
POSTERIOR_FILTER_REL_LIKE "相对裂变率%" posterior 池 B 段 demand_filter_sort_strategy LIKE
POSTERIOR_DRIVE_DIMENSION_TIME "昨日" posterior 池强制 drive_dimension_time

公共强过滤(所有 demand 池 SQL):dt = max(dt) AND status = 1 AND crowd_segment = user.channel AND channel_name = resolveChannelName(param)(可空)。

2.1 resolveChannelName(param) — 入口 → channel_name 映射

为避免同一 crowd_segment 在公众号系与企微系都存在(已确认 gzyhc / wxm 双渠道并存)时,选企微入口却拉到公众号数据,3 个 demand 池 SQL 在源头加 channel_name 强过滤。映射依据 VideoContentListParam:

param.type 含义 映射 channel_name
0 自动回复 / 1 服务号推送 / 4 公众号推送 公众号系 CHANNEL_NAME_GZH
2 企微-社群 / 3 企微-自动回复 企微系 CHANNEL_NAME_QW
999 / 其它 不限平台 进入 fallback

Fallback:type 空或不在上表时,若 param.ghName 非空(只有公众号入口会带 ghName)→ CHANNEL_NAME_GZH;否则返回 null(不加 channel_name 过滤,保留旧行为)。

设计动机:crowd_segment 是按客户(广告主)切的,而同一客户可能同时投公众号 + 企微;数据落库时按"人群_渠道"打 channel_name。前端切换入口后 type 一定会变,所以让 type 当一级路由信号最稳。


3. 四个候选池的构造顺序

3.1 fetchPriorSceneCandidates(场景池)

目的:用户所属 channel 在"场景"维度命中的人群需求-场景行,按视频近 7 日表现(rov)排序。

SELECT ... FROM content_platform_demand_video
WHERE dt=:maxDt AND status=1
  AND crowd_segment=:userChannel
  AND channel_name=:resolvedChannelName  -- 若 resolveChannelName 命中(公众号/企微)
  AND demand_strategy='人群需求-场景'
  AND channel_level3=:ghName             -- 若传入
ORDER BY total_rov DESC, score DESC
LIMIT 10000

退化:如果 ghName 非空但查 0 条,去掉 ghName 再查一次(拿全渠道兜底)。channel_name 在退化时保留(强过滤,跨渠道污染必须先挡掉)。

后处理(顺序):

  1. videoId 去重,保留首次出现(SQL 已按 total_rov DESC, score DESC 排序 → 首次 = 该视频的"最强代表需求"行)
  2. 过滤 rov 为 null 或 ≤0 的视频
  3. 重排序:rov DESC 主键,total_rov DESC 次级 tiebreaker

输出顺序:视频按 rov(近 7 日表现)DESC;同 rov 时按代表需求的 total_rov DESC。

设计动机:场景需求里同一视频会对应多个特征点,但前端只下发一条;用 total_rov 选"最强代表需求",再以视频自身的 rov 决定整体排序,避免按需求维度排序导致同 dimension/标准要素的视频在列表里扎堆。


3.2 fetchPriorCandidates(人群需求池,prior)

目的:人群需求里,只取 dimension='传播的头部' 维度,并按 channel 内"特征需求强度"分位裁掉弱题材。

单段查询:

SELECT ... WHERE ... AND channel_name=:resolvedChannelName(可空)
  AND demand_strategy='人群需求' AND dimension='传播的头部' ...
ORDER BY total_rov DESC, score DESC LIMIT 30000

退化:查询为空且 ghName 非空 → 去 ghName 重查(channel_name 仍保留)。

[新] 特征组分位裁剪 (retainTopGroupsByTotalRov,keepRatio=0.5)

  • (point_type, standard_element) 分组,取每组 max(total_rov)(即该特征的人群需求强度)
  • 按组 total_rov DESC 排,保留 top ⌈N×50%⌉ 个特征组
  • 各渠道 total_rov 分布差异大(cdjh 0.003~0.057,xycsd17 0.014~0.037),用分位避免绝对阈值伤弱渠道

设计动机:total_rov 在 prior 池 = 群体对(point_type, standard_element)特征的需求强度。低 total_rov 说明群体不爱这个题材,把对应视频堆在列表底部没意义,直接剪掉。

groupAndTopK

  • 分组键:(point_type, standard_element)
  • 过滤 rov <= 0 或 null(近 7 日无表现)
  • 不做 selfTitle 过滤
  • 组排序:组内最大 total_rov DESC
  • 组内排序:score DESC,组内 videoId 去重,每组最多 3 条

最后按 limit=10000 截断。

最终顺序:保留 top 50% 特征组内,组按总 ROV,组内按 score。


3.3 fetchPosteriorCandidates(优质相似池,posterior)

目的:优质相似里,"昨日"驱动的"绝对高效率"先出,再出"相对裂变率"。

A、B 两段独立查询:

-- A 段: demand_filter_sort_strategy LIKE '绝对高效率%'
SELECT ... WHERE ... AND channel_name=:resolvedChannelName(可空)
  AND demand_strategy='优质相似'
  AND demand_filter_sort_strategy LIKE '绝对高效率%'
  AND drive_dimension_time='昨日'
  AND (title IS NULL OR demand_content_title IS NULL OR title <> demand_content_title)
ORDER BY total_rov DESC, score DESC LIMIT 30000

-- B 段: demand_filter_sort_strategy LIKE '相对裂变率%'
SELECT ... AND demand_filter_sort_strategy LIKE '相对裂变率%' AND drive_dimension_time='昨日' ...

退化:A、B 都空且 ghName 非空 → 去 ghName 重查(drive_dimension_time='昨日'channel_name 强过滤仍保留)。

每段进 groupAndTopK

  • 分组键:demand_content_id
  • 过滤 rov <= 0 或 null
  • 过滤 excludeSelfTitle=true → 用 TitleNormalizer.isSelfTitle 跳过自标题
  • 组排序:组内最大 total_rov DESC
  • 组内排序:score DESC,去重,每组最多 3 条

A 段 + B 段 顺序拼接 → 跨段 videoId 去重 → 截 10000。

最终顺序:A 段(绝对高效率) 在前;段内组按总 ROV,组内按 score。


3.4 fetchHotCandidates(热门池)

复用现有 planMapperExt.getVideoList(...)

  • dt = videoMaxDtdatastatDt = videoDatastatMaxDt
  • sort/type/channel/strategy 由请求和 param.getSort() 派生
  • 一次性查前 10000 条,未分组、未二次过滤
  • 顺序由 SQL 决定(一般是 fission_rate DESCscore DESC

4. 「全部」模式:四路随机穿插(getInterleavedPage

priorScene → 标 source='prior'
prior      → 标 source='prior'
posterior  → 标 source='posterior'
hot        → 标 source='hot'

priorScene 和 prior 对外都是 source='prior'(前端"粉丝喜欢"统一标签);浮层细节通过 demandStrategy 字段区分场景。

算法

  1. 维护 4 个池的 pointer[i]exhausted[i],以及全局 emittedIds + emittedTitles
  2. 种子:seed = userId ^ LocalDate.now().toString().hashCode()
    • 同一用户同一天翻页/刷新得到的顺序一致
    • 跨天/跨用户顺序变化
  3. 循环直到 4 池全空:
    1. 在未耗尽池中等概率随机选一个
    2. 跳过该池里 video_id 已发标题(归一化后)已发 的候选(shouldSkipForDedup
    3. 取出第一条未发的,加入 merged、记入 emittedIdsemittedTitles
    4. 若该池指针越界,标为 exhausted
  4. paginateCandidatestotalSize = merged.size(),按 pageNum/pageSize 内存切片返回。

标题去重用 TitleNormalizer.normalize(去 emoji/空白/全半角),应对运营把同段内容重复上传成多个 video_id(如 🔴她走了,台湾再无洪秀柱! 对应 67396144 / 67812469 两条)。 单源 source=prior 模式的 interleavePriorWithScene 也用同一套(video_id + 标题)去重。

时间复杂度

  • 每池 1 次 DB Query
  • 主循环 O(总池容量),每个池最大 10000 → 上界约 4 万次操作
  • 单页响应 = 1 次 maxDt + 4 次 SELECT + 内存穿插

排序稳定性

  • 同一用户同一天,所有分页之间顺序一致
  • priorScene / prior / posterior 内部相对顺序保留(场景按视频 rov;prior/posterior 按组 total_rov + 组内 score),随机只影响"哪一池先出"

5. 「粉丝喜欢」单源(source=prior

scene = fetchPriorSceneCandidates(...)
prior = fetchPriorCandidates(...)
list  = interleavePriorWithScene(scene, prior)  // 严格 1:1

interleavePriorWithScene

  • 单次循环:先从 scene 取一条未发的 → 再从 prior 取一条未发的,严格 1:1 交替
  • seen<videoId> 跨池去重,场景优先(先到先得)
  • 一侧用完后,另一侧剩余按原顺序追加

每条 VO 设 source='prior',然后 paginateCandidates 切片。


6. 「已发优质相似」单源(source=posterior

list = fetchPosteriorCandidates(...)   // 顺序 = 绝对高效率段 → 相对裂变率段

每条 VO 设 source='posterior'paginateCandidates 切片。


7. 「全局热门」单源(source=hot

不走候选池:复用原 planMapperExt.getVideoCount + getVideoList 真分页链路,DB 端 LIMIT/OFFSET。VO source='hot'


8. 字段全景:每条 VO 必有 source 字段

source 含义 数据来源 浮层 demand 字段
prior 粉丝喜欢(人群需求-场景 + 人群需求-头部) content_platform_demand_video 有完整字段,可看 demandStrategy 区分
posterior 已发优质相似 content_platform_demand_video 有完整字段
hot 全局热门 content_platform_video 只有基础字段(demand 相关字段为空)

9. 一图总览

        ┌────────────────────────────────────────────────────────┐
        │ 1. 拉 4 个候选池 (每个池都自己分阶段、分组、去重、排序) │
        └────────────────────────────────────────────────────────┘
                              │
   ┌───────────────────────┬──┴──┬─────────────────────────────┐
   ▼                       ▼     ▼                             ▼
priorScene(10000)    prior(10000)  posterior(10000)         hot(10000)
  视频维度 rov DESC   传播头部       A: 绝对高效率 + 昨日       SQL 默认
  (代表需求 total_rov 选最大)        B: 相对裂变率 + 昨日       (sort 决定)
                     组(point_type, standard_element) top3
                     组(demand_content_id) top3
                              │
                              ▼
        ┌────────────────────────────────────────────────────────┐
        │ 2. 四路随机穿插                                          │
        │   - 种子 = userId ^ 今天                                  │
        │   - 跨池 videoId 去重                                     │
        │   - 池内顺序保留                                          │
        └────────────────────────────────────────────────────────┘
                              │
                              ▼
        ┌────────────────────────────────────────────────────────┐
        │ 3. paginateCandidates                                  │
        │   totalSize = 全量, 按 pageNum/pageSize 切片            │
        └────────────────────────────────────────────────────────┘

10. 关键代码位置

内容 位置
主入口路由 ContentPlatformPlanServiceImpl.java:626
单源分页 ContentPlatformPlanServiceImpl.java:649
单源 hot ContentPlatformPlanServiceImpl.java:698
四路穿插 ContentPlatformPlanServiceImpl.java:743
场景池 fetcher ContentPlatformPlanServiceImpl.java:812
人群需求池 fetcher (prior) ContentPlatformPlanServiceImpl.java:840
优质相似池 fetcher (posterior) ContentPlatformPlanServiceImpl.java:875
groupAndTopK 通用排序 ContentPlatformPlanServiceImpl.java:912
段间拼接 + 去重 ContentPlatformPlanServiceImpl.java:956
热门池 fetcher ContentPlatformPlanServiceImpl.java:976
Mapper SQL mapper/contentplatform/ext/ContentPlatformDemandVideoMapperExt.xml (selectForRecommend)