黑色卡片上的白色 Astro 火箭標誌與字樣,火焰為粉紅色

在 Astro 部落格用標籤重疊挑出相關文章 — IDF 加權與實測決定的門檻

如何在建置時用依標籤稀有度加權的重疊挑出相關文章、為什麼對內文做 TF-IDF 在三種語言下會失效,以及分數門檻是怎麼量出來的。

本頁目錄

引言

我在讀自己發布過的一篇文章,捲到最下面時發現那裡什麼都沒有。一排分享按鈕,接著就是頁尾。沒有下一篇、沒有推薦,除了往回捲到頁首的標籤之外沒有任何去處。網站上光是英文就有 32 篇已發布的文章,而讀者讀完一篇之後唯一能做的事情就是離開。

解法是一條相關文章列,但有意思的地方不在版面。而是一個沒有搜尋後端、沒有嵌入向量、也無意導入這兩者的靜態網站,要怎麼判斷哪些文章彼此相關。這個部落格採用的答案是 把標籤重疊依每個標籤的稀有度加權,在 astro build 期間算完並直接寫進 HTML。

本文整理了為什麼兩個看似理所當然的做法都被否決、加權是怎麼運作的、門檻分數如何靠實測而非感覺決定,以及讓這個區塊能比它底下的文章更寬的版面技巧。

這份封存檔的實際樣貌

先講數字,因為底下每個決定都是從這些數字推出來的。開始動工時,網站上有英文 32 篇、日文 31 篇、繁體中文 31 篇已發布的文章。標籤來自 src/data/tags.ts 裡一份封閉的登錄表,在建置時驗證,每篇文章帶 2 到 6 個標籤,中位數是 3 個。

這個分布的偏斜方式很關鍵:

標籤 帶有它的文章數
astro 12
generative-ai 7
developer-tooling 7
claude-code 6
web-api, i18n, automation 5
其餘 14 個標籤 各 1

32 篇裡有 12 篇帶著 astro。光是這一點就足以毀掉素樸的做法;再加上使用中的 39 個標籤裡有 14 個只出現一次,這表示 大約三分之一的詞彙根本不可能出現在交集裡

陷阱 1:對內文做 TF-IDF 會在三種語言裡壞掉兩種

多數範例會採用的做法是對文章內文做 TF-IDF。natural 套件有現成實作,一篇公開的 Astro 相關文章分類器 把它包成 relatinator 套件,以標題、描述、標籤與內文進行訓練。

代理在寫下任何程式碼之前就否決了它,理由只有一個:這個網站的每篇文章都以英文、日文與繁體中文發布,而 日文與中文不會在詞與詞之間放空白。把日文內文交給以空白斷詞的斷詞器,得到的不是詞,而是幾個巨大的偽詞元。英文會算得正確,另外兩個語系則安靜地產出垃圾,沒有任何東西失敗,也沒有錯誤可以察覺。

這種失效在這裡不是假設。同一個網站在搜尋層已經踩過一次:片假名複合詞開始回傳錯誤的頁面之後,Pagefind 查詢端的日文斷詞必須在 scripts/patch-pagefind-ja.ts 裡被關掉。同樣形狀的計分錯誤會更難發現,因為相關文章列表並沒有一個明顯正確的答案可以對照。

改用標籤就完全避開了這個問題。它是一份由 41 個 slug 組成的封閉列舉,靠建置時的檢查確保三個語系檔案之間完全一致,而且在結構上不屬於任何語言。

陷阱 2:素樸的標籤重疊會讓每篇 Astro 文章都彼此相關

以標籤為基礎的素樸計分方式就是數共用了幾個標籤。當 astro 出現在 32 篇裡的 12 篇時,這個計分器會認為那 12 篇彼此的相關程度完全相同,因為它看得到的訊號只有共用的 astro。一篇談 Service Worker 的文章和一篇談目錄捲動追蹤的文章,就憑著共用本站的招牌標籤而成了鄰居。

