data-dice-candidate · module analysis

Candidate 產生邏輯 × Post Snapshot 欄位對照

系統目前 candidate 都需要 Post Snapshot 才能產生。這份文件整理現行流程中每個 snapshot 欄位所對應的過濾邏輯;並針對「新通道召回量級比 snapshot 大 50 倍以上(~10K → 50 萬+)」的情境,分析其架構影響、現有資料來源(GCS / Mongo / ES / Redis / BigQuery)的可共用程度、以及自己需要維護的 post 欄位。

01目前 Candidate 準備邏輯

所有 candidate 共用一條「生成 → 過濾 → 輸出」的流水線。每個 candidate 以獨立 goroutine 平行跑 Generate(),產生 []*dao.Post,再套共用 filter chain,最後只取 post_id 寫回 response。

Request
GetCandidate
帶 memberConfig 與多個 candidate 設定
per-candidate · parallel
Generate()
各自實作;多數讀共用 Post Snapshot
shared
Filter Chain
Impression → 其他 rules
Response
post_id only
+ hawkeye_task_id / boost_strength

service/rpc/get_candidate.go:22 · candidate/main.go:38 · filter/main.go:79

Post Snapshot 是什麼

一份由 postlist.Generator 維護的 in-memory cache(postlist/main.go:24),內容來自 dao.PostDAO.List(),預設實作是 GCSPostDAO——從 GCS 把 JSON 一次讀入記憶體,按 region / forum allow-list 切片,並以 ticker 週期性刷新。絕大多數 candidate(JobTitle、OPHawkeyeV2、Search、Forum 系列…)都呼叫 PostListGenerator.Get(ctx, region) 拿到 []*dao.Post 後做 id 交集。

關鍵觀察: snapshot 存在的目的不是 response(response 只要 post_id),而是給 filter chain 讀欄位做決策,以及讓 generator 做 in-memory join。現行 snapshot 走的是「全站近 72 小時熱文一次載入記憶體」pattern,量約 10K 筆;設計前提是資料量小到可以放 heap、定期 ticker refresh。
量級敏感點: 當新通道的召回池量級放大到 50 萬+(>50×),「全部載入記憶體」就不再是合理選擇——不是欄位多寡的問題,而是 snapshot 這個 pattern 本身的邊界。底下的分析以「如何換 pattern,同時保留 pipeline 其他部份不動」為核心。

02Post Snapshot 的欄位結構

dao.Postdao/post.go:19)與嵌入的 *PostFeaturedao/post_feature.go:12)合計約 20 個欄位。並非全部都被 filter 用到——有些是 ranking 用、有些是完全沒被讀取的殘留欄位。

type Post struct {
    ID                    int64      // 必要:全域識別
    AuthorID              string     // 作者封鎖
    LikeCount             int64      // 品質門檻 + 排序
    ForumAlias            string     // 版規、訂閱、feedback、自殺版
    IsSchool              bool       // 學校版處理
    LayoutType            string     // Layout() 判斷
    CrossPostID, ReplyID  int64      // Layout() 判斷
    Title, Content        string     // 關鍵字封鎖
    Topics                []string   // feedback
    ImageMeta             []struct{} // 品質門檻
    Persona               string     // persona 封鎖
    Gender                string     // 官方文判斷
    CreatedByBrandMember  bool       // 官方文判斷
    CreatedAt             time.Time  // 新鮮度 / 封鎖時間比較
    EnableListLinkPreview bool       // Layout() 判斷
    LinkCount             int        // Layout() 判斷
    PollOptionTitles      []string   // 投票文關鍵字封鎖
    *PostFeature                     // 嵌入
    HawkeyeTaskID         string     // 透傳到 response
    BoostStrength         string     // 透傳到 response
}

