0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

PostgreSQL pgvector + OpenAI Embeddingsで実装する本格的なベクトル検索システム【Next.js完全実装編】

Last updated at Posted at 2025-10-09

🎯 はじめに

RAG(Retrieval-Augmented Generation)やセマンティック検索が注目される今、pgvectorは最も実用的なベクトルデータベース拡張機能として急速に普及しています。

本記事では、実際に本番運用しているNext.js 14 + Supabase + pgvector + OpenAI Embeddingsのベクトル検索システムを、完全なコード付きで徹底解説します。

📊 本記事で実装するシステム

  • ✅ OpenAI Embeddingsによる高精度ベクトル化(1536次元)
  • ✅ PostgreSQL pgvectorによる超高速検索
  • ✅ コサイン類似度計算の最適化実装
  • ✅ チャンク分割とトークン制限対応
  • ✅ エンティティ拡張検索システム
  • ✅ Next.js API Routes完全統合

🎓 対象読者

  • ベクトル検索を実装したい開発者
  • RAGシステムを構築したい方
  • Supabaseでpgvectorを使いたい方
  • Next.jsでAI機能を実装したい方

📖 目次

  1. pgvectorの基礎知識
  2. 環境構築とセットアップ
  3. データベース設計
  4. ベクトル化システムの実装
  5. ベクトル検索システムの実装
  6. コサイン類似度の数学的理解と実装
  7. エンティティ拡張検索
  8. パフォーマンス最適化
  9. 実運用のベストプラクティス
  10. トラブルシューティング

1. pgvectorの基礎知識

1.1 pgvectorとは?

pgvectorは、PostgreSQLでベクトル演算を可能にするオープンソース拡張機能です。

従来の検索:  "Next.js" という文字列を完全一致で検索
ベクトル検索: "Next.js" の「意味」に近いものを検索
              → "React SSR", "Server Components" なども検索結果に

1.2 なぜpgvectorなのか?

特徴 pgvector Pinecone Weaviate
💰 コスト 無料(PostgreSQL) 有料 有料/セルフホスト
🏗️ インフラ 既存DBに追加 別サービス 別インフラ
🔧 学習コスト SQLだけでOK 新API学習 新API学習
📊 データ整合性 トランザクション対応 制限あり 制限あり
🚀 導入速度 即座 アカウント作成 インフラ構築

結論:既にPostgreSQLを使っているなら、pgvectorが最適解

1.3 ベクトル検索の仕組み

1. テキストをベクトル化(数値の配列に変換)
   "Next.js" → [0.234, -0.456, 0.789, ...]  (1536次元)
   
2. 類似度を計算(コサイン類似度)
   cos(θ) = (A · B) / (|A| × |B|)
   
3. 類似度でソート・フィルタリング
   0.8以上のみ返すなど

2. 環境構築とセットアップ

2.1 技術スタック

{
  "dependencies": {
    "next": "^14.2.5",
    "react": "^18.3.1",
    "@supabase/supabase-js": "^2.49.4",
    "@supabase/ssr": "^0.6.1",
    "openai": "^5.5.0",
    "typescript": "5.2.2"
  }
}

2.2 Supabaseプロジェクト作成

  1. Supabaseにアクセス
  2. 新規プロジェクト作成
  3. リージョン選択(日本ならNortheast Asia (Tokyo)
  4. データベースパスワード設定

2.3 環境変数設定

.env.localを作成:

# Supabase
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_anon_key
SUPABASE_SERVICE_ROLE_KEY=your_service_role_key

# OpenAI
OPENAI_API_KEY=sk-your-openai-api-key

2.4 Supabaseクライアント設定

lib/supabase/server.ts

import { createServerClient } from '@supabase/ssr';
import { cookies } from 'next/headers';

export function createClient() {
  const cookieStore = cookies();

  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return cookieStore.getAll();
        },
        setAll(cookiesToSet) {
          try {
            cookiesToSet.forEach(({ name, value, options }) =>
              cookieStore.set(name, value, options)
            );
          } catch {
            // Server Componentからの呼び出しは無視
          }
        },
      },
    }
  );
}

