AI Embeddings

AI 埋め込み

AI Embeddings を分かりやすく

AI Embeddings(埋め込み)は、テキストや画像を「数値のリスト」に変換する技術です。

例え話をしましょう。あなたが図書館で本を整理するとします:

従来の方法: タイトルや著者名でアルファベット順に並べる

  • 「Apple」と「Apricot」は近くに置かれる(文字が似ているから)
  • でも、内容は全く違うかもしれない

Embeddings を使う方法: 内容の「意味」で並べる

  • 「犬の飼い方」と「猫の飼い方」は近くに置く(ペット関連)
  • 「リンゴの栽培」と「ミカンの栽培」は近くに置く(果物栽培)
  • 「Apple Inc.の歴史」は IT コーナーに置く

Embeddings は、テキストを数値ベクトル(例: [0.2, -0.5, 0.8, ...])に変換することで、「意味の類似度」を数学的に計算できるようにします。

Embeddings の仕組み

例えば、以下の3つの文章を考えてみましょう:

  1. 「犬が公園で遊んでいる」
  2. 「猫が庭で遊んでいる」
  3. 「株価が急上昇している」

Embeddings に変換すると(実際はもっと多次元):

文章1: [0.8, 0.6, -0.1]  ← 動物、遊び
文章2: [0.7, 0.5, -0.2]  ← 動物、遊び(1と近い!)
文章3: [-0.3, -0.8, 0.9] ← 経済、数値(1, 2と遠い)

ベクトル間の距離(コサイン類似度など)を計算することで、文章1と文章2が意味的に似ていることが分かります。

Next.js × TypeScript で Embeddings を実装する

ステップ 1: OpenAI Embeddings API を使う

// lib/embeddings/openai.ts
import OpenAI from 'openai'

const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
})

export type EmbeddingModel = 'text-embedding-3-small' | 'text-embedding-3-large'

// テキストを埋め込みベクトルに変換
export async function createEmbedding(
  text: string,
  model: EmbeddingModel = 'text-embedding-3-small'
): Promise<number[]> {
  try {
    const response = await openai.embeddings.create({
      model,
      input: text,
    })

    return response.data[0].embedding
  } catch (error) {
    console.error('Embedding Error:', error)
    throw new Error('埋め込みの生成に失敗しました')
  }
}

// 複数のテキストを一度に埋め込み
export async function createBatchEmbeddings(
  texts: string[],
  model: EmbeddingModel = 'text-embedding-3-small'
): Promise<number[][]> {
  try {
    const response = await openai.embeddings.create({
      model,
      input: texts,
    })

    return response.data.map((item) => item.embedding)
  } catch (error) {
    console.error('Batch Embedding Error:', error)
    throw new Error('バッチ埋め込みの生成に失敗しました')
  }
}

ステップ 2: ベクトルの類似度計算

// lib/embeddings/similarity.ts

// コサイン類似度を計算(-1 〜 1、1が最も類似)
export function cosineSimilarity(a: number[], b: number[]): number {
  if (a.length !== b.length) {
    throw new Error('ベクトルの次元が一致しません')
  }

  const dotProduct = a.reduce((sum, val, i) => sum + val * b[i], 0)
  const magnitudeA = Math.sqrt(a.reduce((sum, val) => sum + val * val, 0))
  const magnitudeB = Math.sqrt(b.reduce((sum, val) => sum + val * val, 0))

  return dotProduct / (magnitudeA * magnitudeB)
}

// ユークリッド距離を計算(0が最も類似)
export function euclideanDistance(a: number[], b: number[]): number {
  if (a.length !== b.length) {
    throw new Error('ベクトルの次元が一致しません')
  }

  const sum = a.reduce((acc, val, i) => acc + Math.pow(val - b[i], 2), 0)
  return Math.sqrt(sum)
}

// 最も類似するアイテムを見つける
export function findMostSimilar<T>(
  queryEmbedding: number[],
  items: Array<{ embedding: number[]; data: T }>,
  topK: number = 5
): Array<{ data: T; similarity: number }> {
  const similarities = items.map((item) => ({
    data: item.data,
    similarity: cosineSimilarity(queryEmbedding, item.embedding),
  }))

  return similarities.sort((a, b) => b.similarity - a.similarity).slice(0, topK)
}

