Publickeyが関連記事の動的生成をPHPとJavaScriptとMovableTypeで実装した方法とは?
今回は「Publickeyが関連記事の動的生成をPHPとJavaScriptとMovableTypeで実装した方法とは?」についてご紹介します。
関連ワード (全部、注意、表示等) についても参考にしながら、ぜひ本記事について議論していってくださいね。
本記事は、Publickey様で掲載されている内容を参考にしておりますので、より詳しく内容を知りたい方は、ページ下の元記事リンクより参照ください。
ブログやニュースサイトなどのWebサイトを構築する際には、「人気記事ランキング」と「関連記事」の表示はぜひWebサイトに組み込みたい機能といえます。
Publickeyでも、この2つの機能を組み込んでいます。具体的には、人気記事の表示はGoogle Analyticsのデータを基にランキング表示を行ってくれる外部サービス「Ranklet4」を採用しています。
問題は「関連記事」です。私の知るところでは、関連記事の中に広告へのリンクが埋め込まれるという形で提供される関連記事表示サービスはたくさんあるのですが、純粋に関連記事の表示機能だけを提供してくれるサービスは有料のものを含めても見つけることができません。
そうした中で、Publickeyが使い続けてきたのがログリー社のLOGLY Liftです。というのも、LOGLY Liftでは関連記事表示と広告を分離して表示できる機能があるため、関連記事に広告を混ぜることなく表示できたためです。
ずっと課題だった関連記事の独自実装に成功!
とはいえ、できれば広告表示に依存せず純粋に関連記事だけを表示できる機能をPublickeyに実装したい、という課題をずっと、Publickeyをスタートして以来10年以上抱えていました。
そして先月(2023年8月)ついに、サンデープログラマーとしての乏しいプログラミング能力しかない私自身の手で、50行程度のPHPとJavaScriptと、そしてCMSとして使っているMovableTypeのテンプレート機能を組み合わせて、少なくとも私自身が考える実用的なレベルでPublickeyのすべての記事に対して自動的に関連記事を表示する機能の実装に成功しました。
現時点でPublickeyの記事の末尾にある「あわせてご覧ください」の表示に続く4本の関連記事は、このPHPとJavaScriptの実装によって動的に表示されています。どの関連記事も、ある程度そのWebページの記事に関連したものになっているはずです。
動的に、というのは、記事が表示されたタイミングでサーバサイドにおいて最新記事を含む全ての記事の中から類似の記事を選択し、Webページに表示する、という仕組みになっているためです。
Publickeyが採用しているCMSのMovableTypeはWebサイトを静的生成する仕組みです。そして静的生成する仕組みをそのまま使って関連記事を表示させてしまうとその時点で関連記事の表示が固定されてしまい、新しい記事が関連記事に反映されないという欠点があります。
この欠点を解決するために、関連記事を動的に生成し表示する仕組みが必要だったのです。
実装するうえで解決すべき2つの課題
ここからは、どうやってこの関連記事の動的生成の仕組みを実装したのかについて紹介しましょう。
関連記事を表示する機能を実装するうえで、解決しなければならない課題は大きく2つありました。
1つ目の課題は、そもそも「ある記事に関連する記事」をどのように見つけるのか、です。
Publickeyには現時点で約5000もの過去記事があります。ある記事が表示されたときに、その記事とそれ以外の5000もの記事とをそれぞれ比較して、類似性の高い上位の記事を選択して表示する、という処理の実装を考えなくてはなりません。
そのために、例えば形態素解析などを用いて日本語の分析処理などにより類似性を判断する機能を実装するのか、あるいはElasticsearchのような検索エンジンの高度な機能を利用するのか、いずれにせよサンデープログラマーの自分には難しそうだな、などと考えていました。
2つ目の課題は、新しい記事が追加されるたびに関連記事をアップデートする必要がある点です。
Publickeyでは基本的にほぼ毎日、新しい記事が1本か2本公開されます。このとき、新しい記事に対して過去記事の中から関連記事を見つけるだけでなく、それぞれの過去記事の関連記事にも新しい記事が含まれるように、過去記事の全ての関連記事も毎日アップデートし続ける必要があります。
つまり1つ目の課題を解決して実装したとして、その仕組みをずっと運用し続けなくてはならないわけです。私個人でこのシステムを維持するとすれば、費用の面でも運用の手間の面でも、できるだけシンプルな実装にする必要がありました。
ChatGPTの登場とsimilar_text関数で、ついに実装への材料が揃う
文字通り10年以上前から、この2つの課題を解決するようなうまい方法がないか、仕事の合間などに調べたり考えてきたわけですが、今年に入って新しいアイデアにつながる2つの出来事がありました。
1つはChatGPTの登場です。ChatGPTには文章を要約してくれる機能があります。このことを知ったとき、Publickeyの本文をChatGPTに短く要約してもらって、それを比較するのであれば、長い本文同士を比較するよりも関連記事を見つけるための処理が簡単になるのではないか、と考えたわけです。
もう1つは、PHPに文章の類似度を比較する「similar_text関数」があることを知ったことです。similar_text関数に文字列Aと文字列Bを渡すと、類似度の値を0から1の範囲で得ることができます。
であれば、Publickeyの記事をChatGPTで短く要約してもらい、それをsimilar_text関数に渡して類似度を得て結果をソートすれば、Publickeyのある記事に類似する別の記事のトップ4なりトップ5を得ることが簡単にできそうです。
これで一気に実装に向けた材料が揃ったのではないかと、そう思った私は早速プロトタイプを作り始めました。
本文や要約ではなく、タイトルを比較すればいいのでは?
プロトタイプを作っているうちに、もっといい方法を思いつくというのは良くあることだと思いますが、今回もそういうことが起こりました。
ChatGPTで本文を要約すればよいのではというアイデアからプロトタイプを作り始めようとしましたが、要約文としてPublickeyのタイトルを使えばいいのではないか、ということを思いついたのです。
Publickeyのタイトルは私のポリシーとして、できるだけ本文の内容を反映すること、そしてもったいぶった書き方、例えば「○○のランキング、注目の1位は意外なあの企業」のような、わざと情報を欠落させることで読者の注意を引こうとするようなタイトルにはせず、ちゃんと情報をタイトルに入れることを心がけています。
つまり、いちいち本文を要約せずとも、タイトルを要約に見立ててタイトル同士が似ている記事であれば、本文も似ていることはほぼ間違いない、ということに思い当たったのです。Publickeyはほぼ全部の記事とタイトルを私が書きましたから、この点については自分で確信が持てます。
タイトルだけを比較して類似性を計測すれば十分である、ということは、今回の実装を簡単にする上で、非常に大きなアイデアの転換でした。
一方で、PHPのsimilar_text関数を使った類似性の比較は、あまり良い結果を得られませんでした。複数のタイトルをsimilar_text関数で比較させると、なぜこれとこれが似ているという結果になるのか? と思ってしまうような変な結果になることが少なくなかったのです。
similar_textの内部でどのような処理が行われているのかは、いくつかの資料に当たってみても判然としなかったのですが、どうやら2つの文字列を文字コードを基にしたバイナリの列としてその類似性を評価しているようで、日本語の文字列の比較には十分な精度を得られないように推測されました。
文字列の近さを比較する「レーベンシュタイン距離」というアルゴリズムの実装も試してみたのですが、これもあまり良い結果は得られませんでした。
なんとか日本語の文字列の類似性を実用的な精度で、しかも簡単な処理で得られる方法はないだろうかと、ここで再び課題にぶつかりました。
同じ単語が多く含まれていれば、それは類似性が高いはずだ
日本語の文字列の類似性をある程度実用的な精度で、しかも簡単な処理で得られる方法を思いついたのは、similar_text関数の評価の時に使っていた正規表現からでした。
そのときは正規表現を使って簡易的に日本語の文章を分かち書きにしてsimilar_text関数に与える、という方法を試していました。日本語を分かち書きにするには、正攻法では形態素解析などを用いる必要がありますが、それは今回の目的には処理が複雑すぎます。
そこで簡易的な分かち書きの方法として、ひらがな、カタカナ、漢字、数字、約物などの区切りに空白を入れる、という処理を正規表現で書いたのです。正規表現なら1行で簡単に処理が書けます。
例えば、先日人気のあったPublickeyの記事のタイトルを例にしましょう。
「Spring Framework 6.1が仮想スレッドに対応へ、9月登場予定のJava 21にも対応予定」
このタイトルに対して、ひらがな、カタカナ、漢字、数字、約物などで区切ると次のようになります。
「Spring|Framework|6.1|が|仮想|スレッド|に|対応|へ|、|9|月登場予定|の|Java|21|にも|対応予定」
このように分解してsimilar_text関数に渡したら精度が高まるかな? と思って試したのです。結果はあまりよくなかったのですが。
そしてこれを基に思いついたのが、次のような単純な比較方法でした。
それは、上記のような正規表現で分解した単語群(ここでは便宜的にトークンと呼びます)を2つの文章で比較して、同じトークンがより多く含まれていたら類似度が高い文章だと評価する、という方法です。
このとき、上記のタイトルの例で見て類似性を評価する上で重要なのは「Spring」とか「スレッド」といった単語の一致であり、「が」や「の」などの一文字のひらがなの一致は類似性の評価には邪魔です。そこで、正規表現を改善して、一文字だけのひらがなと約物は削除するようにしました。
そして、2つのタイトルを上記の正規表現で分解し、分解されたトークンを配列に入れて、2つの配列の同じ要素の数を数えて、その数が多い方が類似性が高い、という処理を書いたところ、ある程度の実用性があるだろうというレベルで類似性が評価できると思えるようになりました。
私は試していませんが、タイトルを分解したトークンだけではなく、例えば記事のタグやカテゴリもトークンに組み込んで比較すれば、より高い精度で記事の類似性を評価できるようになるケースもあるでしょう。それこそChatGPTに記事ごとに複数のキーワードを付けさせて、それを比較するといった方法も考えられそうですね。
ちなみに、こうした正規表現を書くのにChatGPTは非常に役に立ちました。たぶん自分で調べながら正規表現を書いていたら、それだけで丸一日くらいは試行錯誤に時間を費やしていたはずです。ChatGPTのおかげでわずか数分で、思ったような正規表現を書くことができました。
このようにして、ついにPublickeyの2つの記事のある程度の類似性を、そのタイトルを正規表現でトークンに分解し、2つのあいだで同じトークンの数が多いものほど類似性が高いと評価するという、比較的シンプルなアルゴリズムによって得られるようになったのです。
実運用に耐えるレベルで実装できるか?
あとはこれを、実運用に耐える処理速度で実装しなければなりません。ここでも試行錯誤がありましたが、そこは省略してうまくいった実装を紹介しましょう。
前述の通り、Publickeyには約5000の記事があります。そこで、PublickeyのCMSであるMovableTypeを使って、次のようなPHPコードを出力するようにしました。
- 約5000の記事すべてのタイトルとURLをリストアップする
- その5000のタイトルとURLを配列に入れる
- クライアントからAjaxで記事タイトルを1つ受け取る。
- 受け取ったタイトルと、配列に入れた5000のタイトルの類似性を順に評価する(ループが5000回回る)
- 上位4つのタイトルとURLをクライアントに返す(実際には上位5つのうち、最上位は受け取ったタイトルと同じタイトルのはずなので、2位から5位を返す)
CMSは新しい記事が追加されるたびに、新しいタイトルを含んだ上記のPHPコードを同じファイル名で上書き生成していくため、関連記事にはつねに自動的に最新の記事タイトルが反映されます。
お盆の夏休みの3日ぐらいで、JavaScriptで記事のタイトルを読み込んでAjaxを使ってPHPに送り、PHP側で受け取って類似性を評価し、クライアントに返すコードを書きました。PHPのコードは実質は50行ぐらいで済みました。
PHPのコード内では、ループは1つだけでソートはしていない(類似性の値が高いものだけを変数に残してるだけ)ので、ベンチマークは取っていませんが、計算量はO(n)のはずです。
もしもサーバ側の反応が少々遅くなったとしても、Ajaxの非同期処理のおかげでクライアント側の処理が止まることもないはずです。
というわけでお盆休み明けにこのコードをPublickeyの本番環境にデプロイして少し様子を見ていたのですが、いまのところ何も問題は起きていないので、このままこれを使い続けようと思っています。
10年以上にわたる課題であった関連記事の独自生成がついに実現できたことは、とても嬉しい出来事でした。長かった夏休みの宿題が、今年の夏にようやく解けました:-)
そして、これまで関連記事でお世話になったログリーさんにもお礼を申し上げたいと思います。ありがとうございました。
関連記事を簡易に生成するという実装の具体的な例は検索しても出てこなかったので、ここに書いたアイデアがもし参考になるのであれば、自由にお使いいただいてかまいません。