ベクトル検索で実現するオススメ記事; Gatsby.jsの場合
はじめに
Gatsby.jsでブログを自作している方の中でオススメ記事を表示する機能を実装している方は多いのではないでしょうか?
オススメ記事の実装方法としては
- タグやカテゴリーを元に関連記事を取得する
- gatsby-plugin-algoliaで登録したインデックスを元にalgolia recommendを利用する
などが考えられるかなと思います
今回私は第3?の選択肢としてQdrantとOpenAI embeddings APIを用いたベクトル検索でオススメ記事を取得する方法をGatsbyプラグインとして実装しました
作ったもの
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銭 |
ミニマルな設定
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を用意しているのでこちらも併せてチェックしてもらうと良いかもです
実装について
gatsby-plugin-recommend-articleで行っている処理は大きく分けて以下の4工程となっています
- 対象となるNodeの全件取得
- 1で取得したデータのベクトル化
- Qdrantにポイントの登録
- 記事にオススメ記事を紐づける
これらの処理はすべて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を叩いてます
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_payloadとwith_vectorはfalseにしています
ポイントの登録時点で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やイシューお待ちしてます
参考
Discussion