🎯 はじめに
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機能を実装したい方
📖 目次
- pgvectorの基礎知識
- 環境構築とセットアップ
- データベース設計
- ベクトル化システムの実装
- ベクトル検索システムの実装
- コサイン類似度の数学的理解と実装
- エンティティ拡張検索
- パフォーマンス最適化
- 実運用のベストプラクティス
- トラブルシューティング
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プロジェクト作成
- Supabaseにアクセス
- 新規プロジェクト作成
- リージョン選択(日本なら
Northeast Asia (Tokyo)) - データベースパスワード設定
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を使った本格的なベクトル検索システムを完全実装しました。
✅ 実装したこと
- ✅ pgvectorの環境構築とテーブル設計
- ✅ OpenAI Embeddingsによる高精度ベクトル化
- ✅ コサイン類似度計算の最適化実装
- ✅ チャンク分割とトークン制限対応
- ✅ エンティティ拡張検索システム
- ✅ パフォーマンス最適化とキャッシング
- ✅ エラーハンドリングとセキュリティ対策
🚀 次のステップ
- ハイブリッド検索: キーワード検索とベクトル検索の統合
- 再ランキング: Cohere Rerankなどの活用
- 多言語対応: multilingual-e5モデルの導入
- ファインチューニング: 独自ドメインでのモデル調整
📚 参考リンク
執筆者: 鈴木信弘(SNAMO)
専門分野: レリバンスエンジニアリング、ベクトル検索、AI統合システム
ORCID: 0009-0008-3829-3917
ポートフォリオ: https://snamo.jp
質問や改善案があれば、コメント欄でお気軽にどうぞ!💬