2014年から iOS / Android アプリを個人開発してきて(壁紙・癒し系を中心に累計5,000万ダウンロード)、Gemini API と Supabase の組み合わせは「個人開発者が一人で本番運用まで持っていける」数少ない現実的なスタックだと感じています。認証・pgvector・Edge Functions・RLS・コスト管理までを一通り組み上げたうえで、実際に運用してみて初めて分かった調整ポイントまで、順を追って踏み込んでいきます。
取り組みの背景
Gemini API と Supabase の組み合わせは、現代的なAIアプリケーション開発の実用的パートナーです。Supabase がデータベース、認証、リアルタイムサブスクリプション、Edge Functions の統合プラットフォームを提供し、Gemini API がテキスト生成、マルチモーダル処理、埋め込み生成を担当することで、スケーラブルで機能豊富なAIアプリケーションを迅速に構築できます。
-- profiles テーブルCREATE TABLE profiles ( id UUID REFERENCES auth.users(id) ON DELETE CASCADE PRIMARY KEY, display_name TEXT, avatar_url TEXT, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW());-- RLS: ユーザーは自分のプロフィールのみ読み書き可能ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;CREATE POLICY "Users can view own profile" ON profiles FOR SELECT USING (auth.uid() = id);CREATE POLICY "Users can update own profile" ON profiles FOR UPDATE USING (auth.uid() = id);
-- auth.users に新規ユーザーが挿入されたとき、profiles に行を自動作成CREATE FUNCTION public.handle_new_user()RETURNS TRIGGER AS $$BEGIN INSERT INTO public.profiles (id, display_name) VALUES (new.id, new.email); RETURN new;END;$$ LANGUAGE plpgsql SECURITY DEFINER;CREATE TRIGGER on_auth_user_created AFTER INSERT ON auth.users FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
-- pgvector 拡張の有効化CREATE EXTENSION IF NOT EXISTS vector;-- ベクトル型が利用可能になったことを確認SELECT * FROM pg_extension WHERE extname = 'vector';
ドキュメント・埋め込みテーブル設計
RAG システムで利用するドキュメントと埋め込みを格納するテーブルを設計します。
-- documents テーブル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, source_url TEXT, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW());-- document_chunks テーブル(テキスト分割後のチャンク)CREATE TABLE document_chunks ( id UUID DEFAULT gen_random_uuid() PRIMARY KEY, document_id UUID REFERENCES documents(id) ON DELETE CASCADE NOT NULL, chunk_index INT NOT NULL, content TEXT NOT NULL, -- Gemini の embedding-001 モデルは 768 次元 embedding vector(768), created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW());-- セマンティック検索用インデックスCREATE INDEX ON document_chunks USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);-- RLS: ユーザーは自分のドキュメントのみアクセス可能ALTER TABLE documents ENABLE ROW LEVEL SECURITY;ALTER TABLE document_chunks ENABLE ROW LEVEL SECURITY;CREATE POLICY "Users can view own documents" ON documents FOR SELECT USING (auth.uid() = user_id);CREATE POLICY "Users can view own document chunks" ON document_chunks FOR SELECT USING ( document_id IN ( SELECT id FROM documents WHERE user_id = auth.uid() ) );
セマンティック検索クエリ実装
cosine 距離を使用して、クエリベクトルと最も似たドキュメントチャンクを取得します。
-- セマンティック検索(クエリベクトルで最上位 5 件を取得)SELECT dc.id, dc.content, 1 - (dc.embedding <=> query_embedding) AS similarityFROM document_chunks dcWHERE dc.document_id IN ( SELECT id FROM documents WHERE user_id = auth.uid())ORDER BY dc.embedding <=> query_embeddingLIMIT 5;
TypeScript でこれを呼び出す場合:
// lib/semantic-search.tsexport async function semanticSearch( supabase: SupabaseClient, queryEmbedding: number[], userId: string, limit: number = 5) { const { data, error } = await supabase .from('document_chunks') .select('id, content, similarity: similarity') .filter( 'document_id', 'in', `(SELECT id FROM documents WHERE user_id = '${userId}')` ) .order('embedding', { ascending: false }) .limit(limit) if (error) throw new Error(error.message) return data}
Gemini API を活用した Embedding 生成パイプライン
ドキュメントアップロード → Embedding 生成フロー
ユーザーがドキュメントをアップロードした際、自動的に以下の処理を実行します。
テキストを適切なチャンク(例:500 トークン)に分割
各チャンク を Gemini Embedding API で埋め込み生成
pgvector テーブルに保存
Edge Function で実装する例:
// supabase/functions/embed-document/index.tsimport { serve } from 'https://deno.land/std@0.168.0/http/server.ts'import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'const supabaseUrl = Deno.env.get('SUPABASE_URL')!const supabaseKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!const geminiApiKey = Deno.env.get('GEMINI_API_KEY')!const supabase = createClient(supabaseUrl, supabaseKey)// テキストを単語単位で分割(簡易版)function chunkText(text: string, maxTokens: number = 500): string[] { const words = text.split(/\s+/) const chunks: string[] = [] let currentChunk = '' for (const word of words) { if ((currentChunk + ' ' + word).split(' ').length > maxTokens) { chunks.push(currentChunk) currentChunk = word } else { currentChunk += (currentChunk ? ' ' : '') + word } } if (currentChunk) chunks.push(currentChunk) return chunks}// Gemini Embedding API を呼び出しasync function generateEmbedding(text: string): Promise<number[]> { const response = await fetch( 'https://generativelanguage.googleapis.com/v1beta/models/embedding-001:embedContent', { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-goog-api-key': geminiApiKey, }, body: JSON.stringify({ model: 'models/embedding-001', content: { parts: [{ text }], }, }), } ) if (!response.ok) { throw new Error(`Embedding API error: ${response.statusText}`) } const data = await response.json() return data.embedding.values}serve(async (req) => { const { documentId, content, userId } = await req.json() // テキスト分割 const chunks = chunkText(content, 500) // 各チャンクの埋め込みを生成して保存 for (let i = 0; i < chunks.length; i++) { const embedding = await generateEmbedding(chunks[i]) const { error } = await supabase.from('document_chunks').insert({ document_id: documentId, chunk_index: i, content: chunks[i], embedding, }) if (error) { console.error('Insert error:', error) return new Response(`Error: ${error.message}`, { status: 500 }) } } return new Response(JSON.stringify({ success: true, chunkCount: chunks.length }), { headers: { 'Content-Type': 'application/json' }, })})
バッチ処理とレート制限
Gemini API はレート制限があるため、バッチ処理やキューイング戦略を導入しましょう。BullMQ(Redis ベース)や Inngest などの外部ジョブキューを利用するか、Supabase 内で Cron Job を使用できます。
Edge Function から直接 SQL を実行する代わりに、RPC(Remote Procedure Call)を使用し、セマンティック検索を PostgreSQL 関数として実装します。
-- RPC 関数: セマンティック検索CREATE OR REPLACE FUNCTION search_documents( query_embedding vector, user_id uuid, match_limit int DEFAULT 5)RETURNS TABLE ( id uuid, content text, similarity float8) AS $$BEGIN RETURN QUERY SELECT dc.id, dc.content, 1 - (dc.embedding <=> query_embedding)::float8 AS similarity FROM document_chunks dc WHERE dc.document_id IN ( SELECT id FROM documents WHERE documents.user_id = search_documents.user_id ) ORDER BY dc.embedding <=> query_embedding LIMIT match_limit;END;$$ LANGUAGE plpgsql;
-- conversations テーブルCREATE TABLE conversations ( id UUID DEFAULT gen_random_uuid() PRIMARY KEY, user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE NOT NULL, title TEXT, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW());ALTER TABLE conversations ENABLE ROW LEVEL SECURITY;CREATE POLICY "Users can view own conversations" ON conversations FOR SELECT USING (auth.uid() = user_id);CREATE POLICY "Users can create conversations" ON conversations FOR INSERT WITH CHECK (auth.uid() = user_id);CREATE POLICY "Users can update own conversations" ON conversations FOR UPDATE USING (auth.uid() = user_id);CREATE POLICY "Users can delete own conversations" ON conversations FOR DELETE USING (auth.uid() = user_id);-- messages テーブルCREATE TABLE messages ( id UUID DEFAULT gen_random_uuid() PRIMARY KEY, conversation_id UUID REFERENCES conversations(id) ON DELETE CASCADE NOT NULL, user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE NOT NULL, role TEXT NOT NULL CHECK (role IN ('user', 'assistant')), content TEXT NOT NULL, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW());ALTER TABLE messages ENABLE ROW LEVEL SECURITY;CREATE POLICY "Users can view messages in own conversations" ON messages FOR SELECT USING ( conversation_id IN ( SELECT id FROM conversations WHERE user_id = auth.uid() ) );CREATE POLICY "Users can insert messages in own conversations" ON messages FOR INSERT WITH CHECK ( user_id = auth.uid() AND conversation_id IN ( SELECT id FROM conversations WHERE user_id = auth.uid() ) );
AI エンドポイントでの権限検証
Edge Function では、リクエストベアラートークンから JWT を検証し、ユーザー ID を取得します。
CREATE TABLE embedding_cache ( text_hash TEXT PRIMARY KEY, text TEXT NOT NULL, embedding vector(768) NOT NULL, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), access_count INT DEFAULT 1, last_accessed TIMESTAMP WITH TIME ZONE DEFAULT NOW());-- 定期的に古いキャッシュを削除CREATE OR REPLACE FUNCTION cleanup_old_cache()RETURNS void AS $$BEGIN DELETE FROM embedding_cache WHERE last_accessed < NOW() - INTERVAL '30 days' AND access_count < 5;END;$$ LANGUAGE plpgsql;
インデックス最適化
pgvector インデックスの構成を最適化します。
-- HNSW インデックス(IVFFLAT よりも高速・精度が高い)CREATE INDEX ON document_chunks USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 200);-- テーブル統計を更新ANALYZE document_chunks;
async function callGeminiWithRetry( url: string, options: RequestInit, maxRetries: number = 5): Promise<Response> { for (let attempt = 0; attempt < maxRetries; attempt++) { const response = await fetch(url, options) if (response.status === 429) { // Too Many Requests const delay = Math.pow(2, attempt) * 1000 console.log(`Rate limited. Retrying in ${delay}ms...`) await new Promise((resolve) => setTimeout(resolve, delay)) continue } return response } throw new Error('Max retries exceeded')}
使用量ダッシュボード実装
ユーザーの API 使用量を追跡し、警告を送信します。
-- api_usage テーブルCREATE TABLE api_usage ( id UUID DEFAULT gen_random_uuid() PRIMARY KEY, user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE, api_type TEXT NOT NULL CHECK (api_type IN ('embedding', 'generation', 'chat')), tokens_used INT NOT NULL, cost DECIMAL(10, 6) NOT NULL, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW());-- 月次の集計ビューCREATE VIEW monthly_api_usage ASSELECT user_id, DATE_TRUNC('month', created_at) AS month, api_type, SUM(tokens_used) AS total_tokens, SUM(cost) AS total_costFROM api_usageGROUP BY user_id, DATE_TRUNC('month', created_at), api_type;
フロントエンドから使用量を表示:
// lib/usage.tsexport async function getMonthlyUsage(userId: string) { const { data } = await supabase .from('monthly_api_usage') .select('*') .eq('user_id', userId) .eq('month', new Date().toISOString().slice(0, 7)) return data || []}
監視とアラート
Supabase では realtime-logs の監視、また Google Cloud Monitoring(Gemini API 使用時)でアラート設定ができます。
埋め込みは一度作れば使い回せるので、コストの大半は RAG 応答側です。頻出の質問はキャッシュし、長文要約には Flash、複雑な推論が要るときだけ Pro に振り分けると、体感品質を保ったままコストを 3〜4 割抑えられました。
取り組みの背景 — Nuxt 3 × Gemini API を選ぶ理由
Vue.js エコシステムを代表するフルスタックフレームワーク Nuxt 3 は、2026 年現在も多くの開発者に選ばれ続けています。その理由は、ファイルシステムベースのルーティング・自動コード分割・強力な SSR 機能といった生産性向上の仕組みが、フロントエンドとバックエンドを一体的に管理できる点にあります。
AI 機能を組み込む観点で見ると、Nuxt 3 には特有の強みがあります。server/api/ 配下に配置するだけで即座に API ルートが生成される「サーバールート」機能は、クライアントサイドに API キーを露出させずに Gemini API を呼び出すための理想的な実装基盤です。また、Composable(useXxx)によるステート管理の抽象化が、マルチターンチャットの実装コストを大幅に下げてくれます。
プロジェクトのセットアップと環境構築
Nuxt 3 プロジェクトの作成
# Nuxt 3 プロジェクトを作成npx nuxi@latest init gemini-nuxt-appcd gemini-nuxt-app# Gemini API SDK をインストールnpm install @google/genai# 開発サーバーを起動npm run dev
環境変数の設定
Gemini API キーをクライアントサイドに漏洩させないために、.env ファイルで管理し、サーバー側のみからアクセスします。