なぜ Claude API × Supabase なのか
個人開発者やスタートアップがAIアプリを本番環境に届けるとき、最大の課題は「スケーラブルかつセキュアなバックエンド」を素早く構築することです。Claude API は世界最高水準の言語能力を提供しますが、それだけではアプリは完成しません。ユーザーデータの保存、ベクトル検索、リアルタイム更新、認証——これらすべてを一手に引き受けてくれるのが Supabase です。
Supabase は PostgreSQL をベースにしたオープンソースの Firebase 代替であり、以下の機能を単一プラットフォームで提供します。
pgvector : PostgreSQL 内でネイティブにベクトル演算を実行する拡張機能
Realtime : データベース変更をリアルタイムでクライアントに配信
Row Level Security(RLS) : 行単位のアクセス制御によるマルチテナント対応
Edge Functions : Deno ベースのサーバーレス関数
Storage : ファイルストレージ(PDF・画像などの RAG ソース素材に対応)
この組み合わせにより、Claude API の強力な推論能力 × Supabase の堅牢なインフラ という理想的なアーキテクチャが実現します。この統合を本番品質まで持っていくための設計パターンとコードを、実際に動く形で示します。
対象読者は、Claude API の基本的な使い方を理解しており、より高度な本番実装パターンを探している中〜上級開発者です。
1. アーキテクチャ全体像の設計
本記事で構築するシステムは、ナレッジベース型AIアシスタント を想定しています。ユーザーがドキュメントをアップロードし、自然言語で質問すると、Claude が該当箇所を特定して回答する——いわゆる RAG(Retrieval-Augmented Generation)アーキテクチャです。
[クライアント]
↓ ドキュメントアップロード
[Supabase Storage] → [Edge Function: embed] → [pgvector table]
↓ 質問送信
[Edge Function: chat]
↓ ベクトル検索
[pgvector] → 関連チャンク取得
↓ コンテキスト構築
[Claude API] → ストリーミング応答
↓ Realtime
[クライアント] ← リアルタイム表示
このアーキテクチャの特徴は以下の通りです。
Edge Functions : API キーをクライアントに露出させない安全な設計
RLS : ユーザーは自分のドキュメントのみにアクセス可能
pgvector : 専用ベクトルデータベース不要でコストを大幅削減
Realtime + Streaming : 回答生成をリアルタイムでストリーミング表示
2. Supabase セットアップと pgvector 初期化
まず Supabase プロジェクトを作成し、必要な拡張機能とテーブルを設定します。
2.1 pgvector 拡張の有効化
Supabase Dashboard の SQL Editor で以下を実行します。
-- pgvector 拡張を有効化
CREATE EXTENSION IF NOT EXISTS vector ;
-- ドキュメントテーブル
CREATE TABLE documents (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY ,
user_id UUID REFERENCES auth . users (id) ON DELETE CASCADE NOT NULL ,
title TEXT NOT NULL ,
content TEXT NOT NULL , -- 元テキスト(参照用)
embedding VECTOR ( 1536 ), -- Claude Voyage AI の埋め込み次元数
metadata JSONB DEFAULT '{}' ,
created_at TIMESTAMPTZ DEFAULT NOW ()
);
-- ドキュメントチャンクテーブル(長文分割用)
CREATE TABLE document_chunks (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY ,
document_id UUID REFERENCES documents(id) ON DELETE CASCADE NOT NULL ,
user_id UUID REFERENCES auth . users (id) ON DELETE CASCADE NOT NULL ,
chunk_index INTEGER NOT NULL ,
content TEXT NOT NULL ,
embedding VECTOR ( 1536 ),
token_count INTEGER ,
created_at TIMESTAMPTZ DEFAULT NOW ()
);
-- ベクトル検索用インデックス(IVFFlat: 大規模データに推奨)
CREATE INDEX ON document_chunks
USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100 );
-- チャット履歴テーブル
CREATE TABLE chat_sessions (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY ,
user_id UUID REFERENCES auth . users (id) ON DELETE CASCADE NOT NULL ,
title TEXT ,
created_at TIMESTAMPTZ DEFAULT NOW (),
updated_at TIMESTAMPTZ DEFAULT NOW ()
);
CREATE TABLE chat_messages (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY ,
session_id UUID REFERENCES chat_sessions(id) ON DELETE CASCADE NOT NULL ,
user_id UUID REFERENCES auth . users (id) ON DELETE CASCADE NOT NULL ,
role TEXT CHECK ( role IN ( 'user' , 'assistant' )) NOT NULL ,
content TEXT NOT NULL ,
sources JSONB DEFAULT '[]' , -- 参照したドキュメントチャンク
created_at TIMESTAMPTZ DEFAULT NOW ()
);
2.2 Row Level Security の設定
RLS はマルチテナント設計の根幹です。各ユーザーは自分のデータのみ読み書き可能 になります。
-- documents テーブルの RLS
ALTER TABLE documents ENABLE ROW LEVEL SECURITY ;
CREATE POLICY "users_own_documents" ON documents
FOR ALL USING ( auth . uid () = user_id);
-- document_chunks テーブルの RLS
ALTER TABLE document_chunks ENABLE ROW LEVEL SECURITY ;
CREATE POLICY "users_own_chunks" ON document_chunks
FOR ALL USING ( auth . uid () = user_id);
-- chat_sessions テーブルの RLS
ALTER TABLE chat_sessions ENABLE ROW LEVEL SECURITY ;
CREATE POLICY "users_own_sessions" ON chat_sessions
FOR ALL USING ( auth . uid () = user_id);
-- chat_messages テーブルの RLS
ALTER TABLE chat_messages ENABLE ROW LEVEL SECURITY ;
CREATE POLICY "users_own_messages" ON chat_messages
FOR ALL USING ( auth . uid () = user_id);
2.3 ベクトル検索関数の定義
-- ユーザー固有のベクトル類似検索関数
CREATE OR REPLACE FUNCTION match_chunks (
query_embedding VECTOR ( 1536 ),
target_user_id UUID,
match_threshold FLOAT DEFAULT 0 . 7 ,
match_count INT DEFAULT 5
)
RETURNS TABLE (
id UUID,
document_id UUID,
content TEXT ,
similarity FLOAT
)
LANGUAGE plpgsql
AS $$
BEGIN
RETURN QUERY
SELECT
dc . id ,
dc . document_id ,
dc . content ,
1 - ( dc . embedding <=> query_embedding) AS similarity
FROM document_chunks dc
WHERE
dc . user_id = target_user_id
AND 1 - ( dc . embedding <=> query_embedding) > match_threshold
ORDER BY dc . embedding <=> query_embedding
LIMIT match_count;
END ;
$$;
3. ドキュメント埋め込みパイプラインの構築
3.1 テキストチャンキング戦略
長いドキュメントを適切なサイズのチャンクに分割することは、RAG の精度に直結します。単純な文字数分割ではなく、意味的な境界を考慮した分割が重要です。
// supabase/functions/embed-document/index.ts
import { serve } from "https://deno.land/std@0.208.0/http/server.ts" ;
import { createClient } from "https://esm.sh/@supabase/supabase-js@2" ;
import Anthropic from "https://esm.sh/@anthropic-ai/sdk@0.39.0" ;
const anthropic = new Anthropic ({
apiKey: Deno.env. get ( "ANTHROPIC_API_KEY" ) ! ,
});
// テキストをセマンティックなチャンクに分割する
function chunkText ( text : string , maxTokens = 512 , overlap = 64 ) : string [] {
// 段落・見出し・箇条書きを境界として優先
const paragraphs = text
. split ( / \n {2,} / )
. map ( p => p. trim ())
. filter ( p => p. length > 0 );
const chunks : string [] = [];
let currentChunk = "" ;
let currentTokenEstimate = 0 ;
for ( const paragraph of paragraphs) {
// 1トークン ≈ 4文字(英語)/ 2文字(日本語)の概算
const paragraphTokens = Math. ceil (paragraph. length / 3 );
if (currentTokenEstimate + paragraphTokens > maxTokens && currentChunk) {
chunks. push (currentChunk. trim ());
// オーバーラップ: 前のチャンクの末尾を次のチャンクに含める
const overlapText = currentChunk
. split ( / \s + / )
. slice ( - overlap)
. join ( " " );
currentChunk = overlapText + " \n\n " + paragraph;
currentTokenEstimate = Math. ceil (currentChunk. length / 3 );
} else {
currentChunk += (currentChunk ? " \n\n " : "" ) + paragraph;
currentTokenEstimate += paragraphTokens;
}
}
if (currentChunk. trim ()) {
chunks. push (currentChunk. trim ());
}
return chunks;
}
serve ( async ( req ) => {
const { documentId , content , title } = await req. json ();
// 認証チェック
const authHeader = req.headers. get ( "Authorization" ) ! ;
const supabase = createClient (
Deno.env. get ( "SUPABASE_URL" ) ! ,
Deno.env. get ( "SUPABASE_ANON_KEY" ) ! ,
{ global: { headers: { Authorization: authHeader } } }
);
const { data : { user } } = await supabase.auth. getUser ();
if ( ! user) return new Response ( "Unauthorized" , { status: 401 });
// テキストをチャンクに分割
const chunks = chunkText (content);
console. log ( `Document "${ title }": ${ chunks . length } chunks` );
// Claude の埋め込み API を使用(Voyage AI 経由)
// 注意: Anthropic は現在 Voyage AI の埋め込みモデルを推奨
// ここでは OpenAI Compatible な埋め込みエンドポイントを想定
const embeddings = await Promise . all (
chunks. map ( async ( chunk ) => {
// Voyage AI の API を Claude Lab 用に使用
const response = await fetch ( "https://api.voyageai.com/v1/embeddings" , {
method: "POST" ,
headers: {
"Content-Type" : "application/json" ,
"Authorization" : `Bearer ${ Deno . env . get ( "VOYAGE_API_KEY" ) }` ,
},
body: JSON . stringify ({
input: chunk,
model: "voyage-3" ,
}),
});
const data = await response. json ();
return data.data[ 0 ].embedding as number [];
})
);
// Supabase にチャンクと埋め込みを保存
const chunkRecords = chunks. map (( chunk , index ) => ({
document_id: documentId,
user_id: user.id,
chunk_index: index,
content: chunk,
embedding: embeddings[index],
token_count: Math. ceil (chunk. length / 3 ),
}));
const { error } = await supabase
. from ( "document_chunks" )
. insert (chunkRecords);
if (error) {
return Response. json ({ error: error.message }, { status: 500 });
}
return Response. json ({
success: true ,
chunksCreated: chunks. length ,
});
});
4. Claude API × pgvector による RAG チャット実装
4.1 ストリーミング対応チャット Edge Function
// supabase/functions/chat/index.ts
import { serve } from "https://deno.land/std@0.208.0/http/server.ts" ;
import { createClient } from "https://esm.sh/@supabase/supabase-js@2" ;
import Anthropic from "https://esm.sh/@anthropic-ai/sdk@0.39.0" ;
const anthropic = new Anthropic ({
apiKey: Deno.env. get ( "ANTHROPIC_API_KEY" ) ! ,
});
serve ( async ( req ) => {
const { sessionId , message , documentIds } = await req. json ();
// 認証
const authHeader = req.headers. get ( "Authorization" ) ! ;
const supabase = createClient (
Deno.env. get ( "SUPABASE_URL" ) ! ,
Deno.env. get ( "SUPABASE_ANON_KEY" ) ! ,
{ global: { headers: { Authorization: authHeader } } }
);
const { data : { user } } = await supabase.auth. getUser ();
if ( ! user) return new Response ( "Unauthorized" , { status: 401 });
// Step 1: 質問を埋め込みに変換
const queryEmbedding = await getEmbedding (message);
// Step 2: pgvector で関連チャンクを検索
const { data : relevantChunks } = await supabase. rpc ( "match_chunks" , {
query_embedding: queryEmbedding,
target_user_id: user.id,
match_threshold: 0.70 ,
match_count: 6 ,
});
// Step 3: コンテキストを構築
const context = relevantChunks
?. map (( chunk , i ) =>
`[出典 ${ i + 1 }] \n ${ chunk . content }`
)
. join ( " \n\n --- \n\n " ) ?? "関連するドキュメントが見つかりませんでした。" ;
// Step 4: 会話履歴を取得
const { data : history } = await supabase
. from ( "chat_messages" )
. select ( "role, content" )
. eq ( "session_id" , sessionId)
. order ( "created_at" , { ascending: true })
. limit ( 10 ); // 直近10件のみ
const messages : Anthropic . MessageParam [] = [
... (history ?? []). map ( m => ({
role: m.role as "user" | "assistant" ,
content: m.content,
})),
{ role: "user" , content: message },
];
// Step 5: Claude API でストリーミング生成
const stream = anthropic.messages. stream ({
model: "claude-sonnet-4-6" ,
max_tokens: 2048 ,
system: `あなたは提供されたドキュメントに基づいて質問に答えるAIアシスタントです。
以下のルールを厳守してください:
- 提供されたコンテキスト(ドキュメント)に基づいてのみ回答する
- コンテキストに情報がない場合は「ドキュメントに該当する情報がありません」と回答する
- 回答の根拠となる出典番号を必ず示す(例: [出典 1])
- 推測や外部知識を混入させない
## 参照ドキュメント
${ context }` ,
messages,
});
// Step 6: ストリーミングレスポンスをクライアントに転送
const encoder = new TextEncoder ();
let fullContent = "" ;
const readable = new ReadableStream ({
async start ( controller ) {
try {
for await ( const event of stream) {
if (
event.type === "content_block_delta" &&
event.delta.type === "text_delta"
) {
const text = event.delta.text;
fullContent += text;
controller. enqueue (
encoder. encode ( `data: ${ JSON . stringify ({ text }) } \n\n ` )
);
} else if (event.type === "message_stop" ) {
// ストリーミング完了後にDBへ保存
await saveMessages (supabase, sessionId, user.id, message, fullContent, relevantChunks);
controller. enqueue (encoder. encode ( "data: [DONE] \n\n " ));
controller. close ();
}
}
} catch (error) {
controller. error (error);
}
},
});
return new Response (readable, {
headers: {
"Content-Type" : "text/event-stream" ,
"Cache-Control" : "no-cache" ,
"Connection" : "keep-alive" ,
},
});
});
async function saveMessages (
supabase : ReturnType < typeof createClient>,
sessionId : string ,
userId : string ,
userMessage : string ,
assistantMessage : string ,
sources : any []
) {
await supabase. from ( "chat_messages" ). insert ([
{
session_id: sessionId,
user_id: userId,
role: "user" ,
content: userMessage,
},
{
session_id: sessionId,
user_id: userId,
role: "assistant" ,
content: assistantMessage,
sources: sources?. map ( s => ({
id: s.id,
document_id: s.document_id,
similarity: s.similarity,
excerpt: s.content. slice ( 0 , 200 ),
})) ?? [],
},
]);
// セッションの更新時刻を更新
await supabase
. from ( "chat_sessions" )
. update ({ updated_at: new Date (). toISOString () })
. eq ( "id" , sessionId);
}
async function getEmbedding ( text : string ) : Promise < number []> {
const response = await fetch ( "https://api.voyageai.com/v1/embeddings" , {
method: "POST" ,
headers: {
"Content-Type" : "application/json" ,
"Authorization" : `Bearer ${ Deno . env . get ( "VOYAGE_API_KEY" ) }` ,
},
body: JSON . stringify ({ input: text, model: "voyage-3" }),
});
const data = await response. json ();
return data.data[ 0 ].embedding;
}
5. Supabase Realtime でリアルタイムUI を構築する
Supabase Realtime を活用すると、ストリーミング応答の保存完了をプッシュ通知で受け取る ことができます。これにより、複数タブでの同期やコラボレーション機能も実現できます。
5.1 クライアント側のリアルタイム統合
// src/hooks/useRealtimeChat.ts(React × TypeScript)
import { useEffect, useRef, useState } from "react" ;
import { createClient } from "@supabase/supabase-js" ;
const supabase = createClient (
process.env. NEXT_PUBLIC_SUPABASE_URL ! ,
process.env. NEXT_PUBLIC_SUPABASE_ANON_KEY !
);
interface ChatMessage {
id : string ;
role : "user" | "assistant" ;
content : string ;
sources : Array <{
document_id : string ;
similarity : number ;
excerpt : string ;
}>;
created_at : string ;
}
export function useRealtimeChat ( sessionId : string ) {
const [ messages , setMessages ] = useState < ChatMessage []>([]);
const [ streamingContent , setStreamingContent ] = useState ( "" );
const [ isStreaming , setIsStreaming ] = useState ( false );
const abortRef = useRef < AbortController | null >( null );
// Supabase Realtime でメッセージ変更を購読
useEffect (() => {
if ( ! sessionId) return ;
// 既存メッセージを取得
const fetchMessages = async () => {
const { data } = await supabase
. from ( "chat_messages" )
. select ( "*" )
. eq ( "session_id" , sessionId)
. order ( "created_at" , { ascending: true });
setMessages (data ?? []);
};
fetchMessages ();
// リアルタイム購読(INSERT イベントを監視)
const channel = supabase
. channel ( `chat:${ sessionId }` )
. on (
"postgres_changes" ,
{
event: "INSERT" ,
schema: "public" ,
table: "chat_messages" ,
filter: `session_id=eq.${ sessionId }` ,
},
( payload ) => {
const newMessage = payload.new as ChatMessage ;
setMessages ( prev => {
// 重複を防ぐ
if (prev. find ( m => m.id === newMessage.id)) return prev;
return [ ... prev, newMessage];
});
// ストリーミング完了時にストリーミングバッファをクリア
if (newMessage.role === "assistant" ) {
setStreamingContent ( "" );
setIsStreaming ( false );
}
}
)
. subscribe ();
return () => {
supabase. removeChannel (channel);
};
}, [sessionId]);
// メッセージ送信とストリーミング受信
const sendMessage = async ( content : string ) => {
setIsStreaming ( true );
setStreamingContent ( "" );
// ユーザーメッセージを楽観的に UI に追加
const tempUserMsg : ChatMessage = {
id: `temp-${ Date . now () }` ,
role: "user" ,
content,
sources: [],
created_at: new Date (). toISOString (),
};
setMessages ( prev => [ ... prev, tempUserMsg]);
abortRef.current = new AbortController ();
try {
const { data : { session } } = await supabase.auth. getSession ();
const token = session?.access_token;
const response = await fetch (
`${ process . env . NEXT_PUBLIC_SUPABASE_URL }/functions/v1/chat` ,
{
method: "POST" ,
headers: {
"Content-Type" : "application/json" ,
"Authorization" : `Bearer ${ token }` ,
},
body: JSON . stringify ({ sessionId, message: content }),
signal: abortRef.current.signal,
}
);
const reader = response.body ! . getReader ();
const decoder = new TextDecoder ();
while ( true ) {
const { done , value } = await reader. read ();
if (done) break ;
const chunk = decoder. decode (value, { stream: true });
const lines = chunk. split ( " \n " );
for ( const line of lines) {
if (line. startsWith ( "data: " )) {
const data = line. slice ( 6 );
if (data === "[DONE]" ) {
// Realtime が最終メッセージを配信するまで待機
break ;
}
try {
const { text } = JSON . parse (data);
setStreamingContent ( prev => prev + text);
} catch {}
}
}
}
} catch ( error : any ) {
if (error.name !== "AbortError" ) {
console. error ( "Streaming error:" , error);
setIsStreaming ( false );
}
}
};
const stopStreaming = () => {
abortRef.current?. abort ();
setIsStreaming ( false );
};
return { messages, streamingContent, isStreaming, sendMessage, stopStreaming };
}
6. Row Level Security × Claude API でマルチテナント設計を完成させる
6.1 サービスロールによる内部処理の分離
Edge Function 内でユーザーのコンテキストを維持しながら、必要に応じてサービスロール(管理者権限)でのアクセスも行います。これにより、RLS を尊重しつつ管理機能を実装 できます。
// supabase/functions/admin-analytics/index.ts
// ⚠️ このファンクションは管理者のみ呼び出し可能
import { serve } from "https://deno.land/std@0.208.0/http/server.ts" ;
import { createClient } from "https://esm.sh/@supabase/supabase-js@2" ;
serve ( async ( req ) => {
// 管理者認証(カスタムヘッダーで内部呼び出しを識別)
const adminSecret = req.headers. get ( "x-admin-secret" );
if (adminSecret !== Deno.env. get ( "ADMIN_SECRET" )) {
return new Response ( "Forbidden" , { status: 403 });
}
// サービスロールクライアント(RLS をバイパス)
const adminSupabase = createClient (
Deno.env. get ( "SUPABASE_URL" ) ! ,
Deno.env. get ( "SUPABASE_SERVICE_ROLE_KEY" ) ! // ⚠️ 絶対にクライアントに渡さない
);
// 全ユーザーの集計クエリ(RLS バイパス)
const { data : stats } = await adminSupabase
. from ( "document_chunks" )
. select ( "user_id" , { count: "exact" });
return Response. json ({ totalChunks: stats?. length });
});
6.2 Claude API のシステムプロンプトへの RLS コンテキスト統合
Claude に「このユーザーはどのドキュメントにアクセスできるか」を伝えることで、より安全な回答制御が可能になります。
// Edge Function 内での安全なコンテキスト構築
async function buildSecureSystemPrompt (
supabase : ReturnType < typeof createClient>,
userId : string
) : Promise < string > {
// ユーザーのドキュメント一覧を取得(RLS が自動適用される)
const { data : documents } = await supabase
. from ( "documents" )
. select ( "id, title, created_at" )
. eq ( "user_id" , userId)
. order ( "created_at" , { ascending: false })
. limit ( 20 );
const docList = documents
?. map ( d => `- ${ d . title } (ID: ${ d . id })` )
. join ( " \n " ) ?? "なし" ;
return `あなたは以下のドキュメントのみにアクセスできる専属AIアシスタントです。
## アクセス可能なドキュメント
${ docList }
## 重要なルール
1. 上記のドキュメントに含まれる情報のみを使って回答する
2. ドキュメントに記載のない情報は「このドキュメントには記載がありません」と回答する
3. 他のユーザーのデータや外部情報を参照しない
4. 個人情報や機密情報の推測・補完を行わない` ;
}
7. Prompt Caching でコストを最適化する
大規模なドキュメントコレクションを扱う場合、Prompt Caching の活用がコスト削減の鍵になります。同じシステムプロンプトや参照ドキュメントを繰り返し送信する場合、最大 90% のコスト削減が可能です。
// プロンプトキャッシングを活用したチャット実装
async function createCachedChatRequest (
context : string ,
messages : Anthropic . MessageParam []
) : Promise < Anthropic . Message > {
return anthropic.messages. create ({
model: "claude-sonnet-4-6" ,
max_tokens: 2048 ,
system: [
{
type: "text" ,
text: "あなたはドキュメントに基づいて質問に答えるAIアシスタントです。" ,
},
{
type: "text" ,
text: context,
// キャッシュブレークポイント: このブロックをキャッシュ対象に
cache_control: { type: "ephemeral" },
},
],
messages,
});
}
// コスト試算(例: 10,000件の質問応答)
// キャッシュなし: 1,000 tokens × 10,000 = 10M input tokens
// → Claude Sonnet 4.6: $3.00/M tokens = $30.00
// キャッシュあり(90%ヒット率):
// キャッシュ書き込み 1,000 × 1,000 = 1M tokens($3.75)
// キャッシュ読み取り 1,000 × 9,000 = 9M tokens($0.27)
// → 合計 $4.02(約87%削減)
8. 本番環境での監視とパフォーマンスチューニング
8.1 pgvector インデックスのパフォーマンス最適化
-- データ量に応じてインデックスを最適化
-- 10万件以下: HNSW(高速、メモリ消費大)
CREATE INDEX ON document_chunks
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16 , ef_construction = 64 );
-- 10万件以上: IVFFlat(バランス型)
-- lists = sqrt(行数) が推奨値
CREATE INDEX ON document_chunks
USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 316 ); -- sqrt(100000) ≈ 316
-- 検索時のパラメータ調整(精度 vs 速度のトレードオフ)
SET ivfflat . probes = 10 ; -- デフォルト 1、高いほど精度向上・速度低下
8.2 Claude API レート制限への対応
// レート制限対応のリトライロジック
async function claudeWithRetry (
params : Anthropic . MessageCreateParams ,
maxRetries = 3
) : Promise < Anthropic . Message > {
let lastError : Error ;
for ( let attempt = 0 ; attempt < maxRetries; attempt ++ ) {
try {
return await anthropic.messages. create (params);
} catch ( error : any ) {
if (error.status === 429 ) {
// Retry-After ヘッダーを尊重
const retryAfter = error.headers?.[ "retry-after" ];
const waitMs = retryAfter
? parseInt (retryAfter) * 1000
: Math. pow ( 2 , attempt) * 1000 ; // 指数バックオフ
console. warn ( `Rate limited. Waiting ${ waitMs }ms...` );
await new Promise ( resolve => setTimeout (resolve, waitMs));
lastError = error;
} else if (error.status === 529 ) {
// API 過負荷
await new Promise ( resolve => setTimeout (resolve, 5000 ));
lastError = error;
} else {
throw error; // その他のエラーは即時スロー
}
}
}
throw lastError ! ;
}
9. よくある質問(FAQ)
Q1. pgvector と専用ベクトルデータベース(Pinecone 等)はどちらが良いですか?
A. プロジェクトの規模と要件によります。1,000万件未満のベクトルであれば pgvector はコストパフォーマンスに優れています。Supabase の既存インフラと統合できるため、インフラ管理コストも削減できます。ただし、1億件を超えるスケールや ANN(近似最近傍探索)の応答速度が最重要な場合は Pinecone などの専用ソリューションが適切です。
Q2. Voyage AI の埋め込みを使う理由は何ですか?
A. Anthropic は RAG 用途において Voyage AI の埋め込みモデルを公式に推奨しています。特に voyage-3 は日本語を含む多言語テキストで高い精度を発揮し、Claude との組み合わせで最も良い検索精度が得られます。コストも比較的低く、1M トークンあたり $0.06 です(2026年4月時点)。
Q3. ストリーミング中に Edge Function がタイムアウトする場合の対処法は?
A. Supabase Edge Functions のデフォルトタイムアウトは 150 秒です。長い回答生成で問題になる場合は、以下の対策が有効です。(1)回答の最大長を max_tokens で制限する、(2)長時間タスクは Supabase Queue を使って非同期化する、(3)Supabase の function-timeout 設定でタイムアウトを延長する(Pro プラン以上)。
Q4. 日本語ドキュメントの RAG 精度を上げるコツは?
A. 日本語テキストは英語より 1 トークン≒2文字と少ないため、チャンクサイズを英語の 1/2〜2/3 程度(256〜384 トークン相当)に設定することを推奨します。また、MeCab や TinySegmenter による形態素解析でテキストを前処理してから埋め込むと、検索精度が向上するケースがあります。
Q5. RLS が有効な状態でテスト・デバッグするには?
A. Supabase Dashboard の「Authentication」タブで実際のユーザートークンを取得し、curl や Postman でテストするのが最も確実です。また、SET role = authenticated; SET request.jwt.claim.sub = 'ユーザーID'; を SQL Editor で実行すると、特定ユーザーとして RLS をシミュレートしたクエリを実行できます。
まとめ
Claude API × Supabase を組み合わせたプロダクション対応 AI アプリの構築を、ここまで通しでたどってきました。
重要なポイントを整理します。
pgvector : 専用ベクトルDBなしで PostgreSQL 内に RAG を構築できる
Row Level Security : SQL レベルでのマルチテナント対応により、アプリ層でのバグを防ぐ
Supabase Realtime : ストリーミング完了後の最終メッセージを確実に同期できる
Edge Functions : API キーを安全に管理しながら Claude API を呼び出せる
Prompt Caching : 繰り返し使用するコンテキストをキャッシュして最大 90% コスト削減
このアーキテクチャは、個人開発から数万ユーザーのプロダクションまで無理なくスケールできる設計です。まずは無料枠の Supabase で試し、ユーザーが増えたら Pro プランへアップグレードするという段階的な展開が可能です。
Claude API のストリーミングをさらに深く理解したい方は Claude API ストリーミング × リアルタイムチャットUI 本番実装ガイド も参考になります。RAG システムの設計パターンについては Claude API で RAG システムを構築する完全ガイド で詳しく解説しています。本番環境でのセキュリティ対策は Claude API プロダクションセキュリティ完全ガイド も合わせてご確認ください。