2️⃣

ベクトル検索で実現するオススメ記事; Gatsby.jsの場合

2025/01/15に公開

はじめに

Gatsby.jsでブログを自作している方の中でオススメ記事を表示する機能を実装している方は多いのではないでしょうか?
オススメ記事の実装方法としては

などが考えられるかなと思います

今回私は第3?の選択肢としてQdrantOpenAI embeddings APIを用いたベクトル検索でオススメ記事を取得する方法をGatsbyプラグインとして実装しました

作ったもの

gatsby-plugin-recommend-article

https://github.com/miyamo2/gatsby-plugin-recommend-article

https://www.npmjs.com/package/gatsby-plugin-recommend-article

# npm
npm install gatsby-plugin-recommend-article

# yarn
yarn add gatsby-plugin-recommend-article

# pnpm
pnpm add gatsby-plugin-recommend-article

# bun
bun add gatsby-plugin-recommend-article

使い方

Qdrantが利用できる環境とOpenAIのAPIキーが必要です

OpenAIのembeddings APIを利用するため若干ですがお金がかかります
text-embedding-3-smallの場合は100万文字あたり$0.020
text-embedding-3-largeの場合は100万文字あたり$0.130です
日本円に直すと2025/01/15時点でそれぞれ約3円と約20円です

念のため1文字当たりの日本円での金額も記載しておきます

モデル 1文字あたりの金額
text-embedding-3-small 0.00032銭
text-embedding-3-large 0.00208銭

https://openai.com/ja-JP/api/pricing/

ミニマルな設定

module.exports = {
  plugins: [
    {
      resolve: `gatsby-plugin-recommend-article`,
      options: {
        qdrant: {
          url: "http://localhost:6333",
        },
        openai: {
          apiKey: `${process.env.OPENAI_API_KEY}`,
        },
      },
    },
  ],
}

allMarkdownRemarkでオススメ記事を取得する場合のクエリ

query {
  allMarkdownRemark(filter: { id: { eq: "xxxx" } }) {
    nodes {
      id
      html
      frontmatter {
        title
      }
      recommends {
        id
        excerpt(pruneLength: 100)
        frontmatter {
          title
        }
      }
    }
  }
}
{
  "data": {
    "allMarkdownRemark": {
      "nodes": [
        {
          "id": "xxxx",
          "html": "...",
          "frontmatter": {
            "title": "..."
          },
          "recommends": [
            {
              "id": "yyyy",
              "excerpt": "...",
              "frontmatter": {
                "title": "..."
              }
            },
            {
              "id": "zzzz",
              "excerpt": "...",
              "frontmatter": {
                "title": "..."
              }
            },
            ...
          ]
        },
      ]
    }
  }
}

オプション

名称 概要 デフォルト 必須
qdrant object Qdrantに関する設定 -
openai object OpenAIに関する設定 -
limit number オススメ記事の取得件数 5
toPayload function ベクトル化するJSONデータを生成する関数 (node: Node) => JSON.stringify({ body: node.excerpt ?? "" })
nodeType string オススメ記事のフィールドを追加するNodeのタイプ "MarkdownRemark"

qdrant

名称 概要 デフォルト 必須
url string QdrantサーバのURL -
apiKey string QdrantのAPIキー -
https boolean HTTPSを使用するか false
headers object リクエストヘッダー {}
onDisk boolean 'on-disk' false
collectionName string 記事のポイントを登録するQdrantのコレクション名 "articles"

openai

名称 概要 デフォルト 必須
baseURL string OpenAI APIのベースURL -
apiKey string OpenAIのAPIキー -
organization string OpenAIの組織ID -
project string OpenAIのプロジェクトID -
embeddingModel string OpenAIのEmbeddings APIのモデル名. "text-embedding-3-small" または "text-embedding-3-large" "text-embedding-3-small"
embeddingSize number ベクトルの次元数 1536

デモ用のrepoとGitHub Pagesを用意しているのでこちらも併せてチェックしてもらうと良いかもです

https://github.com/miyamo2/gatsby-demo-plugin-recommend-article

https://miyamo2.github.io/gatsby-demo-plugin-recommend-article/

実装について

gatsby-plugin-recommend-articleで行っている処理は大きく分けて以下の4工程となっています

  1. 対象となるNodeの全件取得
  2. 1で取得したデータのベクトル化
  3. Qdrantにポイントの登録
  4. 記事にオススメ記事を紐づける

これらの処理はすべてcreateResolvers内で行われます

Nodeの全件取得~Qdrantへの登録

