
在 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 的文章,只證明了它們都在這個部落格上。
依稀有度為標籤加權
這就是逆文件頻率,實際上只有一行:
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 的三倍,這就是把上面那個直覺寫成算式的結果。
log(N / df) 計分的結果。一組配對的分數,是共用標籤權重的總和除以一個長度正規化項:
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 例行作業的文章,理由是 automation 與 git 都落在工具族群裡。這是比只顯示一張卡片更糟的推薦,所以 這條分支被刪掉了,而不是留成一個沒有人會打開的設定。
從分布而不是從感覺決定門檻
門檻是必要的,否則前 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。
.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%,後者會比任何已經被否決的方案更糟。
三個截圖照不出來的缺陷
這三個在所有目視檢查裡都算繪得完全正確。
覆蓋層把縮圖上的每一次點擊都吃掉了。 這個是我從瀏覽器發現的,不是從程式碼:把滑鼠移到縮圖上時,游標有時候會變成箭頭而不是手指。覆蓋層以 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 enpnpm related:preview ja整份封存檔裡最好的單一測試案例是一篇文章。git-gc-loose-objects-vs-filter-repo-history-rewrite 帶著 [git, webp],而它唯一真正的配對是 png-to-webp-before-the-first-commit,後者只有英文版發布。因此在另外兩個語系裡,git 與 webp 的文件頻率都掉到 1,而只出現在一篇文章上的標籤永遠不可能進入交集。結果這一頁在 英文顯示一張卡片,在日文與中文則完全不顯示這個區塊,用一個網址同時驗證了隱藏路徑、未達上限的路徑,以及依語系計算頻率這個性質。
另一個值得一提的檢查是搜尋索引。相關卡片帶著其他文章的標題與描述,一旦被走訪就會污染索引,所以這個區塊放在帶有 data-pagefind-body 的元素之外。與其相信這一點,這裡直接把建置出來的片段解壓縮讀了一遍。被索引的內容停在文章自己的最後一句,鄰近文章的標題、區塊標題與分享列的標籤全都不在裡面,而頁數與字數跟同一個 commit 在沒有這個功能時建置出來的結果完全一致。
總結
在三語系的靜態網站上做相關文章,結果不需要機器學習,也不需要新的相依套件。真正起作用的部分是:
- 每個共用標籤以
log(N / df)加權,再用兩邊標籤數的sqrt正規化。共用稀有標籤應該比共用招牌標籤更有份量,而少了正規化,標籤最多的文章會贏下一切。 - 除非所有語言都以空白斷詞,否則 不要對多語系語料庫的內文做 TF-IDF。它的失效恰好發生在你最沒有能力校對的語系上。
- 從分數的分布決定門檻,並把它要排除的那組配對寫下來。這次排除的是兩篇各帶 3 個標籤、只共用
astro的文章,而那組配對的分數會隨著封存檔變大而上升。 - sticky 的移動範圍由包含區塊決定。 把區塊移出文章的外框,就能在沒有任何捲動程式碼的情況下讓 sticky 側邊欄停住。
- 算繪得完全正確的缺陷才是最貴的。這裡找到的三個裡面,有兩個在拍過的每一張截圖裡都看不出來。




