系統目前 candidate 都需要 Post Snapshot 才能產生。這份文件整理現行流程中每個 snapshot 欄位所對應的過濾邏輯;並針對「新通道召回量級比 snapshot 大 50 倍以上(~10K → 50 萬+)」的情境,分析其架構影響、現有資料來源(GCS / Mongo / ES / Redis / BigQuery)的可共用程度、以及自己需要維護的 post 欄位。
所有 candidate 共用一條「生成 → 過濾 → 輸出」的流水線。每個 candidate 以獨立 goroutine 平行跑 Generate(),產生 []*dao.Post,再套共用 filter chain,最後只取 post_id 寫回 response。
service/rpc/get_candidate.go:22 · candidate/main.go:38 · filter/main.go:79
一份由 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 交集。
dao.Post(dao/post.go:19)與嵌入的 *PostFeature(dao/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
}
以下逐一列出 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 |
LayoutTypeCrossPostIDReplyIDLinkCountEnableListLinkPreview |
string int64 int64 int bool |
LayoutFilter ViewFilter BlockedKeywords | 透過 p.Layout()(dao/post.go:48)綜合推斷版型;LayoutFilter 做白名單;ViewFilter 對 carousel/圖文等 special layout 用更嚴格的 impression 門檻;BlockedKeywords 需判斷是否為 poll 以決定是否掃 option titles。 |
TitleContentPollOptionTitles |
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 決策,只在可觀測性被讀取。新通道可不填。 |
HawkeyeTaskIDBoostStrength |
string string |
Response | 非 snapshot JSON 欄位(json:"-");由 generator 寫入、透傳到 response。新通道若不屬於 Hawkeye 機制可留空。 |
新通道若想在 experiment_settings.filters 開啟某條 rule,就必須確保 hydrate 了以下欄位;否則零值會導致誤判(例如 AuthorID=="" 會讓 BlockedAuthor 在封鎖清單有空字串的極端情況誤判,或 LikeCount==0 讓 Quality 幾乎全刪)。
| 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 判斷 |
post_id 的方式運作。
情境:召回池是全站 3 天以上舊文,量約 50 萬+(是現行 snapshot ~10K 的 50 倍以上)。問題不在「snapshot 欄位多寡」而在「一次載入 50 萬筆會讓 pod 記憶體與刷新週期崩盤」。底下先看架構影響,再於下一節分析能共用哪些既有資料源。
PostGenerator.Generate 回傳 []*dao.Post,沒規定資料來源。同模組已有類似 pattern——JobTitleCandidate、OPHawkeyeV2 都先從自己的 DAO 拿 post_id,再去 hydrate。新通道只是把 hydrate 的來源從 PostListGenerator 改成自己的 DAO。*dao.Post,不關心欄位怎麼來;啟用哪些 filter 由新通道的 experiment settings 自行決定。p.ID。ForumAlias=="" 會被 ForumFilter 視為不在訂閱版而全刪)。這不是架構問題,是實驗設定要把關:只開啟你 hydrate 過的欄位所支援的 filter。
新通道要解的實際問題,是「召回 clue 源(per-member 幾十到幾百個 post_id)」+「欄位 hydration 源(把這些 id 補回 filter 需要的欄位)」兩件事。下表逐一評估 module 內現有的資料源能否重用。
| 資料源 | 角色 | 量級適性 | 評估 |
|---|---|---|---|
GCSPostDAO + postlist.Generatorpost_gcs.go · postlist/main.go |
欄位源(bulk) | ❌ 不合 |
現行 pattern:整份 JSON 一次 load 進 heap、ticker 刷新。10K 筆 × ~2KB JSON 約 20MB,勉強可接受;50 萬筆直接來到 GB 級,且刷新時間會拉長到以分鐘計。量級邊界就是這條。即使套用既有的「再開一份獨立 snapshot」(如 EvergreenPostSnapshotFiles pattern)也不適用。
|
MongoPostFeatureDAOpost_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 就解決。
|
ESPostDAOpost_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 召回系列:EvergreenCandidateDAOTwoTowerCandidateDAOForumPopularPostDAOTopicPopularPostDAOTagPopularPostDAONewPostDAO …
|
召回 clue 源 | ✅ 模式可借 |
這些 DAO 共通:都回 []int64 或 map[key][]int64,不存任何 post 欄位。尤其 RedisEvergreenCandidateDAO(member:{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。應視為備援而非預設。 |
這個 pattern 的優點:
evergreen_candidate_redis.go;Mongo by-id hydrate 參考 post_feature_mongo.go。dice.posts collection 是否已經有你需要的 raw 欄位(author_id / forum_alias / title / content / is_school 等)。如果有,擴充 MongoPostFeatureDAO 是最低摩擦的路徑;如果沒有,就改走 ES _mget,或請 ETL 多匯一份到 Mongo/新 store。這個驗證一小時可以翻完,能省後面一整輪架構折返。
下圖把 pipeline 切成「強制共用 / 可選共用 / 新通道自建」三層。
PostGenerator 介面契約FilterMatcherImpressionFilterDAO(BloomFilter 過濾已曝光)FeedSnapshotDAO(記錄 candidate 結果)post_id / hawkeye_task_id / boost_strength)PostListGenerator(50 萬量級不建議走)MongoPostFeatureDAO by-id pattern(最有希望直接擴充)ESPostDAO(目前是 keyword search,可加 _mget)evergreen_candidate_redis.go 等)PostViewFilter、WidgetItemViewFilter(pre-filter)experiment_settings.candidates[name])candidate/old_post_recall.go)internal/config/service.goapplication/main.go 注入task codegen:module:data-dice-candidate)與 testcontainers 測試結論:共用比例約 2/3。新通道主要多出來的是「資料來源」本身,pipeline 其餘環節全部是 drop-in。
取決於這條 channel 要搭配哪些 filter。以下列三種典型強度,從最輕到最完整。
新通道負責的 post 清單本身已可信(例如上游推薦系統已過濾版規、品質、NSFW),只用 dice 做 impression 去重與個人層級封鎖。
必填欄位: ID
可搭配的 filter: Impression Reported Denied
DAO 只需: 回傳 []int64。Generator 把它包成 []*dao.Post{{ID: id}}。
除了 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 精簡。
要支援版規、學校版、品質、版級新鮮度、自殺版、官方文等全站保護層。到這個強度基本等於「自己實作一份精簡 snapshot」。
必填欄位: B 的全部 + ForumAlias, IsSchool, LikeCount, ImageMeta(長度即可), CrossPostID, ReplyID, LinkCount, EnableListLinkPreview, Gender, CreatedByBrandMember, PostFeature.MemberID
可搭配的 filter: 幾乎全部。
建議: 這個強度下就要重新評估「是否一定要獨立」——如果召回的舊文其實可以一次 pre-compute 放進另一個小型 GCS snapshot(只挑需要欄位),實作負擔會比每次 query DB 小很多;但若真的是流水更新、量大、難 pre-compute,才值得這條路。
假設採情境 B(最常見),新通道落地的具體工程項目(按「召回 clue + hydration 分離」架構):
dice.posts 有哪些欄位(或問 ETL 維運),決定 hydration 走 Mongo 擴充 projection、ES _mget、還是新 store。member:{id}:evergreen_candidates。dao/old_post_candidate_redis.go,介面只回 []int64。MongoPostFeatureDAO(加 ListMeta(ctx, ids) map[int64]*Post,projection 納入該情境所需欄位);或新增 ESPostMetaDAO 實作 _mget。candidate/old_post_recall.go,Generate 先取 clue、再 hydrate、組 []*dao.Post 回傳;不動 filter chain。application/main.go + cmd/rpc.go 注入並登錄 GeneratorMatcher;外部服務設定放 internal/config/service.go(勿用 envDefault 寫死 addr)。task codegen:module:data-dice-candidate;testcontainers pattern(Mongo / PG / Redis 都有現成)。