ベクター検索のすべて: リアルタイム英会話アプリでの実装事例

ベクター検索のすべて: リアルタイム英会話アプリでの実装事例

目次

  1. はじめに
  2. ベクター検索とはなにか?
  3. ベクター検索の仕組み
  4. ベクター検索の利点と注意点
  5. 実装事例:リアルタイム英会話アプリでの活用
  6. コード実装例
  7. 実装時の工夫
  8. 補足・まとめ

はじめに

現代のAI駆動アプリケーションでは、大量のテキストデータの中から関連情報を効率よく見つけることが重要です。本ブログでは、ベクター検索という技術について、実際のアプリケーション事例を交えながら解説します。

題材となるのは、リアルタイム英会話アプリです。このアプリは、ユーザーが以前のやり取りに言及する際に、セマンティック(意味的)に関連する過去の会話を自動的に検索し、LLMの文脈に含めることで、より自然で一貫した会話を実現しています。


ベクター検索とはなにか?

ベクター検索の定義

ベクター検索(Vector Search または Semantic Search)は、テキスト、画像、音声などのデータを高次元ベクトルに変換し、その類似度に基づいて検索を行う技術です。

従来のキーワード検索と異なり、ベクター検索は意味的な関連性を理解できます。

従来のキーワード検索との違い

項目 キーワード検索 ベクター検索
基本原理 完全一致または部分一致 意味的距離の計算
検索例 「犬」で「犬」のみ検出 「犬」で「ワンちゃん」「ペット」も検出
言語依存性 高(同じ言語が必須) 低(多言語対応可)
処理コスト 中程度~高
精度 シンプルだが機械的 より人間らしい結果

ベクター検索の仕組み

1. テキストのベクトル化(Embedding)

ベクター検索の第一歩は、テキストを数値ベクトルに変換することです。これをエンベッディングと呼びます。

テキスト: "I love traveling to new countries"
   ↓
エンベッディングモデルで処理
   ↓
ベクトル: [0.23, -0.15, 0.87, 0.12, ..., -0.34]  ← 高次元(例:384次元)

エンベッディングモデルは、機械学習により、意味的に似たテキストが近い値を持つように訓練されています。

2. ベクトル空間での距離計算

複数のテキストをベクトル化したら、それらの距離を計算して類似度を判定します。

よく使われる距離指標:

  • コサイン類似度(Cosine Similarity):ベクトル間の角度を利用
  • ユークリッド距離(Euclidean Distance):直線距離
  • マンハッタン距離(Manhattan Distance):格子状の距離
ベクトルA("I enjoy traveling")と
ベクトルB("I love visiting new places")の距離が小さい
   ↓
意味的に関連している = 検索結果として返す

3. インデックスの作成と検索

大量のベクトルを検索する場合、全件スキャンは非効率です。そこでベクトルインデックスを作成します。

[大量のテキストデータ]
   ↓
各テキストをベクトル化
   ↓
ベクトルインデックスに保存
   ↓
クエリ(検索)テキストをベクトル化
   ↓
インデックスを使用して効率的に類似ベクトルを検索
   ↓
結果を返す

主なベクトルインデックス技術:

  • HNSW(Hierarchical Navigable Small World)
  • IVF(Inverted File)
  • PQ(Product Quantization)
  • Flat(全件比較、小規模向け)

ベクター検索の利点と注意点

利点

  1. 意味的な関連性を理解

    • キーワード検索では見つからない、意味的に関連する内容を検出
    • 例:「犬の餌やり」を検索すると「ペットの栄養管理」も見つかる
  2. 多言語対応が容易

    • 言語に依存しないエンベッディングモデルを使えば、言語を超えて検索可能
    • 例:「dog」で「犬」の記事も見つかる(多言語モデルの場合)
  3. ユーザー体験の向上

    • より直感的で期待に沿った検索結果
    • チャットボットやQAシステムで文脈を理解した応答が可能
  4. スケーラビリティ

    • 適切なインデックス技術を使えば、大規模データセットでも高速検索が可能

