Next.jsでのプロンプトエンジニアリング:本番環境でのストリーミングRAG&キャッシング
過去18ヶ月間、法務技術スタートアップからエンタープライズ知識ベースまで、様々なクライアント向けにNext.jsアプリケーションにAI機能を構築してきました。クールなRAGデモと実際のトラフィックを処理して無駄な支出を抑えるプロダクションシステムの間には、膨大なギャップがあります。この記事では、苦労して得られた教訓を紹介しています。プロンプトを一貫した状態に保つ方法、UIを壊さずにレスポンスをストリーミングする方法、コストを60~80%削減するインテリジェントなキャッシング、そして金曜日の午前2時に崩壊しないRAGパイプラインをデプロイする方法をカバーしています。
目次
- 2026年にNext.jsのRAGが意味を持つ理由
- RAGのプロンプトエンジニアリングの基礎
- Next.jsでストリーミングRAGをセットアップする
- キャッシング層:実際の節約ができる場所
- プロダクションアーキテクチャパターン
- 監視、可観測性、デバッグ
- パフォーマンスベンチマークとコスト分析
- FAQ

2026年にNext.jsのRAGが意味を持つ理由
Next.jsはAIを使ったウェブアプリのデフォルトの選択肢になっています。単なるハイプではありません。App Router、Server Actions、Route Handlers、React Server Componentsの組み合わせにより、RAGパイプライン用の本質的に優れたアーキテクチャが実現します。エンベディングロジックをサーバー側に保つことができ、Route Handlersを通じてレスポンスをストリーミングでき、複数のレイヤーで積極的にキャッシュできます。
Vercel AI SDK(現在v4.x)は大幅に成熟しました。ストリーミング、ツール呼び出し、構造化された出力をネイティブに処理します。しかし、SDKは単なる配管工事です。実際のチャレンジはその周囲にあります:プロンプト設計、検索品質、キャッシング戦略、エラーハンドリングです。
Next.jsでの典型的なプロダクションRAGフローは次のようになります:
- ユーザーがクエリを送信する
- クエリがエンベディングされる(またはエンベディングキャッシュにヒット)
- ベクトル検索が関連チャンクを取得
- チャンクがランク付けされてフィルタリングされる
- 慎重にエンジニアリングされたプロンプトがコンテキストをアセンブルする
- LLMがレスポンスをストリーミング
- レスポンスが同様の今後のクエリのためにキャッシュされる
各ステップに失敗モードがあります。各ステップを詳しく見ていきましょう。
RAGのプロンプトエンジニアリングの基礎
RAGのプロンプトエンジニアリングは、バニラLLM相互作用のプロンプトエンジニアリングとは根本的に異なります。モデルに質問をするだけではなく、特定のコンテキストウィンドウを提供し、そのコンテキストからアンサーを合成するよう促します。これは、トレーニングデータが競合する場合はそれを無視することです。
システムプロンプトアーキテクチャ
異なるドメイン全体で良く機能する3部構成のシステムプロンプト構造にたどり着きました:
const buildSystemPrompt = (config: RAGConfig) => `
You are ${config.assistantName}, a ${config.role} for ${config.company}.
## CONTEXT RULES
- Answer ONLY based on the provided context documents
- If the context doesn't contain enough information, say so explicitly
- Never fabricate citations or reference numbers
- When multiple context documents conflict, note the discrepancy

## RESPONSE FORMAT
- Use markdown formatting for readability
- Cite sources using [Source: document_id] notation
- Keep responses under ${config.maxResponseTokens} tokens unless the user asks for detail
- Use ${config.tone} tone
## DOMAIN RULES
${config.domainRules.join('\n')}
`;
重要な洞察:ドメインルールは、RAGを実際に有用にするものを入れる場所です。法務クライアント向けであれば、「法定が適用される管轄区を常に記述してください」かもしれません。医療知識ベース向けであれば、「用量の推奨を決して提供しないでください。常に医療提供者に指示してください」となります。
コンテキストウィンドウ管理
GPT-4oが128kコンテキストで実行され、Claude 3.5が200kで実行されるため、すべてを詰め込みたくなるでしょう。やめてください。より多くのコンテキストはより良い回答を意味しません。実際、多くの場合、より悪い回答を意味しています。
私はティアード方法を使用しています:
const assembleContext = async (
query: string,
retrievedChunks: Chunk[]
): Promise<string> => {
// Tier 1: Top 3 chunks by cosine similarity (always included)
const primary = retrievedChunks.slice(0, 3);
// Tier 2: Next 5 chunks, but only if similarity > threshold
const secondary = retrievedChunks
.slice(3, 8)
.filter(c => c.similarity > 0.78);
// Tier 3: Metadata-enriched summaries of remaining relevant docs
const tertiary = retrievedChunks
.slice(8)
.filter(c => c.similarity > 0.72)
.map(c => c.summary); // Pre-computed summaries, not full text
return formatContextTiers(primary, secondary, tertiary);
};
これにより、通常は30,000以上ではなく、3,000~8,000トークンのコンテキストが生成されます。レスポンス品質が向上し、レイテンシーが低下し、APIの請求額が減少します。
プロンプトバージョニング
これはブログ記事ではほぼ誰も話さないことですが、プロダクションでは誰もが必要です。プロンプトは変わります。その変更を追跡する必要があります。
// prompts/v2.3.ts
export const RAG_PROMPT_V2_3 = {
version: '2.3',
createdAt: '2026-03-15',
changelog: 'Added conflict resolution instruction, reduced hallucination on legal queries by 23%',
system: `...`,
userTemplate: (query: string, context: string) => `...`,
};
プロンプトバージョンはコードに保存し、データベースには保存しません。他のコード変更と同様にPRで確認されます。プロダクションで何か問題が発生した場合、それを特定のプロンプトバージョンにトレースバックできます。
Next.jsでストリーミングRAGをセットアップする
Vercel AI SDKを使用したRoute Handler
本番対応のストリーミングRAGエンドポイントは次の通りです:
// app/api/chat/route.ts
import { openai } from '@ai-sdk/openai';
import { streamText } from 'ai';
import { retrieveContext } from '@/lib/rag/retriever';
import { buildPrompt } from '@/lib/rag/prompts';
import { checkCache, setCache } from '@/lib/rag/cache';
import { rateLimiter } from '@/lib/middleware/rate-limit';
export const runtime = 'nodejs'; // Not edge -- you need Node for most vector DBs
export const maxDuration = 30;
export async function POST(req: Request) {
const { messages, sessionId } = await req.json();
const lastMessage = messages[messages.length - 1].content;
// Rate limiting
const allowed = await rateLimiter.check(sessionId);
if (!allowed) {
return new Response('Rate limited', { status: 429 });
}
// Check semantic cache first
const cached = await checkCache(lastMessage);
if (cached) {
return new Response(cached.response, {
headers: { 'X-Cache': 'HIT', 'Content-Type': 'text/plain' },
});
}
// Retrieve context
const context = await retrieveContext(lastMessage, {
topK: 10,
minSimilarity: 0.72,
namespace: 'production',
});
// Build the prompt
const systemPrompt = buildPrompt(context);
// Stream the response
const result = streamText({
model: openai('gpt-4o'),
system: systemPrompt,
messages,
temperature: 0.3, // Low temp for factual RAG
maxTokens: 1500,
onFinish: async ({ text }) => {
// Cache the completed response
await setCache(lastMessage, text, context.chunks.map(c => c.id));
},
});
return result.toDataStreamResponse();
}
クライアント側ストリーミングUI
フロントエンド側では、useChatフックがストリーミングを上手に処理します:
// components/ChatInterface.tsx
'use client';
import { useChat } from 'ai/react';
import { useRef, useEffect } from 'react';
export function ChatInterface() {
const { messages, input, handleInputChange, handleSubmit, isLoading, error } =
useChat({
api: '/api/chat',
body: { sessionId: getSessionId() },
onError: (err) => {
// Don't just console.log -- show the user something useful
toast.error('Something went wrong. Try rephrasing your question.');
},
});
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
scrollRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
return (
<div className="flex flex-col h-full">
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.map((m) => (
<div key={m.id} className={m.role === 'user' ? 'text-right' : ''}>
<div className="prose prose-sm max-w-none">
<Markdown>{m.content}</Markdown>
</div>
</div>
))}
{isLoading && <TypingIndicator />}
<div ref={scrollRef} />
</div>
<form onSubmit={handleSubmit} className="p-4 border-t">
<input
value={input}
onChange={handleInputChange}
placeholder="Ask a question..."
className="w-full p-3 rounded-lg border"
disabled={isLoading}
/>
</form>
</div>
);
}
ストリーミングエッジケースの処理
プロダクションでのストリーミングは、デモでは決して起こらないことを処理することを意味しています:
- ストリーム中の接続切断:指数バックオフでリトライロジックを実装してください。AI SDKの
onErrorコールバックがあなたの味方です。 - トークン制限を超過:トークン使用量を監視し、モデルが実行する前に厳しいカットオフを実装してください(そのカットオフは醜いものです)。
- 遅い検索:ベクトルDB クエリにタイムアウトを設定してください。検索に2秒以上かかる場合は、より小さなコンテキストまたはキャッシュされた同様のクエリにフォールバックしてください。
キャッシング層:実際の節約ができる場所
キャッシングは、プロダクションRAGシステムに対して実行できる単一の最も影響力のある最適化です。実装する価値がある3つのレイヤーがあります。
レイヤー1:エンベディングキャッシュ
すべてのクエリにはエンベディングが必要です。text-embedding-3-smallでの1Kトークンあたり$0.00002では、クエリごとには安いですが、積み重なります。そしてさらに重要なのは、レイテンシーが増加することです。
import { Redis } from '@upstash/redis';
import { createHash } from 'crypto';
const redis = new Redis({ url: process.env.UPSTASH_REDIS_URL!, token: process.env.UPSTASH_REDIS_TOKEN! });
export async function getEmbedding(text: string): Promise<number[]> {
const hash = createHash('sha256').update(text.toLowerCase().trim()).digest('hex');
// Check cache
const cached = await redis.get<number[]>(`emb:${hash}`);
if (cached) return cached;
// Generate embedding
const embedding = await openai.embeddings.create({
model: 'text-embedding-3-small',
input: text,
});
const vector = embedding.data[0].embedding;
// Cache for 7 days
await redis.set(`emb:${hash}`, vector, { ex: 604800 });
return vector;
}
レイヤー2:セマンティックキャッシュ
大きな1つです。「払い戻しポリシーは何ですか?」と質問する人と「払い戻しを受けるにはどうすればよいですか?」と質問する人は、同じキャッシュされたレスポンスを取得する必要があります。
export async function checkSemanticCache(query: string): Promise<CacheResult | null> {
const embedding = await getEmbedding(query);
// Search the cache index (separate from your content index)
const results = await pinecone.index('cache').query({
vector: embedding,
topK: 1,
includeMetadata: true,
});
if (results.matches[0]?.score > 0.95) {
return {
response: results.matches[0].metadata.response as string,
originalQuery: results.matches[0].metadata.query as string,
cachedAt: results.matches[0].metadata.cachedAt as string,
};
}
return null;
}
0.95のしきい値が重要です。低すぎると、間違った回答を提供します。高すぎると、キャッシュヒットが得られません。0.95で開始して、ドメインに基づいて調整してください。
レイヤー3:レスポンスフラグメントキャッシュ
構造化されたレスポンス(製品仕様またはポリシーサマリーなど)の場合、個別のフラグメントをキャッシュします:
| キャッシング層 | ヒット率(典型的) | レイテンシー削減 | コスト削減 |
|---|---|---|---|
| エンベディングキャッシュ | 40~60% | クエリあたり50~100ms | 100Kクエリで約$50/月 |
| セマンティックキャッシュ | 15~35% | クエリあたり1~3秒 | 100Kクエリで約$300~800/月 |
| フラグメントキャッシュ | 20~40% | クエリあたり500ms~1秒 | 100Kクエリで約$100~200/月 |
| 複合 | 60~80% | 平均1~3秒 | 100Kクエリで約$500~1200/月 |
キャッシュの無効化
古典的な難しい問題です。RAGの場合、私は2つの方向からのアプローチを使用します:
- TTLベース:すべてのキャッシュは、ソースデータが変更される頻度に応じて、24~72時間後に期限切れになります。
- イベントベース:ソースドキュメントが更新されたとき、それらのドキュメントIDを参照していたキャッシュエントリを無効化します(これは、チャンクIDをキャッシュメタデータに保存する理由です)。
プロダクションアーキテクチャパターン
フルスタック
ほとんどのプロダクションRAGデプロイメントで使用するアーキテクチャは次の通りです:
User → Next.js App Router → Route Handler
↓
Rate Limiter (Upstash)
↓
Semantic Cache Check (Pinecone + Redis)
↓ (miss)
Embedding Generation (OpenAI / cached)
↓
Vector Search (Pinecone / Weaviate / pgvector)
↓
Re-ranking (Cohere Rerank / custom)
↓
Prompt Assembly
↓
LLM Streaming (OpenAI / Anthropic)
↓
Response → Cache Write → User
ベクトルデータベースの選択
| データベース | 最適なユースケース | 価格(2026年) | Next.js統合 |
|---|---|---|---|
| Pinecone | マネージド、運用不要 | フリーティア → $70/月スターター | 優秀(REST API) |
| Weaviate Cloud | ハイブリッド検索(ベクトル+キーワード) | $25/月スターター | 良好(JSクライアント) |
| pgvector(Supabase) | 既にPostgresを使用している | フリーティア → $25/月 | 優秀(Supabase SDK) |
| Qdrant Cloud | 高パフォーマンス、フィルタリング | フリーティア → $30/月 | 良好(JSクライアント) |
| Turbopuffer | コスト最適化、S3バックアップ | 約$0.04/GB保存 | 適切(REST API) |
ほとんどのNext.jsプロジェクトでは、既にそのエコシステム内にいる場合はSupabaseのpgvectorで開始するか、運用オーバーヘッドをゼロにしたい場合はPineconeで開始します。これらすべてをプロダクション環境で使用しており、CMS コンテンツがRAGパイプラインをフィードするプロジェクトもあります。
エラーハンドリングとフォールバック
プロダクションRAGは優雅な劣化が必要です:
export async function handleRAGQuery(query: string) {
try {
// Primary path: full RAG
return await fullRAGPipeline(query);
} catch (error) {
if (error instanceof VectorDBError) {
// Fallback 1: Use cached similar queries
const fallback = await getFallbackFromCache(query);
if (fallback) return { ...fallback, degraded: true };
}
if (error instanceof LLMError) {
// Fallback 2: Try a different model
return await fullRAGPipeline(query, { model: 'claude-3-5-sonnet' });
}
// Fallback 3: Return relevant raw chunks without LLM synthesis
const chunks = await retrieveContext(query);
return {
response: 'I couldn\'t generate a full answer, but here are relevant excerpts:',
chunks: chunks.slice(0, 3),
degraded: true,
};
}
}
監視、可観測性、デバッグ
測定できないものを改善することはできません。以下をトラッキングします:
主要なメトリクス
- 検索品質:上位Kチャンクは実際に関連していますか?相似度スコアをログに記録し、毎週スポットチェックしてください。
- レスポンスレイテンシー(p50/p95/p99):ストリーミングTTFB(最初のバイトまでの時間)と完全な完了時間。
- キャッシュヒット率:レイヤーごと。セマンティックキャッシュヒット率が10%未満の場合、しきい値が高すぎる可能性があります。
- クエリあたりのトークン使用量:平均とp99。プロンプトインジェクション試行によってコンテキストが膨らむのを監視してください。
- ユーザーフィードバック信号:サムズアップ/ダウン、コピーイベント、フォローアップ質問(最初の回答が不十分だったことを示す)。
ツーリング
LLM可観測性については、以下で良い結果が得られています:
- Langfuse:オープンソース、自分でホスト可能、優秀なトレース可視化。フリーティアは寛容です。
- Helicone:プロキシベースのロギング、コスト追跡が優秀。1行の統合。
- Braintrust:評価とプロンプト反復に適しています。評価フレームワークは堅実です。
// Example: Langfuse integration with Vercel AI SDK
import { Langfuse } from 'langfuse';
const langfuse = new Langfuse();
export async function POST(req: Request) {
const trace = langfuse.trace({ name: 'rag-query' });
const retrievalSpan = trace.span({ name: 'retrieval' });
const context = await retrieveContext(query);
retrievalSpan.end({ output: { chunkCount: context.length, topScore: context[0]?.similarity } });
const generationSpan = trace.generation({
name: 'llm-response',
model: 'gpt-4o',
input: messages,
});
// ... stream response
generationSpan.end({ output: completedText });
await langfuse.flushAsync();
}
パフォーマンスベンチマークとコスト分析
プロダクションデプロイメントの実数(法務知識ベース、約50Kのドキュメント、約2Mのチャンク):
| メトリック | 最適化なし | 完全な最適化あり |
|---|---|---|
| 中央値レイテンシー(TTFB) | 2.1秒 | 340ms |
| P95レイテンシー(TTFB) | 4.8秒 | 1.2秒 |
| 月間LLMコスト(50Kクエリ) | $2,400 | $680 |
| 月間エンベディングコスト | $180 | $45 |
| 月間ベクトルDBコスト | $70 | $70(変わらず) |
| キャッシュヒット率 | 0% | 67% |
| ユーザー満足度(サムズアップ%) | 71% | 89% |
満足度の改善は、ほぼ完全にプロンプトの改善とランク付けによってもたらされました。キャッシングではなく。ただし、コストとレイテンシーの改善は、ほぼ完全にキャッシングから来ました。
クエリあたりのコスト内訳
月間50Kクエリ、GPT-4oの場合:
- キャッシュなしのクエリ:約$0.048(エンベディング+検索+生成)
- セマンティックキャッシュヒット:約$0.0004(エンベディング検索+キャッシュ読み取り)
- エンベディングキャッシュヒット+セマンティック未検出:約$0.035(エンベディングをスキップ、引き続き生成)
このようなことを構築しており、アーキテクチャについてサポートが必要な場合、私たちは定期的にこの種の作業を行っています。
FAQ
2026年のプロダクションRAGに最適なLLMは何ですか?
ほとんどのユースケースでは、GPT-4oは品質、速度、コストのスイートスポットに達しています。Claude 3.5 Sonnetは、より長いコンテキスト処理が必要な場合またはより微妙な推論が必要な場合に優れています。コスト関連のアプリケーション(検索品質が良い場合の簡単なQ&A)では、GPT-4o-miniまたはClaude 3.5 Haikuが驚くほどうまく機能します(GPT-4oの品質と同等)。モデルは検索品質とプロンプトエンジニアリングより重要ではありません。
RAG Route HandlersにEdge Runtimeを使用するかNode.js Runtimeを使用するか?
Node.js、ほぼ常に。Edge Runtimeには、ほとんどのベクトルデータベースとORMで作業するのを難しくする接続制限があります。Edge の冷たい起動の利点は、ユーザーが既に検索+生成を待機しているストリーミングエンドポイントではわずかです。Edge を単純なプロキシエンドポイントまたは非RAGルートに使用してください。
RAGレスポンスで幻覚を防ぐにはどうすればよいですか?
実際に機能する3つの戦略:(1)モデルに明示的に「コンテキストに十分な情報がない」と言うよう指示する。プロンプトに例を含める。(2)事実的なクエリに低い温度(0.1~0.3)を使用する。(3)引用要件を実装する。モデルが特定のチャンクを引用する必要がある場合、幻覚を起こすのがはるかに難しくなります。事後検証(クレームがソースチャンクに表示されるかどうかの確認)は別の安全レイヤーを追加します。
RAGコンテキスト用に何個のチャンクを取得する必要がありますか?
使用するより多くを取得します。通常、15~20のチャンクを取得し、ランク付けして、上位3~5を主要コンテキストとして使用し、次の3~5のサマリーを含めます。20の完全なチャンクすべてをコンテキストウィンドウにダンプすると、品質が低下します。関連情報が存在する場合でも、モデルは関連のない情報によって混乱します。品質よりも量、常に。
pgvectorはプロダクション十分か、それとも専用ベクトルデータベースが必要か?
約1Mベクトルまでの場合、Supabase または Neon 上のHNSW インデックス付きのpgvectorは完全にプロダクション対応です。クエリパフォーマンスは優秀で、既存のPostgresエコシステムに留まるメリットが得られます。1Mベクトルを超える場合、またはベクトル検索を使用した高度なフィルタリングが必要な場合、PineconeやQdrantなどの専用オプションが引き離し始めます。いくつかのプロジェクトでpgvectorをプロダクション環境で実行しており、問題なく動作しています。
Reactでのローディング状態を使用してストリーミングレスポンスをどのように処理しますか?
Vercel AI SDKのuseChatフックはisLoadingブール値を提供していますが、それより微妙です。フックはアイドル→待機中(トークンなし)→ストリーミング中(トークン到着)→アイドルの状態を遷移します。最高のUX については、待機フェーズ中にタイピングインジケーターを表示し、ストリーミング中にマークダウンを段階的にレンダリングしてください。不完全なマークダウンを適切に処理するマークダウンレンダラーを使用してください。react-markdownは機能していますが、フリッカーする可能性があります。レンダリング前に数トークンをバッファリングすることを検討してください。
RAGでマルチターン会話をどのように処理するのがベストですか?
すべてのメッセージで再取得しないでください。会話履歴を使用して、新しいメッセージが続編(同じコンテキストが必要)であるか、トピックスイッチ(新しい取得が必要)であるかを判断します。簡単な分類器(正規表現ベースのものでもチェック代名詞と参照)は、多くの不要なベクトル検索を節約できます。再取得する場合は、最新のメッセージだけでなく、検索クエリに会話サマリーを含めてください。
RAGのドキュメント用に多頻度で再インデックスする必要がありますか?
ソースデータの変更頻度によって異なります。静的な知識ベースの場合は、週1回の完全な再インデックスで問題ありません。動的コンテンツ(毎日更新されるドキュメント)の場合は、webhook トリガーの増分インデックスを設定してください。重要なのは、すべてを再インデックスしなくても個別のチャンクを更新できるパイプラインを持つことです。これをヘッドレスCMS統合に組み込みます。コンテンツが Sanity または Contentful で更新されると、影響を受けるチャンクが自動的に再エンベディングおよびアップサートされます。