type PostFeature struct {
    ID                            int64   // 同 Post.ID
    MemberID                      int64   // 官方文封鎖名單比對
    AverageDuration, MaleETR,
    FemaleETR, ShareRate, LikeRate float64 // 目前僅供 metric/log
    Tags                          []string
    CategoryL1s                   map[int64]string
}

03欄位 → Filter 對照表

以下逐一列出 snapshot 欄位實際被哪些 filter / generator 讀取,以及讀取方式。這張表是判斷「新通道要不要 hydrate 某欄位」的根據。

欄位 型別 被誰讀 用途描述
ID int64 ImpressionFilter ReportedPost DeniedPost OfficialPost SponsorPost SuicidePost MemberFeedback ViewFilter Response 全域 post 識別。絕大多數 filter 都是拿 p.ID 去各種 map[int64] 查表排除。這是唯一一個完全無法省略的欄位。
AuthorID string BlockedAuthor 比對 Member.BlockedAuthorIDs[p.AuthorID],且會搭配 CreatedAt 判斷「只過濾封鎖之後發的文」。filter/blocked.go:22-26
LikeCount int64 QualityFilter 多個 generator 排序 品質門檻 > 100(或附圖);也被 GCSPostDAO.List / ForumCandidate 用來 descending 排序取 top-K。filter/quality.go:20,35
ForumAlias string ForumFilter PostCreated MemberFeedback SuicidePost SponsorPost Forum 系列 generator 最高頻的欄位。訂閱判斷、persona_ 前綴判斷、版規封鎖、版級新鮮度門檻、feedback 以版為 key、自殺版 bypass 白名單全部吃它。filter/forum.go · filter/post_created.go:24
IsSchool bool ForumFilter (school) 學校版專屬的訂閱/排除邏輯。非訂閱者的學校版直接過濾掉。filter/forum.go:27,47
LayoutType
CrossPostID
ReplyID
LinkCount
EnableListLinkPreview
string
int64
int64
int
bool
LayoutFilter ViewFilter BlockedKeywords 透過 p.Layout()dao/post.go:48)綜合推斷版型;LayoutFilter 做白名單;ViewFilter 對 carousel/圖文等 special layout 用更嚴格的 impression 門檻;BlockedKeywords 需判斷是否為 poll 以決定是否掃 option titles。
Title
Content
PollOptionTitles
string
string
[]string
BlockedKeywords 字串 contains 比對 member 的 filtered keywords;allow-list URL 被正則剔除後再比對。filter/blocked.go:83-97
Topics []string MemberFeedback (topic) 比對 member 的 topic-level feedback map。filter/member_feedback.go:31
ImageMeta []struct{} QualityFilter len(ImageMeta) > 0 代表附圖,可放寬品質門檻。filter/quality.go:20
Persona string BlockedPersona OPHawkeyeV2 generator 比對 Member.BlockedPersonaIDs;Hawkeye persona carousel 任務以 persona 為索引收集文章。filter/blocked.go:38 · candidate/op_hawkeye.go:44
Gender string OfficialPost 只有 Gender == "D" 才進入官方文後續判斷。filter/account.go:32
CreatedByBrandMember bool OfficialPost 官方文判斷的最後 fallback:品牌會員發的文直接保留。filter/account.go:44
CreatedAt time.Time BlockedAuthor PostCreated Forum generator 封鎖時間後才發的文才過濾;版級新鮮度門檻;部份 generator 限制 3 天內。filter/blocked.go:25 · filter/post_created.go:28,49
PostFeature.MemberID int64 OfficialPost BlockedMembers 名單比對;注意這跟 AuthorID不同欄位,且來自 Mongo 的 feature collection。filter/account.go:36
PostFeature.ETR / DTR / ShareRate / LikeRate / AverageDuration float64 僅 metric / log 目前不參與任何 filter 決策,只在可觀測性被讀取。新通道可不填。
HawkeyeTaskID
BoostStrength
string
string
Response 非 snapshot JSON 欄位(json:"-");由 generator 寫入、透傳到 response。新通道若不屬於 Hawkeye 機制可留空。

