🧙

日常のドラマを記録するアプリを作る。タグ検索 vs セマンティック検索 比較実験編。

に公開

はじめに

日常で聞いたセリフなどを素早く記録して、それをあとからキーワードで検索する、というアプリを開発しています。

当面の目的は、「月が綺麗ですね」というセリフを「告白」というキーワードで検索してヒットさせること。

ここまでフロントの開発やAIタグ検索の実験をやってきまして、「月が綺麗ですね」に「夏目漱石」などのタグが付くことを確認しました。

しかし、AIが生成したタグに「告白」が含まれていなかったため、「告白」で検索しても「月が綺麗ですね」がヒットしないという問題が残っています。

今回は、この問題を解決するために「プロンプト改善」と「セマンティック検索」の2つのアプローチを実験してみます。

最初の発見:「告白」問題はあっさり解決

実験を始める前に、まず「月が綺麗ですね」で再度タグを生成してみたところ...

{"tags":["婉曲表現","告白","恋愛","月","風流"]}

あれ、「告白」が出てる!

temperature(出力のランダム性)が0.7に設定されていたため、同じ入力でも毎回少し違うタグが生成されます。

何度かやると、「告白」をちゃんと生成してくれるケースが多かったため、別の文章で実験をしてみることにします。

今回の実験テーマ

「なんか聞いたことはあります」という記録を、「知ったかぶり」で検索してヒットさせたい

このセリフ、よく私が言ってしまう言葉。

「知りません!」って正直に言えばいいのに、「なんか単語だけ聞いたことあるかもぉ~?」とちょっとでも思ったら「なんか、、聞いたことはあります!」って言っちゃうんですよね。

いや、もうそれ知らないに等しいだろって自分で自分に突っ込むんですけどね。

底の浅さが見えますね。

とは言え、「月が綺麗ですね」は有名すぎてAIが文脈を理解しやすいので、こっちのほうが実験になるかなと。

現状の確認

まず、現状のプロンプトで「なんか聞いたことはあります」を記録したとき、どんなタグが生成されるか確認します。

curl -X POST http://localhost:3000/api/generate-tags \
  -H "Content-Type: application/json" \
  -d '{"content": "なんか聞いたことはあります", "type": "dialogue"}'

結果:

{"tags":["既知感","曖昧","記憶","知識","あいまい"]}

表面的なタグばかり。「知ったかぶり」「興味なし」「適当」といった裏(?)の意味を表すタグは生成されていません。

当然、「知ったかぶり」で検索してもヒットしません。


アプローチA: プロンプト改善

まずはプロンプトを改善して、より深い意味のタグを生成させてみます。

改善内容

元のプロンプト条件:

- 短い日本語の単語(1〜3語程度)
- 文章の本質、感情、テーマ、文脈を表すもの
- 後で検索に使えるような抽象的なキーワード

改善後:

- 短い日本語の単語(1〜3語程度)
- 文章の本質、感情、テーマ、文脈を表すもの
- 後で検索に使えるような抽象的なキーワード
- 発話者の意図や心理、この言葉が使われる典型的な状況も考慮してください
- ネガティブな意味や裏の意図がある場合は、それも含めてください
- この発言をする人の性格や態度を表す言葉も含めてください
- 本音と建前のギャップがある場合は、それを表す言葉も含めてください

改善後の結果

{"tags":["知識不足","曖昧","興味薄","無関心","聞き流し"]}

かなり改善されました。

改善前 改善後
既知感 知識不足
曖昧 曖昧
記憶 興味薄
知識 無関心
あいまい 聞き流し

「興味薄」「無関心」「聞き流し」など、発話者の態度を表すタグが生成されるようになりました。

しかし...

「知ったかぶり」というタグは直接生成されません。

「無関心」で検索すればヒットしますが、ユーザーが「知ったかぶり」で検索した場合はヒットしない。

プロンプト改善だけでは限界があるかも?(もっと改良すればいける気もしますが)


アプローチB: セマンティック検索

次に、セマンティック検索を試してみます。

セマンティック検索とは?

従来の検索(タグ検索):

  • 「知ったかぶり」という文字列がタグに含まれているか?
  • 含まれていなければヒットしない

セマンティック検索:

  • 「知ったかぶり」と「なんか聞いたことはあります」の意味が近いか?
  • 意味が近ければヒットする

実装

Gemini Embedding API(gemini-embedding-001)を使って、テキストをベクトル化し、コサイン類似度で比較します。

