向量搜尋 staging 實測
rumors-api · cofacts/rumors-api#388 · 混合搜尋(BM25 + kNN)

向量搜尋 staging 實測

2026-10-08staging(production snapshot)996 篇已 backfill 的文章gemini-embedding-2 · 768 維

在 staging backfill 了 1,000 篇文章的 embedding,用離線實驗量測:同一則謠言改寫、轉傳、翻譯之後,向量還找不找得到原文;以及不相關的文章會不會被誤判成相似。這份報告整理測了什麼、看到什麼,以及在全量 backfill 之前建議先改的地方。

重點摘要

  1. taskType 對 gemini-embedding-2 完全沒有作用。同一段文字用 RETRIEVAL_QUERY、RETRIEVAL_DOCUMENT、SEMANTIC_SIMILARITY 算出的向量逐位元相同。官方做法是把任務寫進文字前綴。
  2. 只有網址的文章(佔文字文章 34%)會互相黏在一起。兩個內容無關的 Facebook 分享連結 cosine 可達 0.999;相似度最高的 50 組配對中,46 組是這類文章。改用「原文+連結標題與摘要」來算 embedding 可以大幅改善。
  3. 0.7 門檻幾乎過濾不掉雜訊。在 996 篇隨機文章裡,一半的查詢光是「最像的不相關文章」就超過 0.777。到了 27.9 萬篇的全庫,kNN 實際上會變成「取最近的 k 篇」,而不是「取相似度超過門檻的」。
  4. 用文字查不到圖片和影片。拿圖片的 OCR 文字去比對該圖片自己的向量,cosine 中位數只有 0.62,100% 低於 0.7。因為 kNN 是硬性過濾,開啟 hybrid 後文字查詢會把所有圖片文章排除掉,即使 BM25 本來找得到。
  5. 近似重複的偵測很穩,改寫和翻譯則中等。加「轉傳」前綴、去標點、改簡體、英譯等情況,cosine 大多在 0.85~0.95。只用關鍵字查詢時最弱,現行設定下有 26% 低於 0.7。

建議先改完 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 筆向量。

類型篇數說明
TEXT741其中 251 篇只有網址;232 篇的 hyperlinks 有解析出可用的標題或摘要
IMAGE175向量從圖片本身算出;比對時用的是 text 欄位裡的 OCR 文字
VIDEO80向量從影片前 80 秒算出;比對時用的是逐字稿

方法

這是離線實驗:從 airesponses 讀出文章向量,查詢向量直接呼叫 Gemini 產生,再在記憶體裡算 cosine。沒有經過 ES 的 kNN 查詢,也沒有測 BM25 和 hybrid 的排序。查詢和正確答案的配對是人工造出來的:拿池子裡的一篇文章做變形當成查詢,正確答案就是原文。

每個查詢都和池子裡全部 996 篇算 cosine,記錄以下指標:

比較的格式

因為 taskType 沒有作用,改成比較官方建議的文字前綴,以及「把連結內容一起算進去」的作法。

代號查詢端的文字文件端的文字
RAW(PR 現況){text}{text}
ASYM_SEARCHtask: search result | query: {text}title: none | text: {text}
ASYM_FACTCHECKtask: fact checking | query: {text}同上
SYM_SIMtask: sentence similarity | query: {text}同左
EXP_RAW原文+連結標題與摘要原文+連結標題與摘要
EXP_ASYM_SEARCHtask: search result | query: 原文+連結內容title: none | text: 原文+連結內容

「連結內容」取自 articles.hyperlinks[].title / summary,並過濾掉 FB 登入牆(例如「このページを見るには、ログイン…」)、「Sorry, we're having trouble playing this video」這類沒有實際內容的文字。查詢端只在網址還留在查詢裡時才展開,模擬 ListArticles 用 scrapUrls 的作法。媒體文章一律沿用已存的向量,不加前綴,因為官方不建議對多模態輸入加前綴。

限制:996 篇隨機樣本裡幾乎沒有「同一則謠言的不同版本」這種天然配對,所以這份報告量不到真實的 recall,只能看各種變形下向量的穩定度,以及雜訊的程度。真實 recall 需要全量資料加上 reply 群組當標準答案,見最後的「交接」一節。

觀察 1: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,不會發生「一模一樣的文字被門檻擋掉」的情況。

觀察 2:只有網址的文章互相黏在一起

RAW 格式只看得到網址字串,所以不同的 Facebook 分享連結長得幾乎一樣。996 篇兩兩比對,相似度最高的 50 組裡:

文件端格式兩邊都只有網址其中連結相同(真的重複)其中連結不同內文真的相似第 50 名的 cosine
RAW(現況)46133300.996
ASYM_SEARCH35142100.991
EXP_ASYM_SEARCH28161260.948

其餘的配對都是媒體文章。另外 3 組 cosine 1.0 的影片配對是因為三篇用同一個檔案,所以向量本來就一樣。

RAW 下被誤判為幾乎相同的配對

加上連結內容之後

加上連結內容後,排在前面的變成真正的重複:同一支 YouTube 影片只差追蹤參數,或同一則謠言的不同版本。url_strip_params(把網址的追蹤參數去掉再查)找到原文排第一的比例,從 RAW 的 0.62 提高到 0.96。