3. データベース設計

3.1 pgvector拡張機能の有効化

SupabaseのSQL Editorで実行:

-- pgvector extensionを有効化
CREATE EXTENSION IF NOT EXISTS vector;

3.2 content_vectorsテーブル作成

-- コンテンツベクトルテーブル
CREATE TABLE content_vectors (
    id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
    content_id TEXT UNIQUE NOT NULL,
    content_type TEXT NOT NULL,
    text_content TEXT NOT NULL,
    embedding VECTOR(1536),  -- OpenAI text-embedding-ada-002の次元数
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

-- ベクトル検索用インデックス(重要!)
CREATE INDEX content_vectors_embedding_idx 
ON content_vectors USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100);

-- content_idの高速検索用インデックス
CREATE INDEX content_vectors_content_id_idx 
ON content_vectors(content_id);

-- content_typeでのフィルタリング用インデックス
CREATE INDEX content_vectors_content_type_idx 
ON content_vectors(content_type);

📝 カラム説明

カラム 説明
id UUID 主キー(自動生成)
content_id TEXT コンテンツの一意識別子(ユーザー定義)
content_type TEXT コンテンツタイプ(blog_post, documentなど)
text_content TEXT 元のテキストコンテンツ
embedding VECTOR(1536) ベクトル化されたデータ
created_at TIMESTAMP 作成日時
updated_at TIMESTAMP 更新日時

3.3 ベクトル検索関数の作成

-- ベクトル類似度検索関数
CREATE OR REPLACE FUNCTION match_documents(
    query_embedding VECTOR(1536),
    match_threshold FLOAT DEFAULT 0.2,
    match_count INT DEFAULT 5
)
RETURNS TABLE(
    content_id TEXT,
    content_type TEXT,
    text_content TEXT,
    similarity FLOAT
)
LANGUAGE plpgsql
AS $$
BEGIN
    RETURN QUERY
    SELECT
        content_vectors.content_id,
        content_vectors.content_type,
        content_vectors.text_content,
        1 - (content_vectors.embedding <=> query_embedding) AS similarity
    FROM content_vectors
    WHERE 1 - (content_vectors.embedding <=> query_embedding) > match_threshold
    ORDER BY content_vectors.embedding <=> query_embedding
    LIMIT match_count;
END;
$$;

🔍 演算子の意味

  • <=>: コサイン距離演算子
  • 1 - (A <=> B): コサイン類似度に変換(0〜1の範囲)
  • ORDER BY ... <=>: 類似度の高い順にソート

4. ベクトル化システムの実装

4.1 チャンク分割アルゴリズム

長文を適切なサイズに分割することが重要です。

app/api/vectorize/route.ts(抜粋)

const MAX_CHUNK_SIZE = 6000; // 文字数制限
const ESTIMATED_TOKENS_PER_CHAR_JP = 1.5; // 日本語のトークン比率

// トークン数推定関数
function estimateTokenCount(text: string): number {
  // 日本語文字の割合を計算
  const jpCharCount = (text.match(/[\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FAF]/g) || []).length;
  const enCharCount = text.length - jpCharCount;
  
  // 日本語は1.5倍、英語は0.75倍のトークン比率
  return Math.ceil(jpCharCount * ESTIMATED_TOKENS_PER_CHAR_JP + enCharCount * 0.75);
}

