Просмотр исходного кода

posterior 池去掉 demand_filter_sort_strategy 两段白名单,合并为单段查询

全表 distinct 确认 demand_filter_sort_strategy 在优质相似下只有 2 个值(A 段 97%/B 段 3%),
LIKE 白名单实际不丢数据。合并后:
- 3 个退化分支各减 1 次 SQL,主查询 3 段拼接 → 1 段排序
- retainTopGroupsByTotalRov 在并集上跑一次中位数过滤(POSTERIOR_GROUP_KEEP_RATIO=0.5),
  数据验证 A 段保留 50.3%、B 段保留 44.9%,B 段不会被一刀切光
- 单段输出复用 concatDedup(out, [], limit) 保留 id+归一化标题去重
刘立冬 4 часов назад
Родитель
Сommit
c704a509fc

+ 26 - 23
api-module/src/main/java/com/tzld/piaoquan/api/service/contentplatform/impl/ContentPlatformPlanServiceImpl.java

@@ -622,9 +622,10 @@ public class ContentPlatformPlanServiceImpl implements ContentPlatformPlanServic
     private static final String CHANNEL_NAME_GZH = "公众号合作-即转-稳定";
     private static final String CHANNEL_NAME_QW  = "群/企微合作-稳定";
     private static final double PRIOR_GROUP_KEEP_RATIO = 0.5;
-    private static final String POSTERIOR_FILTER_ABS_LIKE = "绝对高效率%";
-    private static final String POSTERIOR_FILTER_REL_LIKE = "相对裂变率%";
     private static final String POSTERIOR_DRIVE_DIMENSION_TIME = "昨日";
+    /** posterior 按 demand_content_id 分组后保留 total_rov 排名前 50% 的需求组,
+     * 砍掉群体表现弱的需求,避免低 total_rov 的 demand 带回来的相似变体稀释结果。 */
+    private static final double POSTERIOR_GROUP_KEEP_RATIO = 0.5;
     private static final String SOURCE_PRIOR = "prior";
     private static final String SOURCE_POSTERIOR = "posterior";
     private static final String SOURCE_HOT = "hot";