04反過來:每個 Filter 需要什麼欄位

新通道若想在 experiment_settings.filters 開啟某條 rule,就必須確保 hydrate 了以下欄位;否則零值會導致誤判(例如 AuthorID=="" 會讓 BlockedAuthor 在封鎖清單有空字串的極端情況誤判,或 LikeCount==0Quality 幾乎全刪)。

Filter 需要的欄位 備註
ImpressionFilter ID get_candidate.go:145 以 BloomFilter 先過;ID-only OK
ReportedPost ID map 查表;ID-only OK
DeniedPost ID map 查表;ID-only OK
ViewFilter / PostViewFilter / WidgetItemViewFilter ID, LayoutType, CrossPostID, ReplyID, LinkCount, EnableListLinkPreview p.Layout() 決定 threshold
BlockedAuthor AuthorID, CreatedAt 時間比較不可少
BlockedPersona Persona
BlockedKeywords Title, Content, LayoutType, PollOptionTitles Poll 文才需要 option titles
ForumFilter (subscribed / school / persona prefix / denied) ForumAlias, IsSchool 版規保護最核心
PostCreated ForumAlias, CreatedAt 版級新鮮度
QualityFilter LikeCount, ImageMeta 只有 ImageMeta 長度會被讀
LayoutFilter LayoutType, CrossPostID, ReplyID, LinkCount, EnableListLinkPreview 透過 p.Layout()
MemberFeedback ID, ForumAlias, Topics post / forum / topic 三層
OfficialPost Gender, PostFeature.MemberID, CreatedByBrandMember, ID 需要額外接 Mongo 的 PostFeature
SponsorPost ID, ForumAlias
SuicidePost ID, ForumAlias bypass 白名單以 ForumAlias 判斷
最小 ID-only 的實用組合: Impression / Reported / Denied / (單純 post-id 類) MemberFeedback。只要新通道的實驗設定限制在這幾條 filter,就能用純 post_id 的方式運作。

05新增 Candidate 通道的影響

情境:召回池是全站 3 天以上舊文,量約 50 萬+(是現行 snapshot ~10K 的 50 倍以上)。問題不在「snapshot 欄位多寡」而在「一次載入 50 萬筆會讓 pod 記憶體與刷新週期崩盤」。底下先看架構影響,再於下一節分析能共用哪些既有資料源。

新通道在 pipeline 中的位置

既有 candidates
Forum / Search / Hawkeye …
→ PostListGenerator (共用 GCS snapshot)
新通道(~50 萬池)
OldPostRecall(示意)
召回 clue → 按需 hydrate
不做 in-memory bulk load
shared · 不動
Filter Chain
ImpressionFilter + experiment rules
shared · 不動
Response / FeedSnapshotDAO
只寫 post_id

既有設計邏輯會受影響嗎?

唯一需要小心的設計邏輯: 新通道「不走 snapshot」的前提下,若下游 filter 讀取未 hydrate 的欄位會拿到零值造成誤判(例如 ForumAlias=="" 會被 ForumFilter 視為不在訂閱版而全刪)。這不是架構問題,是實驗設定要把關:只開啟你 hydrate 過的欄位所支援的 filter。

5.5資料來源盤點:共用既有 DAO 的可行性

新通道要解的實際問題,是「召回 clue 源(per-member 幾十到幾百個 post_id)」+「欄位 hydration 源(把這些 id 補回 filter 需要的欄位)」兩件事。下表逐一評估 module 內現有的資料源能否重用。