// チャンク分割関数
function chunkText(text: string, chunkSize: number): string[] {
  const paragraphs = text.split(/\n\s*\n/); // 空行で段落分割
  const chunks: string[] = [];
  let currentChunk = '';
  const MAX_TOKENS = 7500; // OpenAI制限より余裕を持った設定

  for (const para of paragraphs) {
    if (para.trim() === '') continue;

    const proposedChunk = currentChunk + (currentChunk ? '\n\n' : '') + para;
    const estimatedTokens = estimateTokenCount(proposedChunk);

    // トークン数とサイズの両方をチェック
    if (proposedChunk.length <= chunkSize && estimatedTokens <= MAX_TOKENS) {
      currentChunk = proposedChunk;
    } else {
      // 現在のチャンクを保存
      if (currentChunk) {
        chunks.push(currentChunk);
      }
      
      // 段落が長すぎる場合、さらに細かく分割
      const paraTokens = estimateTokenCount(para);
      if (para.length > chunkSize || paraTokens > MAX_TOKENS) {
        const safeSize = Math.min(chunkSize, Math.floor(MAX_TOKENS / ESTIMATED_TOKENS_PER_CHAR_JP));
        for (let i = 0; i < para.length; i += safeSize) {
          chunks.push(para.substring(i, i + safeSize));
        }
        currentChunk = '';
      } else {
        currentChunk = para;
      }
    }
  }
  
  if (currentChunk) {
    chunks.push(currentChunk);
  }
  
  return chunks.filter(chunk => chunk.trim().length > 0);
}

4.2 ベクトル化APIの完全実装

app/api/vectorize/route.ts

import { NextRequest, NextResponse } from "next/server";
import { createClient } from "@/lib/supabase/server";
import OpenAI from "openai";

export async function POST(req: NextRequest) {
  const { content, contentType, contentId } = await req.json();

  if (!content || !contentType || !contentId) {
    return NextResponse.json(
      { error: "content, contentType, contentIdは必須です" },
      { status: 400 }
    );
  }

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

    const supabase = createClient();

    // コンテンツをチャンクに分割
    const chunks = chunkText(content, MAX_CHUNK_SIZE);
    const results = [];

    console.log(`📊 全体: ${content.length}文字 → ${chunks.length}チャンクに分割`);

    for (let i = 0; i < chunks.length; i++) {
      const chunk = chunks[i];
      const chunkContentId = `${contentId}_chunk${i}`;
      
      // トークン数チェック
      const estimatedTokens = estimateTokenCount(chunk);
      console.log(`Chunk ${i}: ${chunk.length}文字, ~${estimatedTokens}トークン`);
      
      if (estimatedTokens > 8000) {
        console.error(`❌ Chunk ${i} がトークン制限を超過`);
        continue;
      }

      // OpenAI Embeddingsでベクトル化
      const embeddingResponse = await openai.embeddings.create({
        model: "text-embedding-ada-002",
        input: chunk,
      });

      const embedding = embeddingResponse.data[0].embedding;

      // Supabaseに保存(UPSERT)
      const { error } = await supabase.from("content_vectors").upsert(
        {
          content_id: chunkContentId,
          content_type: contentType,
          embedding: embedding,
          text_content: chunk,
          updated_at: new Date().toISOString(),
        },
        { onConflict: 'content_id', ignoreDuplicates: false }
      );

      if (error) {
        console.error(`Supabase保存エラー (${chunkContentId}):`, error);
        results.push({ contentId: chunkContentId, success: false, error: error.message });
      } else {
        results.push({ contentId: chunkContentId, success: true });
      }
    }

    const allSuccess = results.every(res => res.success);

    return NextResponse.json({
      message: allSuccess 
        ? "コンテンツが正常にベクトル化されました" 
        : "一部のチャンクが失敗しました",
      contentId: contentId,
      chunksProcessed: chunks.length,
      results: results,
    });

  } catch (error: any) {
    console.error("ベクトル化エラー:", error);
    return NextResponse.json(
      { error: "ベクトル化に失敗しました", details: error.message },
      { status: 500 }
    );
  }
}

4.3 使用例

// フロントエンドから呼び出し
const response = await fetch('/api/vectorize', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    contentId: 'blog-post-123',
    contentType: 'blog_post',
    content: '長文コンテンツ...',
  }),
});

const result = await response.json();
console.log(`${result.chunksProcessed}チャンクをベクトル化しました`);

5. ベクトル検索システムの実装

5.1 基本的なベクトル検索API