注意点・トレードオフ

  1. 計算コストが高い

    • ベクトル化にはGPUやCPUリソースが必要
    • リアルタイム処理が必要な場合、ユーザーの待機時間が増加する可能性
  2. ストレージ増加

    • 元データに加えてベクトルデータを保存する必要がある
    • 例:100万件のテキスト + ベクトル = ストレージ使用量が倍増することも
  3. エンベッディングモデルの選択が重要

    • モデル選択により精度が大きく左右される
    • ドメイン特化モデルが必要な場合も
  4. レイテンシ増加

    • 初回クエリベクトル化時に遅延が生じる
    • タイムアウト対応の実装が必須
  5. バージョン管理の複雑化

    • エンベッディングモデルを更新すると、既存ベクトルとの互換性が失われる
    • マイグレーション戦略が必要
  6. セマンティックな誤解も発生

    • ベクトル検索も完璧ではなく、意図と異なる結果が返される場合がある
    • 例:「バナナ」と「黄色」が関連付けられすぎる

実装事例

リアルタイム英会話アプリの概要

本アプリは、以下の特徴を持つAI英会話練習アプリです:

  • ユーザーの日本語入力 → 英語に翻訳 + ネイティブ音声で返答
  • ユーザーの英語入力 → 文法チェック + ネイティブな返答 + 音声出力
  • 過去の会話を記録し、セマンティック検索で関連会話を抽出
  • LLM(gpt-4o-mini) に過去の関連会話を文脈として提供
Live2Dのキャラは、Vtemサイトからフリーモデルを利用させて頂いています。https://vtem.jp/listing/model_0004_favorite/Pro

ベクター検索の役割

ユーザーが「I mentioned about my trip last week」と言った場合、アプリは:

  1. このテキストをベクトル化
  2. SQLiteデータベース内に保存されたすべての過去会話ベクトルから、類似度が高い会話を検索
  3. 見つかった関連会話(例:「I went to Tokyo」「I took photos of Mount Fuji」)をLLMのプロンプトに含める
  4. LLMはその文脈を使って、より自然で一貫した返答を生成

技術スタック

コンポーネント 技術 用途
エンベッディングモデル Xenova/all-MiniLM-L6-v2 テキスト → ベクトル(384次元)
ベクトル管理 sqlite-vec ベクトルストレージと検索
データベース SQLite + WAL 会話ログ + ベクトル保存
LLM OpenAI gpt-4o-mini 会話応答生成
ランタイム Node.js サーバーサイド処理

SQLite の WAL とは

WAL は Write-Ahead Logging(ライトアヘッドログ)の略です。

何をする仕組みか

データを書き込むとき、まず通常のデータファイルを直接更新するのではなく、変更内容を WAL ファイルに追記します
その後で本体ファイルに反映されるので、書き込み中にクラッシュしてもデータ破損を防ぎやすくなります

SQLite でのメリット

同時読み取りと書き込みをより高効率にできる
通常の DELETE/INSERT/UPDATE のパフォーマンスが改善しやすい
トランザクションの整合性が向上する

このアプリでの意味

アプリでは db.pragma('journal_mode = WAL') を使い、会話ログやベクトル索引の更新を安全かつ高速に行えるようにしています。

つまり WAL は、SQLite のデータ更新を「先にログに書いてから本体へ反映する」ことで、信頼性と同時実行性を高める仕組みです。


コード実装例

1. ベクトル化処理

// Transformers.jsを使用したエンベッディング
async function createTextEmbedding(text) {
  const normalizedText = String(text || '').trim();
  if (!normalizedText) {
    return [];
  }

  // エンベッディングパイプラインを取得(初回のみモデルをロード)
  const extractor = await getEmbeddingPipeline();
  
  // テキストをベクトル化(平均プーリング + 正規化)
  const output = await extractor(normalizedText, { 
    pooling: 'mean', 
    normalize: true 
  });
  
  // 384次元のベクトルに正規化
  return normalizeEmbeddingVector(output.data);
}

// ベクトルを JSON 文字列に変換(DB保存用)
function serializeEmbedding(vector) {
  return JSON.stringify(normalizeEmbeddingVector(vector));
}

平均プーリングとは

平均プーリング は、モデルから出力された複数のトークンベクトルを1つのベクトルにまとめる方法です。

具体的には:

Transformer などは入力文をトークンごとにベクトル化する
各トークンベクトルをすべて足し合わせる
その合計をトークン数で割る
こうして「文全体を表す1つのベクトル」を作ります。

正規化 は、その結果ベクトルを長さ1になるようにスケーリングする処理です。これにより、ベクトルの大きさではなく、方向(意味の近さ)で類似度を比較しやすくなります。

トークンとは

トークンは、テキストを処理しやすい単位に分割したものです。
自然言語処理モデルでは、文章を「単語」だけでなく「サブワード」「記号」「文字列片」などに分割して扱います。
たとえば Hello, world! は次のようなトークンに分かれる場合があります:
Hello
,
world
!
なぜトークンを使うのか?
モデルは文字列全体ではなく、トークンの列を入力として扱うため
大量テキストを効率よく数値化(エンベッディング化)するため
未知の単語や複雑な語形変化にも対応しやすくするため
どう使われるか?
Transformer系モデルは、各トークンに対応するベクトルを生成します
その後、複数トークンのベクトルを平均したり、特定トークンのベクトルを使ったりして文全体の表現を作ります
つまり、トークンは「モデルが理解するための最小単位」です。

2. データベーススキーマ

// 会話ターン記録テーブル
CREATE TABLE IF NOT EXISTS conversation_turns (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  username TEXT NOT NULL,
  user_text TEXT NOT NULL,              -- ユーザーの入力
  assistant_reply TEXT NOT NULL,         -- AIの返答
  emotion TEXT NOT NULL DEFAULT 'neutral',
  created_at TEXT NOT NULL,
  FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE
);

// ベクトルを保存する仮想テーブル(sqlite-vec)
CREATE VIRTUAL TABLE IF NOT EXISTS conversation_turn_vectors USING vec0(
  embedding float[384]                   -- 384次元ベクトル
);

3. ベクトルの保存処理

// 会話ターン保存時にベクトル化してインデックス
async function indexConversationTurnEmbedding(turnId, userText, assistantReply) {
  if (!isSemanticMemoryEnabled() || !turnId) {
    return false;
  }

  // テキストをベクトル化
  const embeddingText = buildEmbeddingText(userText, assistantReply);
  const embedding = await createTextEmbedding(embeddingText);
  
  // トランザクション内で安全に保存
  const database = getDb();
  const writeVector = database.transaction((id, serializedEmbedding) => {
    // 古いベクトルがあれば削除
    database.prepare('DELETE FROM conversation_turn_vectors WHERE rowid = ?').run(id);
    
    // 新しいベクトルを挿入
    database.prepare(`
      INSERT INTO conversation_turn_vectors(rowid, embedding)
      VALUES (CAST(? AS INTEGER), ?)
    `).run(id, serializedEmbedding);
  });
  
  writeVector(Number(turnId), serializeEmbedding(embedding));
  return true;
}

// ベクトル化するテキストの準備
function buildEmbeddingText(userText, assistantReply) {
  return [
    `User: ${String(userText || '').trim()}`,
    `Assistant: ${String(assistantReply || '').trim()}`,
  ].filter(Boolean).join('\n');
}

4. ベクター検索処理

// 関連する過去会話をセマンティック検索
async function loadRelevantConversationMemory(username, userText, options = {}) {
  const lang = options.lang || 'en';
  
  // 英語の場合のみセマンティック検索を実行
  if (!username || !String(userText || '').trim() || !shouldUseSemanticMemory(lang)) {
    return [];
  }

  // クエリテキストをベクトル化
  const embedding = await createTextEmbedding(userText);
  
  // ベクトル検索を実行
  return queryRelevantConversationMemory(username, embedding, {
    limit: options.limit || 4,           // 最大4件の関連会話を取得
    excludeRecent: options.excludeRecent || 0,
    since: options.since || '',
  });
}