const points: IPoint[] = await Promise.all(
  getNodesByType(nodeType).map(async (node: Node) => {
    const payload = toPayload(node);
    const response = await fetch(openaiAPIEndpoint, {
      method: "POST",
      headers: openaiAPIHeaders,
      body: JSON.stringify({
        model: openaiOptions.embeddingModel,
        input: payload,
        dimensions: openaiOptions.embeddingSize,
      }),
    });
    if (!response.ok) {
      reporter.error(
        `gatsby-plugin-recommend-article: openaiAPI failed: ${response.statusText}`,
      );
      return {
        id: node.id,
        vector: [],
      };
    }
    const body = await response.json();
    const vector = body?.data[0]?.embedding;
    return {
      id: node.id,
      vector: vector,
    };
  }),
);

await qdrantClient.upsert(qdrantOption.collectionName, {
  wait: true,
  points: points,
});

getNodeByTypeでターゲットとなるNodeの取得
デフォルトでは前述の通りMarkdownRemarkです

getNodesByType(nodeType)

toPayloadでNodeをベクトルの元ネタとなるJSON文字列に変換します
デフォルトは(node) => JSON.stringify({ body: node.excerpt ?? "" })

const payload = toPayload(node);

OpenAI embeddings APIでベクトルの取得
ワケあって公式SDKを使わずfetchでAPIを叩いてます

https://github.com/openai/openai-node/issues/903

const response = await fetch(openaiAPIEndpoint, {
  method: "POST",
  headers: openaiAPIHeaders,
  body: JSON.stringify({
    model: openaiOptions.embeddingModel,
    input: payload,
    dimensions: openaiOptions.embeddingSize,
  }),
});
if (!response.ok) {
  reporter.error(
    `gatsby-plugin-recommend-article: openaiAPI failed: $
{response.statusText}`,
  );
  return {
    id: node.id,
    vector: [],
  };
}
const body = await response.json();
const vector = body?.data[0]?.embedding;

Qdrantへの登録
Pointにはpayloadを含まずIDとベクトルのみを登録しています
PointのIDにNodeのIDをそのまま流用しているため、NodeのIDの採番が(UUID | 符号なし64bit int)でない場合はQdrant側でエラーとなります

const points: IPoint[] = await Promise.all(
  getNodesByType(nodeType).map(async (node: Node) => {
    ...
    return {
      id: node.id,
      vector: vector,
    };
  }),
);

await qdrantClient.upsert(qdrantOption.collectionName, {
  wait: true,
  points: points,
});

記事にオススメ記事を紐づける

const resolvers = {};
resolvers[nodeType] = {
  recommends: {
    type: [nodeType],
    resolve: async (source, args, context, info) => {
      const id = source.id as string;
      const recommends = await qdrantClient.recommend(
        qdrantOption.collectionName,
        {
          positive: [id],
          limit: limit,
          with_payload: false,
          with_vector: false,
        },
      );
      const ids = recommends.map((point) => {
        return point.id as string;
      });
      const { entries } = await context.nodeModel.findAll({
        type: nodeType,
        query: {
          filter: { id: { in: ids } },
        },
      });
      return entries;
    },
  },
};
createResolvers(resolvers);

Qdrant Reccomendation APIを利用したベクトル検索

sourceは親となるNodeでこのNodeに対してオススメされるNodeのIDを取得します
positiveにNodeのID(=事前に登録されたポイントのID)を指定することでそのポイントに対してのオススメを取得できます
IDだけ取得できれば中身はGatsbyのデータソースから取得できるため、with_payloadwith_vectorfalseにしています
ポイントの登録時点でIDとベクトルのみ登録しているのはこのためです

const id = source.id as string;
const recommends = await qdrantClient.recommend(
  qdrantOption.collectionName,
  {
    positive: [id],
    limit: limit,
    with_payload: false,
    with_vector: false,
  },
);

reccomendsフィールドの解決
NodeModel.findAllを使って先ほど取得したIDを元にオススメされたNodeを取得します
今回はtotalCountは使わないのでentriesだけを返しています

const ids = recommends.map((point) => {
    return point.id as string;
});
const { entries } = await context.nodeModel.findAll({
    type: nodeType,
    query: {
        filter: { id: { in: ids } },
    },
});
return entries;

おわりに

皆さんにご活用いただけると嬉しいです
感想、ご意見、PRやイシューお待ちしてます

参考

https://zenn.dev/vs_blog/articles/c51accd5b6d016

https://zenn.dev/microcms/articles/f39a09d8f1ac01

https://www.ultra-noob.com/blog/2022/12/

GitHubで編集を提案

Discussion