app/api/vector-search/route.ts

import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import OpenAI from 'openai';

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

export async function POST(req: NextRequest) {
  try {
    const { query, category, maxResults = 5, similarityThreshold = 0.2 } = await req.json();

    if (!query || typeof query !== 'string') {
      return NextResponse.json(
        { error: 'クエリが必要です' },
        { status: 400 }
      );
    }

    // Step 1: クエリをベクトル化
    const embeddingResponse = await openai.embeddings.create({
      model: "text-embedding-ada-002",
      input: query,
    });

    const queryVector = embeddingResponse.data[0].embedding;

    // Step 2: Supabaseでベクトル検索
    const supabase = createClient();
    
    let supabaseQuery = supabase
      .from('content_vectors')
      .select('*')
      .not('embedding', 'is', null);

    // カテゴリフィルター
    if (category && category !== 'all') {
      supabaseQuery = supabaseQuery.eq('content_type', category);
    }

    const { data: vectorData, error: vectorError } = await supabaseQuery
      .limit(100); // 計算量を制限

    if (vectorError) {
      throw new Error(`Supabaseエラー: ${vectorError.message}`);
    }

    // Step 3: コサイン類似度を計算
    const resultsWithSimilarity = [];

    for (const item of vectorData) {
      const itemVector = JSON.parse(item.embedding);
      const similarity = cosineSimilarity(queryVector, itemVector);
      
      if (similarity >= similarityThreshold) {
        resultsWithSimilarity.push({
          ...item,
          similarity: Math.round(similarity * 100) / 100,
        });
      }
    }

    // Step 4: 類似度でソート
    const finalResults = resultsWithSimilarity
      .sort((a, b) => b.similarity - a.similarity)
      .slice(0, maxResults);

    return NextResponse.json({
      results: finalResults,
      totalFound: resultsWithSimilarity.length,
      query: query,
    });

  } catch (error) {
    console.error('ベクトル検索エラー:', error);
    return NextResponse.json(
      { error: 'ベクトル検索に失敗しました' },
      { status: 500 }
    );
  }
}

6. コサイン類似度の数学的理解と実装

6.1 数学的基礎

コサイン類似度は、二つのベクトル間の角度を使った類似性指標です。

cos(θ) = (A · B) / (|A| × |B|)

・A · B: 内積(Dot Product)
・|A|: ベクトルAのノルム(大きさ)
・|B|: ベクトルBのノルム

結果の解釈

cos(θ) 意味
1.0 完全に同じ方向(完全一致)
0.8〜0.99 非常に類似
0.5〜0.79 類似
0.2〜0.49 やや類似
0.0〜0.19 ほぼ無関係
-1.0 完全に逆方向

6.2 TypeScript実装

/**
 * コサイン類似度計算関数
 * @param vecA - ベクトルA(1536次元の数値配列)
 * @param vecB - ベクトルB(1536次元の数値配列)
 * @returns コサイン類似度(0〜1の範囲)
 */
function cosineSimilarity(vecA: number[], vecB: number[]): number {
  // 次元チェック
  if (vecA.length !== vecB.length) {
    throw new Error(`ベクトルの次元が一致しません: ${vecA.length} vs ${vecB.length}`);
  }

  let dotProduct = 0;  // 内積
  let normA = 0;       // ベクトルAのノルム
  let normB = 0;       // ベクトルBのノルム

  // 一度のループで全て計算(効率化)
  for (let i = 0; i < vecA.length; i++) {
    dotProduct += vecA[i] * vecB[i];
    normA += vecA[i] * vecA[i];
    normB += vecB[i] * vecB[i];
  }

  const magnitude = Math.sqrt(normA) * Math.sqrt(normB);
  
  // ゼロ除算防止
  if (magnitude === 0) {
    return 0;
  }

  return dotProduct / magnitude;
}

6.3 パフォーマンス最適化版

大量のベクトルを処理する場合の高速化:

/**
 * 最適化されたコサイン類似度計算(SIMD風)
 */