// 実際の SQL ベクター検索クエリ
function queryRelevantConversationMemory(username, embedding, options = {}) {
  if (!username || !isSemanticMemoryEnabled()) {
    return [];
  }

  const limit = Math.min(Math.max(Number(options.limit || 4), 1), 10);
  const params = [
    serializeEmbedding(embedding),       // クエリベクトル
    Math.max(limit + (options.excludeRecent || 0), limit),
    username
  ];
  
  // sqlite-vec の MATCH 演算子を使用
  return getDb().prepare(`
    SELECT
      t.id,
      t.user_text AS userText,
      t.assistant_reply AS assistantReply,
      t.emotion,
      strftime('%Y-%m-%dT%H:%M:%fZ', t.created_at) AS createdAt,
      v.distance                         -- コサイン距離
    FROM conversation_turn_vectors v
    JOIN conversation_turns t ON t.id = v.rowid
    WHERE v.embedding MATCH ?            -- ベクター検索
      AND k = ?                           -- 結果件数
      AND t.username = ?                  -- ユーザーフィルタ
    ORDER BY v.distance                  -- 距離でソート(小さい = より類似)
    LIMIT ${limit}
  `).all(...params);
}

5. テキスト検索とセマンティック検索の融合

// テキスト検索とセマンティック検索の結果をマージ
async function getConversationHistory(username, options = {}) {
  const { limit, search, since, semantic } = normalizeHistoryOptions(options);
  
  // まずテキスト検索(キーワード検索)を実行
  const textResults = getConversationHistoryByText(username, { 
    limit, 
    search, 
    since 
  });

  // セマンティック検索が有効かつ検索キーワードがある場合
  if (!semantic || !search) {
    return textResults;
  }

  // セマンティック検索を実行
  const semanticResults = await loadRelevantConversationMemory(username, search, {
    lang: 'en',
    limit,
    since,
  }).catch(err => {
    console.warn('Semantic history search failed.', err.message);
    return [];
  });

  // 結果をマージ(重複排除)
  return mergeConversationHistoryResults(textResults, semanticResults, limit);
}

// キーワード検索
function getConversationHistoryByText(username, options = {}) {
  const { limit, search, since } = normalizeHistoryOptions(options);
  const where = ['username = ?'];
  const params = [username];

  if (since) {
    where.push('datetime(created_at) >= datetime(?)');
    params.push(since);
  }

  if (search) {
    // LIKE で部分一致検索
    const likePattern = `%${escapeLikePattern(search)}%`;
    where.push("(user_text LIKE ? ESCAPE '\\' OR assistant_reply LIKE ? ESCAPE '\\')");
    params.push(likePattern, likePattern);
  }

  return getDb().prepare(`
    SELECT
      id,
      user_text AS userText,
      assistant_reply AS assistantReply,
      emotion,
      strftime('%Y-%m-%dT%H:%M:%fZ', created_at) AS createdAt
    FROM conversation_turns
    WHERE ${where.join(' AND ')}
    ORDER BY id DESC
    LIMIT ?
  `).all(...params, limit);
}

// テキスト検索とセマンティック検索結果をマージ
function mergeConversationHistoryResults(primaryResults, secondaryResults, limit) {
  const seen = new Set();
  const merged = [];
  
  // 両方の結果を結合し、重複を排除
  for (const turn of [...primaryResults, ...secondaryResults]) {
    if (!turn?.id || seen.has(turn.id)) {
      continue;
    }
    seen.add(turn.id);
    merged.push(turn);
    if (merged.length >= limit) {
      break;
    }
  }
  
  return merged;
}

6. LLMプロンプトへの組み込み

// 検索された関連会話を LLM のプロンプトに組み込む
function buildRelevantConversationInstruction(relevantHistory = []) {
  const turns = relevantHistory
    .filter(turn => turn.userText || turn.assistantReply)
    .slice(0, 4);  // 最大4つ

  if (!turns.length) {
    return '';
  }

  const lines = turns.map((turn, index) => [
    `Relevant past conversation ${index + 1}:`,
    turn.userText ? `User: ${turn.userText}` : '',
    turn.assistantReply ? `Assistant: ${turn.assistantReply}` : '',
  ].filter(Boolean).join('\n'));

  return [
    'Use these semantically similar past conversation snippets only when they help answer references to earlier topics.',
    'Do not mention that retrieval was used, and ignore snippets that are not relevant to the current turn.',
    ...lines,
  ].join('\n');
}

