テキストエンベディングで関連記事を取得してみる

テキストエンベディングで関連記事を取得してみる

ブログ記事の最後に「関連する記事」の項目を追加したはいいものの、どうやって記事を選べばいいか...。

  • microCMSのカスタムフィールドで手動で選ぶ
  • 記事の内容に合わせて自動で選ぶ

手動での選定は「絶対にこの記事は外せない!」という場合には便利ですが、記事数が増えてくると管理が大変になってきます。それに、数年前に書いた記事なんて忘れてしまっている可能性もあります。

そこで今回は、「テキストエンベディング」という技術を使ってビルドの段階で類似度を算出させることで、関連記事を表示してみることにしました。

テキストエンベディングとは?

テキストエンベディングとは、文章をコンピュータが処理しやすい数値ベクトル(N次元配列)で表現することを指します。

例えば、「昨日の夜ご飯はラーメンを食べた」と「近所のラーメン屋が新しくオープンした」という文章は、どちらもラーメンについて書かれているので、テキストエンベディングを用いると、これらの文章は意味的に近いと判断されます。

人間は、文章を読んで意味を理解できますが、コンピュータはそのままでは理解することができません。そこで、テキストエンベディング技術を用いて、文章を数値ベクトルに変換することで、コンピュータによる計算や分析を可能にします。

テキストエンベディングモデルは、word2vecやBERT、GPTなど様々な種類が存在し、それぞれ異なる特徴を持っています。これらのモデルに共通して言えることは、エンベディング処理によって、比較対象の次元数を統一できる点です。次元数が揃うことで、比較対象の類似度を容易に計算できるようになります。類似度の計算には、コサイン類似度などが一般的に用いられます。コサイン類似度とは、2つのベクトルの角度に基づいて類似度を計算する方法です。類似度として-1から1の間の数値が出力され、1に近いほど類似度が高いと言えます。

また、テキストエンベディングによって高次元の文章データが低次元のベクトルに変換される事で、計算コストが下がる事や、高次元データを可視化して分析する事が容易になるメリットもあります。

ブログ更新時の差分チェック

このブログはGitHub Actionsで入稿データ以外の資材をS3にアップしています。また、前回のビルドから記事数・タグ数・カテゴリ数に差分が生じた場合にCodebuildのビルド(next build)が開始されるようにしています。

差分チェック時に参照する実際のjsonは以下です。

{
  "lastBuildDate": "2025-03-09T00:00:00.000Z",
  "totalPublished": {
    "articles": 10,
    "categories": 4,
    "tags": 7,
  }
}

記事数・カテゴリ数・タグ数はmicrocms-js-sdkで取得します。記事数を取得するサンプルコードは以下です。

import { createClient } from "microcms-js-sdk";

export const client = createClient({
  apiKey: "API_KEY",
  serviceDomain: "SERVICE_DOMAIN",
});

export const fetchArticlesCount = async (fields: string, filters: string): Promise<number> => {
  const data = await client.get<{
    totalCount: number;
  }>({
    endpoint: "articles",
    queries: {
      fields: fields,
      filters: filters,
    },
  });

  return data.totalCount;  
};

テキストエンベディング・コサイン類似度の計算

記事の関連性を正しく評価するために、buildSpec.ymlに、全記事のテキストエンベディングおよびコサイン類似度の計算を行うプログラムを実行するコマンドを記載します。

テキストエンベディングにはGemini APIのtext-embedding-004モデルを利用しました(ドキュメント)。

サンプルコードは以下です。

import { GenerativeModel, GoogleGenerativeAI } from "@google/generative-ai"; 

export async function main() {
  const genAI = new GoogleGenerativeAI("API_KEY");
  const model = genAI.getGenerativeModel({ model: "text-embedding-004"});

  vecA = (await model.embedContent("昨日の夜ご飯はラーメンを食べた")).embedding.values;
  vecB = (await model.embedContent("近所のラーメン屋が新しくオープンした")).embedding.values;

  // コサイン類似度を計算
  const score = cosineSimilarity(vecA, vecB);

  // コサイン類似度を計算する関数
  function cosineSimilarity(vectorA: number[], vectorB: number[]): number {
    const dotProduct = vectorA.reduce((sum, a, idx) => sum + a * vectorB[idx], 0);
    const magnitudeA = Math.sqrt(vectorA.reduce((sum, a) => sum + a * a, 0));
    const magnitudeB = Math.sqrt(vectorB.reduce((sum, b) => sum + b * b, 0));
    return dotProduct / (magnitudeA * magnitudeB);
  }
}

if(import.meta.url == `file://${process.argv[1]}`) {
  main()
    .then(() => {
      console.log("success");
      process.exit(0);
    })
    .catch((err) =>
      console.error(err);
      process.exit(1);
    });
}

実際のコードでは、全ての記事の組み合わせについて、コサイン類似度を計算しました。

  • nCr = n! / (r! * (n-r)!)
    n: 総記事数
    r: 2

またモデルに与える文章ですが、microCMSのAPIはリッチテキストで出力されるためHTMLタグが含まれてしまっているので、精度低下を防ぐためにも省いておきます(日本語はアルファベットに比べてもトークン数が多いので、1リクエストにおけるトークン数を節約することは重要です)。

感想

類似度の高い記事を上位3件表示する機能を実装してみましたが、いくつかの課題が見えてきました。

まず、総記事数が少ない場合、類似度が低い記事でも上位に表示されてしまう点です。閾値を設けて足切りすることも考えられますが、どの程度のスコアを類似度が高いと判断すべきか、明確な基準を見つけるのが難しいと感じています。

また、記事全文をモデルに読み込ませるアプローチが最適なのか、別の方法を検討する必要があるのか悩ましいという点です。

このように、いくつかの課題は残りましたが、Gemini APIを利用することで、数時間で実装を完了できたのは大きな収穫でした。スムーズに開発を進めることができ、非常に楽しい週末を過ごせました!

\ シェアする /

この記事を書いた人

プロフィール画像

1996年生まれ。中部大学大学院にてコンピュータビジョン関連の深層学習の研究に注力したのち、2021年4月に株式会社medibaに入社。 KDDIグループ企業各社のDeveloperが集い、エンジニアが楽しめるイベントを提供するコミュニティ「KGDC」のイベント運営も行っています。

記事に関する質問は、フッター掲載の各種SNSにてお問い合わせください。