Next.jsブログの関連記事を、カテゴリー・タグ・公開日のスコアで自動選出する【多様性より関連性を選んだ話】
- この記事で分かること
- Markdownのfrontmatterだけを材料に、関連記事をスコアリングで選ぶ関数の実装と、手動指定・未公開記事・カテゴリーの偏りへの対処
- 実際に使った環境
- Next.js 16.3.1(App Router、SSG)/ TypeScript。このブログの全記事の末尾に表示している関連記事ブロックの実装そのものです
- 筆者の結論
- スコアの式そのものより、「多様性のために別カテゴリーを必ず混ぜる」ルールの扱いが難しかった。最終的に、関連性の薄い記事を無理に混ぜるくらいなら混ぜない、という判断に落ち着いた
このブログの記事の末尾には、「この記事を読んだ人はこちらも読んでいます」という関連記事のブロックがあります。ここに並ぶ記事(最大4件)は、手で選んでいるわけではありません。各記事のfrontmatter(カテゴリー・タグ・公開日)から、スコアを計算して自動で選んでいます。
この記事では、そのスコアリングの実装と、途中で突き当たった「多様性と関連性のどちらを優先するか」という問題を紹介します。
🛠 実装してみて分かったこと
最初は「同じカテゴリーの記事ばかり並ぶのはつまらないから、別カテゴリーを最低1件は混ぜよう」と考えていました。ところが実際の選出結果を見ると、その1件は共通のタグが1つあるだけの、ほとんど関係の無い記事でした。AIライティングツールの記事の下に、ドメイン取得サービスの記事が出てくる。多様性のためのルールが、読者にとってはただのノイズになっていました。
スコアの式
関連度のスコアは、次の3つを足したものです。
| 要素 | 加点 |
|---|---|
| カテゴリーが同じ | +3 |
| 共通のタグ1つにつき | +2 |
| 公開日の近さ | 0〜+1(同じ日なら+1、180日以上離れていれば0) |
/** カテゴリーが一致したときの加点。 */
const SAME_CATEGORY_SCORE = 3;
/** 共通タグ1つあたりの加点。 */
const SHARED_TAG_SCORE = 2;
/** 公開日の近さボーナス(0〜1)が0になるまでの日数。半年以上離れていたら加点しない。 */
const DATE_PROXIMITY_HORIZON_DAYS = 180;
function dateProximityBonus(dateA: string, dateB: string): number {
const diffDays = daysBetween(dateA, dateB);
return Math.max(0, 1 - diffDays / DATE_PROXIMITY_HORIZON_DAYS);
}
function sharedTagCount(a: string[] = [], b: string[] = []): number {
const setB = new Set(b);
return a.filter((tag) => !FORMAT_TAGS.has(tag) && setB.has(tag)).length;
}FORMAT_TAGSは、記事の話題ではなく形式を表すタグの一覧です。これを共通タグから除いている理由は、記事の後半「その後」で説明します。
公開日の近さは、最大でも+1です。カテゴリーやタグの一致に比べて小さくしているのは、あくまで同点のときの順位付けに使う程度にしたかったためです。同じ話題の記事なら、新しい記事同士のほうが前提(使っているバージョンなど)が揃っている可能性が高い、という程度の重みです。
これらの重みに厳密な根拠はありません。実際の記事で選ばれる結果を見ながら、違和感が無いかを確認して決めました。
候補の絞り込みと、手動での指定
スコアを計算する前に、候補を絞り込みます。
- 自分自身を除く
- 検索エンジンにインデックスさせている公開記事だけを候補にする: 下書き、予約投稿中の記事、
noindexの記事は関連記事に出しません。予約投稿中の記事を出すと、リンク先がまだ404になるためです
また、記事のfrontmatterにrelatedとしてslugを並べると、その記事をスコアとは関係なく先頭に固定できます。「この記事の続きはこれ」のように、明確に読んでほしい記事があるときのための仕組みです。
const pinnedSlugs: string[] = [];
for (const slug of base.related ?? []) {
if (slug === base.slug) continue;
if (!existingSlugs.has(slug)) {
console.warn(
`[related-posts] "${base.slug}"のrelatedに存在しないslug "${slug}" が指定されています。`
);
continue;
}
if (!candidatesBySlug.has(slug)) {
// ファイル自体は存在するが未公開(draftまたは予約投稿中)のため、関連記事には出さない。
continue;
}
if (!pinnedSlugs.includes(slug)) pinnedSlugs.push(slug);
}扱いを2つに分けているのがポイントです。
- 存在しないslug: タイプミスの可能性が高いので、ビルド時に警告を出します
- 存在するが未公開のslug: 予約投稿中の記事を先に指定しておくのは、運用上ありうることです。警告は出さず、公開されるまで静かに除外します。公開日を過ぎれば、自動的に表示されるようになります
残りの枠は、スコアの高い順に埋めます。スコアが同じ場合は、新しい記事を優先します。ただし、スコアが2.0未満の記事は、枠が余っていても採用しません(これも「その後」で説明します)。
問題: 「別カテゴリーを最低1件」のルールが、無関係な記事を連れてきた
このスコアの式だと、カテゴリー一致の+3が大きいため、選ばれる4件がすべて同じカテゴリーになりがちです。最初の実装では、これを避けるために次のルールを入れていました。
自動で選んだ4件がすべて同じカテゴリーなら、最下位の1件を、別カテゴリーで最もスコアの高い記事と入れ替える
ところが、実際の結果を5つの記事で確認すると、5つすべてで、入れ替えで入ってきた別カテゴリーの記事のスコアが3.0前後でした。例えば、AIライティングツールを比べた記事の関連記事に、ドメイン取得サービスを比べた記事が入っていました。当時の共通点は「ツール比較」というタグが1つあるだけです。
スコア3.0前後というのは、カテゴリーが違い、共通タグが1つ(+2)、公開日が近い(+1未満)という状態です。「別カテゴリーの中では一番」でも、関連性としては低い記事でした。
検討した2つの対策
対策として、2つの方向を考えました。
- 同じカテゴリーの記事は最大3件まで、のように上限を設ける
- 「最低1件は別カテゴリー」のルールは残し、別カテゴリーの記事が一定のスコアに届かない場合は入れ替えない
1は、多様性をさらに強制する方向です。しかし、Next.jsの記事を読んでいる人に、Next.jsの別の記事を関連記事として出すのは、むしろ自然なことです。カテゴリーを無理に分散させると、関連性の低い記事がさらに入り込みやすくなります。
そこで2を選びました。関連性のある別カテゴリーの記事があれば混ぜる。無ければ、無理に混ぜずに同じカテゴリーの上位で埋める。多様性より関連性を優先するという判断です。
最低スコアの閾値を入れる
別カテゴリーの記事として採用してよい最低スコアを、4.0にしました。
/**
* 別カテゴリー枠として採用してよい最低スコア。
* 共通タグが1つあるだけ(スコア3前後)の関連性の薄い記事を、多様性のためだけに
* 無理に押し込まないための閾値。これを満たす候補が無ければ、別カテゴリー枠は
* 設けず同カテゴリーの上位で埋める。
*/
const MIN_DIFFERENT_CATEGORY_SCORE = 4.0;別カテゴリーの記事は、カテゴリー一致の+3がもらえません。公開日の近さは最大でも+1なので、共通タグが1つだけなら、スコアは最大でも3.0です。4.0に届くには、共通のタグが少なくとも2つ必要になります。「たまたまタグが1つかぶっただけの記事」は採用されず、複数の観点で話題が重なる記事だけが別カテゴリー枠に入ります。
入れ替えの処理は次のとおりです。
let autoFilled = scoredRest.slice(0, remainingSlots);
const allSameCategory =
[...pinned, ...autoFilled].length > 0 &&
[...pinned, ...autoFilled].every((p) => p.category === base.category);
if (allSameCategory && autoFilled.length > 0) {
const autoFilledSlugs = new Set(autoFilled.map((p) => p.slug));
const differentCategoryCandidate = scoredRest.find(
(p) =>
p.category !== base.category &&
!autoFilledSlugs.has(p.slug) &&
p.score >= MIN_DIFFERENT_CATEGORY_SCORE
);
if (differentCategoryCandidate) {
autoFilled = [...autoFilled.slice(0, -1), differentCategoryCandidate].sort(
(a, b) => b.score - a.score
);
}
}
return [...pinned, ...autoFilled];入れ替えの対象は、自動で選んだ枠(autoFilled)だけです。frontmatterのrelatedで手動指定した記事は、書いた人の判断として尊重し、入れ替えません。
同じ5つの記事で確認し直すと、5つすべてで、スコア3.0前後だった別カテゴリーの記事が外れ、それより高いスコアの同じカテゴリーの記事に置き換わりました。
その後: 「形式」を表すタグで、閾値を超えてしまった
この記事を書くにあたって、今の選出結果を計算し直したところ、想定外のことが分かりました。閾値を入れたときに外れたはずの「AIライティングツールの記事→ドメイン取得サービスの記事」という組み合わせが、今は再び表示されています。
原因はタグの追加です。閾値を入れた後、比較記事22本すべてに料金プランの節を加筆し、あわせて「料金比較」というタグを付けました。その結果、この2つの記事の共通タグは「ツール比較」と「料金比較」の2つになり、スコアは5.0と、閾値の4.0を超えるようになりました。
「ツール比較」も「料金比較」も、記事の話題ではなく形式を表すタグです。形式が同じ記事はサイト内に多いので、こうしたタグが2つ重なるだけで、話題がまったく違う記事でも「関連性が高い」と判定されてしまいます。閾値の考え方自体は正しくても、スコアの材料であるタグの付け方次第で、効き方が変わってしまうということです。
そこで、形式を表すタグを共通タグの加点から除くことにしました。タグページや一覧の表示には影響させず、関連度の計算でだけ無視します。
/**
* 記事の「話題」ではなく「形式」を表すタグ。共通タグの加点から除外する。
*/
const FORMAT_TAGS = new Set(["ツール比較", "料金比較", "実装記録", "トレード記録"]);「実装記録」「トレード記録」も、同じ理由で含めています。
形式タグが隠していた、もう1つの問題
形式タグを除いて全記事の結果を計算し直すと、今度は別の問題が見えてきました。ノーコードのカテゴリーは、検索エンジンにインデックスさせている記事が3本しかありません。同じカテゴリーの候補だけでは4つの枠が埋まらず、空いた枠に、公開日が近いだけの記事(スコア1.0前後)が入っていました。ノーコードのアンケートフォームの記事の下に、AI自動売買のスケジューラの記事が出る、といった具合です。
これまでは、形式タグの一致がスコアを底上げしていたため、この問題は見えていませんでした。「同じ比較記事」という形式の共通点が、話題の共通点の代わりを果たしていたわけです。
対策として、自動選出の枠に入れてよい最低スコアを2.0にしました。カテゴリーが同じ(+3)か、話題のタグが1つ以上一致する(+2)記事だけが候補になり、公開日の近さ(最大+1)だけの記事は、枠が余っていても入りません。
const MIN_AUTO_FILL_SCORE = 2.0;
const scoredRest = candidates
.filter((p) => !pinnedSet.has(p.slug))
.map((post) => {
const { score, breakdown } = scoreCandidate(base, post);
return { ...post, score, scoreBreakdown: { ...breakdown, pinned: false } };
})
.filter((p) => p.score >= MIN_AUTO_FILL_SCORE)
.sort((a, b) => b.score - a.score || (a.date < b.date ? 1 : -1));その結果、ノーコードの記事10本では、関連記事が4件から2〜3件に減りました。件数を揃えるより、関係の無い記事を出さないほうを優先しています。「多様性より関連性」と同じ考え方を、「件数より関連性」にも当てはめた形です。
偏りの確認: ラベルごとに固まっていないか
もう1つ気になっていたのが、記事の検証度合いを表すラベル(実装記録・実際に使用・調査ベースなど)で、関連記事が固まっていないかという点です。「実装記録の記事からは、調査ベースの記事ばかりに誘導している」といった偏りがあると、読者の体験としてよくありません。
当時の全43記事で、関連記事として選ばれる記事のラベルを集計しました。
- 実装記録の記事から、実装記録の記事が選ばれる割合は76.9%
- 調査ベースの記事から、調査ベースの記事が選ばれる割合は64.3%
一見すると固まっているように見えますが、これはカテゴリーごとのラベルの構成比をそのまま反映したものでした。Next.jsのカテゴリーは全記事が実装記録で、ノーコードのカテゴリーは約9割が調査ベースです。同じカテゴリーの記事が選ばれやすい以上、ラベルも揃います。関連記事の枠全体でのラベルの割合は、サイト全体の記事のラベルの割合とほぼ同じで、スコアの式そのものが偏りを生んでいるわけではないと判断しました。
表示部分
選ばれた記事は、RelatedPostsコンポーネントでカードとして表示しています。サムネイルには専用の画像を用意せず、各記事のOGP画像(/blog/{slug}/opengraph-image)を流用しています。OGP画像は1200×630pxのPNGなので、そのまま使うと重くなります。Next.jsの画像最適化エンドポイント(/_next/image)を通して、実際の表示サイズにリサイズしたWebPを配信しています。
関連記事のような自動のリンクとは別に、本文中の手動のリンクをどう設計しているかは内部リンク設計の記事にまとめています。
よくある質問
Q. 関連記事を手動で指定することはできますか?
A. できます。記事のfrontmatterにrelatedとしてslugを並べると、スコアとは関係なく先頭に固定表示されます。存在しないslugを書いた場合はビルド時に警告が出ます。
Q. スコアの重みはどうやって決めましたか? A. 厳密な根拠のある値ではありません。カテゴリー一致を+3、共通タグ1つを+2、公開日の近さを最大+1とし、実際の記事で選ばれる結果を見て違和感が無いかを確認しながら決めました。
Q. 記事数が少なくても使えますか? A. 使えます。候補が表示件数に満たない場合は、あるだけ表示します。ただし記事数が少ないうちはカテゴリーやタグが重なる記事自体が少ないため、スコアの差がつきにくくなります。
まとめ
関連記事の自動選出は、カテゴリー・タグ・公開日を足し合わせるだけの、素朴なスコアで十分に機能しています。難しかったのは式ではなく、「多様性のために別カテゴリーを混ぜる」というルールの扱いでした。
多様性は目的ではなく、読者が次に読みたい記事に出会うための手段です。関連性の低い記事を混ぜるくらいなら、混ぜないほうがいい。閾値を1つ入れるだけの小さな変更で、当時の関連記事の中身ははっきり良くなりました。
ただ、スコアはタグの付け方にそのまま左右されます。後から形式を表すタグを増やしたことで、一度外した組み合わせが戻ってきました。仕組みを入れた後も、材料になるデータが変わったら、結果を確かめ直す必要があると感じています。