@@ -990,8 +991,9 @@ public class ContentPlatformPlanServiceImpl implements ContentPlatformPlanServic
     }
 
     /**
-     * 优质相似池(posterior):A 段 "绝对高效率" → B 段 "相对裂变率"。
-     * 每段按 demand_content_id 分组,组按 total_rov DESC、组内 score DESC 取前 K;段间拼接 + video_id 去重。
+     * 优质相似池(posterior):一段查询,不再按 demand_filter_sort_strategy 子分类。
+     * 按 demand_content_id 分组,组按 total_rov DESC、组内 score DESC 取前 K;
+     * 跨组用 video_id + 归一化标题去重,截到 limit。
      */
     private List<VideoContentItemVO> fetchPosteriorCandidates(VideoContentListParam param, ContentPlatformAccount user, int limit) {
         String dt = demandVideoMapperExt.getMaxDt();
@@ -1004,32 +1006,33 @@ public class ContentPlatformPlanServiceImpl implements ContentPlatformPlanServic
         int fetchLimit = Math.max(limit * 3, DEMAND_CANDIDATE_LIMIT);
 
         String category = StringUtils.hasText(param.getCategory()) ? param.getCategory() : null;
-        List<ContentPlatformDemandVideo> stageAbs = demandVideoMapperExt.selectForRecommend(
-                dt, channelName, crowdSegment, DEMAND_STRATEGY_POSTERIOR, null, null, POSTERIOR_FILTER_ABS_LIKE, ghName, POSTERIOR_DRIVE_DIMENSION_TIME, category, fetchLimit, true);
-        List<ContentPlatformDemandVideo> stageRel = demandVideoMapperExt.selectForRecommend(
-                dt, channelName, crowdSegment, DEMAND_STRATEGY_POSTERIOR, null, null, POSTERIOR_FILTER_REL_LIKE, ghName, POSTERIOR_DRIVE_DIMENSION_TIME, category, fetchLimit, true);
-
-        // 退化:该 ghName 在两阶段都无数据 → 退回渠道粒度(drive_dimension_time 仍严格为"昨日")
-        if (ghName != null && stageAbs.isEmpty() && stageRel.isEmpty()) {
-            stageAbs = demandVideoMapperExt.selectForRecommend(
-                    dt, channelName, crowdSegment, DEMAND_STRATEGY_POSTERIOR, null, null, POSTERIOR_FILTER_ABS_LIKE, null, POSTERIOR_DRIVE_DIMENSION_TIME, category, fetchLimit, true);
-            stageRel = demandVideoMapperExt.selectForRecommend(
-                    dt, channelName, crowdSegment, DEMAND_STRATEGY_POSTERIOR, null, null, POSTERIOR_FILTER_REL_LIKE, null, POSTERIOR_DRIVE_DIMENSION_TIME, category, fetchLimit, true);
+        // 公众号粒度(channel_level3 有值)时放开 drive_dimension_time:单公众号数据更稀疏,"昨日"窗口经常拿不到几条,
+        // 放宽为不限制能把"最近 15 日"那一档相似召回也带进来。渠道粒度兜底仍按"昨日"。
+        String stageDriveDimensionTime = (ghName != null) ? null : POSTERIOR_DRIVE_DIMENSION_TIME;
+        List<ContentPlatformDemandVideo> rows = demandVideoMapperExt.selectForRecommend(
+                dt, channelName, crowdSegment, DEMAND_STRATEGY_POSTERIOR, null, null, null, ghName, stageDriveDimensionTime, category, fetchLimit, true);
+
+        // 退化:该 ghName 无数据 → 退回渠道粒度(drive_dimension_time 仍严格为"昨日")
+        if (ghName != null && rows.isEmpty()) {
+            rows = demandVideoMapperExt.selectForRecommend(
+                    dt, channelName, crowdSegment, DEMAND_STRATEGY_POSTERIOR, null, null, null, null, POSTERIOR_DRIVE_DIMENSION_TIME, category, fetchLimit, true);
         }
         // 跨渠道退化:channel_name 命中但 crowd_segment 在对侧 0 行 → 去 crowd_segment 拉通用数据
-        if (channelName != null && stageAbs.isEmpty() && stageRel.isEmpty()) {
-            stageAbs = demandVideoMapperExt.selectForRecommend(
-                    dt, channelName, null, DEMAND_STRATEGY_POSTERIOR, null, null, POSTERIOR_FILTER_ABS_LIKE, null, POSTERIOR_DRIVE_DIMENSION_TIME, category, fetchLimit, true);
-            stageRel = demandVideoMapperExt.selectForRecommend(
-                    dt, channelName, null, DEMAND_STRATEGY_POSTERIOR, null, null, POSTERIOR_FILTER_REL_LIKE, null, POSTERIOR_DRIVE_DIMENSION_TIME, category, fetchLimit, true);
+        if (channelName != null && rows.isEmpty()) {
+            rows = demandVideoMapperExt.selectForRecommend(
+                    dt, channelName, null, DEMAND_STRATEGY_POSTERIOR, null, null, null, null, POSTERIOR_DRIVE_DIMENSION_TIME, category, fetchLimit, true);
         }
 
         Function<ContentPlatformDemandVideo, String> keyFn = r ->
                 r.getDemandContentId() == null ? "" : r.getDemandContentId();
 
-        List<VideoContentItemVO> outAbs = groupAndTopK(stageAbs, keyFn, TOP_K_PER_DEMAND, true);
-        List<VideoContentItemVO> outRel = groupAndTopK(stageRel, keyFn, TOP_K_PER_DEMAND, true);
-        return concatDedup(outAbs, outRel, limit);
+        // 按 demand_content_id 的 total_rov 中位数过滤:保留中位数及以上(top 50%)的 demand 组,
+        // 砍掉群体表现弱的需求,避免低 total_rov 的 demand 带回来的相似变体稀释结果。
+        rows = retainTopGroupsByTotalRov(rows, keyFn, POSTERIOR_GROUP_KEEP_RATIO);
+
+        List<VideoContentItemVO> out = groupAndTopK(rows, keyFn, TOP_K_PER_DEMAND, true);
+        // 单段也要去归一化标题重复(同段内运营把同内容上传成多 video_id 的情况)
+        return concatDedup(out, Collections.emptyList(), limit);
     }
 
     /**

+ 27 - 19
docs/recommend-ordering.md

@@ -39,9 +39,8 @@ source 空(默认)  ──────►  四路随机穿插 (getInterleave
 | `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` |
+| `POSTERIOR_GROUP_KEEP_RATIO` | `0.5` | posterior 池 demand_content_id 组按 total_rov 中位数及以上保留 |
+| `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)`(可空)。
 
@@ -130,35 +129,44 @@ ORDER BY total_rov DESC, score DESC LIMIT 30000
 
 ### 3.3 `fetchPosteriorCandidates`(优质相似池,posterior)
 
-**目的:优质相似里,"昨日"驱动的"绝对高效率"先出,再出"相对裂变率"。**
+**目的:取所有"优质相似"行(不再按 `demand_filter_sort_strategy` 子分类),按需求组的 total_rov 中位数砍掉群体表现弱的需求,组内取 top K。**
 
-A、B 两段独立查询:
+> 与旧版差异:旧版"绝对高效率%"(A 段)无条件优先于"相对裂变率%"(B 段)。新版统一按 `total_rov DESC, score DESC` 排,段间优先级取消;由于 B 段(相对裂变率)群体强度普遍低于 A 段,中位数裁剪后大概率被砍掉,这是已知且接受的行为。
+
+单段查询:
 
 ```sql
--- 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 drive_dimension_time=:stageDriveDimensionTime  -- 见下
   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` 重查;A、B 仍都空且 `channel_name` 非空 → 再去 `crowd_segment` 重查(跨渠道兜底)。`drive_dimension_time='昨日'` 与 `channel_name` 强过滤始终保留。
+**`drive_dimension_time` 公众号粒度放开**:
+- `ghName` 非空(公众号粒度,channel_level3 命中)→ `stageDriveDimensionTime = null`,不限驱动时间维度(把"最近 15 日"也带进来,单公众号"昨日"窗口经常拿不到几条)
+- `ghName` 为空(渠道粒度)→ 仍强制 `drive_dimension_time='昨日'`
+- 两条退化(去 `ghName` / 去 `crowd_segment`)路径都用 `'昨日'`,只在主查询放宽
+
+退化阶梯:主查询空且 `ghName` 非空 → 去 `ghName` 重查(drive_dimension_time 严回"昨日");仍空且 `channel_name` 非空 → 再去 `crowd_segment` 重查(跨渠道兜底)。`channel_name` 强过滤始终保留。
 
-每段进 `groupAndTopK`:
+**[新] 需求组分位裁剪 (`retainTopGroupsByTotalRov`,`keepRatio=POSTERIOR_GROUP_KEEP_RATIO=0.5`)**:
+- 按 `demand_content_id` 分组,取每组 `max(total_rov)`(需求组的群体强度)
+- 按组 total_rov DESC 排,**保留 top ⌈N×50%⌉ 个需求组**(即中位数及以上)
+- 组数 < 2 时全部保留,避免空/单组数据被意外裁掉
+
+> 设计动机:同一 `demand_strategy='优质相似'` 里,低 total_rov 的 demand 会带回很多 sim=1.0 的克隆视频稀释推荐,先在需求组层面砍一刀比在视频层面砍更稳定。
+
+进 `groupAndTopK`:
 - 分组键:`demand_content_id`
 - **过滤** `rov <= 0` 或 null
 - **过滤** `excludeSelfTitle=true` → 用 `TitleNormalizer.isSelfTitle` 跳过自标题
 - 组排序:组内最大 `total_rov` DESC
-- 组内排序:`score` DESC,去重,每组最多 3 条
+- 组内排序:`score` DESC,去重,每组最多 3 条
 
-A 段 + B 段 顺序拼接 → 跨段 `videoId` 去重 → 截 10000。
+最后 `concatDedup(out, [], limit)` 用 `videoId` + `TitleNormalizer.normalize(title)` 双键去重,截到 10000。
 
-最终顺序:**A 段(绝对高效率) 在前;段内组按总 ROV,组内按 score。**
+最终顺序:**保留 top 50% 需求组内,组按总 ROV,组内按 score。**
 
 ---
 
@@ -230,7 +238,7 @@ list  = interleavePriorWithScene(scene, prior)  // 严格 1:1
 ## 6. 「已发优质相似」单源(`source=posterior`)
 
 ```java
-list = fetchPosteriorCandidates(...)   // 顺序 = 绝对高效率段 → 相对裂变率段
+list = fetchPosteriorCandidates(...)   // 顺序 = total_rov DESC, score DESC (单段)
 ```
 
 每条 VO 设 `source='posterior'`,`paginateCandidates` 切片。
@@ -263,8 +271,8 @@ list = fetchPosteriorCandidates(...)   // 顺序 = 绝对高效率段 → 相对
    ┌───────────────────────┬──┴──┬─────────────────────────────┐
    ▼                       ▼     ▼                             ▼
 priorScene(10000)    prior(10000)  posterior(10000)         hot(10000)
-  视频维度 rov DESC   传播头部       A: 绝对高效率 + 昨日       SQL 默认
-  (代表需求 total_rov 选最大)        B: 相对裂变率 + 昨日       (sort 决定)
+  视频维度 rov DESC   传播头部       优质相似 + 驱动时间(ghName 时放开) SQL 默认
+  (代表需求 total_rov 选最大)        组 total_rov 中位数及以上            (sort 决定)
                      组(point_type, standard_element) top3
                      组(demand_content_id) top3