缺少的是這一點:共用稀有標籤是證據,共用常見標籤則不是。兩篇都帶 pagefind 的文章,由於這個標籤只出現在兩篇上,幾乎可以確定彼此有關。兩篇都帶 astro 的文章,只證明了它們都在這個部落格上。

依稀有度為標籤加權

這就是逆文件頻率,實際上只有一行:

src/lib/related.ts
export function inverseDocumentFrequency(
articles: readonly RelatedInput[],
): Map<TagSlug, number> {
const documentFrequency = new Map<TagSlug, number>();
for (const article of articles) {
for (const tag of article.tags) {
documentFrequency.set(tag, (documentFrequency.get(tag) ?? 0) + 1);
}
}
const idf = new Map<TagSlug, number>();
for (const [tag, frequency] of documentFrequency) {
idf.set(tag, Math.log(articles.length / frequency));
}
return idf;
}

N = 32 時,idf(pagefind) = log(32/2) ≈ 2.77,而 idf(astro) = log(32/12) ≈ 0.98共用 pagefind 的份量幾乎是共用 astro 的三倍,這就是把上面那個直覺寫成算式的結果。

單純標籤共用數與 IDF 加權的比較每個共用標籤一列,共兩列。左欄中兩個標籤都是 1.0,因此共用 astro 與共用 pagefind 無法區分。右欄中同樣的標籤變成 0.98 與 2.77,因為 astro 出現在 32 篇中的 12 篇,而 pagefind 只有 2 篇。同一個共用標籤,兩種計分方式單純共用數依稀有度加權共用標籤: astro32 篇中有 12 篇1.000.98共用標籤: pagefind32 篇中有 2 篇1.002.77idf = log(N / df)單純共用數無法區分兩者。加權後,稀有標籤的份量高出 2.8 倍。
素樸的重疊給兩個共用標籤相同的權重,因此分不出招牌標籤與稀有標籤。加權欄位是同一組配對以 log(N / df) 計分的結果。

一組配對的分數,是共用標籤權重的總和除以一個長度正規化項:

src/lib/related.ts
if (a.tags.length === 0 || b.tags.length === 0) return 0;
let overlap = 0;
for (const tag of a.tags) {
if (!b.tags.includes(tag)) continue;
overlap += idf.get(tag)!;
}
return overlap / Math.sqrt(a.tags.length * b.tags.length);

分母的 sqrt 是餘弦式的長度正規化,它是真的有在運作,不是裝飾。每篇文章的標籤數從 2 到 6 不等。少了它,帶 6 個標籤的文章會純粹靠數量贏下每一場比較:標籤越多,相交的機會就越多,跟兩篇是不是在講同一件事無關。

零標籤的防護也不是最佳化。sqrt(0 * n)0,而沒有標籤的文章其交集必然是空的,所以結果會是 0 / 0。這個 NaN 確實會在後續被濾掉,因為任何與 NaN 的比較都是 false,但那只是碰巧。schema 以 .default([]) 宣告 tags,因此這個狀態是到得了的。

否決一個備案,靠的是實測而不是爭論

標籤登錄表還把 41 個標籤分成四個族群:AI 與 LLM、雲端與平台、網頁與瀏覽器、工具與語言。一個看似合理的改良是:當兩篇文章沒有共用標籤但屬於同一族群時給一點小加分,讓標籤很少的文章也能有推薦。

代理沒有爭論而是直接量,結果發現這個選項根本到不了。族群加分要能顯示,分數必須達到門檻以上;要永遠不超過真正的標籤重疊,又必須低於實際存在的最弱重疊,也就是 0.327。這兩個條件夾出來的窗口是空的。

它的鑑別力也很差。在 353 組完全沒有重疊的英文配對中,有 147 組、也就是 42% 屬於同一族群,對這個母體給一律的加分,只會退化成「由新到舊」。若收緊成兩篇的主標籤同族群,會降到 353 分之 55,好一些但仍然粗糙。