剩下的 12 組幾乎都是 FB share/p、share/r 連結,url-resolver 只拿到登入牆,沒有任何可用內容。這類文章算 embedding 只會製造雜訊,建議不要算。但目前 kNN 是硬性過濾,沒有向量的文章會被直接排除,所以這一點要和觀察 4 的建議一起改。

觀察 3:0.7 門檻與 recall

背景值是「最像的那篇非原文文章的 cosine」,代表門檻至少要設多高才擋得住雜訊。下表是各格式在 996 篇池子裡的背景值:

RAW 的背景中位數是 0.777。也就是說,在這麼小、這麼隨機的池子裡,一半的查詢光靠不相關的文章就能超過 0.7。到了 27.9 萬篇的全庫,超過門檻的候選只會更多,所以 kNN 實際上會被 num_candidates 或 k 的上限卡住,變成「取最近的 k 篇」。不同格式的 cosine 分數尺度不同(例如 SYM_SIM 整體偏高),所以門檻要針對最後選定的格式重新校準,不能直接沿用 0.7。

下表是 RAW(現況)在各種變形下,原文 cosine 低於門檻、會被 kNN 過濾掉的比例:

觀察 4:用文字查不到圖片和影片

拿圖片的 OCR 文字去比對該圖片自己的向量(跨模態)時:

因為 kNN 是 bool.filter 加上 minimum_should_match: 0,被 kNN 刷掉的文章 BM25 也救不回來。只要開啟 hybrid,文字查詢就幾乎不會出現圖片文章,即使圖片的 OCR 文字和查詢一模一樣。例子:

這兩例中,排在原圖前面的那篇文字文章其實內容相關,同樣是詐騙手法。問題在於跨模態的分數整體比文字對文字低一截,所以不管門檻設多少,混在同一個池子裡排序時,圖片都會輸給文字。

觀察 5:各格式比較

下圖是各種變形下,原文排第一的比例。藍色是現況,橘色是加上官方前綴,綠色是加上前綴並展開連結內容。

RAW(現況)ASYM_SEARCHEXP_ASYM_SEARCH

怎麼讀這張圖:

從這點推測,比較好的作法可能是每篇文章存兩個向量:一個只用原文,一個用原文+連結內容。embeddings 本來就是 nested array,搭配 score_mode: max 就能各取所長。這個作法還沒測,建議列為下一輪的比較組。

完整數字表(6 種格式 × 18 種變形)

其他問題

給 PR 的建議

  1. 拿掉 taskType,改用文字前綴。文件端用 title: none | text: …,查詢端用 task: search result | query: …;媒體不加前綴。task: fact checking 的結果和 search 差不多,search 稍好。
  2. embedding 的輸入要包含連結內容。文件端用原文加上過濾過的 hyperlinks 標題與摘要;查詢端在 ListArticles 裡把 scrapUrls 的結果用同樣方式組進去(BM25 已經這樣做了)。只有網址而且解析不出內容的文章,不要算向量。可以評估「每篇存兩個向量」的作法。
  3. kNN 不要做成硬性過濾。至少改成「kNN 命中,或這篇沒有向量」才保留。更好的作法是 kNN 和 BM25 取聯集再融合排序(例如 RRF),避免跨模態和還沒 backfill 的文章整批消失。
  4. 門檻依最後選定的格式重新校準。以 ASYM_SEARCH 為例,非網址文章的背景第 95 百分位約 0.89,RAW 的 0.7 不能直接沿用。
  5. cache key 要包含設定版本(模型、維度、格式),或把版本存進 airesponses 並在讀取時檢查。
  6. 修掉音訊 400 的問題,之後再全量 backfill。

交接:下一輪測試

YuTin 會把 production 的 ES 備份 restore 到自己的機器上,就能做 staging 上做不到的完整測試。建議順序如下:

  1. 先選定格式。依上面的建議改完 PR,加上「每篇存兩個向量」作為比較組。可以先用評估腳本在小池子上比較,不需要全量資料。
  2. 全量 backfill 文字文章和 replies。staging 上 concurrency 2 大約每秒 1.6 篇,27.9 萬篇約 48 小時,建議調高 concurrency 並注意 429。媒體文章可以晚點再做。
  3. 用 reply 群組當標準答案量真實 recall。連到同一則 reply 的多篇文章視為同一則謠言。只用 RUMOR、NOT_RUMOR、OPINIONATED 類型的 reply,並排除被連到太多篇的通用回覆。每個群組取一篇當查詢,透過 ListArticles 比較 BM25 和 hybrid 的 Recall@10/@50、BM25 找得到但 hybrid 沒有的比例,以及 hybrid 額外找到的比例。
  4. 驗證 ES 的行為。同一個查詢用 first: 10 和 first: 50,比較 totalCount;確認沒有向量的文章在 hybrid 下的表現;呼叫時要帶 app 憑證。
  5. 媒體測試。圖片重新壓縮、裁切、截圖後再截圖;音訊關鍵內容在 80 秒之後;文字查圖片的情況,對應觀察 4。
  6. ListReplies。replies 是用 text+reference 算 embedding,reference 裡的網址可能也有觀察 2 的問題。

評估腳本

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 分鐘。