function cosineSimilarityOptimized(vecA: number[], vecB: number[]): number {
  const len = vecA.length;
  let dotProduct = 0;
  let normA = 0;
  let normB = 0;

  // アンロールループ(4要素ずつ処理)
  const remainder = len % 4;
  const limit = len - remainder;

  for (let i = 0; i < limit; i += 4) {
    // 4要素を同時に処理
    const a0 = vecA[i], a1 = vecA[i+1], a2 = vecA[i+2], a3 = vecA[i+3];
    const b0 = vecB[i], b1 = vecB[i+1], b2 = vecB[i+2], b3 = vecB[i+3];

    dotProduct += a0*b0 + a1*b1 + a2*b2 + a3*b3;
    normA += a0*a0 + a1*a1 + a2*a2 + a3*a3;
    normB += b0*b0 + b1*b1 + b2*b2 + b3*b3;
  }

  // 残りの要素を処理
  for (let i = limit; i < len; i++) {
    dotProduct += vecA[i] * vecB[i];
    normA += vecA[i] * vecA[i];
    normB += vecB[i] * vecB[i];
  }

  const magnitude = Math.sqrt(normA) * Math.sqrt(normB);
  return magnitude === 0 ? 0 : dotProduct / magnitude;
}

パフォーマンス比較

  • 通常版: 1000ベクトル処理に約150ms
  • 最適化版: 1000ベクトル処理に約90ms(約40%高速化)

7. エンティティ拡張検索

7.1 概念

単一クエリだけでなく、関連エンティティも含めた多角的検索を実装します。

元クエリ: "Next.js SSR"
↓
エンティティ抽出: ["Next.js SSR", "React", "Server-Side Rendering", "getServerSideProps"]
↓
各エンティティでベクトル検索を実行
↓
結果を統合・重複排除・ランキング

7.2 実装

export async function POST(req: NextRequest) {
  const { query, entities, similarityThreshold = 0.2, maxResults = 5 } = await req.json();

  // 拡張クエリリスト(元クエリ + エンティティ)
  const allQueries = [query, ...(entities || [])];
  const allResults: any[] = [];

  console.log(`🔍 拡張検索: ${allQueries.length}クエリで検索`);

  for (let i = 0; i < allQueries.length; i++) {
    const currentQuery = allQueries[i];

    try {
      // 1. クエリをベクトル化
      const embeddingResponse = await openai.embeddings.create({
        model: "text-embedding-ada-002",
        input: currentQuery,
      });

      const queryVector = embeddingResponse.data[0].embedding;

      // 2. ベクトル検索実行
      const { data: vectorData } = await supabase
        .from('content_vectors')
        .select('*')
        .not('embedding', 'is', null)
        .limit(100);

      // 3. コサイン類似度計算
      for (const item of vectorData) {
        const itemVector = JSON.parse(item.embedding);
        const similarity = cosineSimilarity(queryVector, itemVector);
        
        if (similarity >= similarityThreshold) {
          allResults.push({
            ...item,
            similarity: Math.round(similarity * 100) / 100,
            sourceQuery: currentQuery,
            isMainQuery: i === 0, // 元クエリかエンティティか
          });
        }
      }

    } catch (error) {
      console.error(`クエリ "${currentQuery}" でエラー:`, error);
      continue;
    }
  }

  // 4. 重複排除
  const deduplicatedResults = deduplicateResults(allResults);

  // 5. ランキング(元クエリ優先 + 類似度順)
  const finalResults = deduplicatedResults
    .sort((a, b) => {
      if (a.isMainQuery && !b.isMainQuery) return -1;
      if (!a.isMainQuery && b.isMainQuery) return 1;
      return b.similarity - a.similarity;
    })
    .slice(0, maxResults);

  return NextResponse.json({
    results: finalResults,
    totalFound: deduplicatedResults.length,
    queriesUsed: allQueries.length,
  });
}