// LLM へのプロンプト構築(例)
const systemPrompt = `You are an English conversation partner.
${buildUserProfileInstruction(userProfile)}
${buildRelevantConversationInstruction(relevantPastConversations)}
${buildTopicSuggestionInstruction(userProfile, newsTopics)}`;

実装時の工夫

1. タイムアウト制御

ベクター化と検索は計算集約的なため、レスポンス時間が増加する可能性があります。本アプリではタイムアウト機構を実装:

// セマンティック検索にタイムアウトを設定(デフォルト 450ms)
async function loadRelevantConversationMemoryWithTimeout(username, userText, options = {}) {
  return withTimeout(
    loadRelevantConversationMemory(username, userText, options)
      .catch(err => {
        console.warn('Semantic memory search failed.', err.message);
        return [];
      }),
    options.timeoutMs || SEMANTIC_MEMORY_TIMEOUT_MS,  // 環境変数から読み込み
    []  // タイムアウト時は空配列を返す(エラーではなく単に検索結果なし)
  );
}

async function withTimeout(promise, timeoutMs, fallbackValue) {
  let timeoutId;
  try {
    return await Promise.race([
      promise,
      new Promise(resolve => {
        timeoutId = setTimeout(() => resolve(fallbackValue), timeoutMs);
      }),
    ]);
  } finally {
    clearTimeout(timeoutId);
  }
}

// .env での設定例
// SEMANTIC_MEMORY_TIMEOUT_MS=450

2. 軽量なエンベッディングモデル

大規模なモデルは精度が高い一方、計算量が増加します。本アプリでは小規模で軽量なモデルを採用:

// 384次元、高速、軽量
const EMBEDDING_MODEL = process.env.EMBEDDING_MODEL || 'Xenova/all-MiniLM-L6-v2';
const EMBEDDING_DIMENSIONS = 384;

// 量子化オプションで、さらにメモリ効率を向上
const embeddingPipeline = await pipeline('feature-extraction', EMBEDDING_MODEL, {
  quantized: process.env.EMBEDDING_QUANTIZED !== 'false',  // デフォルト有効
});
モデル 次元 用途 速度 メモリ
all-MiniLM-L6-v2 384 本アプリ採用 高速
all-mpnet-base-v2 768 より高精度 中速 中程度
all-roberta-large-v1 768 高精度 低速

3. 条件付きインデックス作成

セマンティック検索は英語のみで有効化し、日本語の場合はスキップ:

function shouldUseSemanticMemory(lang = 'en') {
  return isSemanticMemoryEnabled() && String(lang || 'en').toLowerCase().startsWith('en');
}

function scheduleConversationEmbeddingIndex(turnId, userText, assistantReply, lang = 'en') {
  if (!shouldUseSemanticMemory(lang)) {
    return;  // 英語以外の場合、ベクトル化をスキップ
  }

  indexConversationTurnEmbedding(turnId, userText, assistantReply)
    .catch(err => console.warn('Failed to index conversation embedding.', err.message));
}

4. トランザクション処理による安全性

データベース操作をトランザクション内で実行し、一貫性を確保:

async function indexConversationTurnEmbedding(turnId, userText, assistantReply) {
  if (!isSemanticMemoryEnabled() || !turnId) {
    return false;
  }

  const embedding = await createTextEmbedding(buildEmbeddingText(userText, assistantReply));
  const database = getDb();
  
  // トランザクション化:成功または失敗がアトミックに実行
  const writeVector = database.transaction((id, serializedEmbedding) => {
    database.prepare('DELETE FROM conversation_turn_vectors WHERE rowid = ?').run(id);
    database.prepare(`
      INSERT INTO conversation_turn_vectors(rowid, embedding)
      VALUES (CAST(? AS INTEGER), ?)
    `).run(id, serializedEmbedding);
  });
  
  writeVector(Number(turnId), serializeEmbedding(embedding));
  return true;
}

5. テキスト検索とセマンティック検索の融合戦略

単一の手法ではなく、両者の長所を活かす:

// テキスト検索: 精度が高い、高速、スケーラブル
// セマンティック検索: 意味的関連性を捉える、言語に依存しない

// 戦略:
// 1. テキスト検索で完全一致を取得(高信頼度)
// 2. セマンティック検索で意味的関連性のある結果を取得
// 3. 両者をマージして返す(テキスト検索結果を優先)