資料源 角色 量級適性 評估
GCSPostDAO + postlist.Generator
post_gcs.go · postlist/main.go
欄位源(bulk) ❌ 不合 現行 pattern:整份 JSON 一次 load 進 heap、ticker 刷新。10K 筆 × ~2KB JSON 約 20MB,勉強可接受;50 萬筆直接來到 GB 級,且刷新時間會拉長到以分鐘計。量級邊界就是這條。即使套用既有的「再開一份獨立 snapshot」(如 EvergreenPostSnapshotFiles pattern)也不適用。
MongoPostFeatureDAO
post_feature_mongo.go
欄位源(by-id batch) ✅ 架構適合 已經是 Find({_id: {$in: ids}}) 搭配 projection,是「按 post_id 批次補欄位」的標準 pattern,處理幾十到幾百個 id 毫無壓力。但目前 projection 只包含 PostFeature 欄位(ETR、DTR、share_rate、member_id、category_tags),不包含 raw metadata(author_id / forum_alias / title / content…)。

能否擴充:要看 Mongo dice.posts collection 實際有沒有這些 raw 欄位。從 ETL 看,dcard-production.dice.post_snapshots_72hr_with_content 是 BigQuery 表;是否也同步寫入 Mongo 需要翻 dice ETL 確認,或請教維運。若有,只要加個 ListMeta(ctx, ids, projection) method 就解決。
ESPostDAO
post_es.go
召回 clue · 潛在欄位源 ✅ 可延伸 目前 Search() 只做 keyword search,且 FetchSource(false) 明確不拉欄位、Filter(created_at ≥ now-MaxAge) 限制新鮮文——對 50 萬舊文、非關鍵字召回的情境全部不合

但背後 ES index 必然索引了完整 post 欄位(這是 search index 的前提)。要重用的話,加一支 MultiGet(ctx, ids) map[int64]*Post_mget 實作,是最接近「既有基礎 + 小幅擴充」的選擇。延遲上 _mget 數百 id 很快。
Redis 召回系列:
EvergreenCandidateDAO
TwoTowerCandidateDAO
ForumPopularPostDAO
TopicPopularPostDAO
TagPopularPostDAO
NewPostDAO
召回 clue 源 ✅ 模式可借 這些 DAO 共通:都回 []int64map[key][]int64不存任何 post 欄位。尤其 RedisEvergreenCandidateDAOmember:{id}:evergreen_candidates)已經是「每個 member 的舊文候選 id 清單」pattern,跟新通道需求結構幾乎一致。

