この記事は、【 可茂IT塾 Advent Calendar 2025 】の5日目の記事です。
個人開発で地図でお店を検索するようなアプリを作っています。アプリからお店を探すときに、自然言語で対話式に探せたら面白いなと思い、実験的な機能として導入してみました。
従来のキーワード検索とは異なり、「〇〇地域で〇〇系のお店を探したい」「現在地から3km以内で〇〇向けの店」といった自然な表現で検索できるように、ベクトル検索とAIを組み合わせた自然言語検索システムを実装しました。
結論としてはまだまだ精度が物足りず、今後改善していきたいですが、形にはなったので、実装内容をまとめます。
text-embedding-3-small モデルで埋め込みベクトルを生成gpt-4o-mini でクエリ最適化や結果要約を実装
システムは以下の3つの主要な処理フローで構成されています。
店舗データから検索用のテキストを作成し、OpenAIの埋め込みモデルでベクトル化します。
export function createShopContent(shop: Shop, masterData: MasterData): string {
const parts: string[] = []
// 店名、住所、スタイル、特徴などを結合
if (shop.name) parts.push(`店名: ${shop.name}`)
if (shop.address) parts.push(`住所: ${shop.address}`)
if (styles.length > 0) parts.push(`スタイル: ${styles.join(', ')}`)
// ... 他の属性も追加
return parts.join('\n')
}
生成したテキストをOpenAI APIでベクトル化し、Supabaseに保存します。
pgvector拡張機能を使い、コサイン類似度で検索します。
CREATE INDEX shop_embeddings_embedding_idx
ON shop_embeddings
USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100);
検索時は、ユーザーのクエリも同様にベクトル化し、類似度の高い店舗を取得します。さらに、都道府県フィルタや位置情報による距離ソートも実装しました。
export async function searchShopsByVector(
query: string,
limit = 5,
threshold = 0.5,
prefIdFilter: string | null = null,
userLocation: { latitude: number; longitude: number } | null = null
): Promise<ShopSearchResult[]> {
const queryEmbedding = await generateEmbedding(query)
// 位置情報がある場合は距離ソート検索
if (userLocation) {
const result = await supabase.rpc('search_shops_by_similarity_with_distance', {
query_embedding: queryEmbedding,
user_latitude: userLocation.latitude,
user_longitude: userLocation.longitude,
pref_id_filter: prefIdFilter,
match_threshold: threshold,
match_count: limit,
})
return result.data
}
// 通常のベクトル検索
// ...
}
ユーザーの自然な表現を、検索に適したクエリに変換します。位置情報も考慮します。
クエリから地名を抽出し、座標を推定する機能も実装しました。AIが地名を認識し、適切な検索半径も自動判定します。
export async function extractLocationFromQuery(
query: string
): Promise<ExtractedLocation> {
// GPT-4o-miniで地名と座標を抽出
const response = await openai.chat.completions.create({
model: 'gpt-4o-mini',
messages: [
{
role: 'system',
content: 'クエリから地名と座標を抽出してください...'
},
],
})
// 駅名→1km、商店街→2km、区→5kmなど、地域に応じて検索半径を決定
return {
locationName: result.locationName,
latitude: result.latitude,
longitude: result.longitude,
searchRadiusKm: result.searchRadiusKm,
}
}
ベクトル検索で取得した候補(最大100件)を、AIが2〜3件に絞り込み、自然な文章で要約します。
export async function summarizeWithContext(
userMessage: string,
searchResults: ShopSearchResult[]
): Promise<Summary> {
// 検索結果をテキスト形式に変換
const resultsText = searchResults
.slice(0, 50)
.map((result, index) => {
return `${index + 1}. ID: ${result.id}, 店舗名: ${result.shopName}
内容: ${result.content}
類似度: ${Math.round(result.similarity * 100)}%`
})
.join('\n\n')
// GPT-4o-miniで結果を要約
const response = await openai.chat.completions.create({
model: 'gpt-4o-mini',
messages: [
{
role: 'system',
content: '検索結果を要約し、ユーザーに分かりやすく提示してください...'
},
{
role: 'user',
content: `ユーザーメッセージ: "${userMessage}"\n\n検索結果:\n${resultsText}`
},
],
response_format: { type: 'json_object' },
})
return {
summary: parsed.summary,
selectedShopIds: parsed.selectedShopIds,
}
}
AIが選択した店舗のみFirestoreから詳細データを取得することで、コストを最適化しました。
「〇〇地域で〇〇系のお店」という自然な表現でも、ベクトル検索により意味的に近い店舗を検索できるようになりました。キーワードマッチングでは難しかった、表現のバリエーションにも対応できます。
「現在地から3km以内」や「〇〇周辺」という検索にも対応。クエリから地名を抽出し、適切な検索半径でフィルタリングします。距離によるソートも実装し、ユーザーの位置に近い店舗を優先表示できます。
単純なベクトル検索だけでなく、以下の最適化を実装しました:
Supabaseのpgvector拡張機能により、PostgreSQL上でベクトル検索が可能になりました。ivfflatインデックスを使うことで、大量のデータでも高速な検索を実現できます。
ベクトル検索に加えて、都道府県フィルタや距離ソートを組み合わせることで、より精度の高い検索が可能になりました。SQL関数として実装することで、データベース側で効率的に処理できます。
最初にベクトル検索でメタデータのみ取得し、AIが絞り込んだ後に詳細データを取得する方式により、Firestoreの読み取りコストを削減できました。
ベクトル検索とAIを組み合わせることで、自然言語での検索が実現できました。ユーザーは「〇〇地域で〇〇系のお店を探したい」という自然な表現で検索でき、システムが位置情報を理解して結果を返す形になっています。
まだまだ精度が物足りない部分もあり、検索結果が期待通りでないケースもありますが、実験的な機能としては形になったと思います。今後の改善点として、検索精度の向上や、検索結果のフィードバックを学習に活用したり、より高度なパーソナライズ機能を追加したりすることも検討しています。
可茂IT塾ではFlutter/Reactのインターンを募集しています!可茂IT塾のエンジニアの判断で、一定以上のスキルをを習得した方には有給でのインターンも受け入れています。
Read More可茂IT塾ではFlutter/Reactのインターンを募集しています!可茂IT塾のエンジニアの判断で、一定以上のスキルをを習得した方には有給でのインターンも受け入れています。
Read More