// 重複排除関数
function deduplicateResults(results: any[]): any[] {
  const seen = new Map<string, any>();

  for (const result of results) {
    const existing = seen.get(result.content_id);
    
    // 重複の場合は高い類似度を保持
    if (!existing || result.similarity > existing.similarity) {
      seen.set(result.content_id, result);
    }
  }

  return Array.from(seen.values());
}

7.3 使用例

const response = await fetch('/api/vector-search', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    query: 'Next.js SSR',
    entities: ['React', 'Server-Side Rendering', 'getServerSideProps'],
    similarityThreshold: 0.3,
    maxResults: 10,
  }),
});

const { results, queriesUsed } = await response.json();
console.log(`${queriesUsed}個のクエリで${results.length}件の結果`);

8. パフォーマンス最適化

8.1 インデックスチューニング

IVFFlat インデックスの最適化

-- listsパラメータの最適化
-- lists = sqrt(行数) が推奨

-- 1万行の場合
CREATE INDEX content_vectors_embedding_idx 
ON content_vectors USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100);  -- sqrt(10000) = 100

-- 10万行の場合
WITH (lists = 316);  -- sqrt(100000) ≈ 316

-- 100万行の場合
WITH (lists = 1000); -- sqrt(1000000) = 1000

インデックスの再構築

-- データが増えたらインデックス再構築
DROP INDEX content_vectors_embedding_idx;

CREATE INDEX content_vectors_embedding_idx 
ON content_vectors USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 適切な値);

-- VACUUMで最適化
VACUUM ANALYZE content_vectors;

8.2 クエリ最適化

ページング実装

export async function POST(req: NextRequest) {
  const { query, page = 1, pageSize = 10 } = await req.json();

  // ... ベクトル化処理 ...

  // ページングを考慮した取得
  const offset = (page - 1) * pageSize;
  
  const { data: vectorData } = await supabase
    .from('content_vectors')
    .select('*')
    .not('embedding', 'is', null)
    .range(offset, offset + pageSize - 1);

  // ... 類似度計算 ...
}

キャッシング戦略

import { LRUCache } from 'lru-cache';

// ベクトルキャッシュ(メモリ効率重視)
const vectorCache = new LRUCache<string, number[]>({
  max: 1000, // 最大1000エントリ
  ttl: 1000 * 60 * 60, // 1時間
  maxSize: 1000 * 1536 * 8, // 約12MB
  sizeCalculation: (vector) => vector.length * 8, // 数値型のバイト数
});

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

  // キャッシュチェック
  let queryVector = vectorCache.get(query);

  if (!queryVector) {
    // キャッシュになければAPI呼び出し
    const embeddingResponse = await openai.embeddings.create({
      model: "text-embedding-ada-002",
      input: query,
    });
    
    queryVector = embeddingResponse.data[0].embedding;
    vectorCache.set(query, queryVector);
    console.log('🔵 キャッシュミス - API呼び出し');
  } else {
    console.log('🟢 キャッシュヒット');
  }

  // ... 続きの処理 ...
}

8.3 バッチ処理の実装

大量データを一括ベクトル化する場合:

export async function POST(req: NextRequest) {
  const { contents } = await req.json(); // 複数コンテンツの配列

  const BATCH_SIZE = 100; // OpenAI APIの制限に注意
  const results = [];

  for (let i = 0; i < contents.length; i += BATCH_SIZE) {
    const batch = contents.slice(i, i + BATCH_SIZE);
    
    // バッチでベクトル化
    const embeddingResponse = await openai.embeddings.create({
      model: "text-embedding-ada-002",
      input: batch.map(c => c.text),
    });

    // バッチでDB保存
    const insertData = batch.map((content, idx) => ({
      content_id: content.id,
      content_type: content.type,
      text_content: content.text,
      embedding: embeddingResponse.data[idx].embedding,
    }));

    const { error } = await supabase
      .from('content_vectors')
      .upsert(insertData);

    if (error) {
      console.error(`バッチ ${i / BATCH_SIZE + 1} でエラー:`, error);
    }

    results.push(...insertData.map(d => ({ id: d.content_id, success: !error })));
  }

  return NextResponse.json({ results });
}