若新通道的 clue 可 pre-compute 成 per-member post_id 清單,就依樣葫蘆做一支新 DAO;無需為 clue 本身動既有基礎設施。
PostTagDAO (gRPC)
post_tag_grpc.go
欄位源(tag 專用) 片面 只回 tag 名稱。可拿來補 Topics/Tags,但其他欄位還是要別處拿。
BigQuery(dice.post_snapshots_*
Dcard-ETL DAG
欄位的真正源頭 ❌ 線上不合 所有 Post 欄位在 BQ 最完整,但 BQ query 延遲以秒計、並發成本高,不可能用在 request-time hydration。適合 offline 工作:把舊文召回池/欄位匯出到 Redis / Mongo / ES / 新 store,供線上讀。
新的 store(PG / Bigtable / 新 Mongo collection) 欄位源 可選 fallback 若 Mongo / ES 經確認都湊不齊所需欄位,才走這條。優點是可以精簡 schema、只存新通道需要的欄位;代價是要建 ETL 寫入端 + 新 DAO + migrations + wiring。應視為備援而非預設

建議架構:召回與 hydration 分離

offline · batch
BigQuery → Redis
pre-compute per-member 舊文候選 id 清單
online · Generate()
召回 clue DAO
依 member 取回幾十~幾百筆 post_id
模仿 EvergreenCandidateDAO
online · Hydrate
by-id batch
Mongo 擴充 projection · 或 ES _mget
只針對本次 request 的 id 撈
shared · 不動
Filter / Response
照跑 filter chain

這個 pattern 的優點:

先確認一件事再動手: Mongo dice.posts collection 是否已經有你需要的 raw 欄位(author_id / forum_alias / title / content / is_school 等)。如果有,擴充 MongoPostFeatureDAO 是最低摩擦的路徑;如果沒有,就改走 ES _mget,或請 ETL 多匯一份到 Mongo/新 store。這個驗證一小時可以翻完,能省後面一整輪架構折返。

06與其他 Candidate 的共用程度

下圖把 pipeline 切成「強制共用 / 可選共用 / 新通道自建」三層。

強制共用 · 不必自建

  • PostGenerator 介面契約
  • Filter chain 與 FilterMatcher
  • ImpressionFilterDAO(BloomFilter 過濾已曝光)
  • FeedSnapshotDAO(記錄 candidate 結果)
  • Response 格式(post_id / hawkeye_task_id / boost_strength
  • Metric / tracing

可選共用 · 看需求決定

  • PostListGenerator(50 萬量級不建議走)
  • MongoPostFeatureDAO by-id pattern(最有希望直接擴充)
  • ESPostDAO(目前是 keyword search,可加 _mget
  • Redis 召回 DAO pattern(evergreen_candidate_redis.go 等)
  • PostViewFilterWidgetItemViewFilter(pre-filter)
  • 共用 filter:Impression / Reported / Denied / Blocked 系列
  • 實驗設定結構(沿用 experiment_settings.candidates[name]

新通道自建

  • 召回 clue DAO(per-member post_id 清單)+ offline pipeline
  • Hydration DAO(理想情況下是擴充既有 Mongo / ES;若都不夠才新增 store)
  • Generator 檔案(candidate/old_post_recall.go
  • 外部服務設定放 internal/config/service.go
  • Wiring:application/main.go 注入
  • Mocks(task codegen:module:data-dice-candidate)與 testcontainers 測試

結論:共用比例約 2/3。新通道主要多出來的是「資料來源」本身,pipeline 其餘環節全部是 drop-in。

07自己需要維護的 Post 欄位

取決於這條 channel 要搭配哪些 filter。以下列三種典型強度,從最輕到最完整。

情境 A:最輕量的獨立召回post_id only

新通道負責的 post 清單本身已可信(例如上游推薦系統已過濾版規、品質、NSFW),只用 dice 做 impression 去重與個人層級封鎖。

必填欄位: ID

可搭配的 filter: Impression Reported Denied

DAO 只需: 回傳 []int64。Generator 把它包成 []*dao.Post{{ID: id}}

情境 B:含個人層級過濾個人封鎖

除了 impression 去重,還要套用使用者自己的封鎖(作者、persona、關鍵字、feedback)。這是多數「真正給使用者看的」通道會到的強度。

必填欄位: ID, AuthorID, Persona, Title, Content, CreatedAt, Topics, LayoutType(以及 poll 情境的 PollOptionTitles

可搭配的 filter: A 的全部 + BlockedAuthor BlockedPersona BlockedKeywords MemberFeedback

DAO 設計: 一支 batch GetMetaByIDs([]int64) map[int64]PostMeta,欄位比 snapshot 精簡。

情境 C:完整站內保護相當於 snapshot 等價

要支援版規、學校版、品質、版級新鮮度、自殺版、官方文等全站保護層。到這個強度基本等於「自己實作一份精簡 snapshot」。

必填欄位: B 的全部 + ForumAlias, IsSchool, LikeCount, ImageMeta(長度即可), CrossPostID, ReplyID, LinkCount, EnableListLinkPreview, Gender, CreatedByBrandMember, PostFeature.MemberID

可搭配的 filter: 幾乎全部。

建議: 這個強度下就要重新評估「是否一定要獨立」——如果召回的舊文其實可以一次 pre-compute 放進另一個小型 GCS snapshot(只挑需要欄位),實作負擔會比每次 query DB 小很多;但若真的是流水更新、量大、難 pre-compute,才值得這條路。


具體 TODO 清單

假設採情境 B(最常見),新通道落地的具體工程項目(按「召回 clue + hydration 分離」架構):