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

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

- Source: https://oharu121.com/zh-tw/blog/astro-related-posts-tag-overlap-idf-weighting-score-floor/
- Published: 2026-08-24T09:22:48+09:00
- Tags: Astro, TypeScript, 國際化, SEO

---
**重點摘要**

- 只要把標籤重疊依每個標籤的稀有度加權，就能得到堪用的相關文章，不需要嵌入向量、不需要內文索引，也不必新增任何相依套件。
- 對內文做 TF-IDF 在三語系網站上會無聲失效。日文與中文不用空白斷詞，因此只有英文算得正確，另外兩種語言會退化成雜訊。
- 憑感覺定的分數門檻無法驗證。這次的門檻是從實際分數的分布決定的，而量完才發現它距離失效只剩下大約兩篇文章的餘裕。
- 把區塊移到內文欄之外，sticky 的側邊欄就會自己停下來，因為 sticky 的移動範圍是由包含區塊決定的，而不是由任何捲動處理決定。

## 引言

我在讀自己發布過的一篇文章，捲到最下面時發現那裡什麼都沒有。一排分享按鈕，接著就是頁尾。沒有下一篇、沒有推薦，除了往回捲到頁首的標籤之外沒有任何去處。網站上光是英文就有 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 相關文章分類器](https://darko.io/posts/build-you-a-related-post-classifier/) 把它包成 `relatinator` 套件，以標題、描述、標籤與內文進行訓練。

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

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

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

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

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

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

## 依稀有度為標籤加權

這就是逆文件頻率，實際上只有一行：

```ts title="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` 的三倍**，這就是把上面那個直覺寫成算式的結果。

*Figure — IdfWeighting: 素樸的重疊給兩個共用標籤相同的權重，因此分不出招牌標籤與稀有標籤。加權欄位是同一組配對以 `log(N / df)` 計分的結果。*

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

```ts title="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 例行作業的文章，理由是 `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。

*Figure — StickyContainingBlock: 兩種配置唯一的差別在於 `.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%，後者會比任何已經被否決的方案更糟。

*Figure — ScrimLadder: 每個色塊都在同一張模擬封面上，以標示的不透明度畫出真正的遮罩，因此前兩格顯示的正是算術宣稱不會存在的殘留。*

## 三個截圖照不出來的缺陷

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

**覆蓋層把縮圖上的每一次點擊都吃掉了。** 這個是我從瀏覽器發現的，不是從程式碼：把滑鼠移到縮圖上時，游標有時候會變成箭頭而不是手指。覆蓋層以 `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` 載入真正的計分模組，而不是另外重寫一份：

```bash
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`，後者只有英文版發布。因此在另外兩個語系裡，`git` 與 `webp` 的文件頻率都掉到 1，而只出現在一篇文章上的標籤永遠不可能進入交集。結果這一頁在 **英文顯示一張卡片，在日文與中文則完全不顯示這個區塊**，用一個網址同時驗證了隱藏路徑、未達上限的路徑，以及依語系計算頻率這個性質。

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

## 總結

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

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

## 參考連結

- [為 Astro 打造相關文章分類器，也就是本文否決的那套對內文做 TF-IDF 的做法](https://darko.io/posts/build-you-a-related-post-classifier/)
- [在 Astro.js 建立 Similar Posts 元件，素樸的標籤比對版本](https://www.joshfinnie.com/blog/creating-a-similar-posts-component-in-astrojs/)
- [Pagefind 的索引文件，包含 data-pagefind-body 如何把索引限制在帶有該屬性的元素上](https://pagefind.app/docs/indexing/)
- [MDN 的 position: sticky，說明決定移動範圍的正是包含區塊](https://developer.mozilla.org/en-US/docs/Web/CSS/position#sticky_positioning)
- [Shopify 談內部連結，相關文章 3 到 5 則這個指引的出處](https://www.shopify.com/blog/internal-links-seo)