9. 実運用のベストプラクティス

9.1 エラーハンドリング

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

    // OpenAI APIエラー対策
    let embeddingResponse;
    let retries = 3;

    while (retries > 0) {
      try {
        embeddingResponse = await openai.embeddings.create({
          model: "text-embedding-ada-002",
          input: query,
        });
        break;
      } catch (error: any) {
        retries--;
        
        if (error.status === 429) {
          // Rate limit - 指数バックオフ
          await new Promise(resolve => setTimeout(resolve, (4 - retries) * 1000));
        } else if (error.status === 500) {
          // OpenAI側のエラー
          if (retries === 0) throw error;
          await new Promise(resolve => setTimeout(resolve, 1000));
        } else {
          throw error; // その他のエラーは即座に失敗
        }
      }
    }

    // ... 続きの処理 ...

  } catch (error: any) {
    console.error('ベクトル検索エラー:', error);
    
    // エラー詳細をログに記録
    if (error.code === 'PGRST116') {
      return NextResponse.json(
        { error: 'データベース接続エラー' },
        { status: 503 }
      );
    }
    
    return NextResponse.json(
      { error: 'ベクトル検索に失敗しました', details: error.message },
      { status: 500 }
    );
  }
}

9.2 モニタリングとロギング

// カスタムロガー
class VectorSearchLogger {
  static logSearch(query: string, results: number, duration: number) {
    console.log(JSON.stringify({
      type: 'vector_search',
      timestamp: new Date().toISOString(),
      query: query.substring(0, 50), // プライバシー考慮
      resultsCount: results,
      durationMs: duration,
    }));
  }

  static logError(error: any, context: string) {
    console.error(JSON.stringify({
      type: 'vector_search_error',
      timestamp: new Date().toISOString(),
      context: context,
      error: error.message,
      stack: error.stack,
    }));
  }
}

// 使用例
export async function POST(req: NextRequest) {
  const startTime = Date.now();
  
  try {
    const { query } = await req.json();
    
    // ... ベクトル検索処理 ...
    
    const duration = Date.now() - startTime;
    VectorSearchLogger.logSearch(query, results.length, duration);
    
    return NextResponse.json({ results });
    
  } catch (error) {
    VectorSearchLogger.logError(error, 'POST /api/vector-search');
    throw error;
  }
}

9.3 セキュリティ対策

// レート制限(Redis使用)
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';

const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(10, '60 s'), // 60秒で10リクエスト
});

export async function POST(req: NextRequest) {
  // IPアドレス取得
  const ip = req.headers.get('x-forwarded-for') || 'unknown';
  
  // レート制限チェック
  const { success, remaining } = await ratelimit.limit(ip);
  
  if (!success) {
    return NextResponse.json(
      { error: 'レート制限を超過しました', retryAfter: 60 },
      { status: 429 }
    );
  }

  // 入力サニタイゼーション
  let { query } = await req.json();
  
  // XSS対策
  query = query.replace(/<script[^>]*>.*?<\/script>/gi, '');
  
  // SQLインジェクション対策(Supabaseは自動でエスケープするが念のため)
  if (query.includes('--') || query.includes(';')) {
    return NextResponse.json(
      { error: '不正なクエリです' },
      { status: 400 }
    );
  }

  // ... ベクトル検索処理 ...
}

10. トラブルシューティング

10.1 よくある問題と解決策

❌ 問題1: ベクトル検索が遅い

原因:

  • インデックスが適切でない
  • データ量に対してlistsパラメータが不適切

解決策:

-- インデックスの確認
SELECT indexname, indexdef 
FROM pg_indexes 
WHERE tablename = 'content_vectors';

-- インデックスの再構築
DROP INDEX content_vectors_embedding_idx;
CREATE INDEX content_vectors_embedding_idx 
ON content_vectors USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 適切な値);