真正拍板的是這個方案原本要拯救的那篇文章。git-gc-loose-objects-vs-filter-repo-history-rewrite 帶著 [git, webp],真正的配對剛好只有一篇。用族群層去補位的結果,補進來的是一篇 Claude Code 例行作業的文章,理由是 automationgit 都落在工具族群裡。這是比只顯示一張卡片更糟的推薦,所以 這條分支被刪掉了,而不是留成一個沒有人會打開的設定

從分布而不是從感覺決定門檻

門檻是必要的,否則前 N 名永遠會回傳 N 筆,不管配對有多弱。問題在於那個數字是多少,而誠實的答案是沒有人猜得到。

於是用一支拋棄式腳本把所有分數印出來。32 篇文章可以組成 496 組配對,其中 有 143 組至少共用一個標籤,分數落在 0.200 到 2.264 之間。不過真正決定門檻的並不是最小值,而是 0.327,也就是兩篇各帶 3 個標籤、且只共用 astro 時得到的分數:

log(32 / 12) / 3 = 0.327

這正是門檻存在的意義所在,要排除的就是這種配對。「這兩篇都提到 Astro」不構成推薦的理由。0.35 的門檻高過它,而在這個設定下每篇文章的卡片數分布是:

門檻 0 張 1 2 3 4 張以上
0.25 0 1 0 1 30
0.35 0 1 1 3 27
0.45 0 2 1 6 23
0.55 0 4 5 8 15

在 0.55 之下,32 篇裡只有 15 篇還能達到 4 張以上,調得太緊。在 0.25 之下,只共用 astro 的配對又回來了。產出這份資料的腳本後來變成了 pnpm related:preview,留著就是為了讓這個數字可以重新推導,而不是只能相信。

最後那一欄是「4 張以上」而不是剛好 4 張,原因正是本文提到的第三個缺陷:腳本把長條圖截在窄螢幕的卡片數上限,而不是版面本身的 6。因此這張表看不出有幾篇文章填滿了整個格線,只看得出有幾篇達到 4 張。這裡仍然保留當初量到的原始數據,因為若改用現在更大的語料庫重測,等於默默換掉了當時選定門檻所依據的證據。

門檻會隨著封存檔變大而上升

這件事沒有人去找它。它是在發布過程中、而不是在開發過程中意外冒出來的。

合併時必須先 rebase 到一篇新發布的文章上。那讓 N 從 32 變成 33,重跑預覽後發現只共用 astro 的配對分數也跟著動了,從 0.327 變成 0.3372。一篇文章就吃掉了固定門檻 0.35 上方大約三分之一的餘裕。

算式是 log(N / 12) / 3門檻停在被設定的位置不動,而這個值會隨著封存檔變大而上升,大約在 N = 35 時越過 0.35。從那天起,只共用 astro 的配對又會悄悄變成推薦。沒有錯誤、沒有失敗的檢查、沒有看得見的變化,只有推薦品質變差。

這個常數現在把這段算式寫成了 TODO,而 pnpm related:preview 在 README 裡的條目也註明要隨著語料庫成長重跑。憑感覺挑的門檻會有同樣的問題,而且連察覺的辦法都沒有。

把區塊移到內文欄之外,側邊欄就停住了

版面這一半有一個真正出乎意料的機制。原本的規劃是一條三張卡片並排的全寬區塊,而文章 68ch 的內文欄放不下。看起來最明顯的障礙是待在右側留白的 sticky 目錄,以及待在左側的分享側欄,兩者都會跟更寬的區塊相撞。

結果解法就是同一個動作,而且因果關係跟直覺是反過來的。.page 正是 sticky 目錄與絕對定位側欄的包含區塊。sticky 元素無法移動到包含它的方框之外。 把區塊算繪在 .page 之外,兩者就會自己在文章結束的地方停下,不需要捲動監聽器,也不需要額外的 CSS。

