# Rerank & Freqcap(重排 / 去重 / 频控)|Plan > 对应规范:`spec_kit/Personalized Reco/modules/rerank-freqcap/spec.md` > > 规则来源(必须对齐): > > - `设计说明文档/個性化推薦算法規則.md`(Feed MMR λ=0.7;Push/Widget 冷却口径) > - `spec_kit/Personalized Reco/overview.md`(模块边界:本模块在 Soft Scoring 之后执行) --- ## 1. 目标与交付物 ### 1.1 目标 - 将 Soft Scoring 后的候选集变为**可下发的最终排序**(长度 ≤ k)。 - 实现 V1 最小集合: - **去重**:排除 `already_recommended_ids ∪ touched_or_viewed_ids` - **Feed 序列多样性**:MMR 重排(离散特征版) - **Push/Widget 频控与冷却**:至少保证“同句不重复”;作者/模板按输入能力做增强 - 输出稳定的 `meta` 统计字段,用于定位 served_k 不足的原因(dedup/freqcap 导致清空等)。 ### 1.2 交付物 - `modules/rerank-freqcap/plan.md`:本技术计划(本文件)。 - 代码实现(tasks 阶段落地)建议位置: - `server/app/features/personalized_reco/rerank_freqcap/` - 包含: - 纯函数 `rerank_and_freqcap(...) -> RerankResult` - `RerankConfig` 与默认参数(按 scene) - `Sim/Tag` 构造工具函数(Feed MMR) - 单元测试(tasks 阶段落地): - 去重正确性 - Feed MMR 的 Top1 + 多样性选择 - Push/Widget 冷却规则(在给定历史集合输入下) --- ## 2. 模块职责边界(V1 约定) ### 2.1 本模块负责 - **从 scored_candidates 中做过滤/重排**: - Dedup:按历史集合过滤 - Freqcap:按冷却维度(句子/作者/模板)做“硬过滤或强约束” - Feed:MMR 生成序列(保证多样性) - 输出 `ranked_items` 与 `meta`(候选规模、过滤数量、缺失输入统计等)。 ### 2.2 不在本模块实现 - **不计算 Soft Scoring 分数**:只消费 `final_score`(或等价的 score)。 - **不做 Hard Filter**:Hard Filter 发生在更早阶段,本模块只处理已通过 Hard Filter 的候选。 - **不维护服务端长期历史**(V1):冷却窗口“X 天”由客户端在请求时传入对应的“最近窗口内集合”,或由未来服务端侧补齐。 > 说明(V1 冷却窗口语义):本模块以“输入集合代表冷却窗口内的历史”为准。`cooldown_*_days` 作为配置与可观测字段保留,便于未来接入服务端历史后真正按时间计算。 --- ## 3. 输入/输出与数据结构(V1) ### 3.1 输入 - `scene`: `feed | push | widget` - `scored_candidates`: `List[ScoredCandidate]`,至少包含: - `content_id: int` - `final_score: float`(或 `score`) - `author_id: str | None` - `template_id: str | None` - `content_profile`(用于 feed 标签:stage/need/context 等;缺失时可退化) - `already_recommended_ids`: `List[str|int]` - `touched_or_viewed_ids`: `List[str|int]` - 可选历史(若客户端暂不传,V1 作为增强项): - `recent_author_ids: List[str] | None` - `recent_template_ids: List[str] | None` - `k`: 目标条数(feed 默认 30;push/widget 默认 1) - `config`: - `mmr_lambda`(Feed 默认 0.7) - `cooldown_sentence_days/cooldown_author_days/cooldown_template_days`(按场景默认) ### 3.2 输出 - `ranked_items`: `List[ScoredCandidate]`(长度 ≤ k) - `meta`(V1 必须字段): - `candidate_pool_size_after_dedup: int` - `candidate_pool_size_after_freqcap: int` - `freqcap_filtered_counts: { sentence?: int, author?: int, template?: int }`(可选但建议) - `missing_history_fields: List[str]`(例如 `recent_author_ids` 未提供) --- ## 4. 关键技术决策(V1) ### 4.1 ID 归一化(避免 str/int 混用导致漏过滤) 由于输入历史集合可能是 `str|int`,V1 统一做: - 尽量将 `content_id` 归一化为 `int` - 无法转换的值忽略并记录 debug(不影响主流程) ### 4.2 Push/Widget 的频控策略:先保证“同句不重复”,再增强作者/模板 V1 选择“安全且可落地”的策略: - **句子冷却(必做,硬过滤)**: - 若 `content_id` 出现在历史集合中,则直接过滤 - **作者/模板冷却(增强项)**: - 若 `recent_author_ids/recent_template_ids` 有输入,则对命中者执行硬过滤 - 若无输入,则跳过该维度,但在 `meta.missing_history_fields` 记录缺失,便于可观测 > 说明:规范允许作者/模板作为硬频控或强降权。V1 采用“有输入就硬过滤、无输入就跳过”的方式,避免伪实现与误杀。 ### 4.3 Feed 的多样性:MMR(离散特征版) V1 实现 MMR 的离散相似度(不依赖 embedding): \[ MMR(c)=\lambda\cdot Rel(c) - (1-\lambda)\cdot \max_{s\in S} Sim(c,s) \] - `Rel(c)`:使用 `final_score` - `Sim(c,s)`: - `content_id` 相同:`Sim=1` - `template_id` 相同且非空:`Sim += 0.6` - `author_id` 相同且非空:`Sim += 0.3` - 标签重合(Jaccard):`Sim += 0.1 * Jaccard(tags_c, tags_s)` - clamp 到 `[0,1]` 标签集合 `tags_*` 的 V1 落地定义(必须可算、且对缺字段鲁棒): - `stage:`(例如 `stage:general/expecting/parenting/unknown`) - `need:`:从 `need_suitability` 中取 **最大值的 key** 作为代表标签(若为空则跳过) - `context:`:从 `context_suitability` 中取 **最大值的 key** 作为代表标签(若为空则跳过) > 说明:内容画像是 suitability(0/0.5/1)结构;V1 取 argmax 能保证标签集合小且稳定,便于测试。后续可扩展为“取所有 ≥0.5 的 key”以增强多样性。 --- ## 5. 具体算法流程(V1) ### 5.1 Dedup(必做,三场景共用) 输入: - `seen_ids = already_recommended_ids ∪ touched_or_viewed_ids` 处理: - 过滤 `content_id ∈ seen_ids` 的候选 输出: - `candidate_pool_size_after_dedup = len(filtered_candidates)` ### 5.2 Freqcap(Push/Widget 必做;Feed 可选) V1 频控实现顺序(先句子,再作者/模板): 1. 句子冷却:过滤 `content_id ∈ seen_ids` 2. 作者冷却(若提供 `recent_author_ids`):过滤 `author_id ∈ recent_author_ids` 3. 模板冷却(若提供 `recent_template_ids`):过滤 `template_id ∈ recent_template_ids` 输出: - `candidate_pool_size_after_freqcap` - `freqcap_filtered_counts`(按维度统计被过滤数量) ### 5.3 Feed:MMR 序列重排(建议实现) 步骤: - Top1:直接取 `final_score` 最高者 - 对后续位置 t=2..k: - 对每个未选候选 c 计算 `MMR(c)` - 选择 `MMR` 最大者加入序列 性能与实现约束(V1): - 候选数 N(例如 200~500)时,朴素 \(O(kN^2)\) 仍可能偏大;V1 可采用: - 先截断到 `top_n_for_mmr`(例如 200)再做 MMR - 或缓存 `Sim(c,s)` 的最大值并增量更新(实现复杂度更高,V1 可不做) ### 5.4 Push/Widget:选 TopK 在 dedup+freqcap 后: - 按 `final_score` 降序取前 k 条作为 `ranked_items` --- ## 6. 默认参数(V1 建议) ### 6.1 Feed - `mmr_lambda = 0.7` - `top_n_for_mmr = 200`(避免候选过大导致重排过慢) ### 6.2 Push(冷却窗口口径来自算法规则的工程默认) - `cooldown_sentence_days = 14`(同句 14 天不重复) - `cooldown_author_days = 7`(同作者 7 天不重复,需输入 `recent_author_ids` 才能执行) - `cooldown_template_days = 7`(同模板 7 天不重复,需输入 `recent_template_ids` 才能执行) ### 6.3 Widget - `cooldown_sentence_days = 7` - `cooldown_author_days = 7` - `cooldown_template_days = 7` > 说明:V1 冷却天数在本模块主要用于配置与可观测字段;真正“按天”判断需要历史带时间戳或服务端持久化,后续迭代补齐。 --- ## 7. 可观测与 meta(V1) 本模块建议输出(供 `observability` 子模块汇总): - `candidate_pool_size_after_dedup` - `candidate_pool_size_after_freqcap` - `freqcap_filtered_counts`(sentence/author/template) - `missing_history_fields`: - 例如客户端未提供 `recent_author_ids` → 记录 `author` - 未提供 `recent_template_ids` → 记录 `template` > 目标:当 served_k 过少时,能快速判断是 dedup/freqcap 导致,还是上游候选不足。 --- ## 8. 测试计划(V1) ### 8.1 单元测试(纯函数) - Dedup: - 输入历史包含某些 `content_id`,输出必须不包含这些 id - `str/int` 混用能正确归一化 - Freqcap: - 仅提供 `content_id` 历史时:句子冷却生效 - 提供 `recent_author_ids` 时:作者维度过滤生效;未提供时 `meta.missing_history_fields` 正确 - 提供 `recent_template_ids` 时:模板维度过滤生效;未提供时 `meta.missing_history_fields` 正确 - Feed MMR: - Top1 恒等于最高分 - 后续序列在候选足够时避免连续同作者/同模板(可用统计阈值断言) - `tags` 缺失时仍能稳定运行(只使用可得字段) ### 8.2 最小集成验证(与 reco-engine 串联时) - 输入一批 scored_candidates + 历史集合: - Feed:输出长度 ≤ k,且 meta 规模统计正确 - Push/Widget:在历史命中时能过滤掉重复句子 --- ## 9. 风险与后续演进 ### 9.1 已知风险 - V1 冷却窗口“按天”无法严格执行:因为历史输入缺少时间戳或服务端持久化。本模块已通过“输入集合代表窗口内历史”做可落地实现,但需要在产品/客户端侧保证窗口裁剪正确。 - Feed MMR 的性能:候选过大时重排可能变慢;V1 用 `top_n_for_mmr` 截断兜底。 ### 9.2 V1.1+ 演进方向 - 服务端侧持久化冷却历史(按用户维度记录 sentence/author/template 的最近触达时间),真正按 `cooldown_*_days` 判定。 - 将“作者/模板冷却”从硬过滤升级为“强降权 + 允许破例”,并在 meta 中记录“破例原因”(候选不足等)。 - 将 `P_repeat/P_fatigue` 由 `rerank-freqcap` 产出并注入 `scoring` 的 `external_terms`,实现更平滑的序列控制(而非一刀切过滤)。