Engenharia de Prompts com Next.js: Streaming RAG e Cache em Produção
Passei os últimos dezoito meses construindo recursos de IA em aplicações Next.js para clientes que variam de startups de legal tech até bases de conhecimento empresariais. A lacuna entre uma demo RAG legal e um sistema em produção que lida com tráfego real sem gastar dinheiro é enorme. Este artigo cobre as lições difíceis -- como engenheirar prompts que permanecem consistentes, fazer streaming de respostas sem quebrar sua UI, fazer cache inteligente para reduzir custos em 60-80%, e fazer deploy de pipelines RAG que não caem às 2 da manhã de uma sexta-feira.
Índice
- Por que RAG em Next.js Faz Sentido em 2026
- Fundamentos de Engenharia de Prompts para RAG
- Configurando RAG com Streaming em Next.js
- A Camada de Cache: Onde Você Economiza Dinheiro Real
- Padrões de Arquitetura em Produção
- Monitoramento, Observabilidade e Debugging
- Benchmarks de Desempenho e Análise de Custos
- FAQ

Por que RAG em Next.js Faz Sentido em 2026
Next.js se tornou a escolha padrão para aplicações web alimentadas por IA, e não é apenas hype. A combinação de App Router, Server Actions, Route Handlers e React Server Components oferece uma arquitetura genuinamente boa para pipelines RAG. Você pode manter sua lógica de embedding no lado do servidor, fazer streaming de respostas através de Route Handlers e fazer cache agressivamente em múltiplas camadas.
O Vercel AI SDK (agora na v4.x) amadureceu significativamente. Ele lida com streaming, chamadas de ferramentas e saída estruturada nativamente. Mas o SDK é apenas encanamento -- o verdadeiro desafio é tudo em volta: design de prompts, qualidade de recuperação, estratégia de cache e tratamento de erros.
Eis como um fluxo RAG típico em produção se parece em Next.js:
- Usuário envia uma query
- Query obtém um embedding (ou atinge um cache de embedding)
- Busca em vetor recupera chunks relevantes
- Chunks são classificados e filtrados
- Um prompt cuidadosamente engenheirado monta o contexto
- O LLM faz streaming de uma resposta
- A resposta é colocada em cache para queries futuras similares
Cada etapa tem modos de falha. Vamos investigar cada uma.
Fundamentos de Engenharia de Prompts para RAG
Engenharia de prompts para RAG é fundamentalmente diferente de engenharia de prompts para interações vanilla com LLM. Você não está apenas fazendo uma pergunta ao modelo -- você está dando um window de contexto específico e pedindo que ele sintetize uma resposta a partir desse contexto enquanto ignora seus dados de treinamento quando entram em conflito.
Arquitetura do System Prompt
Cheguei a uma estrutura de system prompt de três partes que funciona bem em diferentes domínios:
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')}
`;
A ideia chave: regras de domínio são onde você coloca as coisas que tornam seu RAG realmente útil. Para um cliente legal, isso pode ser "Sempre note a jurisdição à qual um estatuto se aplica." Para uma base de conhecimento médico, "Nunca forneça recomendações de dosagem; sempre dirija para um provedor de saúde."
Gerenciamento da Janela de Contexto
Com GPT-4o rodando em 128k contexto e Claude 3.5 em 200k, é tentador apenas colocar tudo dentro. Não faça isso. Mais contexto não significa respostas melhores -- frequentemente significa piores.
Eu uso uma abordagem em camadas:
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);
};
Isso tipicamente resulta em 3.000-8.000 tokens de contexto em vez de 30.000+. A qualidade de resposta sobe, a latência desce e sua fatura de API encolhe.
Versionamento de Prompts
Isso é algo que quase ninguém fala em posts de blog mas todo mundo precisa em produção. Seus prompts vão mudar. Você precisa rastrear essas mudanças.
// 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) => `...`,
};
Armazenamos versões de prompts em código, não em um banco de dados. Elas são revisadas em PRs assim como qualquer outra mudança de código. Quando algo dá errado em produção, você pode rastreá-lo até uma versão específica de prompt.
Configurando RAG com Streaming em Next.js
Route Handler com Vercel AI SDK
Aqui está um endpoint RAG com streaming pronto para produção:
// 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();
}
Interface de Usuário com Streaming no Client
No frontend, o hook useChat lida com streaming bem:
// 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>
);
}
Tratando Casos Extremos de Streaming
Streaming em produção significa lidar com coisas que nunca acontecem em demos:
- Conexão cai durante streaming: Implemente lógica de retry com backoff exponencial. O callback
onErrordo AI SDK é seu amigo. - Limite de token excedido: Monitore o uso de tokens e implemente cutoffs duros antes que o modelo faça isso para você (seus cutoffs são feios).
- Recuperações lentas: Defina timeouts em suas queries de banco de dados vetorial. Se a recuperação leva > 2s, retorne para um contexto menor ou uma query cached similar.
A Camada de Cache: Onde Você Economiza Dinheiro Real
Cache é a otimização de impacto único mais elevado que você pode fazer para um sistema RAG em produção. Há três camadas que valem a implementação.
Camada 1: Cache de Embedding
Toda query precisa de um embedding. Em $0.00002 por 1K tokens com text-embedding-3-small, é barato por query, mas se acumula e -- mais importante -- adiciona latência.
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;
}
Camada 2: Cache Semântico
Essa é a grande. Se alguém pergunta "Qual é sua política de reembolso?" e outra pessoa pergunta "Como faço para obter um reembolso?", eles deveriam obter a mesma resposta em cache.
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;
}
O threshold 0.95 é importante. Muito baixo e você servará respostas erradas. Muito alto e você não terá cache hits. Comece em 0.95 e ajuste baseado no seu domínio.
Camada 3: Cache de Fragmento de Resposta
Para respostas estruturadas (como especificações de produto ou resumos de política), coloque em cache fragmentos individuais:
| Camada de Cache | Taxa de Hit (Típica) | Economia de Latência | Economia de Custo |
|---|---|---|---|
| Cache de Embedding | 40-60% | 50-100ms por query | ~$50/mês em 100K queries |
| Cache Semântico | 15-35% | 1-3s por query | ~$300-800/mês em 100K queries |
| Cache de Fragmento | 20-40% | 500ms-1s por query | ~$100-200/mês em 100K queries |
| Combinado | 60-80% | 1-3s média | $500-1200/mês em 100K queries |
Invalidação de Cache
O problema clássico difícil. Para RAG, uso uma abordagem de dois prongs:
- Baseada em TTL: Todos os caches expiram após 24-72 horas dependendo de com que frequência seus dados de origem mudam.
- Baseada em evento: Quando documentos de origem são atualizados, invalide qualquer entrada de cache que tenha referenciado esses IDs de documento (é por isso que armazenamos IDs de chunk nos metadados do cache).
Padrões de Arquitetura em Produção
A Stack Completa
Aqui está a arquitetura que usamos para a maioria das implantações RAG em produção em Social Animal:
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
Escolhendo Seu Banco de Dados Vetorial
| Banco de Dados | Melhor Para | Pricing (2026) | Integração com Next.js |
|---|---|---|---|
| Pinecone | Managed, zero-ops | Free tier → $70/mês starter | Excelente (REST API) |
| Weaviate Cloud | Busca híbrida (vetor + keyword) | $25/mês starter | Bom (cliente JS) |
| pgvector (Supabase) | Já usando Postgres | Free tier → $25/mês | Ótimo (Supabase SDK) |
| Qdrant Cloud | Alto desempenho, filtragem | Free tier → $30/mês | Bom (cliente JS) |
| Turbopuffer | Otimizado para custo, S3-backed | ~$0.04/GB armazenado | Decente (REST API) |
Para a maioria dos projetos Next.js, eu começaria com pgvector em Supabase se você já está nesse ecossistema, ou Pinecone se quiser zero overhead operacional. Usamos todos esses em projetos de headless CMS onde o conteúdo do CMS alimenta o pipeline RAG.
Tratamento de Erro e Fallbacks
RAG em produção precisa de degradação graciosa:
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,
};
}
}
Monitoramento, Observabilidade e Debugging
Você não pode melhorar o que não pode medir. Aqui está o que rastrear:
Métricas Chave
- Qualidade de recuperação: Os chunks top-K são realmente relevantes? Registre os scores de similaridade e faça revisão manual semanalmente.
- Latência de resposta (p50/p95/p99): Streaming TTFB (tempo até primeiro byte) e tempo de conclusão total.
- Taxa de cache hit: Por camada. Se sua taxa de cache hit semântico está abaixo de 10%, seu threshold pode estar muito alto.
- Uso de token por query: Média e p99. Observe por tentativas de prompt injection que inflam contexto.
- Sinais de feedback do usuário: Thumbs up/down, eventos de cópia, perguntas de follow-up (indica que a primeira resposta não foi boa o suficiente).
Ferramental
Para observabilidade de LLM, tive bons resultados com:
- Langfuse: Open-source, self-hostable, visualização excelente de traces. Free tier é generoso.
- Helicone: Logging baseado em proxy, ótimo para rastreamento de custo. Integração de uma linha.
- Braintrust: Bom para avaliação e iteração de prompts. O framework de eval é sólido.
// 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();
}
Benchmarks de Desempenho e Análise de Custos
Números reais de uma implantação em produção (base de conhecimento legal, ~50K documentos, ~2M chunks):
| Métrica | Sem Otimização | Com Otimização Completa |
|---|---|---|
| Latência mediana (TTFB) | 2.1s | 340ms |
| Latência P95 (TTFB) | 4.8s | 1.2s |
| Custo mensalmente com LLM (50K queries) | $2.400 | $680 |
| Custo mensalmente com embedding | $180 | $45 |
| Custo mensalmente com banco de dados vetorial | $70 | $70 (inalterado) |
| Taxa de cache hit | 0% | 67% |
| Satisfação do usuário (thumbs up %) | 71% | 89% |
A melhoria de satisfação veio principalmente de prompts melhores e re-ranking, não de cache. Mas as melhorias de custo e latência vieram quase inteiramente de cache.
Breakdown de Custo Por Query
Em 50K queries/mês com GPT-4o:
- Query sem cache: ~$0.048 (embedding + recuperação + geração)
- Cache hit semântico: ~$0.0004 (lookup de embedding + leitura de cache)
- Cache hit de embedding + miss semântico: ~$0.035 (pula embedding, ainda gera)
Se você está construindo algo assim e precisa de ajuda com a arquitetura, fazemos este tipo de trabalho regularmente -- confira nossa página de preços ou entre em contato.
FAQ
Qual é o melhor LLM para RAG em produção em 2026?
Para a maioria dos casos de uso, GPT-4o atinge o doce ponto de qualidade, velocidade e custo. Claude 3.5 Sonnet é excelente quando você precisa lidar com contexto mais longo ou raciocínio mais nuançado. Para aplicações sensíveis a custo com queries mais simples, GPT-4o-mini ou Claude 3.5 Haiku funcionam surpreendentemente bem -- vimos eles corresponder a qualidade GPT-4o em Q&A direto quando a recuperação é boa. O modelo importa menos que sua qualidade de recuperação e engenharia de prompts.
Devo usar Edge Runtime ou Node.js Runtime para RAG Route Handlers?
Node.js, quase sempre. Edge Runtime tem limitações de conexão que tornam doloroso trabalhar com a maioria dos bancos de dados vetoriais e ORMs. A vantagem de cold start do Edge é negligenciável para endpoints de streaming já que o usuário já está esperando por recuperação + geração. Use Edge para endpoints proxy simples ou rotas não-RAG.
Como prevenho alucinações em respostas RAG?
Três estratégias que realmente funcionam: (1) Instrua explicitamente o modelo a dizer "Não tenho informação suficiente" quando contexto é insuficiente -- e inclua exemplos no seu prompt. (2) Use temperatura baixa (0.1-0.3) para queries factuais. (3) Implemente um requisito de citação -- quando o modelo deve citar chunks específicos, é muito mais difícil para ele alucinar. Verificação post-hoc (checar se as claims aparecem nos chunks de origem) adiciona outra camada de segurança.
Quantos chunks devo recuperar para contexto RAG?
Recupere mais do que você usa. Tipicamente recupero 15-20 chunks, re-rankeio, depois uso os top 3-5 como contexto primário e incluo resumos dos próximos 3-5. Despejar 20 chunks completos na janela de contexto degrada qualidade. O modelo fica confuso com informação irrelevante mesmo quando informação relevante está presente. Qualidade sobre quantidade, sempre.
pgvector é bom o suficiente para produção, ou eu preciso de um banco de dados vetorial dedicado?
Para até ~1M vetores, pgvector com indexação HNSW em Supabase ou Neon é absolutamente pronto para produção. O desempenho da query é excelente e você ganha o benefício de ficar no seu ecossistema Postgres existente. Além de 1M vetores ou se você precisa de filtragem avançada com busca vetorial, opções dedicadas como Pinecone ou Qdrant começam a se destacar. Rodamos pgvector em produção para vários projetos Astro e Next.js sem problemas.
Como lido com respostas de streaming com loading states em React?
O hook useChat do Vercel AI SDK oferece um booleano isLoading, mas é mais nuançado que isso. O hook transiciona através de estados: idle → waiting (sem tokens ainda) → streaming (tokens chegando) → idle. Para o melhor UX, mostre um indicador de digitação durante a fase de waiting e renderize markdown progressivamente durante streaming. Use um renderizador markdown que lida com markdown incompleto graciosamente -- react-markdown funciona mas pode piscar; considere fazer buffer de alguns tokens antes de renderizar.
Qual é a melhor forma de lidar com conversas multi-turn em RAG?
Não faça re-retrieve em toda mensagem. Use o histórico de conversa para determinar se a nova mensagem é um follow-up (precisa do mesmo contexto) ou uma mudança de tópico (precisa de novo retrieval). Um classificador simples -- até um baseado em regex checando por pronomes e referências -- pode poupar muitas buscas desnecessárias em vetor. Quando você faz re-retrieve, inclua o resumo da conversa na query de recuperação, não apenas a última mensagem.
Com que frequência devo re-indexar meus documentos para RAG?
Depende de com que frequência seus dados de origem mudam. Para bases de conhecimento estáticas, um re-index completo semanal é bom. Para conteúdo dinâmico (sites dirigidos por CMS, documentação que atualiza diariamente), configure indexação incremental acionada por webhook. A chave é ter um pipeline que pode atualizar chunks individuais sem re-indexar tudo. Construímos isso em nossas integrações de headless CMS -- quando conteúdo atualiza em Sanity ou Contentful, os chunks afetados são re-embarcados e upsertados automaticamente.