こんにちは!
株式会社ブレインパッド プロダクトユニット、新卒2年目の @y-tsukasa です!
業務では、主力プロダクトである「Rtoaster」の開発および運用・保守を担当しています。
また、データ活用におけるPoCやMVP開発など、新規価値創出のフェーズにも携わらせていただいてます!
はじめに
皆さんはQiitaで記事を探すとき、
「あれ、あの記事の内容なんだっけ...キーワードが出てこない...」
と頭を抱えた経験はないでしょうか?
あるいは、具体的なライブラリやフレームワークの名前は分からないけど、「こういう機能を実現したい」「このエラーの原因を知りたい」といった明確な 「意図」 はあるのに、適切なキーワードが思いつかず検索に失敗することはありませんか?
従来のキーワード検索では、入力された単語が記事内に含まれているかどうかが重要視されます。そのため、表現の揺らぎや、抽象的な概念での検索には弱いという側面があります。
そこで今回は、ベクトル検索 (Vector Search) の技術を用いて、キーワードの一致ではなく 「意味」 や 「文脈」 でQiita記事を検索できるオレオレ曖昧検索システムを作ってみました。
結果だけ見たい方はこちらへ → 確認してみる
ベクトル検索とEmbeddingの仕組み
本題へ入る前に、今回のシステムの核となる ベクトル検索 と Embedding (埋め込み表現) の概要について説明します。
Embedding (埋め込み) とは?
Embeddingとは、自然言語 (テキスト) や画像などの非構造化データを、計算機が扱いやすい 固定次元のベクトル (数値の列) に変換する技術のことです。
この変換によって、言葉の「意味」を多次元空間上の「点 (座標)」として表現できるようになります。
例えば、「王様」という単語から「男」という意味を引き、「女」という意味を足すと「女王」になる、といった演算が可能になるという話を聞いたことがあるかもしれません。これは、単語の意味がベクトル空間上で幾何学的な関係として保存されているためです。
数学的には、テキスト $x$ を $d$ 次元のベクトル空間 $\mathbb{R}^d$ への写像関数 $f$ で変換する操作と言えます。
$$
\mathbf{v} = f(x), \quad \mathbf{v} \in \mathbb{R}^d
$$
今回の実装では、Googleの Gemini Embedding モデルを使用しており、テキストを 2048次元のベクトル に変換しています。
ベクトル空間に変換すると言葉の『意味の近さ』を、数学的な『距離』や『角度』として計算できるという嬉しさがあります。ベクトル検索ではこれを応用します。
ベクトル検索 (近傍探索)
テキストをベクトル化できれば、文章同士の「意味の近さ」を、ベクトル空間上の「距離の近さ」として計算できるようになります。
ベクトル検索では、検索クエリ (ユーザーの入力) をベクトル化し、データベースに保存された記事のベクトル群の中から、最も距離が近い (=意味が似ている) ものを探し出します。
この「距離」の定義にはいくつかありますが、ベクトルのなす角の小ささを表す コサイン類似度 (Cosine Similarity) がよく用いられます。
2つのベクトル $\mathbf{A}$ と $\mathbf{B}$ のコサイン類似度は以下の式で定義されます。
$$
\text{similarity} = \cos(\theta) = \frac{\mathbf{A} \cdot \mathbf{B}}{|\mathbf{A}| |\mathbf{B}|} = \frac{\sum_{i=1}^{n} A_i B_i}{\sqrt{\sum_{i=1}^{n} A_i^2} \sqrt{\sum_{i=1}^{n} B_i^2}}
$$
この値は $-1$ から $1$ の範囲を取り、 $1$ に近いほど2つのベクトルの向きが近く、意味的に類似していると判断できます。
Firestoreのベクトル検索機能も、このコサイン類似度を用いて、高速に類似ドキュメントを検索する仕組みを提供しています。
※ 正確には ANN (Approximate Nearest Neighbor; 近似近傍探索) において、探索の際の 距離関数として"コサイン類似度"を選択できる という表現になります。ANNを実現するアルゴリズムとしてはKD-TreeやHNSWがよく知られています。
参考: HNSWアルゴリズム入門:高速な類似検索の仕組み
ちなみに、本番環境 (Google Cloud) でベクトル検索を行う場合は事前にベクトルインデックスの作成が必要ですが、FirestoreのLocal Emulatorを使用している場合はインデックスなしでも動作します。
本番デプロイ時にはインデックス設定を忘れないよう注意しましょう。
(絞り込みをしたい時は複合インデックスも併せて設定が必要です)
作ったもの
ベクトル検索の応用としてQiita記事を意味検索するためのAPIサーバーと、検索の入力と検索結果を表示するフロントエンドを用意しました。
エンドポイントは以下の1つだけです。
// クエリパラメータスキーマ
export const searchParamsSchema = z.object({
query: z.string(), // 意味検索クエリ
limit: z.coerce.number().default(5), // 類似ドキュメントをいくつ取得するか
});
// レスポンススキーマ
export const searchResponseSchema = z.object({
items: z.array(
z.object({
title: z.string(), // 記事タイトル
url: z.string(), // 記事への本URL
likes_count: z.number(), // LGTM
stocks_count: z.number(), // ストック
comments_count: z.number(), // コメント
}),
),
});
// GET: /searchで定義
export const searchRoute = createRoute({
method: "get",
path: "/search",
request: {
query: searchParamsSchema,
},
responses: {
200: {
content: {
"application/json": {
schema: searchResponseSchema,
},
},
description: "",
},
500: {
content: {
"application/json": {
schema: z.object({
error: z.string(),
}),
},
},
description: "Internal Server Error",
},
},
});
アーキテクチャ
今回のシステムの全体構成は以下のようになっています。
(こちらもNano Banana Proに作ってもらいました、すごい)
▼ シーケンス図
採用した技術スタック
-
Backend Framework: Hono
- 軽量・高速で、Web標準に準拠したTypeScript製フレームワーク
-
hono/clientを使うことで、フロントエンドと型安全にAPI通信ができるのが開発体験としてとても嬉しいですね
-
AI Framework: Genkit
- Googleが開発しているAI開発フレームワーク
- Zodを使ったスキーマ定義で、LLMの入出力を型安全に扱える点がHonoと相性抜群です (構造化出力、structured output)
-
LLM & Embedding: Gemini 2.5 Flash & gemini-embedding-001
- 高速かつ安価、そして精度の高いGoogleのモデルを採用
-
Database: Cloud Firestore
- NoSQLデータベースですが、最近ベクトル検索機能がサポートされました (2024年中旬頃にGA!)
- 今回は開発環境としてFirebaseの Local Emulator Suite を使用しました。ローカルでベクトル検索まで完結して試せるのが非常に便利です
-
Frontend: Next.js
- 特筆不要のReactフレームワーク、Honoとの相性が良いです
- エンドポイントが1つしかないのでフロントはLLMに機能要件だけ伝えて作ってもらいました
実装の詳細
実装は大きく分けて、「データの準備 (Embedding)」と「検索APIの実装」の2パートに分かれます。
Qiita APIから記事を取得
ベクトル検索をするにあたって検索対象となるデータが必要になりますが、QiitaがQiita APIを公式で用意してくれているので利用させていただきました。
取得部分は詳しく掲載しませんが、 GET /api/v2/itemsでQiita記事を作成日降順で最大1万件まで取得できます。
今回はこのAPIを利用して、LGTM数が1以上、もしくはストック数が1以上 の記事をフィルタし、Embedding対象データとしました。
(リクエストには、zodで型付できるaxios系のapi clientライブラリとしてzodiosを使いました)
Qiita APIには、レートリミットがあります。
大量に取得する際には、sleepを処理に入れて攻撃しないようにしましょう。
データの準備とドキュメント拡張
ただ記事の本文をそのままベクトル化しても、検索精度には限界があります。
例えば、記事の中に検索キーワードそのものが含まれていない場合や、記事が長すぎて文脈が薄まってしまう場合です。
そこで今回は、RAGの精度向上テクニックの一つである ドキュメント拡張 のようなアプローチを取り入れました。
具体的には、記事をそのまま保存するのではなく、LLM (Gemini) に記事を読ませ、「この記事はどのような検索クエリで検索されるべきか (User Intent)」 を生成させ、それをメタデータとして付与してベクトル化を行いました。
「拡張 (expansion)」には、従来「文書そのものに関連語を追加する」「ユーザーの検索クエリを広げる」など複数の技法があり、今回の手法は前者に近いです。
また、記事本文が長文になったり、より細かい粒度で検索させたい場合のテクニックとして chunking がありますが、今回は要約によって情報を圧縮しているため実施していません。
以下は、データ作成スクリプトの主要部分です。
// Geminiによる記事要約と検索クエリの生成
const summarizeItem = async (
item: z.infer<typeof itemsResponseSchema>[number],
) => {
// Genkitを使って構造化されたデータを生成
const summarizedResult = await ai.generate({
model: vertexAI.model("gemini-2.5-flash"),
prompt: dedent`
以下の技術記事の内容に基づき、この記事が検索でヒットすべき「検索意図 (ユーザーの悩み)」と「記事の要約」を出力してください。
// ... (省略: 文字数の制限など細かい指示) ...
# 記事本文:
${item.body}
`,
output: {
schema: zGenkit.object({
search_queries: zGenkit // genkit用にzodがwrapされているのでaliasをつけてる
.array(zGenkit.string())
.describe("ユーザーが検索しそうな具体的なクエリや質問リスト"),
summary: zGenkit
.string()
.describe("技術的な詳細、使用ツール、解決策を含んだ簡潔な要約"),
}),
},
});
return summarizedResult;
};
このように、記事本文 (item.body) をLLMに渡し、そこから search_queries (想定される検索クエリ)と summary (要約)を生成させています。
出力される想定検索クエリと要約の例
対象のQiita記事 : 高尾山に登ったら次は?行列分解で「登山レコメンド」を作る
# 想定検索クエリ
- 推薦システム 実装 Python
- Implicit Matrix Factorization 解説
- ALS 協調フィルタリング Python
- レコメンドシステム 登山データ
- implicit library 推薦システム
# 要約
本記事は、Implicit Matrix Factorization (IMF) を用いた推薦システムのPython実装を解説します。
明示的な評価がないユーザー行動データ(暗黙的フィードバック)から、協調フィルタリングの手法で最適なアイテムを推薦する仕組みを詳述。
特に、登山データを題材に、ユーザーの登山履歴から「次におすすめの山」を推薦する事例を通して、IMFの理論(信頼度、最適化問題)と、Pythonの`implicit`ライブラリを使用したAlternating Least Squares (ALS) によるモデル構築・推論の流れを具体的に示します。
ヤマレコAPIからデータを収集し、疎行列化してモデル学習を行う一連のプロセスと、その結果の解釈までを提示し、実用的な推薦システム構築の入門としています。
そして、これらを組み合わせてベクトル化対象のテキストを作成します。
const createEmbeddingContent = (
item: z.infer<typeof itemsResponseSchema>[number],
summarizedResult: Awaited<ReturnType<typeof summarizeItem>>,
) =>
dedent`
Title: ${item.title}
Tags: ${item.tags.map((tag) => tag.name).join(", ")}
## Search Queries (User Intents)
${summarizedResult.output?.search_queries.map((q) => `- ${q}`).join("\n")}
Content:
${summarizedResult.output?.summary ?? ""}
`;
こうすることで、「記事内の単語」だけでなく、「ユーザーが抱くであろう疑問や悩み」も含めてベクトル化されるため、検索者の意図にマッチしやすくなります。
Firestoreに埋め込み
続いて、生成したメタデータをもとにEmbeddingを作成し、それをFirestoreに保存していきます。
今回の検索エンジンは 「すべてのQiita記事を1ドキュメント=1ベクトル」としてFirestoreのitemsコレクションに格納する作りです。
Embeddingの作成
まずはVertexAIのgemini-embedding-001を使って、整形済みテキストから2048次元のベクトルを生成します。Firestoreのベクトル検索では 事前に次元数を固定 しておく必要があるため、outputDimensionality: 2048を明示しています。
const embedder = vertexAI.embedder("gemini-embedding-001", {
outputDimensionality: 2048, // Firestore の vector に合わせる
});
const getEmbedding = async (data: string) => {
const result = await ai.embed({
embedder,
content: data,
});
return result[0]?.embedding;
};
Firestoreへの保存
Embeddingが作成できたら、あとはFirestoreに保存するだけです。Firestoreの Vector Fieldは以下のようにFieldValue.vector()で格納できます。(これだけで準備完了です)
const saveItemToFirestore = async (
item: z.infer<typeof itemsResponseSchema>[number],
embedding: number[],
) => {
await firestore.collection("items").doc(item.id).set({
title: item.title,
url: item.url,
page_views_count: item.page_views_count,
likes_count: item.likes_count,
stocks_count: item.stocks_count,
comments_count: item.comments_count,
embedding: FieldValue.vector(embedding),
});
};
Hono + Genkit による検索APIの実装
バックエンドの実装は驚くほどシンプルです。
genkitを使うことで、わずか数行でベクトル検索APIが記述できます。
検索エンドポイントのコア部分の抜粋です。
const search = new OpenAPIHono().openapi(searchRoute, async (c) => {
const { query, limit } = c.req.valid("query");
// 1. ユーザーの検索クエリをEmbedding
const queryEmbeddingResult = await ai.embed({
embedder,
content: query,
});
const queryEmbedding = queryEmbeddingResult[0]?.embedding;
if (!queryEmbedding) return c.json({ error: "Embedding failed" }, 500);
// 2. Firestoreでベクトル検索 (findNearest)
const items = await firestore
.collection("items")
.findNearest({
vectorField: "embedding",
queryVector: FieldValue.vector(queryEmbedding),
limit,
distanceMeasure: "COSINE", // コサイン類似度を使用
})
.get()
.then((result) => result.docs);
// ... レスポンスの返却
});
findNearest() を用いてembedding済のクエリを渡すだけで、類似度の高いドキュメントを取得できます。
フロントエンドの実装
フロントエンドはNext.jsで構築しました。
バックエンド (Hono) との通信には hono/client を使用しています。
これにより、バックエンドで定義したAPIの型定義をそのままフロントエンドでも利用できるため、型安全かつスムーズに実装が進みました。
▼ 実装のイメージ
// lib/client.ts
import { AppType } from "../../../backend/src";
import { hc } from "hono/client";
// 型安全なクライアントの作成
export const client = hc<AppType>("http://localhost:3000");
// 実際の呼び出しイメージ
const res = await client.search.$get({
query: {
query: searchQuery,
limit: searchLimit,
},
});
デザインやコンポーネントの実装については、要件だけを伝えてLLMに任せることで、爆速でプロトタイプを作成できました。
確認してみる
実際に構築したシステムで検索を行ってみました。
例えば、「Reactのレンダリングパフォーマンスを改善したい」 という意図で検索してみます。
キーワード検索だと「React」「パフォーマンス」「改善」などが含まれる記事がヒットしますが、ベクトル検索ではどうなるでしょうか。
useMemo や useCallbackといった具体的な解決策が書かれた記事が上位にヒットしました。
検索クエリには「useMemo」という単語は含めていませんが、LLMによる前処理で「再描画を防ぐにはuseMemoを使う」といった関連性が学習 (Embedding) されているため、文脈を捉えた検索が可能になっています。
▼ Qiitaにて、同じ文言で検索した結果