sticky 的移動範圍由包含區塊決定兩張欄位圖並排。第一張中,相關文章的方框位於 .page 容器內,sticky 目錄旁的移動範圍長條延伸到整個容器的高度,一路越過卡片。第二張中,相關文章的方框位於 .page 下方、容器之外,容器本身較短,移動範圍長條與文章內文齊平結束。放在 .page 內側.page文章內文目錄(sticky)相關文章移動範圍涵蓋整欄,目錄會一路跟到卡片旁邊放在 .page 外側.page文章內文目錄(sticky)相關文章移動範圍止於 .page 的結尾,目錄停在文章末端
兩種配置唯一的差別在於 .page 在哪裡結束。目錄旁邊那條長條就是它的 sticky 移動範圍,而它取決於容器的高度而非內容的高度。

在瀏覽器裡量測,目錄的下緣、側欄的下緣與相關區塊的上緣全都落在同一個像素上:

tocBottom: 88 railBottom: 88 relatedTop: 88

寬度是跟著一起來的。樣式表裡本來就有一個給頁尾用的 --measure-wide,值是 76rem,旁邊的註解說明內文欄之所以保持窄是因為那是可讀性的上限,而頁尾並不是拿來讀的。卡片是用看的而不是用讀的,所以同一套理由直接適用,不必新增 token。

說明文字移到封面上,而遮罩調錯了軸

卡片的處理方式是我的決定,而且跟代理的建議相反。代理主張在每個標題底下保留看得見的說明文字,依據是實測出來的一件事:封面是依主題而非依文章準備的美術素材。在整份封存檔中,31 個頁面裡有 24 個至少有兩張卡片共用同一張封面圖,其中一頁有四張卡片都帶著同一個 Claude 標誌。代理的立場是,沒有說明文字的話那些卡片看起來就像重複的。

我還是否決了,並要求一個能同時解決兩個問題的替代方案:滑鼠移上去時把縮圖調暗,並在上面顯示說明文字。卡片高度保持一致,文字也還在,這是兩個方案單獨都做不到的結果。

把遮罩調對花了三次,而前兩次都是代理對自己的算術過度自信而算錯。多數縮圖是平白底上的深色標誌。在 86% 不透明度下,標誌可以直接從文字後面看穿。92% 時仍然看得到。代理算出 97% 時封面只剩下 3%,並斷定那是看不見的,但這是錯的:在接近黑色的底上,3% 的殘留在 相對 亮度上是很大的一階,而人的亮度知覺在那個區段大致是對數的。以完全不透明作為對照並排算繪之後,97% 與 92% 彼此無法區分,而且兩者都明顯不夠。

提高不透明度本來就救不了,因為軸選錯了。把封面模糊掉會破壞標誌的形狀,於是上面的遮罩就不必再用亮度去蓋住它。 最後採用的規則是 86% 不透明度加上 10px 的 backdrop-filter 模糊,包在 @supports 的防護裡,而後備方案是完全不透明,而不是把濾鏡拿掉的 86%,後者會比任何已經被否決的方案更糟。

遮罩不透明度與模糊遮罩的比較同一張模擬封面的四個色塊。在 0.92 與 0.97 的不透明度下,文字後方的標誌仍可辨識。完全不透明時標誌消失,但封面的存在感也一併消失。第四個在模糊後的封面上疊加 0.86 的不透明度,標誌以柔和的暈染保留下來,文字仍然清晰。同一張封面上的四種遮罩說明文字說明文字說明文字說明文字0.92文字後面還看得到標誌0.97仍然看得見1.00乾淨,但封面完全消失0.86 + 模糊像是被調暗的封面提高不透明度是錯的方向。模糊會破壞標誌的形狀,遮罩因此可以更淡。
每個色塊都在同一張模擬封面上,以標示的不透明度畫出真正的遮罩,因此前兩格顯示的正是算術宣稱不會存在的殘留。

三個截圖照不出來的缺陷

這三個在所有目視檢查裡都算繪得完全正確。

覆蓋層把縮圖上的每一次點擊都吃掉了。 這個是我從瀏覽器發現的,不是從程式碼:把滑鼠移到縮圖上時,游標有時候會變成箭頭而不是手指。覆蓋層以 inset: 0 鋪滿整張封面,而 opacity: 0 並不會讓元素停止接收指標事件,所以它始終擋在游標與封面連結之間。命中測試證實了這一點:

