在 staging backfill 了 1,000 篇文章的 embedding,用離線實驗量測:同一則謠言改寫、轉傳、翻譯之後,向量還找不找得到原文;以及不相關的文章會不會被誤判成相似。這份報告整理測了什麼、看到什麼,以及在全量 backfill 之前建議先改的地方。
taskType 對 gemini-embedding-2 完全沒有作用。同一段文字用 RETRIEVAL_QUERY、RETRIEVAL_DOCUMENT、SEMANTIC_SIMILARITY 算出的向量逐位元相同。官方做法是把任務寫進文字前綴。建議先改完 embedding 的輸入格式和 kNN 的套用方式,確定設定後再全量 backfill。目前 cache key 只有 docId,改了設定後舊的向量會被悄悄沿用。
staging 的資料是 production 的 snapshot。用 PR 附的 backfillVertexEmbeddings 跑了 --limit 1000,scroll 沒有排序,所以等於從約 27.9 萬篇沒有 embedding 的文章中隨機抽樣。其中 999 篇成功;失敗的 1 篇是音訊文章,Gemini 回傳 400 INVALID_ARGUMENT(見「其他問題」一節)。有 3 篇影片文章共用同一個媒體檔,因此 airesponses 裡實際只有 996 筆向量。
| 類型 | 篇數 | 說明 |
|---|---|---|
| TEXT | 741 | 其中 251 篇只有網址;232 篇的 hyperlinks 有解析出可用的標題或摘要 |
| IMAGE | 175 | 向量從圖片本身算出;比對時用的是 text 欄位裡的 OCR 文字 |
| VIDEO | 80 | 向量從影片前 80 秒算出;比對時用的是逐字稿 |
這是離線實驗:從 airesponses 讀出文章向量,查詢向量直接呼叫 Gemini 產生,再在記憶體裡算 cosine。沒有經過 ES 的 kNN 查詢,也沒有測 BM25 和 hybrid 的排序。查詢和正確答案的配對是人工造出來的:拿池子裡的一篇文章做變形當成查詢,正確答案就是原文。
每個查詢都和池子裡全部 996 篇算 cosine,記錄以下指標:
因為 taskType 沒有作用,改成比較官方建議的文字前綴,以及「把連結內容一起算進去」的作法。
| 代號 | 查詢端的文字 | 文件端的文字 |
|---|---|---|
| RAW(PR 現況) | {text} | {text} |
| ASYM_SEARCH | task: search result | query: {text} | title: none | text: {text} |
| ASYM_FACTCHECK | task: fact checking | query: {text} | 同上 |
| SYM_SIM | task: sentence similarity | query: {text} | 同左 |
| EXP_RAW | 原文+連結標題與摘要 | 原文+連結標題與摘要 |
| EXP_ASYM_SEARCH | task: search result | query: 原文+連結內容 | title: none | text: 原文+連結內容 |
「連結內容」取自 articles.hyperlinks[].title / summary,並過濾掉 FB 登入牆(例如「このページを見るには、ログイン…」)、「Sorry, we're having trouble playing this video」這類沒有實際內容的文字。查詢端只在網址還留在查詢裡時才展開,模擬 ListArticles 用 scrapUrls 的作法。媒體文章一律沿用已存的向量,不加前綴,因為官方不建議對多模態輸入加前綴。
taskType 被忽略三種 taskType 是三次獨立的 API 呼叫、cache key 也不同,但回傳的向量最大絕對差是 0。Gemini embeddings 文件明確寫到 gemini-embedding-2 不能用 task_type,要把任務寫進文字:查詢用 task: search result | query: …,文件用 title: none | text: …。
這代表 PR 裡 createEmbedding 傳的 taskType 沒有任何效果,現在查詢端和文件端其實都是用原文、不加前綴去算。好消息是原文查自己的 cosine 是 1.0,不會發生「一模一樣的文字被門檻擋掉」的情況。
RAW 格式只看得到網址字串,所以不同的 Facebook 分享連結長得幾乎一樣。996 篇兩兩比對,相似度最高的 50 組裡:
| 文件端格式 | 兩邊都只有網址 | 其中連結相同(真的重複) | 其中連結不同 | 內文真的相似 | 第 50 名的 cosine |
|---|---|---|---|---|---|
| RAW(現況) | 46 | 13 | 33 | 0 | 0.996 |
| ASYM_SEARCH | 35 | 14 | 21 | 0 | 0.991 |
| EXP_ASYM_SEARCH | 28 | 16 | 12 | 6 | 0.948 |
其餘的配對都是媒體文章。另外 3 組 cosine 1.0 的影片配對是因為三篇用同一個檔案,所以向量本來就一樣。
加上連結內容後,排在前面的變成真正的重複:同一支 YouTube 影片只差追蹤參數,或同一則謠言的不同版本。url_strip_params(把網址的追蹤參數去掉再查)找到原文排第一的比例,從 RAW 的 0.62 提高到 0.96。
share/p、share/r 連結,url-resolver 只拿到登入牆,沒有任何可用內容。這類文章算 embedding 只會製造雜訊,建議不要算。但目前 kNN 是硬性過濾,沒有向量的文章會被直接排除,所以這一點要和觀察 4 的建議一起改。背景值是「最像的那篇非原文文章的 cosine」,代表門檻至少要設多高才擋得住雜訊。下表是各格式在 996 篇池子裡的背景值:
RAW 的背景中位數是 0.777。也就是說,在這麼小、這麼隨機的池子裡,一半的查詢光靠不相關的文章就能超過 0.7。到了 27.9 萬篇的全庫,超過門檻的候選只會更多,所以 kNN 實際上會被 num_candidates 或 k 的上限卡住,變成「取最近的 k 篇」。不同格式的 cosine 分數尺度不同(例如 SYM_SIM 整體偏高),所以門檻要針對最後選定的格式重新校準,不能直接沿用 0.7。
下表是 RAW(現況)在各種變形下,原文 cosine 低於門檻、會被 kNN 過濾掉的比例:
拿圖片的 OCR 文字去比對該圖片自己的向量(跨模態)時:
因為 kNN 是 bool.filter 加上 minimum_should_match: 0,被 kNN 刷掉的文章 BM25 也救不回來。只要開啟 hybrid,文字查詢就幾乎不會出現圖片文章,即使圖片的 OCR 文字和查詢一模一樣。例子:
這兩例中,排在原圖前面的那篇文字文章其實內容相關,同樣是詐騙手法。問題在於跨模態的分數整體比文字對文字低一截,所以不管門檻設多少,混在同一個池子裡排序時,圖片都會輸給文字。
下圖是各種變形下,原文排第一的比例。藍色是現況,橘色是加上官方前綴,綠色是加上前綴並展開連結內容。
怎麼讀這張圖:
從這點推測,比較好的作法可能是每篇文章存兩個向量:一個只用原文,一個用原文+連結內容。embeddings 本來就是 nested array,搭配 score_mode: max 就能各取所長。這個作法還沒測,建議列為下一輪的比較組。
400 INVALID_ARGUMENT。可能的原因有三個:GCS 上的 contentType 不被模型接受、音訊不該帶 videoMetadata.endOffset,或檔案本身有問題,還沒釐清。ERROR 不會被 cache,所以每次重跑 backfill 都會再打一次。docId 為 key。以後改格式、換模型或改維度,舊的向量都會被悄悄沿用。bool.filter,所以還沒 backfill、或刻意不算向量的文章,一開 hybrid 就搜不到。createEmbedding 需要 user 才能建立,否則丟出例外,被 catch 之後就只跑 BM25。做線上 A/B 時一定要帶 app 憑證,並確認 airesponses 有產生 query-text:* 的紀錄。size(待驗證):ES 的 knn query 的 k 預設等於 request 的 size。如果是這樣,first: 10 時每個 shard 只會留最近的 10 筆,totalCount 和翻頁都會受影響。taskType,改用文字前綴。文件端用 title: none | text: …,查詢端用 task: search result | query: …;媒體不加前綴。task: fact checking 的結果和 search 差不多,search 稍好。hyperlinks 標題與摘要;查詢端在 ListArticles 裡把 scrapUrls 的結果用同樣方式組進去(BM25 已經這樣做了)。只有網址而且解析不出內容的文章,不要算向量。可以評估「每篇存兩個向量」的作法。YuTin 會把 production 的 ES 備份 restore 到自己的機器上,就能做 staging 上做不到的完整測試。建議順序如下:
ListArticles 比較 BM25 和 hybrid 的 Recall@10/@50、BM25 找得到但 hybrid 沒有的比例,以及 hybrid 額外找到的比例。first: 10 和 first: 50,比較 totalCount;確認沒有向量的文章在 hybrid 下的表現;呼叫時要帶 app 憑證。evalEmbeddings.js(另外提供)是單一檔案的 Node 腳本,只會讀 ES,不會寫入。它會找出 airesponses 裡 userId: embedding-backfill 的向量,自己呼叫 Gemini 產生查詢向量並快取,可以中斷後續跑。在 rumors-api 根目錄執行:
# 會用到 ./build/util/genai 與 ./build/util/client(若有),否則改用 @google/genai + @elastic/elasticsearch
# env:ELASTICSEARCH_URL、GOOGLE_APPLICATION_CREDENTIALS(Vertex AI)
node evalEmbeddings.js --out /tmp/eval --dry-run
node evalEmbeddings.js --out /tmp/eval --limit 30 --llm-sample 5
node evalEmbeddings.js --out /tmp/eval --concurrency 8
node evalEmbeddings.js --help # --arms、--perturb-arms、--llm-sample 等參數
產出的檔案包括 summary.json、self.csv、perturb.csv、background.csv、pairs_*.csv、examples.json,以及 queries.jsonl。queries.jsonl 收錄所有查詢原文,可以直接拿去打 ListArticles 做線上 A/B。這次 staging 那輪的設定是 6 種格式,約 2.1 萬次 embedding 呼叫,花了 27 分鐘。