// セマンティック検索API
async function getEmbedding(text: string): Promise<number[]> {
  const response = await fetch(
    `https://generativelanguage.googleapis.com/v1beta/models/gemini-embedding-001:embedContent?key=${API_KEY}`,
    {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        model: "models/gemini-embedding-001",
        content: { parts: [{ text }] },
      }),
    }
  )
  const data = await response.json()
  return data.embedding.values
}

function cosineSimilarity(a: number[], b: number[]): number {
  let dotProduct = 0
  let normA = 0
  let normB = 0
  for (let i = 0; i < a.length; i++) {
    dotProduct += a[i] * b[i]
    normA += a[i] * a[i]
    normB += b[i] * b[i]
  }
  return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB))
}

セマンティック検索の結果

「知ったかぶり」で検索:

{
  "results": [
    {"content": "なんか聞いたことはあります", "similarity": 69.7},
    {"content": "月が綺麗ですね", "similarity": 56.6}
  ]
}

「なんか聞いたことはあります」が類似度69.7%でヒット!

タグに「知ったかぶり」という言葉がなくても、意味的に近いと判断されて検索結果に表示されました。


比較結果

同じ条件で両方のアプローチを比較します。

テスト条件

  • 記録: 「なんか聞いたことはあります」
  • 検索ワード: 「知ったかぶり」
検索方法 結果
タグ検索(改善後) ❌ ヒットしない(タグ: 知識不足, 曖昧, 興味薄, 無関心, 聞き流し)
セマンティック検索 ✅ ヒット!(類似度 69.7%)

考察

プロンプト改善の利点と限界

利点

  • 実装コストが低い(プロンプトを変えるだけ)
  • 運用コストが低い(タグ生成は保存時の1回のみ)
  • タグが見えるので、なぜその記録が検索されたかが分かりやすい

限界

  • ユーザーが検索に使う言葉を予測できない
  • 「知ったかぶり」のような特定の表現を網羅するのは難しい
  • タグの数を増やすと、逆にノイズが増える可能性

セマンティック検索の利点と限界

利点

  • ユーザーがどんな言葉で検索しても、意味が近ければヒットする
  • 予測不可能な検索クエリに対応できる

限界

  • 実装コストが高い(埋め込みAPIの実装が必要)
  • 運用コストが高い(検索のたびにAPIを呼び出す)
  • なぜヒットしたかが分かりにくい(類似度スコアだけでは説明しにくい)

結論

観点 プロンプト改善 セマンティック検索
実装コスト ◎ 低い △ 高い
運用コスト ◎ 低い △ 高い(検索のたびにAPI)
検索精度 △ 限界あり ◎ 高い
透明性 ◎ タグが見える △ なぜヒットしたか不明
柔軟性 △ 予測できる言葉のみ ◎ どんな言葉でもOK

個人的な結論

今回のような個人の記録アプリでは、まずはタグ検索(プロンプト改善)で十分かなぁという感じです。
両方のアプローチの特性が理解できたので、将来の拡張の選択肢として持っておきたいと思います。
データが増えてきたりしたら、また導入を試みるのもありかもしれません。


今回のコード(MEMO)

セマンティック検索のAPIは以下のように実装:

/api/semantic-search/route.ts

import { NextRequest, NextResponse } from "next/server"

const GEMINI_API_KEY = process.env.GOOGLE_API_KEY

async function getEmbedding(text: string): Promise<number[]> {
  const response = await fetch(
    `https://generativelanguage.googleapis.com/v1beta/models/gemini-embedding-001:embedContent?key=${GEMINI_API_KEY}`,
    {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        model: "models/gemini-embedding-001",
        content: { parts: [{ text }] },
      }),
    }
  )
  const data = await response.json()
  return data.embedding.values
}

function cosineSimilarity(a: number[], b: number[]): number {
  let dotProduct = 0
  let normA = 0
  let normB = 0
  for (let i = 0; i < a.length; i++) {
    dotProduct += a[i] * b[i]
    normA += a[i] * a[i]
    normB += b[i] * b[i]
  }
  return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB))
}

export async function POST(request: NextRequest) {
  const { query, records } = await request.json()

  const queryEmbedding = await getEmbedding(query)

  const results = await Promise.all(
    records.map(async (record) => {
      const recordEmbedding = await getEmbedding(record.content)
      const similarity = cosineSimilarity(queryEmbedding, recordEmbedding)
      return { ...record, similarity: Math.round(similarity * 1000) / 10 }
    })
  )

  results.sort((a, b) => b.similarity - a.similarity)
  return NextResponse.json({ results })
}

使用技術:

  • Next.js 14 (App Router)
  • TypeScript
  • Gemini 2.0 Flash API(タグ生成)
  • Gemini Embedding API(セマンティック検索)
  • localStorage

Discussion