3 WordPressの
「タグ一致数」の
関連記事ブロックの
プラグインを作った

WordPressで関連記事を表示するプラグインを作りました。
最初はIDF(逆文書頻度)という手法を使って「賢く」関連性を計算しようとしたのですが、結果は期待外れ。
最終的には、単純に「タグが何個一致するか」を数える方法に落ち着きました。

関連記事

1. 作ったもの

Gutenbergブロックとして動作する関連記事リストです。
記事の編集画面でブロックを追加すると、その記事に関連する他の記事を自動で選んで表示します。

1. 作ったもの

ブロック名は chiilabo/related-post-list で、表示件数を設定できるようにしました。デフォルトは3件です。

1. 作ったもの

2. 最初の設計:IDF重み付きJaccard

WordPressの記事には「タグ」を付けられます。たとえば料理ブログなら「レシピ」「簡単」「時短」といったタグです。

関連記事を探すとき、同じタグが付いている記事を選べば良さそうに思えます。でも問題があります。「お知らせ」のような頻繁に使われるタグです。

たとえばこんな状況を考えてください。

  • 記事A:「新メニュー開発の裏側」タグ:開発、レシピ、お知らせ
  • 記事B:「営業時間変更のご案内」タグ:店舗、お知らせ
  • 記事C:「季節の野菜レシピ集」タグ:レシピ、野菜、料理

記事Aと記事Bは「お知らせ」タグで一致します。でも内容は全然違います。一方、記事Aと記事Cは「レシピ」で一致していて、こちらの方が明らかに関連性が高いです。

最初の設計:IDF重み付きJaccard IDF(逆文書頻度) 頻出タグ → 重要度低 / レアタグ → 重要度高 問題1 マイナータグの 偶然の一致で 高スコア化 問題2 タグ少数記事で 1個一致だけでも 高スコア

この問題を解決するため、IDF(Inverse Document Frequency:逆文書頻度)という手法を使おうと考えました。IDFは、たくさんの記事で使われているタグの重要度を下げ、少数の記事だけで使われているタグの重要度を上げる計算方法です1

2.1. 重み付きJaccardとは

タグの重要度が計算できたら、次は2つの記事がどれくらい似ているかを測る必要があります。ここで使ったのが「重み付きJaccard係数」です。

Jaccard係数は、2つの集合がどれくらい似ているかを0から1の間の数値で表します2。たとえば、

  • 記事Aのタグ:{料理、レシピ、簡単}
  • 記事Bのタグ:{レシピ、簡単、時短}

この場合、共通部分は{レシピ、簡単}の2つ、全体では{料理、レシピ、簡単、時短}の4つです。Jaccard係数は 2÷4 = 0.5 になります。

重み付きJaccardは、この計算にタグごとの重要度(IDFで計算した値)を掛け合わせます3。重要なタグが一致していれば高いスコアになり、どうでもいいタグが一致してもスコアは上がりにくくなるわけです。

2.2. 実装して気づいた問題

実際にコードを書いて動かしてみると、期待とは違う結果になりました。

まず「たまたま一致が強くなる」問題です。あるマイナーなタグが2つの記事だけで使われているとします。IDFの計算上、このタグは非常に高い重要度を持ちます。すると、たまたまこのタグを共有している2つの記事は、内容が似ていなくても高スコアになってしまいました。

次に「テーマ的な近さが薄れる」問題です。重み付きJaccardは割り算を含む計算なので、タグが少ない記事同士だと、1つ一致しただけで高スコアになります。でも、人間の感覚では「タグが1個一致」より「タグが3個一致」の方が関連性が高いと感じます。

計算式が複雑になるほど、直感とズレが生まれました。

3. 方針転換:タグ一致数でスコアリング

IDF重み付きJaccardを諦めて、最もシンプルな方法に戻しました。それが「タグが何個一致するか数える」という方法です4

  • 記事Aのタグ:{料理、レシピ、簡単、和食、時短}
  • 記事Bのタグ:{レシピ、簡単、時短、お弁当}
  • 一致数:3個(レシピ、簡単、時短)

記事Cと比べるときも同じように数えて、一致数が多い方を「より関連性が高い」と判定します。同点の場合は、公開日が新しい記事を優先しました。

3.1. なぜこれが良かったのか

理由は3つあります。

  • 1つ目は予測可能性です。タグが3個一致すれば、誰が見てもスコアは3です。計算式が複雑だと、なぜこの記事が関連記事として選ばれたのか説明できません。シンプルなルールなら、結果を見て納得できます。
  • 2つ目は直感との一致です。人間は「共通点が多い」ことを「似ている」と感じます。重み付きJaccardは数学的には正しくても、人間の直感とズレることがあります。タグ一致数は、そのまま「共通点の数」なので違和感がありません。
  • 3つ目は実装のシンプルさです。IDFを計算するには、全記事のタグ情報を集計する必要があります。記事が増えるたびに再計算が必要で、処理も複雑になります。一方、タグ一致数は配列の共通部分を数えるだけです。コードが短く、バグも入りにくくなります。

4. 実装の詳細

まず関連記事の候補を探します。WordPressの WP_Query を使い、現在の記事と1つでもタグが一致する記事を最大200件取得します5

実装の詳細 1 WP_Query 候補200件取得 2 array_intersect() タグ一致数を計算 3 スコア順に ソート 4 SVGタグを除去 タイトル整形 tag__in パラメータで1つ以上一致する記事を取得
$query = new WP_Query( array(
    'post_type'      => 'post',
    'posts_per_page' => 200,
    'post__not_in'   => array( $post_id ),
    'tag__in'        => $tag_ids,
) );
Code language: PHP (php)