Qiitaの検索結果では伝統的なキーワード検索のため、その意味までは拾えてないことが分かりますね。
まとめ
今回は、Hono、Genkit、Firestoreを用いて、Qiita記事のベクトル検索エンジンを自作してみました。
個人的に感じた良いポイント
-
Hono + Genkitの相性が良い
- どちらもTypeScriptファーストで、入出力の型定義がしっかりしているため、バックエンドの実装が堅牢かつスムーズにできました
- 意図しないバグなどが起きず、出戻りが少なかったので、全行程1日かからないぐらいで作れました
-
ドキュメント拡張が効いている
- LLMに「検索意図」を生成させてからEmbeddingすることで、曖昧な検索クエリでも適切な記事を拾えるようになりました
- また、意味的な近さを優先するため、人気記事などに埋もれてしまった記事でもユーザーに届けることが可能です
-
Firestoreの手軽さ
- RDBでpgvectorなどを使うのも手ですが、Firestoreならインフラ管理不要でサクッとベクトル検索が導入できるため、個人開発の規模感には最適かも
- ローカルで試す分にはembeddingとgemini-flashの課金だけで済むのも良いですね
また、開発体験についてですが、Firebase Emulator Suiteの Firestore Local Emulator が非常に優秀です。
クラウドにデプロイすることなく、ローカル環境だけでベクトル検索の挙動を確認できるため、トライアンドエラーのサイクルを高速に回すことができます。(ここが一番の推しポイントかも)

↑ firebaseのfirestore emulatorならローカル環境でリッチにデバッグできます
本番運用する際に考慮すべきポイント
ローカルで試す分には特に問題は起こらないですが、実際に本番環境に導入する時は以下の点に気をつけたいですね。
- embedding処理のキャッシュ
- 少なくとも同じ検索クエリに対するembeddingはキャッシュしておき再計算しないようにする
- アイテムの更新
- バッチ処理で定期的に最新のアイテム(記事)をembeddingして保存する
- (故にリアルタイムな記事に追従するのが難しく、ここは明確な弱点でもある)
- APIにはRate Limitを設ける
- EmbeddingはDoS攻撃に弱いのでレート制限は厳格に設ける
- キーワード検索とハイブリッドにする
- 意味検索は強力ですが、クエリによってはノイズが混じるので従来の完全一致によるキーワード検索と組み合わせるとよりUXが良さそうです
最後に
もし「自分のブログやサービスにも意味検索機能をつけたい」と考えている方がいれば、ぜひ試してみてください!
最後まで読んでいただき、ありがとうございました!
※ 本記事は、技術検証および個人の学習を目的としており、取得したデータの再配布や公衆送信を意図したものではありません





