環境構築 — Supabase × pgvector のセットアップ
まず、ベクトルデータベースの準備から始めましょう。Supabase は PostgreSQL ベースのBaaS(Backend as a Service)で、pgvector 拡張を有効にすることでベクトル検索が可能になります。
Supabase プロジェクトの作成
Supabase ダッシュボードから新しいプロジェクトを作成したら、SQL Editor で以下を実行します。
-- pgvector 拡張を有効化
create extension if not exists vector ;
-- ドキュメントチャンク格納テーブル
create table documents (
id bigserial primary key ,
content text not null ,
metadata jsonb default '{}' ,
embedding vector ( 768 ), -- Gemini text-embedding-004 の次元数
created_at timestamptz default now ()
);
-- コサイン類似度検索用のインデックス
create index on documents
using ivfflat (embedding vector_cosine_ops)
with (lists = 100 );
-- 類似度検索の RPC 関数
create or replace function match_documents (
query_embedding vector ( 768 ),
match_threshold float default 0 . 7 ,
match_count int default 5
)
returns table (
id bigint ,
content text ,
metadata jsonb,
similarity float
)
language plpgsql
as $$
begin
return query
select
documents . id ,
documents . content ,
documents . metadata ,
1 - ( documents . embedding <=> query_embedding) as similarity
from documents
where 1 - ( documents . embedding <=> query_embedding) > match_threshold
order by documents . embedding <=> query_embedding
limit match_count;
end ;
$$;
ポイントは vector(768) の次元数です。これは使用する埋め込みモデルの出力次元に合わせる必要があります。Gemini の text-embedding-004 は768次元を出力するため、この値を設定しています。
IVFFlat インデックスの選択理由
pgvector では ivfflat と hnsw の2種類のインデックスが利用できます。ここで扱うのはivfflat を採用していますが、それぞれの特性を理解しておきましょう。
IVFFlat : 構築が高速で、メモリ消費が少ないです。データ量が10万件以下の場合に適しています。lists パラメータでクラスタ数を調整する
HNSW : 検索精度が高く、リアルタイム更新に強い。データ量が多い場合やインデックス再構築を避けたい場合に有利。ただしメモリ消費が大きい
モバイルアプリのナレッジベースは一般的に数千〜数万チャンク規模なので、IVFFlat で十分なパフォーマンスが得られます。
チャンク分割戦略 — 品質を左右する最重要ポイント
RAG パイプラインの品質を最も大きく左右するのが、チャンク分割の戦略です。分割が粗すぎると検索精度が下がり、細かすぎるとコンテキストが断片的になります。
基本的なチャンク分割の実装
// utils/chunker.ts — テキストをオーバーラップ付きで分割する
interface ChunkOptions {
chunkSize : number ; // 1チャンクの最大文字数
chunkOverlap : number ; // チャンク間の重複文字数
separator : string ; // 分割の区切り文字
}
const DEFAULT_OPTIONS : ChunkOptions = {
chunkSize: 500 ,
chunkOverlap: 50 ,
separator: ' \n\n ' ,
};
export function splitIntoChunks (
text : string ,
options : ChunkOptions = DEFAULT_OPTIONS
) : string [] {
const { chunkSize , chunkOverlap , separator } = options;
const paragraphs = text. split (separator);
const chunks : string [] = [];
let currentChunk = '' ;
for ( const paragraph of paragraphs) {
// 現在のチャンクに追加しても制限内なら追加
if ((currentChunk + separator + paragraph). length <= chunkSize) {
currentChunk = currentChunk
? currentChunk + separator + paragraph
: paragraph;
} else {
// 現在のチャンクを確定して新しいチャンクを開始
if (currentChunk) {
chunks. push (currentChunk. trim ());
// オーバーラップ: 末尾の文字列を次のチャンクの先頭に引き継ぐ
const overlap = currentChunk. slice ( - chunkOverlap);
currentChunk = overlap + separator + paragraph;
} else {
// 単一の段落がchunkSizeを超える場合は強制分割
currentChunk = paragraph;
}
}
}
if (currentChunk. trim ()) {
chunks. push (currentChunk. trim ());
}
return chunks;
}
チャンクサイズの選定ガイドライン
最適なチャンクサイズは、ドキュメントの種類によって異なります。
FAQ・Q&A : 300〜500文字。1つの質問と回答が1チャンクに収まるサイズが理想
技術ドキュメント : 500〜800文字。セクション単位で分割し、コード例を分断しない
長文記事・マニュアル : 800〜1200文字。段落のまとまりを保ちつつ、十分なコンテキストを維持する
対話ログ : 200〜400文字。発話単位で短めに分割し、検索精度を上げる
オーバーラップ(chunkOverlap)は、チャンクサイズの10〜15%程度が目安です。オーバーラップを設けることで、チャンク境界で意味が途切れるリスクを軽減できます。
セマンティックチャンキング — より高度なアプローチ
単純な文字数ベースの分割ではなく、意味的なまとまりを考慮した分割方法もあります。
// utils/semantic-chunker.ts — 見出し構造を活用した分割
export function semanticChunk ( markdown : string ) : string [] {
const sections : string [] = [];
const lines = markdown. split ( ' \n ' );
let currentSection = '' ;
let currentHeading = '' ;
for ( const line of lines) {
// H2 または H3 で新しいセクションを開始
if (line. match ( / ^ # {2,3} \s / )) {
if (currentSection. trim ()) {
sections. push (
`${ currentHeading } \n ${ currentSection . trim () }`
);
}
currentHeading = line;
currentSection = '' ;
} else {
currentSection += line + ' \n ' ;
}
}
if (currentSection. trim ()) {
sections. push (
`${ currentHeading } \n ${ currentSection . trim () }`
);
}
return sections;
}
セマンティックチャンキングは、見出し構造が明確なドキュメント(技術マニュアル、FAQ等)で特に有効です。見出しをチャンクの先頭に含めることで、検索時にどのセクションからの情報かが明確になり、回答の精度が向上します。
埋め込み生成 — Gemini text-embedding-004 の活用
チャンクをベクトルに変換するために、Gemini の埋め込みモデルを使用します。
Supabase Edge Function での実装
// supabase/functions/generate-embeddings/index.ts
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts' ;
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2' ;
const GEMINI_API_URL =
'https://generativelanguage.googleapis.com/v1beta/models/text-embedding-004:embedContent' ;
interface EmbeddingRequest {
chunks : { content : string ; metadata ?: Record < string , unknown > }[];
}
serve ( async ( req ) => {
const { chunks } = ( await req. json ()) as EmbeddingRequest ;
const supabase = createClient (
Deno.env. get ( 'SUPABASE_URL' ) ! ,
Deno.env. get ( 'SUPABASE_SERVICE_ROLE_KEY' ) !
);
const results = [];
// バッチ処理: 5件ずつ並列で埋め込みを生成(レート制限対策)
for ( let i = 0 ; i < chunks. length ; i += 5 ) {
const batch = chunks. slice (i, i + 5 );
const embeddings = await Promise . all (
batch. map ( async ( chunk ) => {
const res = await fetch (
`${ GEMINI_API_URL }?key=${ Deno . env . get ( 'GEMINI_API_KEY' ) }` ,
{
method: 'POST' ,
headers: { 'Content-Type' : 'application/json' },
body: JSON . stringify ({
model: 'models/text-embedding-004' ,
content: { parts: [{ text: chunk.content }] },
taskType: 'RETRIEVAL_DOCUMENT' ,
}),
}
);
const data = await res. json ();
return {
content: chunk.content,
metadata: chunk.metadata || {},
embedding: data.embedding.values,
};
})
);
// Supabase に保存
const { error } = await supabase
. from ( 'documents' )
. insert (embeddings);
if (error) throw error;
results. push ( ... embeddings. map (( e ) => ({ success: true })));
// レート制限対策: バッチ間で100ms待機
if (i + 5 < chunks. length ) {
await new Promise (( r ) => setTimeout (r, 100 ));
}
}
return new Response (
JSON . stringify ({ processed: results. length }),
{ headers: { 'Content-Type' : 'application/json' } }
);
});
taskType パラメータの重要性
Gemini の埋め込みAPIには taskType パラメータがあり、用途に応じて最適なベクトルを生成します。
RETRIEVAL_DOCUMENT : ドキュメント側のチャンクを埋め込む際に使用。インデクシングフェーズで指定する
RETRIEVAL_QUERY : ユーザーの検索クエリを埋め込む際に使用。クエリフェーズで指定する
SEMANTIC_SIMILARITY : テキスト間の意味的類似度を計算する場合に使用
ドキュメント側とクエリ側で異なる taskType を指定することで、検索精度が大幅に向上します。これはGemini埋め込みモデルの非対称検索(asymmetric retrieval)最適化によるものです。
類似度検索の実装 — ユーザーの質問に最適なチャンクを見つける
ユーザーの質問をベクトルに変換し、データベースから関連するチャンクを検索する部分を実装します。
// supabase/functions/search-documents/index.ts
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts' ;
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2' ;
const GEMINI_API_URL =
'https://generativelanguage.googleapis.com/v1beta/models/text-embedding-004:embedContent' ;
serve ( async ( req ) => {
const { query , matchCount = 5 , threshold = 0.7 } = await req. json ();
// 1. クエリの埋め込みを生成(taskType: RETRIEVAL_QUERY)
const embeddingRes = await fetch (
`${ GEMINI_API_URL }?key=${ Deno . env . get ( 'GEMINI_API_KEY' ) }` ,
{
method: 'POST' ,
headers: { 'Content-Type' : 'application/json' },
body: JSON . stringify ({
model: 'models/text-embedding-004' ,
content: { parts: [{ text: query }] },
taskType: 'RETRIEVAL_QUERY' ,
}),
}
);
const embeddingData = await embeddingRes. json ();
const queryEmbedding = embeddingData.embedding.values;
// 2. pgvector で類似度検索
const supabase = createClient (
Deno.env. get ( 'SUPABASE_URL' ) ! ,
Deno.env. get ( 'SUPABASE_SERVICE_ROLE_KEY' ) !
);
const { data : documents , error } = await supabase. rpc (
'match_documents' ,
{
query_embedding: queryEmbedding,
match_threshold: threshold,
match_count: matchCount,
}
);
if (error) throw error;
return new Response (
JSON . stringify ({ documents }),
{ headers: { 'Content-Type' : 'application/json' } }
);
});
検索精度を向上させるテクニック
ハイブリッド検索
ベクトル検索だけでなく、全文検索(PostgreSQL の tsvector)を組み合わせることで、キーワードの完全一致と意味的類似性の両方をカバーできます。
-- ハイブリッド検索関数
create or replace function hybrid_search (
query_text text ,
query_embedding vector ( 768 ),
match_count int default 5 ,
vector_weight float default 0 . 7 ,
text_weight float default 0 . 3
)
returns table (
id bigint ,
content text ,
metadata jsonb,
combined_score float
)
language plpgsql
as $$
begin
return query
with vector_results as (
select
d . id ,
d . content ,
d . metadata ,
1 - ( d . embedding <=> query_embedding) as vector_score
from documents d
order by d . embedding <=> query_embedding
limit match_count * 2
),
text_results as (
select
d . id ,
d . content ,
d . metadata ,
ts_rank(
to_tsvector( 'japanese' , d . content ),
plainto_tsquery( 'japanese' , query_text)
) as text_score
from documents d
where to_tsvector( 'japanese' , d . content )
@@ plainto_tsquery( 'japanese' , query_text)
limit match_count * 2
)
select
coalesce ( v . id , t . id ) as id,
coalesce ( v . content , t . content ) as content,
coalesce ( v . metadata , t . metadata ) as metadata,
( coalesce ( v . vector_score , 0 ) * vector_weight +
coalesce ( t . text_score , 0 ) * text_weight) as combined_score
from vector_results v
full outer join text_results t on v . id = t . id
order by combined_score desc
limit match_count;
end ;
$$;
リランキング(Re-ranking)
初回検索で取得した候補を、より精密なモデルで再順位付けする手法です。検索結果の上位5〜10件に対してリランキングを適用すると、最終的な回答品質が向上します。
// utils/reranker.ts — Gemini を使った簡易リランキング
export async function rerankDocuments (
query : string ,
documents : { content : string ; similarity : number }[],
apiKey : string
) : Promise <{ content : string ; score : number }[]> {
const prompt = `以下の質問に対して、各ドキュメントの関連度を0.0〜1.0で評価してください。
JSON配列で返してください。
質問: ${ query }
ドキュメント:
${ documents . map (( d , i ) => `[${ i }] ${ d . content . slice ( 0 , 200 ) }` ). join ( ' \n ' ) }
回答形式: [{"index": 0, "score": 0.9}, ...]` ;
const res = await fetch (
`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${ apiKey }` ,
{
method: 'POST' ,
headers: { 'Content-Type' : 'application/json' },
body: JSON . stringify ({
contents: [{ parts: [{ text: prompt }] }],
generationConfig: { temperature: 0 },
}),
}
);
const data = await res. json ();
const text = data.candidates[ 0 ].content.parts[ 0 ].text;
const scores = JSON . parse (
text. replace ( /```json \n ?| \n ? ```/ g , '' )
);
return scores
. map (( s : { index : number ; score : number }) => ({
content: documents[s.index].content,
score: s.score,
}))
. sort (
( a : { score : number }, b : { score : number }) =>
b.score - a.score
);
}
回答生成 — コンテキスト付きプロンプトの設計
検索で見つかったチャンクをコンテキストとしてLLMに渡し、回答を生成する部分です。プロンプト設計がRAGの最終的な回答品質を決定します。
// utils/rag-chain.ts — RAG回答生成チェーン
interface RAGContext {
query : string ;
documents : { content : string ; metadata : Record < string , unknown > }[];
systemPrompt ?: string ;
}
export function buildRAGPrompt ( context : RAGContext ) : string {
const { query , documents , systemPrompt } = context;
const contextText = documents
. map (
( doc , i ) =>
`[出典${ i + 1 }] ${ doc . metadata ?. source || '不明'} \n ${ doc . content }`
)
. join ( ' \n\n --- \n\n ' );
const defaultSystemPrompt = `あなたは親切なアシスタントです。
以下のルールに従って回答してください:
1. 提供されたコンテキスト情報のみに基づいて回答すること
2. コンテキストに含まれない情報については「この情報はナレッジベースに含まれていません」と正直に答えること
3. 回答には出典番号 [出典N] を含めること
4. 推測や一般知識で補完しないこと
5. 簡潔かつ正確に回答すること` ;
return `${ systemPrompt || defaultSystemPrompt }
## コンテキスト情報
${ contextText }
## ユーザーの質問
${ query }
## 回答` ;
}
export async function generateRAGResponse (
context : RAGContext ,
apiKey : string
) : Promise < string > {
const prompt = buildRAGPrompt (context);
const res = await fetch (
`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${ apiKey }` ,
{
method: 'POST' ,
headers: { 'Content-Type' : 'application/json' },
body: JSON . stringify ({
contents: [{ parts: [{ text: prompt }] }],
generationConfig: {
temperature: 0.3 , // 事実に基づく回答のため低めに設定
maxOutputTokens: 1024 ,
topP: 0.8 ,
},
}),
}
);
const data = await res. json ();
return data.candidates[ 0 ].content.parts[ 0 ].text;
}
プロンプト設計のベストプラクティス
RAG用のプロンプトでは以下の点を意識しましょう。
明示的な制約 : 「コンテキストに含まれない情報には答えない」と明記することで、ハルシネーションを抑制する
出典の要求 : 回答に出典番号を含めるよう指示することで、ユーザーが情報の根拠を確認できる
temperature の調整 : 事実ベースの回答には 0.1〜0.3 の低い値を設定します。創造的な応答が必要な場合は 0.5〜0.7 に上げる
フォーマットの指定 : Markdown、箇条書き、番号付きリストなど、期待する回答形式を指定する
Rork アプリへの統合 — React Native での実装
ここまでのバックエンド構成を、Rork で構築するモバイルアプリのフロントエンドと統合します。
チャット画面のコンポーネント構成
// components/RAGChat.tsx — RAGチャットの主要コンポーネント
import React, { useState, useRef, useCallback } from 'react' ;
import {
View,
TextInput,
FlatList,
Text,
TouchableOpacity,
ActivityIndicator,
StyleSheet,
KeyboardAvoidingView,
Platform,
} from 'react-native' ;
interface Message {
id : string ;
role : 'user' | 'assistant' ;
content : string ;
sources ?: { title : string ; excerpt : string }[];
timestamp : Date ;
}
const SUPABASE_URL = 'YOUR_SUPABASE_URL' ;
const SUPABASE_ANON_KEY = 'YOUR_SUPABASE_ANON_KEY' ;
export default function RAGChat () {
const [ messages , setMessages ] = useState < Message []>([]);
const [ input , setInput ] = useState ( '' );
const [ loading , setLoading ] = useState ( false );
const flatListRef = useRef < FlatList >( null );
const sendMessage = useCallback ( async () => {
if ( ! input. trim () || loading) return ;
const userMessage : Message = {
id: Date. now (). toString (),
role: 'user' ,
content: input. trim (),
timestamp: new Date (),
};
setMessages (( prev ) => [ ... prev, userMessage]);
setInput ( '' );
setLoading ( true );
try {
// Supabase Edge Function を呼び出してRAG処理を実行
const response = await fetch (
`${ SUPABASE_URL }/functions/v1/rag-chat` ,
{
method: 'POST' ,
headers: {
'Content-Type' : 'application/json' ,
Authorization: `Bearer ${ SUPABASE_ANON_KEY }` ,
},
body: JSON . stringify ({ query: userMessage.content }),
}
);
const data = await response. json ();
const assistantMessage : Message = {
id: (Date. now () + 1 ). toString (),
role: 'assistant' ,
content: data.answer,
sources: data.sources,
timestamp: new Date (),
};
setMessages (( prev ) => [ ... prev, assistantMessage]);
} catch (error) {
// エラー時もフィードバックを表示
setMessages (( prev ) => [
... prev,
{
id: (Date. now () + 1 ). toString (),
role: 'assistant' ,
content:
'申し訳ありません。回答の生成中にエラーが発生しました。もう一度お試しください。' ,
timestamp: new Date (),
},
]);
} finally {
setLoading ( false );
}
}, [input, loading]);
const renderMessage = ({ item } : { item : Message }) => (
< View
style = {[
styles.messageContainer,
item.role === 'user'
? styles.userMessage
: styles.assistantMessage,
]}
>
<Text style={styles.messageText}>{item.content}</Text>
{item.sources && item.sources.length > 0 && (
<View style={styles.sourcesContainer}>
<Text style={styles.sourcesTitle}>参考情報:</ Text >
{item.sources.map((source, index) => (
<Text key = {index} style = {styles.sourceItem} >
[{index + 1 }] {source.title}
</Text>
))}
</View>
)}
</View>
);
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : 'height' }
>
< FlatList
ref = {flatListRef}
data = {messages}
renderItem = {renderMessage}
keyExtractor = {(item) => item.id}
onContentSizeChange = {() =>
flatListRef.current?.scrollToEnd()
}
contentContainerStyle = {styles.messagesList}
/>
< View style = {styles.inputContainer} >
< TextInput
style = {styles.input}
value = {input}
onChangeText = {setInput}
placeholder = "質問を入力..."
multiline
editable = {!loading}
/>
< TouchableOpacity
style = {[
styles.sendButton,
loading && styles.sendButtonDisabled,
]}
onPress={sendMessage}
disabled={loading}
>
{loading ? (
<ActivityIndicator color="#fff" size="small" />
) : (
< Text style = {styles.sendButtonText} > 送信 </ Text >
)}
</ TouchableOpacity >
</ View >
</ KeyboardAvoidingView >
);
}
// 期待する動作:
// 1. ユーザーが質問を入力して送信ボタンをタップ
// 2. ローディングインジケーターが表示される
// 3. RAGバックエンドが関連ドキュメントを検索し、回答を生成
// 4. 回答と出典情報がチャット画面に表示される
const styles = StyleSheet. create ({
container: { flex: 1 , backgroundColor: '#f5f5f5' },
messagesList: { padding: 16 },
messageContainer: {
maxWidth: '80%' ,
padding: 12 ,
borderRadius: 16 ,
marginBottom: 8 ,
},
userMessage: {
alignSelf: 'flex-end' ,
backgroundColor: '#007AFF' ,
},
assistantMessage: {
alignSelf: 'flex-start' ,
backgroundColor: '#fff' ,
borderWidth: 1 ,
borderColor: '#e0e0e0' ,
},
messageText: { fontSize: 15 , lineHeight: 22 },
sourcesContainer: {
marginTop: 8 ,
paddingTop: 8 ,
borderTopWidth: 1 ,
borderTopColor: '#e0e0e0' ,
},
sourcesTitle: {
fontSize: 12 ,
fontWeight: '600' ,
color: '#666' ,
marginBottom: 4 ,
},
sourceItem: { fontSize: 12 , color: '#888' , marginBottom: 2 },
inputContainer: {
flexDirection: 'row' ,
padding: 12 ,
backgroundColor: '#fff' ,
borderTopWidth: 1 ,
borderTopColor: '#e0e0e0' ,
},
input: {
flex: 1 ,
backgroundColor: '#f0f0f0' ,
borderRadius: 20 ,
paddingHorizontal: 16 ,
paddingVertical: 10 ,
fontSize: 15 ,
maxHeight: 100 ,
},
sendButton: {
marginLeft: 8 ,
backgroundColor: '#007AFF' ,
borderRadius: 20 ,
paddingHorizontal: 16 ,
justifyContent: 'center' ,
},
sendButtonDisabled: { opacity: 0.6 },
sendButtonText: {
color: '#fff' ,
fontSize: 15 ,
fontWeight: '600' ,
},
});
パフォーマンス最適化 — モバイル環境での工夫
モバイルアプリでは、ネットワーク状況やデバイスの処理能力に制約があります。RAG パイプラインを本番運用する際に考慮すべき最適化ポイントを解説します。
キャッシュ戦略
同じ質問や類似の質問に対して、毎回ベクトル検索とLLM呼び出しを行うのは非効率です。
// utils/rag-cache.ts — 質問・回答のキャッシュ機構
import AsyncStorage from '@react-native-async-storage/async-storage' ;
const CACHE_PREFIX = 'rag_cache_' ;
const CACHE_TTL = 24 * 60 * 60 * 1000 ; // 24時間
interface CacheEntry {
answer : string ;
sources : { title : string ; excerpt : string }[];
timestamp : number ;
}
export async function getCachedAnswer (
query : string
) : Promise < CacheEntry | null > {
const key = CACHE_PREFIX + hashQuery (query);
const cached = await AsyncStorage. getItem (key);
if ( ! cached) return null ;
const entry : CacheEntry = JSON . parse (cached);
// TTL チェック
if (Date. now () - entry.timestamp > CACHE_TTL ) {
await AsyncStorage. removeItem (key);
return null ;
}
return entry;
}
export async function setCachedAnswer (
query : string ,
answer : string ,
sources : { title : string ; excerpt : string }[]
) : Promise < void > {
const key = CACHE_PREFIX + hashQuery (query);
const entry : CacheEntry = {
answer,
sources,
timestamp: Date. now (),
};
await AsyncStorage. setItem (key, JSON . stringify (entry));
}
// 簡易ハッシュ関数(正規化してからハッシュ化)
function hashQuery ( query : string ) : string {
const normalized = query
. toLowerCase ()
. trim ()
. replace ( / \s + / g , ' ' );
let hash = 0 ;
for ( let i = 0 ; i < normalized. length ; i ++ ) {
const char = normalized. charCodeAt (i);
hash = (hash << 5 ) - hash + char;
hash |= 0 ;
}
return Math. abs (hash). toString ( 36 );
}
ストリーミングレスポンス
ユーザー体験を向上させるために、回答をストリーミングで段階的に表示する実装も有効です。
// hooks/useStreamingRAG.ts — ストリーミング回答フック
import { useState, useCallback } from 'react' ;
export function useStreamingRAG ( supabaseUrl : string , apiKey : string ) {
const [ streamedText , setStreamedText ] = useState ( '' );
const [ isStreaming , setIsStreaming ] = useState ( false );
const streamAnswer = useCallback (
async ( query : string ) => {
setStreamedText ( '' );
setIsStreaming ( true );
try {
const response = await fetch (
`${ supabaseUrl }/functions/v1/rag-chat-stream` ,
{
method: 'POST' ,
headers: {
'Content-Type' : 'application/json' ,
Authorization: `Bearer ${ apiKey }` ,
},
body: JSON . stringify ({ query }),
}
);
const reader = response.body?. getReader ();
const decoder = new TextDecoder ();
if ( ! reader) throw new Error ( 'Stream not available' );
while ( true ) {
const { done , value } = await reader. read ();
if (done) break ;
const chunk = decoder. decode (value, { stream: true });
setStreamedText (( prev ) => prev + chunk);
}
} finally {
setIsStreaming ( false );
}
},
[supabaseUrl, apiKey]
);
return { streamedText, isStreaming, streamAnswer };
}
レート制限とエラーハンドリング
APIのレート制限に対応するリトライロジックも、本番運用では必須です。
// utils/retry.ts — 指数バックオフ付きリトライ
export async function withRetry < T >(
fn : () => Promise < T >,
maxRetries : number = 3 ,
baseDelay : number = 1000
) : Promise < T > {
for ( let attempt = 0 ; attempt <= maxRetries; attempt ++ ) {
try {
return await fn ();
} catch ( error : unknown ) {
if (attempt === maxRetries) throw error;
const status =
error instanceof Response ? error.status : 0 ;
// 429 (Rate Limit) または 5xx の場合のみリトライ
if (status !== 429 && status < 500 && status !== 0 ) {
throw error;
}
const delay =
baseDelay * Math. pow ( 2 , attempt) +
Math. random () * 1000 ;
await new Promise (( r ) => setTimeout (r, delay));
}
}
throw new Error ( 'Max retries exceeded' );
}
セキュリティとプライバシーの考慮事項
ナレッジベースに機密情報を含む場合、セキュリティ対策は不可欠です。
Row Level Security(RLS)の設定
Supabase の RLS を活用して、ユーザーごとにアクセスできるドキュメントを制限します。
-- ユーザーごとのドキュメントアクセス制御
alter table documents enable row level security ;
create policy "Users can only access their own documents"
on documents for select
using (
metadata ->> 'user_id' = auth . uid ():: text
or metadata ->> 'visibility' = 'public'
);
PII(個人情報)のフィルタリング
ナレッジベースに投入する前に、個人情報を検出・除去する仕組みを設けることを推奨します。また、ユーザーの質問がLLMに送信される際も、不必要な個人情報が含まれないよう注意が必要です。
APIキーの安全な管理
モバイルアプリのコード内にAPIキーをハードコードすることは絶対に避けてください。Supabase Edge Function をプロキシとして使用し、APIキーはサーバーサイドでのみ管理します。本記事のコード例でも、Gemini APIキーは Deno.env.get('GEMINI_API_KEY') でサーバー側の環境変数から取得しています。
個人開発者の視点から(実体験メモ)
全体を振り返って
RAG(検索拡張生成)は、モバイルアプリにおけるAIチャット機能を劇的に進化させるアーキテクチャパターンです。ここで扱うのはチャンク分割から埋め込み生成、ベクトル検索、回答生成、そしてRork アプリへの統合まで、プロダクションレベルの実装手順を一通り解説しました。
RAG導入の最大のメリットは、LLMの学習データに含まれない独自の情報を、正確かつリアルタイムにユーザーへ届けられることです。FAQ対応アプリ、社内ナレッジ検索、教育コンテンツの質問応答など、あらゆるドメイン特化型アプリでその真価を発揮します。
まずは小さなナレッジベース(数十件のFAQ等)から始めて、RAGパイプラインの動作を理解した上で、段階的に規模を拡大していくアプローチをおすすめします。