tag__in パラメータで、指定したタグIDのいずれかを持つ記事を取得できます6。自分自身は post__not_in で除外します。

4.1. スコア計算

候補記事それぞれについて、タグの一致数を計算します。

private static function score_candidates_by_tag_overlap( $post_id, $tag_ids, $candidate_posts ) {
    $scores = array();
    
    foreach ( $candidate_posts as $candidate ) {
        $candidate_tag_ids = wp_get_post_terms( 
            $candidate->ID, 
            'post_tag', 
            array( 'fields' => 'ids' ) 
        );
        
        $overlap = array_intersect( $tag_ids, $candidate_tag_ids );
        $score = count( $overlap );
        
        if ( $score < 1 ) {
            continue;
        }
        
        $scores[ $candidate->ID ] = array(
            'score' => $score,
            'date'  => $candidate->post_date,
        );
    }
    
    uasort( $scores, function( $a, $b ) {
        if ( $a['score'] === $b['score'] ) {
            return strcmp( $b['date'], $a['date'] );
        }
        return $b['score'] <=> $a['score'];
    } );
    
    return $scores;
}
Code language: PHP (php)

array_intersect() で2つの配列の共通要素を取り出し、その個数を数えます7
スコアが同じ場合は日付文字列を比較して、新しい記事を上位にします8

4.2. タイトルのSVG除去

このサイトでは、記事タイトルに装飾用のSVGアイコンを含めています。
これをそのまま表示するとHTMLタグが見えてしまうので、除去する処理を追加しました。

private static function strip_svg_from_title( $title ) {
    $title = preg_replace( '/<svg\b[^>]*>.*?<\/svg>/is', '', $title );
    return wp_strip_all_tags( $title );
}
Code language: PHP (php)

正規表現で <svg> から </svg> までを空文字列に置き換え、その後 wp_strip_all_tags() で残りのHTMLタグも除去します9

5. 学んだこと

複雑な計算式が常に良い結果を生むわけではありません。
IDFや重み付きJaccardは理論的には優れていますが、実際のデータで試すと直感と合わない結果になりました10

シンプルな方法の方が、説明しやすく、メンテナンスしやすく、結果も予測しやすいです。
「賢くしよう」と複雑にする前に、まず単純な方法を試すべきでした。

6. まとめ

WordPressの関連記事ブロックを作り、IDF重み付きJaccardから単純なタグ一致数へと方針を変更しました。

最終的な実装は、タグが何個一致するかを数えるだけのシンプルなものです。
複雑な計算式より、この方法の方が直感的で予測可能な結果を生みました。

  1. IDFは対数を取った値として計算されます。具体的には IDF(t) = log(N / df(t)) で、Nは総文書数、df(t)はタームtを含む文書数です。この対数変換により、文書頻度の差が重要度の差として適切にスケーリングされます。 – TF-IDF(項目頻度-逆文書頻度)
  2. Jaccard係数(Jaccard Index)は、植物学者Paul Jaccardが考案した集合間の類似度を測る指標です。2つの集合の共通部分を和集合で割ることで、0(全く共通点なし)から1(完全一致)の範囲で類似度を表現します。 – ジャッカード類似度(Jaccard Similarity)/ジャッカード係数(Jaccard Index)とは?
  3. ここで説明している「重み付きJaccard」は一般的な用語ではなく、実装固有のアプローチです。学術的には、要素に重みを持たせた類似度計算として、TF-IDFベクトルのコサイン類似度などが標準的な手法として広く使われています。 – 特定の語重み付きJaccard係数を持つ予め記述されたFAQに対するユーザの問合せeメールマッチング手法
  4. この方法は集合論における「積集合の要素数」を数えることに相当します。PHPのarray_intersect()関数は、複数の配列の共通要素を抽出する関数で、値の比較を行います。タグIDの配列に対して使用することで、効率的に一致タグを特定できます。 – PHP: array_intersect – Manual
  5. 候補数を200件に制限しているのは、パフォーマンスとのバランスを考慮したためです。記事数が数千件を超えるサイトでは、全記事を対象にスコア計算を行うと処理時間が増大します。実運用では、サイトの規模に応じてこの値を調整することが推奨されます。
  6. tag__inは「OR条件」で動作するため、指定したタグのいずれか1つでも持っていれば候補として取得されます。このため、取得した200件の中にはタグが1個しか一致しない記事も含まれており、その後のスコアリング処理で一致数順に並び替える必要があります。 – 【WordPress】同じタグを持つ記事を関連記事として表示する方法
  7. array_intersect()は値ベースで比較を行うため、同じタグIDが両方の配列に含まれている場合にのみ共通要素として抽出されます。キーではなく値を比較するこの特性により、タグID配列の一致判定に適しています。
  8. WordPressのpost_dateフィールドは ‘YYYY-MM-DD HH:MM:SS’ 形式で保存されるため、strcmp()による文字列比較でも正しく日付順にソートできます。ただし、この方法は日付フォーマットに依存するため、カスタムフィールドなど異なる形式の日付を扱う場合は注意が必要です。
  9. この正規表現パターンの ‘/is’ フラグのうち、’i’ は大文字小文字を区別しない、’s’ はPCRE_DOTALLモード(ドット ‘.’ が改行文字にもマッチ)を意味します。ただし、この実装はネストしたSVG要素には対応していません。複雑なSVG構造がある場合は、DOMパーサーの使用も検討する価値があります。
  10. TF-IDFを用いた文書類似度計算では、一般的にコサイン類似度が使われます。これはベクトル間の角度を測る手法で、文書の長さに影響されにくい特性があります。関連記事抽出においても、タグ一致数よりコサイン類似度の方が適している場合があります。 – 検索ロジックについて | ゆるふわ検索 | 医中誌Web HELP