
Astro ブログで関連記事をタグの重なりから選ぶ — IDF 重み付けと実測で決めたしきい値
タグの希少度で重み付けした重なりからビルド時に関連記事を選ぶ方法、本文への TF-IDF が 3 言語で破綻する理由、そしてスコアのしきい値を実測で決めた過程。
目次
はじめに
自分が公開した記事を読んでいて、最後まで来たときに何もないことに気づきました。共有ボタンの列があり、その下はフッターです。次の記事も、おすすめも、ヘッダーのタグまでスクロールで戻る以外の進み先もありません。英語版だけで 32 本の記事が公開されているのに、たどり着いた読者が 1 本読み終えてできることは離脱だけでした。
対処は関連記事の一覧ですが、面白いのはレイアウトではありません。検索バックエンドも埋め込みも持たず、どちらも導入する気がない静的サイトが、どうやって記事の関連度を決めるかです。このブログが採った答えは タグの重なりを、各タグの希少度で重み付けする というもので、astro build の最中に計算して HTML に焼き込んでいます。
本記事では、分かりやすい 2 つの手法をなぜ捨てたのか、重み付けの仕組み、しきい値を感覚ではなく実測から決めた過程、そしてセクションを記事より広く表示できるようにしたレイアウトの仕掛けを整理します。
対象となったアーカイブ
まず数字からです。以降の判断はすべてこの数字から導かれています。作業を始めた時点でサイトには英語 32 本、日本語 31 本、繁體中文 31 本の公開記事がありました。タグは src/data/tags.ts の閉じたレジストリから取り、ビルド時に検証されます。1 記事あたりのタグは 2 個から 6 個で、中央値は 3 個です。
分布は無視できない偏り方をしています。
| タグ | 付いている記事数 |
|---|---|
astro |
12 |
generative-ai |
7 |
developer-tooling |
7 |
claude-code |
6 |
web-api, i18n, automation |
5 |
| 残り 14 タグ | 各 1 |
32 本のうち 12 本に astro が付いています。この 1 点が素朴な手法を壊します。さらに使用中の 39 タグのうち 14 個はちょうど 1 回しか出てこないため、語彙のおよそ 3 分の 1 はそもそも共通部分に現れません。
落とし穴 1: 本文への TF-IDF は 3 言語のうち 2 つで破綻する
例としてよく出てくるのは本文への TF-IDF です。natural パッケージが実装を持っており、公開されている Astro 向けの関連記事分類器 はそれを relatinator というパッケージにまとめ、タイトル・説明・タグ・本文で学習させています。
エージェントはコードを書く前にこれを退けました。理由は 1 つで、このサイトは全記事を英語・日本語・繁體中文で公開しており、日本語と中国語は単語の間に空白を置かない からです。空白で区切るトークナイザーに日本語の本文を渡すと、単語ではなく巨大な疑似トークンがいくつかできるだけです。英語は正しく採点され、残り 2 ロケールは静かにゴミを出し、何も失敗せず、気づくためのエラーも出ません。
この壊れ方はここでは仮定の話ではありません。同じサイトが検索層ですでに踏んでいます。カタカナの複合語が誤ったページを返し始めたため、Pagefind のクエリ側の日本語分割を scripts/patch-pagefind-ja.ts で無効化しました。同じ形の採点バグはもっと見つけにくかったはずです。関連記事の一覧には、突き合わせる明らかな正解がないからです。
タグならこの問題は起きません。41 個のスラグからなる閉じた列挙で、ビルド時の検査によって 3 つのロケールファイル間で同一であることが保証され、構造上どの言語にも属しません。
落とし穴 2: 素朴なタグの重なりでは Astro 記事どうしが全部関連になる
タグを使う素朴な採点は、共通するタグの数を数えます。32 本中 12 本に astro が付いていると、その採点器は 12 本すべてが互いに同程度に関連していると判断します。共通する astro しか手がかりが見えないからです。Service Worker の記事と目次のスクロール追従の記事が、このサイトの看板タグを共有しているというだけで近い隣人として出てきます。
抜けているのは、希少なタグの共有は証拠になるが、ありふれたタグの共有はならない という点です。2 本の記事がどちらも pagefind を持つなら、そのタグは 2 本にしか付いていないので、まず間違いなく互いに関係があります。どちらも astro を持つ 2 本が示したのは、両方このブログの記事だということだけです。
タグを希少度で重み付けする
これが逆文書頻度で、実質 1 行です。
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 の共有のおよそ 3 倍の重みを持つ わけで、これは上で述べた直感を算術に置き換えたものです。
log(N / df) で採点したものです。1 組のスコアは、共有タグの重みの合計を、長さの正規化項で割ったものです。
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 はコサイン類似度と同じ長さの正規化で、飾りではなく効いています。1 記事あたりのタグは 2 個から 6 個です。これがないと、タグが 6 個ある記事が量だけであらゆる比較に勝ちます。タグが多いほど交わる機会が増えるためで、記事の内容が同じかどうかとは関係ありません。
タグ 0 個のガードも最適化ではありません。sqrt(0 * n) は 0 になり、タグのない記事の共通部分は常に空なので、結果は 0 / 0 になります。この NaN は実際には後段で除外されます。NaN との比較はすべて false になるからです。ただしそれは偶然にすぎません。スキーマは tags を .default([]) で宣言しているので、この状態には到達しえます。
代替案を、議論ではなく実測で退ける
タグレジストリは 41 個のタグを 4 つの系統にも分けています。AI と LLM、クラウドと基盤、Web とブラウザ、ツールと言語です。分かりやすい改良案として、タグ自体は共有していなくても系統が同じなら小さなボーナスを与え、タグの少ない記事にもおすすめが出るようにする、というものがあります。
エージェントは議論せずに測り、この選択肢が到達不能であることが分かりました。系統だけのボーナスが表示されるためには、しきい値以上のスコアでなければなりません。本物のタグの重なりを上回らないためには、実在する最弱の重なりである 0.327 より低くなければなりません。この 2 つの条件が挟む窓は空です。
判別力もほとんどありません。重なりが 0 の英語の組 353 のうち 147 組、42% が系統を共有しています。この母集団に一律のボーナスを与えても「新しい順」に退化するだけです。両記事の主タグどうしに絞ると 353 分の 55 まで下がり、多少ましになりますが依然として粗いままです。
決め手になったのは、この案が救うはずだった記事でした。git-gc-loose-objects-vs-filter-repo-history-rewrite は [git, webp] を持ち、本物の一致はちょうど 1 件です。系統の層から埋めた結果出てきたのは Claude Code のルーチンの記事で、automation と git がどちらもツール系統にあるという理由でした。タイル 1 枚を出すより悪いおすすめなので、この分岐は、誰も有効にしない設定として残すのではなく削除しました。
しきい値を感覚ではなく分布から決める
しきい値は必要です。ないと上位 N 件は、どれだけ弱くても必ず N 件返します。問題はその数字で、正直なところ誰にも当てられません。
そこで使い捨てのスクリプトで全スコアを出しました。32 本の記事から作れる組は 496 通りで、そのうち 143 組が少なくとも 1 つのタグを共有し、スコアは 0.200 から 2.264 の範囲 に分布していました。ただし、しきい値を決めるのは最小値ではありません。決めるのは 0.327 で、これはタグ 3 個の記事 2 本が astro だけを共有したときのスコアです。
log(32 / 12) / 3 = 0.327これがしきい値の排除すべき組です。「どちらも Astro の話をしている」はおすすめの理由になりません。0.35 のしきい値はこれを上回り、そのときの 1 記事あたりのタイル数の分布はこうなります。
| しきい値 | タイル 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 本中 4 枚以上に届くのが 15 本しかなく、絞りすぎです。0.25 では astro だけの組が戻ってきます。これを出したスクリプトは pnpm related:preview になりました。数字を信じるのではなく導き直せるようにするためです。
最後の列がちょうど 4 枚ではなく「4 枚以上」なのは、この記事で挙げる 3 つ目の不具合そのものが理由です。スクリプトはヒストグラムを、レイアウト上の上限である 6 ではなく、狭い画面でのタイル数で打ち切っていました。したがってこの表からは、グリッドを 埋めきった 記事が何本あるかは読み取れず、4 枚に届いた本数しか分かりません。それでも測定した当時のまま残してあります。いまの大きくなったコーパスで取り直すと、しきい値を実際に選んだときの根拠を黙って差し替えることになるからです。
しきい値はアーカイブが育つと上がっていく
これは誰も探していませんでした。作業中ではなくリリース中に、偶然表に出てきたものです。
マージにあたって、新しく公開された記事の上にリベースする必要がありました。それで N が 32 から 33 になり、プレビューを再実行すると astro だけの組のスコアも一緒に動いていて、0.327 から 0.3372 になっていました。記事 1 本で、固定されたしきい値 0.35 までの余裕をおよそ 3 分の 1 食べたことになります。
式は log(N / 12) / 3 です。しきい値が置かれた場所に留まる一方で、アーカイブが育つほどこの値は上がり、N = 35 あたりで 0.35 を越えます。その日から astro だけの組は再び静かにおすすめに戻ります。エラーも、失敗する検査も、目に見える変化もなく、ただおすすめの質が落ちます。
定数にはいまこの算術が TODO として書かれており、pnpm related:preview の README の項目にはコーパスが育ったら再実行するようにと書いてあります。感覚で選んだしきい値なら、同じ問題を抱えたうえ、気づく手立てもなかったはずです。
セクションを本文カラムの外に出したらサイドバーが止まった
レイアウト側には 1 つだけ本当に意外な仕掛けがありました。計画はカード 3 枚が横に並ぶ全幅の帯で、これは記事の 68ch の本文カラムには収まりません。障害として明らかだったのは、右の余白にいる sticky な目次と左にいる共有レールで、どちらも幅の広いセクションとぶつかります。
解決は同じ操作そのもので、因果関係は直感と逆向きでした。.page が、sticky な目次と絶対配置のレールにとっての包含ブロックです。sticky な要素は自分を含むボックスより先へは移動できません。 セクションを .page の外に出せば、どちらもスクロールリスナーも追加の CSS もなしに、記事の終わりで自然に止まります。
.page がどこで終わるかだけが違います。目次の横のバーはその sticky の移動範囲で、これは中身の高さではなくコンテナの高さです。ブラウザーで測ると、目次の下端、レールの下端、関連セクションの上端がすべて同じピクセルに揃いました。
tocBottom: 88 railBottom: 88 relatedTop: 88幅はそれに付いてきました。スタイルシートにはフッター用の --measure-wide トークンが 76rem ですでにあり、本文カラムが狭いままなのは可読性の上限であってフッターは読むものではないから、というコメントが添えられていました。カードは読むものではなく見るものなので、新しいトークンを足さずに同じ理屈が通りました。
説明文をカバー画像の上に移し、スクリムは軸が間違っていた
カードの扱いは私の判断で、エージェントの推奨とは逆でした。エージェントは各タイトルの下に説明文を出したままにすることを主張しました。根拠は、カバー画像が記事ごとではなくトピックごとの絵だという実測です。アーカイブ全体で 31 ページのうち 24 ページで、同一のカバー画像を持つタイルが 2 枚以上並んでいました。1 ページには同じ Claude のマークを持つタイルが 4 枚並んでいました。説明文がなければそれらのカードは重複に見える、というのがエージェントの立場でした。
それでも私はその案を退け、両方の問題を解く代替案を求めました。ホバー時にサムネイルを暗くし、その上に説明文を出すというものです。カードの高さは揃ったまま、テキストも残ります。どちらの案も単独では届かなかった結果です。
スクリムを詰めるには 3 回かかり、最初の 2 回はエージェントが自分の算術に自信を持って間違えていました。サムネイルの多くは平らな白地に濃いロゴです。不透明度 86% ではロゴが文字越しにそのまま読めました。92% でもまだ読めました。エージェントは 97% ならカバーの 3% しか残らないと計算し、それは見えないと断じましたが、これは誤りです。ほぼ黒の地に対する 3% の残りは 相対的な 輝度差としては大きく、人間の明るさの知覚はその領域ではおおむね対数的だからです。完全な不透明を対照として横に並べて描画したところ、97% と 92% は互いに区別がつかず、どちらも明らかに不十分でした。
不透明度を上げても解決しなかったのは、軸が違ったからです。カバーをぼかすとロゴが形として崩れるので、その上のスクリムは値として隠す必要がなくなります。 実装したルールは不透明度 86% に 10px の backdrop-filter のぼかしで、@supports のガードの内側にあります。フォールバックは 86% からフィルターを外したものではなく完全な不透明です。前者ならすでに退けたどの案より悪くなります。
スクリーンショットでは映らなかった 3 つの不具合
3 つとも、目視の確認ではすべて正しく描画されていました。
オーバーレイがサムネイルへのクリックを飲み込んでいました。 これはコードではなくブラウザーで見つけました。サムネイルにホバーすると、ポインターではなく矢印のカーソルになることがあったのです。オーバーレイはカバー全面に inset: 0 で置かれており、opacity: 0 は要素がポインターイベントを受け取ることを止めないため、常にカーソルとカバーのリンクの間に居座っていました。ヒットテストで裏が取れました。
centreOfThumbnail: { tag: "P", cursor: "auto", insideCoverLink: false }診断の手がかりは「ときどき」という言葉でした。アーカイブとタグのページはオーバーレイのない別のカードを使っているので、そちらは動いたままだったのです。pointer-events: none を 1 行足して直り、そのルールには、これがないことはスクリーンショットには映らないというコメントが付いています。将来ノイズとして消したくなるのが目に見えているからです。
空のラッパーが 64px を占有し続けていました。 Astro.slots.has('after') はスロットに何かが 渡された ときに true であり、中身が描画されたときではありません。レイアウトはこのコンポーネントを無条件に渡します。そのため候補がすべてしきい値を下回った記事でも、padding-block-end: 4rem を持つ空のラッパーが出力され、この機能が「何も出さない」と約束したまさにそのページで、フッターの手前に空白の帯ができていました。縦方向の余白をコンポーネント自身のセクションに移して直しました。これはレビューの回で、ソースではなくビルド後の HTML と突き合わせて見つかりました。
自分の看板の数字を検証できないツール。 プレビュースクリプトはヒストグラムを 4 で打ち切っていました。4 は狭い画面でのタイル数の上限で、レイアウト側の上限は 6 です。候補が 4 件以上ある記事はすべて 1 つのバケットに潰れていたため、コンポーネントとしきい値の定数がどちらも引用している「33 本中 23 本が 6 枚のグリッドを埋める」という数字を、このスクリプトでは再現できませんでした。
検証
採点器は値としての import を意図的に持ちません。唯一の import は import type { TagSlug } で、これは Node が型除去の際に消すため、プレビュースクリプトは実装を写し直すのではなく本物の採点モジュールを素の node で読み込めます。
pnpm related:preview enpnpm related:preview jaアーカイブで最良の単独テストケースは 1 本の記事です。git-gc-loose-objects-vs-filter-repo-history-rewrite は [git, webp] を持ち、本物の一致は png-to-webp-before-the-first-commit だけで、こちらは英語版しか公開されていません。したがって他の 2 ロケールでは git も webp も文書頻度 1 に落ち、ちょうど 1 本にしか付いていないタグは共通部分に現れません。結果としてこのページは 英語ではタイル 1 枚、日本語と中国語ではセクションそのものが出ません。非表示の経路、上限未満の経路、ロケールごとの頻度という性質を、1 つの URL で同時に確かめられます。
もう 1 つ挙げておきたい確認は検索インデックスです。関連タイルは他の記事のタイトルと説明文を持つため、走査されればインデックスを汚します。そこでセクションは data-pagefind-body を持つ要素の外に置いています。それを信じる代わりに、ビルドされたフラグメントを展開して直接読みました。インデックスされた内容は記事自身の最後の文で終わっており、隣接記事のタイトルもセクション見出しも共有ボタンのラベルもすべて含まれておらず、ページ数と語数はこの機能なしで同じコミットをビルドしたときと一致していました。
まとめ
3 言語の静的サイトでの関連記事は、機械学習も新しい依存も必要としませんでした。効いた要素は次のとおりです。
- 共有タグごとに
log(N / df)で重み付けし、両者のタグ数のsqrtで正規化します。希少なタグの共有は看板タグの共有より重く扱うべきで、正規化がないとタグの多い記事がすべてを勝ち取ります。 - 全言語が空白で区切られるのでない限り、多言語コーパスに本文の TF-IDF をかけない こと。失敗は、自分が最も校正しにくいロケールでちょうど静かに起きます。
- しきい値はスコアの分布から決め、それが排除すべき組を書き残すこと。今回排除するのは
astroだけを共有するタグ 3 個の記事 2 本で、その組のスコアはアーカイブが育つと上がります。 - sticky の移動範囲は包含ブロックで決まります。 セクションを記事のラッパーの外に出せば、スクロールのコードなしで sticky なサイドバーが止まります。
- 正しく描画される不具合が、いちばん高くつきます。ここで見つかった 3 つのうち 2 つは、撮ったどのスクリーンショットにも映っていませんでした。