centreOfThumbnail: { tag: "P", cursor: "auto", insideCoverLink: false }

「有時候」這個詞就是診斷的線索。封存頁與標籤頁使用的是另一種沒有覆蓋層的卡片,所以那些頁面一直是好的。加上一行 pointer-events: none 就修好了,而那條規則現在帶著一段註解,說明它的缺席在截圖裡是看不出來的,因為未來最容易發生的編輯就是把它當成雜訊刪掉。

一個空的外框仍然佔著 64px。 Astro.slots.has('after') 為真的條件是插槽被 傳入,而不是它算繪出了任何東西,而版面是無條件傳入這個元件的。因此當一篇文章的候選全都低於門檻時,仍然會輸出一個帶著 padding-block-end: 4rem 的空外框,在這個功能承諾要保持乾淨的那些頁面上,於頁尾之前留下一條空白帶。把縱向間距移到元件自己的區塊上就修好了。這一個是在審查回合中發現的,比對的是建置後的 HTML 而不是原始碼。

一個無法驗證自己招牌數字的工具。 預覽腳本把長條圖截在 4,而 4 是窄螢幕下的卡片數上限,版面本身的上限是 6。所有候選達到 4 個以上的文章都被壓進同一個分組,因此這支腳本無法重現元件與門檻常數都在引用的那個數字:「33 篇裡有 23 篇能填滿 6 張卡片的格線」。

驗證

計分器刻意不帶任何值層級的 import。它唯一的 import 是 import type { TagSlug },而 Node 在去除型別時會把它抹掉,因此預覽腳本能以純 node 載入真正的計分模組,而不是另外重寫一份:

終端機視窗
pnpm related:preview en
pnpm related:preview ja

整份封存檔裡最好的單一測試案例是一篇文章。git-gc-loose-objects-vs-filter-repo-history-rewrite 帶著 [git, webp],而它唯一真正的配對是 png-to-webp-before-the-first-commit,後者只有英文版發布。因此在另外兩個語系裡,gitwebp 的文件頻率都掉到 1,而只出現在一篇文章上的標籤永遠不可能進入交集。結果這一頁在 英文顯示一張卡片,在日文與中文則完全不顯示這個區塊,用一個網址同時驗證了隱藏路徑、未達上限的路徑,以及依語系計算頻率這個性質。

另一個值得一提的檢查是搜尋索引。相關卡片帶著其他文章的標題與描述,一旦被走訪就會污染索引,所以這個區塊放在帶有 data-pagefind-body 的元素之外。與其相信這一點,這裡直接把建置出來的片段解壓縮讀了一遍。被索引的內容停在文章自己的最後一句,鄰近文章的標題、區塊標題與分享列的標籤全都不在裡面,而頁數與字數跟同一個 commit 在沒有這個功能時建置出來的結果完全一致。

總結

在三語系的靜態網站上做相關文章,結果不需要機器學習,也不需要新的相依套件。真正起作用的部分是:

  • 每個共用標籤以 log(N / df) 加權,再用兩邊標籤數的 sqrt 正規化。共用稀有標籤應該比共用招牌標籤更有份量,而少了正規化,標籤最多的文章會贏下一切。
  • 除非所有語言都以空白斷詞,否則 不要對多語系語料庫的內文做 TF-IDF。它的失效恰好發生在你最沒有能力校對的語系上。
  • 從分數的分布決定門檻,並把它要排除的那組配對寫下來。這次排除的是兩篇各帶 3 個標籤、只共用 astro 的文章,而那組配對的分數會隨著封存檔變大而上升。
  • sticky 的移動範圍由包含區塊決定。 把區塊移出文章的外框,就能在沒有任何捲動程式碼的情況下讓 sticky 側邊欄停住。
  • 算繪得完全正確的缺陷才是最貴的。這裡找到的三個裡面,有兩個在拍過的每一張截圖裡都看不出來。

參考連結

分享這篇文章