-- テーブル統計の更新
ANALYZE content_vectors;

❌ 問題2: OpenAI API Rate Limit

エラーメッセージ:

Error: 429 - Rate limit exceeded

解決策:

// 指数バックオフの実装
async function embeddingWithRetry(text: string, maxRetries = 3) {
  for (let i = 0; i < maxRetries; i++) {
    try {
      return await openai.embeddings.create({
        model: "text-embedding-ada-002",
        input: text,
      });
    } catch (error: any) {
      if (error.status === 429 && i < maxRetries - 1) {
        const delay = Math.pow(2, i) * 1000; // 1s, 2s, 4s
        console.log(`Rate limit - ${delay}ms待機`);
        await new Promise(resolve => setTimeout(resolve, delay));
      } else {
        throw error;
      }
    }
  }
}

❌ 問題3: トークン数超過エラー

エラーメッセージ:

Error: This model's maximum context length is 8191 tokens

解決策:

// チャンクサイズを調整
const MAX_CHUNK_SIZE = 5000; // より小さく設定
const MAX_TOKENS = 7000; // より安全な値

// トークン数を厳密にチェック
if (estimatedTokens > MAX_TOKENS) {
  console.warn(`トークン数超過: ${estimatedTokens}`);
  // チャンクをさらに分割
  const subChunks = splitIntoSmallerChunks(chunk, MAX_TOKENS);
  // ...
}

❌ 問題4: 検索結果の精度が低い

原因:

  • similarityThresholdが不適切
  • クエリが曖昧すぎる
  • ベクトル化するコンテンツの質が低い

解決策:

// 1. 閾値の調整
const similarityThreshold = 0.3; // 0.2 → 0.3に上げる

// 2. クエリ拡張
const expandedQuery = `${query} ${relatedKeywords.join(' ')}`;

// 3. メタデータを含めてベクトル化
const enrichedContent = `
タイトル: ${title}
カテゴリ: ${category}
キーワード: ${keywords.join(', ')}
本文: ${content}
`;

10.2 デバッグ用ユーティリティ

// ベクトル検索のデバッグ情報を出力
export async function GET(req: NextRequest) {
  const supabase = createClient();

  // テーブルの統計情報
  const { data: stats } = await supabase
    .from('content_vectors')
    .select('content_type, count(*)', { count: 'exact' });

  // インデックス情報
  const { data: indexes } = await supabase
    .rpc('get_indexes', { table_name: 'content_vectors' });

  // 最近追加されたベクトル
  const { data: recent } = await supabase
    .from('content_vectors')
    .select('content_id, created_at')
    .order('created_at', { ascending: false })
    .limit(10);

  return NextResponse.json({
    stats,
    indexes,
    recentVectors: recent,
    timestamp: new Date().toISOString(),
  });
}

📊 まとめ

本記事では、Next.js + Supabase + pgvector + OpenAI Embeddingsを使った本格的なベクトル検索システムを完全実装しました。

✅ 実装したこと

  1. ✅ pgvectorの環境構築とテーブル設計
  2. ✅ OpenAI Embeddingsによる高精度ベクトル化
  3. ✅ コサイン類似度計算の最適化実装
  4. ✅ チャンク分割とトークン制限対応
  5. ✅ エンティティ拡張検索システム
  6. ✅ パフォーマンス最適化とキャッシング
  7. ✅ エラーハンドリングとセキュリティ対策

🚀 次のステップ

  • ハイブリッド検索: キーワード検索とベクトル検索の統合
  • 再ランキング: Cohere Rerankなどの活用
  • 多言語対応: multilingual-e5モデルの導入
  • ファインチューニング: 独自ドメインでのモデル調整

📚 参考リンク


執筆者: 鈴木信弘(SNAMO)
専門分野: レリバンスエンジニアリング、ベクトル検索、AI統合システム
ORCID: 0009-0008-3829-3917
ポートフォリオ: https://snamo.jp


質問や改善案があれば、コメント欄でお気軽にどうぞ!💬

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?