const textResults = getConversationHistoryByText(username, { limit, search, since });
const semanticResults = await loadRelevantConversationMemory(username, search, {...});
return mergeConversationHistoryResults(textResults, semanticResults, limit);

6. モデルロードのレイジー初期化

エンベッディングパイプラインを初回使用時に遅延ロードし、起動時間を短縮:

let embeddingPipelinePromise = null;

async function getEmbeddingPipeline() {
  if (!embeddingPipelinePromise) {
    // 初回のみ、非同期でモデルをロード
    embeddingPipelinePromise = import('@xenova/transformers')
      .then(({ pipeline }) => pipeline('feature-extraction', EMBEDDING_MODEL, {
        quantized: process.env.EMBEDDING_QUANTIZED !== 'false',
      }));
  }
  return embeddingPipelinePromise;
}

7. 設定可能なパラメータ

本番環境に合わせて調整可能なパラメータ:

// .env ファイルから読み込み
const SEMANTIC_MEMORY_ENABLED = process.env.SEMANTIC_MEMORY_ENABLED !== 'false';
const SEMANTIC_MEMORY_TOP_K = Math.min(
  Math.max(Number(process.env.SEMANTIC_MEMORY_TOP_K || 4), 1), 
  10
);
const SEMANTIC_MEMORY_TIMEOUT_MS = Math.min(
  Math.max(Number(process.env.SEMANTIC_MEMORY_TIMEOUT_MS || 450), 50),
  5000
);
const EMBEDDING_MODEL = process.env.EMBEDDING_MODEL || 'Xenova/all-MiniLM-L6-v2';

// 使用例:
// SEMANTIC_MEMORY_ENABLED=true
// SEMANTIC_MEMORY_TOP_K=4          # 検索結果の上限
// SEMANTIC_MEMORY_TIMEOUT_MS=450   # タイムアウト時間(ミリ秒)
// EMBEDDING_MODEL=Xenova/all-MiniLM-L6-v2
// EMBEDDING_QUANTIZED=true         # モデル量子化の有効化

補足・まとめ

主な学習ポイント

  1. ベクター検索の基礎

    • ベクトル化(エンベッディング)、距離計算、インデックスの3ステップ
    • 従来のキーワード検索との使い分けが重要
  2. 実装の複雑性管理

    • 計算量、メモリ、レイテンシのバランスを考慮
    • タイムアウト、軽量モデル、条件付き実行などで最適化
  3. テキスト検索との融合

    • 単一の手法に依存せず、複数手法の長所を活かす
    • マージロジックで信頼度を管理
  4. 段階的な展開

    • 英語のみ有効化、タイムアウト機構、モデル量子化など
    • 段階的に機能を追加・最適化できる設計

実際の効果

本アプリのユーザーは、以下のメリットを享受:

  • 🎯 より文脈を理解した返答:過去の会話に自動的に言及
  • 応答遅延の最小化:タイムアウト機構で安全性を確保
  • 📱 軽量で実行可能:大規模GUIなしでCPU上で実行
  • 🌍 多言語対応への道:ベクター化により言語を超えた検索が可能

今後の展開

  1. マルチモーダル対応

    • 画像やオーディオもベクトル化し、統一的に検索
  2. ハイブリッドモデル

    • より大規模なモデルと軽量モデルの組み合わせ
  3. オンデバイス処理の拡張

    • エッジデバイスでのベクター化・検索
  4. セマンティックキャッシング

    • 頻繁に検索されるクエリのベクトルをキャッシュ

まとめ

ベクター検索は、現代のAIアプリケーションで必須の技術です。本ブログで取り上げたリアルタイム英会話アプリの事例から、以下が分かります:

  • 効果的な実装には、エンベッディング、インデックス、タイムアウト制御の組み合わせが必要
  • パフォーマンス最適化は、モデル選択と条件付き実行によって達成される
  • テキスト検索との融合により、精度と性能のバランスが取れる

ベクター検索を適切に活用することで、ユーザーにとってより直感的で有用なアプリケーションが実現できます。


参考資料


技術スタック: Node.js, Express, SQLite, sqlite-vec, Transformers.js, OpenAI API