ステップ 3: セマンティック検索の実装

// app/api/semantic-search/route.ts
import { createEmbedding } from '@/lib/embeddings/openai'
import { findMostSimilar } from '@/lib/embeddings/similarity'

// サンプルデータ(実際にはデータベースから取得)
type Article = {
  id: string
  title: string
  content: string
  embedding: number[]
}

// 実際のアプリでは、これらの埋め込みはデータベースに保存
const articles: Article[] = []

export async function POST(req: Request) {
  try {
    const { query } = await req.json()

    if (!query || typeof query !== 'string') {
      return new Response('query が必要です', { status: 400 })
    }

    // クエリを埋め込みベクトルに変換
    const queryEmbedding = await createEmbedding(query)

    // 類似する記事を検索
    const results = findMostSimilar(
      queryEmbedding,
      articles.map((article) => ({
        embedding: article.embedding,
        data: {
          id: article.id,
          title: article.title,
          content: article.content,
        },
      })),
      5 // 上位5件
    )

    return Response.json({
      query,
      results: results.map((result) => ({
        ...result.data,
        similarity: result.similarity,
      })),
    })
  } catch (error) {
    console.error('Semantic Search Error:', error)
    return new Response('検索に失敗しました', { status: 500 })
  }
}

ステップ 4: 記事のインデックス化

// app/api/articles/index/route.ts
import { createBatchEmbeddings } from '@/lib/embeddings/openai'

export async function POST(req: Request) {
  try {
    const { articles } = await req.json()

    if (!Array.isArray(articles) || articles.length === 0) {
      return new Response('articles 配列が必要です', { status: 400 })
    }

    // すべての記事のテキストを抽出
    const texts = articles.map((article: any) => {
      return `${article.title}\n\n${article.content}`
    })

    // バッチで埋め込みを生成
    const embeddings = await createBatchEmbeddings(texts)

    // 記事と埋め込みを紐付け
    const indexedArticles = articles.map((article: any, i: number) => ({
      ...article,
      embedding: embeddings[i],
    }))

    // 実際にはデータベースに保存
    // await prisma.article.createMany({ data: indexedArticles })

    return Response.json({
      success: true,
      indexed: indexedArticles.length,
    })
  } catch (error) {
    console.error('Indexing Error:', error)
    return new Response('インデックス化に失敗しました', { status: 500 })
  }
}

ステップ 5: セマンティック検索のUI

// app/semantic-search/page.tsx
'use client'

import { useState } from 'react'

type SearchResult = {
  id: string
  title: string
  content: string
  similarity: number
}

export default function SemanticSearchPage() {
  const [query, setQuery] = useState('')
  const [results, setResults] = useState<SearchResult[]>([])
  const [isLoading, setIsLoading] = useState(false)

  const handleSearch = async (e: React.FormEvent) => {
    e.preventDefault()
    if (!query.trim()) return

    setIsLoading(true)
    try {
      const response = await fetch('/api/semantic-search', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ query }),
      })

      const data = await response.json()
      setResults(data.results)
    } catch (error) {
      console.error('Search Error:', error)
      alert('検索に失敗しました')
    } finally {
      setIsLoading(false)
    }
  }

  return (
    <div className="container mx-auto max-w-4xl p-4">
      <h1 className="text-3xl font-bold mb-6">セマンティック検索</h1>

      {/* 説明 */}
      <div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
        <h2 className="font-semibold mb-2">セマンティック検索とは?</h2>
        <p className="text-sm">
          キーワードの一致ではなく、<strong>意味の類似度</strong>で検索します。
        </p>
        <p className="text-sm mt-2">: 「犬の飼い方」と検索すると、「ペットの世話」という記事も見つかります。
        </p>
      </div>

      {/* 検索フォーム */}
      <form onSubmit={handleSearch} className="mb-6">
        <div className="flex gap-2">
          <input
            type="text"
            value={query}
            onChange={(e) => setQuery(e.target.value)}
            placeholder="検索キーワードを入力..."
            className="flex-1 border border-gray-300 rounded-lg px-4 py-2"
            disabled={isLoading}
          />
          <button
            type="submit"
            disabled={isLoading || !query.trim()}
            className="bg-blue-500 text-white px-6 py-2 rounded-lg hover:bg-blue-600 disabled:bg-gray-300"
          >
            {isLoading ? '検索中...' : '検索'}
          </button>
        </div>
      </form>

      {/* 検索結果 */}
      {results.length > 0 && (
        <div>
          <h2 className="text-xl font-semibold mb-4">検索結果({results.length}件)</h2>
          <div className="space-y-4">
            {results.map((result) => (
              <div key={result.id} className="border border-gray-200 rounded-lg p-4">
                <div className="flex justify-between items-start mb-2">
                  <h3 className="text-lg font-semibold">{result.title}</h3>
                  <span className="text-sm text-gray-500">
                    類似度: {(result.similarity * 100).toFixed(1)}%
                  </span>
                </div>
                <p className="text-gray-700">{result.content.substring(0, 200)}...</p>
              </div>
            ))}
          </div>
        </div>
      )}
    </div>
  )
}

Embeddings の高度な活用

マルチモーダル Embeddings

// テキストと画像を同じベクトル空間に埋め込む
import { pipeline } from '@xenova/transformers'

export async function createMultimodalEmbedding(input: string | Blob) {
  const extractor = await pipeline('feature-extraction', 'Xenova/clip-vit-base-patch32')
  const result = await extractor(input)
  return Array.from(result.data)
}

クラスタリング

// 類似するアイテムをグループ化
export function clusterEmbeddings<T>(
  items: Array<{ embedding: number[]; data: T }>,
  numClusters: number
): Array<Array<T>> {
  // K-Means クラスタリング(簡易実装)
  // 実際には専門のライブラリを使用
  const clusters: Array<Array<T>> = []

  // 実装は省略(K-Meansアルゴリズム)

  return clusters
}

次元削減(可視化用)

// 高次元ベクトルを2次元に削減(t-SNE、PCA など)
export function reduceDimensions(embeddings: number[][]): Array<[number, number]> {
  // t-SNEやPCAで次元削減
  // 実装は省略
  return []
}

ベクトルデータベースとの統合

実用的なアプリケーションでは、Pinecone や Weaviate などのベクトルデータベースを使います。

// lib/vector-db/pinecone.ts
import { Pinecone } from '@pinecone-database/pinecone'
import { createEmbedding } from '@/lib/embeddings/openai'

const pinecone = new Pinecone({
  apiKey: process.env.PINECONE_API_KEY!,
})

const index = pinecone.index('articles')

// ベクトルをアップロード
export async function upsertVector(id: string, text: string, metadata: any) {
  const embedding = await createEmbedding(text)

  await index.upsert([
    {
      id,
      values: embedding,
      metadata,
    },
  ])
}

// ベクトル検索
export async function searchVectors(query: string, topK: number = 5) {
  const queryEmbedding = await createEmbedding(query)

  const results = await index.query({
    vector: queryEmbedding,
    topK,
    includeMetadata: true,
  })

  return results.matches
}

まとめ

AI Embeddings は、セマンティック検索、RAG、推薦システムなど、様々な AI アプリケーションの基盤技術です。Next.js と TypeScript を組み合わせることで、型安全で高性能な検索システムを構築できます。

重要なポイント:

  1. 意味の理解: テキストの「意味」を数値ベクトルで表現
  2. 類似度計算: コサイン類似度やユークリッド距離で類似度を測定
  3. スケーラビリティ: ベクトルデータベースで数百万件のデータを高速検索
  4. マルチモーダル: テキスト、画像、音声を同じ空間に埋め込み可能
  5. 応用範囲の広さ: 検索、分類、クラスタリング、推薦など

Embeddings を活用することで、従来のキーワード検索では実現できなかった、意味的に関連する情報の発見が可能になります。