Objetivo da Aula
Implementar um pipeline RAG completo: indexação com embeddings → busca por similaridade → geração com contexto
Calcular similaridade cosseno entre embeddings para encontrar documentos relevantes
Distinguir embedding simplificado (didático) de embedding de produção e suas implicações
Projetar a estratégia de chunking adequada para diferentes tipos de documento
Avaliar qualidade de um sistema RAG com métricas de faithfulness e relevância
Por que isso importa
O problema mais comum que empresas encontram ao adotar LLMs: o modelo não conhece seus dados. Dados de clientes, documentação interna, histórico de tickets, contratos — tudo isso fica de fora do conhecimento do modelo.
FinOps: TCO Comparativo Fine-tuning vs RAG vs Context Stuffing
Três estruturas de custo fundamentalmente diferentes: Fine-tuning tem CAPEX alto ($1k–$100k de treino) + custo recorrente de inferência do modelo ajustado + re-treino quando dados mudam. RAG tem custo inicial de indexação (tokens da base × preço de embedding) + custo por query (embedding + tokens de contexto + LLM) + storage do vector store — tudo OPEX variável. Context stuffing tem custo por query proporcional ao tamanho do documento inteiro — cresce com o corpus. Pivô de decisão: se os dados mudam frequentemente, RAG vence; se o comportamento precisa ser consistente em alto volume, fine-tuning pode ter payback positivo após ~7 meses de economia de tokens.
Três soluções possíveis: 1. Fine-tuning: treinar o modelo com seus dados. Caro ($1.000-$100.000), lento (dias), e os dados de treino ficam “congelados” no modelo — difícil de atualizar. 2. Context stuffing: colocar todos os documentos no prompt. Para mais de ~100 páginas, excede a context window ou fica proibitivamente caro. 3. RAG: indexar documentos como embeddings, buscar apenas os relevantes, inserir no contexto. Escalável, atualizável, custo controlável.
RAG ganha em 90% dos casos porque: - Documentos podem ser atualizados a qualquer momento (re-indexar é barato) - Você paga apenas pelos tokens dos documentos relevantes, não de toda a base - Você pode citar fontes (faithfulness), o que fine-tuning não permite facilmente - Funciona com bases de milhões de documentos (limitação de context window não se aplica à base)
Um caso concreto: empresa com 10.000 páginas de documentação técnica. Fine-tuning seria inviável (atualização semanal de docs). Context stuffing impossível (excede context window por 100×). RAG: indexa uma vez, busca em < 200ms, resposta com citação de fonte.
Conceitos Fundamentais
Como RAG funciona: o pipeline completo
FASE DE INDEXAÇÃO (roda uma vez, offline):
Documentos brutos
(PDFs, Markdown, DB)
↓
Chunking
(dividir em pedaços de ~500 tokens)
↓
Geração de embeddings
(API de embedding → vetor float[])
↓
Armazenamento no vector store
(pgvector, Pinecone, Qdrant, Chroma)
FASE DE CONSULTA (roda a cada pergunta):
Pergunta do usuário
↓
Embedding da pergunta
↓
Busca por similaridade no vector store
(top-K mais similares)
↓
Construção do prompt com contexto
(pergunta + documentos encontrados)
↓
LLM gera resposta fundamentada Embeddings: o coração do RAG
Um embedding transforma texto em um vetor numérico de alta dimensão onde textos semanticamente similares ficam próximos:
"O MCP foi criado pela Anthropic" → [0.23, -0.87, ..., 0.14] (1536 dims)
"Model Context Protocol da Anthropic" → [0.21, -0.85, ..., 0.12] (1536 dims)
"Receita de bolo de chocolate" → [-0.45, 0.23, ..., -0.78] (1536 dims)
Distância cosseno:
MCP ↔ MCP_alternativo: 0.97 (muito similar ✓)
MCP ↔ receita: 0.08 (muito diferente ✓)Modelos de embedding em produção:
FinOps: Custo de Indexação e Break-even Self-hosted
Indexar 1 milhão de documentos de 500 tokens cada = 500M tokens de embedding. A $0.0001/1k tokens (ada-002), isso custa $50 na primeira indexação. Re-indexação completa ao trocar de modelo: o mesmo custo de novo. Modelos self-hosted (como all-MiniLM rodando em CPU) têm custo zero marginal mas exigem infraestrutura: instância CPU (~$50-200/mês) ou GPU. Break-even: se você indexa e re-indexa frequentemente com volume alto, self-hosted paga em meses. Se o volume é baixo ou a indexação é única, API paga é mais barata (sem custo de infra idle). O vetor de decisão é volume × frequência de re-indexação.
Modelo | Dimensões | Custo | Melhor para |
text-embedding-ada-002 (OpenAI) | 1536 | $0.0001/1k tokens | Inglês, geral |
text-embedding-3-large (OpenAI) | 3072 | $0.00013/1k tokens | Melhor qualidade |
voyage-3 (Anthropic) | 1024 | $0.00006/1k tokens | Multilíngue, código |
all-MiniLM-L6 (local) | 384 | Grátis (CPU) | Privacidade total, baixo custo |
Para português: voyage-3 da Anthropic tem melhor performance multilíngue. text-embedding-ada-002 funciona mas foi treinado majoritariamente em inglês.
Chunking: dividindo documentos para indexação
Documentos longos não podem ser indexados como um bloco único — perderiam precisão na busca. Chunking divide em pedaços menores:
Decisão de Arquitetura: Estratégia de Chunking
Chunking determina a granularidade do que o LLM recebe como contexto. Três estratégias com casos de uso distintos:
• Fixed-size (N tokens com overlap): simples, funciona bem para texto contínuo homogêneo (artigos, relatórios). Problema: pode cortar uma ideia no meio.
• Semantic (por parágrafo/seção): preserva unidades de significado. Use para documentos estruturados em seções (manuais, FAQs).
• Parent-child: indexa chunks pequenos (para recuperação precisa) mas entrega o chunk pai maior ao LLM (para contexto suficiente). Use quando precisão de recuperação e qualidade de resposta são ambas importantes.
Reranking (cross-encoder): adiciona uma segunda fase mais precisa mas mais cara. Use quando recall do retriever inicial é alto mas você precisa melhorar precisão — e quando latência da segunda fase é aceitável.
Fixed-size chunking: divide em N tokens com overlap
def chunkar_fixo(texto, tamanho=500, overlap=50):
palavras = texto.split()
chunks = []
for i in range(0, len(palavras), tamanho - overlap):
chunk = ' '.join(palavras[i:i + tamanho])
chunks.append(chunk)
return chunksSimples mas ignora estrutura do documento — pode cortar no meio de um parágrafo.
Semantic chunking: divide em parágrafos ou seções naturais
def chunkar_semantico(texto):
# Divide por parágrafos (linhas em branco)
paragrafos = [p.strip() for p in texto.split('\n\n') if p.strip()]
# Agrupa parágrafos pequenos, divide parágrafos grandes
return [p for p in paragrafos if len(p) > 50]Mais inteligente — preserva contexto semântico. Melhor para documentação técnica e artigos.
Parent-child chunking (avançado): indexa chunks pequenos (para precisão) mas retorna o chunk pai (para contexto):
Documento: "Como configurar autenticação JWT. [Seção 1] Gerar chave privada... [Seção 2] Configurar middleware..."
Indexação:
chunk1 = "Gerar chave privada com openssl genrsa -out private.key 2048"
chunk2 = "Configurar middleware com jwt.verify(token, publicKey)"
Busca por "como verificar JWT":
→ Encontra chunk2 (alta similaridade)
Retorno para o LLM:
→ Retorna o documento pai completo (toda a seção de JWT)
→ Mais contexto = resposta melhorFundamento: API de Embeddings
Gerar embeddings é uma chamada de API separada da inferência do LLM — outro endpoint, outra cobrança. Você chama a API de embeddings com um texto e recebe um vetor de N dimensões (ex: 1536 para text-embedding-ada-002). Regra crítica: o modelo de embeddings usado na indexação deve ser o mesmo usado na query — vetores de modelos diferentes vivem em espaços incompatíveis. Se você trocar o modelo de embeddings, precisa re-indexar todo o corpus. As dimensões do vetor são fixas por modelo — você não escolhe. Para o vector store, cada documento indexado custa uma chamada de embeddings; cada query também. Esse custo de indexação inicial (e re-indexação) deve entrar no TCO do sistema RAG.
k-NN e ANN: como o vector store busca
k-NN (k-Nearest Neighbors) exato: compara a query com TODOS os vetores do banco.
Para 1M de documentos:
1M × similaridade_cosseno → ordena → retorna top-K
Tempo: O(n) linear — pode ser lentoANN (Approximate Nearest Neighbors): usa índices (HNSW, IVF) que permitem busca em tempo sublinear com precisão de ~95-99%.
HNSW (Hierarchical Navigable Small World):
Pré-computa um grafo de "vizinhança" entre vetores
Busca navega o grafo em vez de comparar todos
Tempo: O(log n) — muito mais rápido
Trade-off: ~5% de resultados podem não ser os exatos mais similaresPara a maioria dos casos de uso, ANN com 99% de recall é indistinguível de k-NN exato, com 10-100× mais velocidade.
Aprofundamento Técnico
Métricas de qualidade de RAG
Como saber se seu RAG está funcionando bem? Três métricas principais:
Faithfulness (fidelidade): a resposta está ancorada nos documentos recuperados?
# Teste manual: a resposta pode ser derivada dos documentos?
documentos = recuperar(pergunta)
resposta = llm.gerar(pergunta, documentos)
# Se a resposta menciona informação que NÃO está em nenhum documento recuperado
# → alucinação de RAG (o modelo "completou" com conhecimento próprio)Context Relevance (relevância do contexto): os documentos recuperados são relevantes para a pergunta?
# Top-3 recuperados para "O que é pgvector?"
doc1: "pgvector é uma extensão do PostgreSQL..." → ✓ relevante
doc2: "RAG combina busca semântica com geração..." → parcialmente relevante
doc3: "Prompt caching reduz custos..." → ✗ irrelevante
# Precision@3 = 2/3 = 67% — pode melhorarAnswer Relevance (relevância da resposta): a resposta realmente responde a pergunta?
# Pergunta: "Qual a diferença entre RAG e fine-tuning?"
# Resposta: "RAG foi proposto em 2020..." (menciona data mas não responde)
# → Baixa answer relevanceRAGAS é um framework Python que automatiza essas métricas usando LLM-as-judge.
Reranking: segunda camada de relevância
Vector search por embedding tem limitações — captura semântica geral mas pode perder nuances. Reranking adiciona uma segunda fase mais precisa:
Fase 1 (rápida): embedding search recupera top-50 candidatos
Fase 2 (precisa): cross-encoder reranker analisa a query junto com cada candidato
→ retorna top-3 mais relevantes
Cross-encoder: modelo que recebe [query, documento] e retorna score de relevância
→ Mais lento (O(k) vs O(1) do embedding search)
→ Muito mais preciso (considera interação direta entre query e documento)Models de reranking: cross-encoder/ms-marco-MiniLM-L-6-v2 (open source), Cohere Rerank (API), Voyage Rerank (Anthropic).
HyDE: melhorando queries ambíguas
HyDE (Hypothetical Document Embeddings): para queries vagas, o LLM primeiro gera uma resposta hipotética, depois usa o embedding dessa resposta para buscar documentos:
def hyde_search(query, vector_store):
# 1. LLM gera documento hipotético que responderia a query
doc_hipotetico = llm.gerar(f"Escreva um parágrafo respondendo: {query}")
# 2. Usa embedding do documento hipotético (não da query!) para buscar
embedding_hipotetico = embedder.embed(doc_hipotetico)
documentos = vector_store.buscar(embedding_hipotetico, top_k=5)
# 3. Usa documentos reais encontrados para gerar resposta final
return llm.gerar(query, documentos)Por que funciona? “O que é pgvector?” tem embedding de uma pergunta. Um parágrafo explicando pgvector tem embedding de resposta. Documentos no banco são explicações — mais similares à explicação hipotética do que à pergunta.
Exemplos Anotados
Exemplo 1: Implementação completa do VectorStore
const Anthropic = require('@anthropic-ai/sdk')
const client = new Anthropic.default()
// Embedding simplificado baseado em palavras-chave
// NOTA: Em produção, use API real de embeddings (voyage-3, ada-002)
// A diferença de qualidade é enorme — esse é apenas para fins didáticos
function gerarEmbeddingSimples(texto) {
const palavrasChave = {
mcp: 1, anthropic: 1, protocol: 1,
rag: 2, retrieval: 2, busca: 2,
embedding: 3, vetor: 3, similaridade: 3,
transformer: 4, attention: 4, token: 4,
finetuning: 5, treinamento: 5, modelo: 5,
}
const textoNorm = texto.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '')
const vetor = new Array(5).fill(0)
for (const [palavra, categoria] of Object.entries(palavrasChave)) {
if (textoNorm.includes(palavra)) {
vetor[categoria - 1] += 1 // incrementa a dimensão da categoria
}
}
return vetor
}
function similCosseno(a, b) {
// Produto escalar usando reduce — mais conciso que loop explícito
const dot = a.reduce((sum, ai, i) => sum + ai * b[i], 0)
// sqrt da soma dos quadrados = norma L2
const normA = Math.sqrt(a.reduce((sum, ai) => sum + ai * ai, 0))
const normB = Math.sqrt(b.reduce((sum, bi) => sum + bi * bi, 0))
// Proteção contra divisão por zero (vetor nulo = documento sem palavras conhecidas)
return normA === 0 || normB === 0 ? 0 : dot / (normA * normB)
}
class VectorStore {
constructor() {
this.indices = [] // [{ id, texto, embedding }]
}
indexar(documentos) {
// Para cada documento, geramos e armazenamos seu embedding
// Em produção: batch requests de embedding para economizar custo
this.indices = documentos.map((doc) => ({
id: doc.id,
texto: doc.texto,
embedding: gerarEmbeddingSimples(doc.texto), // em produção: await embedder.embed(doc.texto)
}))
console.log(` Indexados ${this.indices.length} documentos`)
}
buscar(query, topK = 3) {
// Geramos embedding da query com o MESMO modelo dos documentos
// Usar modelos diferentes quebraria a comparação (espaços vetoriais diferentes)
const embeddingQuery = gerarEmbeddingSimples(query)
// Calculamos similaridade com todos os documentos indexados
const resultados = this.indices.map((doc) => ({
...doc,
similaridade: similCosseno(embeddingQuery, doc.embedding),
}))
// Ordenamos do mais para o menos similar e retornamos os top-K
return resultados
.sort((a, b) => b.similaridade - a.similaridade)
.slice(0, topK)
}
}Exemplo 2: Geração com contexto fundamentado
async function gerarResposta(pergunta, documentosContexto) {
// Formata os documentos como contexto numerado
// Numeração permite que a resposta cite fontes: "De acordo com o documento 2..."
const contexto = documentosContexto
.map((doc) => `[Documento ${doc.id}]: ${doc.texto}`)
.join('\n\n')
const prompt = `Com base nos documentos a seguir, responda à pergunta.
Cite os números dos documentos usados na resposta.
Se a resposta não estiver nos documentos, diga "Essa informação não está na base de conhecimento."
${contexto}
Pergunta: ${pergunta}
Resposta:`
const resposta = await client.messages.create({
model: 'claude-haiku-4-5-20251001',
max_tokens: 300,
messages: [{ role: 'user', content: prompt }],
})
return resposta.content[0].text
}
// Uso completo:
const store = new VectorStore()
store.indexar(documentos)
const pergunta = 'Qual a diferença entre RAG e fine-tuning?'
const docsRelevantes = store.buscar(pergunta, 3)
const resposta = await gerarResposta(pergunta, docsRelevantes)
console.log(resposta)
// "De acordo com os documentos 2 e 5, RAG usa recuperação em tempo de inferência
// enquanto fine-tuning adapta os pesos do modelo durante treinamento..."Output esperado:
Documentos recuperados:
0.82 - "RAG combina busca semântica com geração..."
0.71 - "Fine-tuning adapta um modelo pré-treinado..."
0.45 - "Embeddings são representações vetoriais..."
Resposta:
De acordo com os documentos 2 e 5, RAG usa recuperação de contexto em tempo de
inferência — busca documentos relevantes e os inclui no prompt — enquanto fine-tuning
adapta os pesos do próprio modelo durante treinamento com exemplos rotulados.
RAG é mais fácil de atualizar (re-indexar documentos), enquanto fine-tuning congela
o conhecimento no modelo e exige re-treinamento para atualizar.Padrões e Armadilhas
Padrões recomendados
Padrão 1: Sempre cite as fontes no prompt Instrua o LLM a mencionar os IDs ou títulos dos documentos usados. Isso habilita: (a) o usuário verificar a fonte, (b) você detectar alucinações (resposta que não está em nenhum documento citado), (c) auditoria em sistemas regulados.
Padrão 2: Use o mesmo modelo de embedding para indexação e query Se você indexa com voyage-3 e busca com text-embedding-ada-002, os vetores estão em espaços diferentes — a busca retorna lixo. Trate o modelo de embedding como um contrato: mude e precisa re-indexar toda a base.
Padrão 3: Armazene texto original junto com embedding No vector store, armazene { embedding, texto_original, metadata }. Você vai precisar do texto para incluir no prompt de geração. Metadados (data, autor, URL) permitem filtragem antes da busca.
Armadilhas comuns
⚠️ Armadilha 1: Chunks muito grandes reduzem precisão da busca O que acontece: você indexa páginas inteiras (2.000+ palavras). O embedding de um documento longo captura a média do conteúdo — fica “genérico”. A busca retorna documentos que tangenciam o tópico mas não o respondem diretamente. Versão correta: chunks de 300-800 tokens com 10-15% de overlap. Para documentação técnica, um chunk = um conceito ou procedimento específico.
⚠️ Armadilha 2: Não testar com perguntas que deveriam falhar O que acontece: você testa só com perguntas que têm resposta nos documentos. Em produção, usuário pergunta sobre algo fora da base — sistema inventa resposta com confiança. Versão correta: teste com perguntas “fora do domínio”. A instrução “se a resposta não estiver nos documentos, diga X” precisa ser testada explicitamente.
⚠️ Armadilha 3: Re-indexar apenas documentos novos mas não detectar remoções O que acontece: você remove um documento (política desatualizada, produto descontinuado). O embedding permanece no vector store. Usuários continuam recebendo respostas baseadas no documento removido. Versão correta: implemente controle de versão no vector store (soft delete com tombstone). Ao re-indexar, marque embeddings de documentos removidos e os exclua da busca.
Com RAG dominado, você está pronto para a Parte II — onde o ecossistema de provedores e as nuances de custo, cache e confiabilidade de API transformam esses conceitos em sistemas de produção.
Se não for realizar o laboratório, pule para o próximo capítulo.
Ponte para o Lab
O starter tem 3 TODOs:
TODO 1 — VectorStore.indexar() Seção de referência: “Exemplos Anotados → Exemplo 1 → método indexar”. Para cada documento em docs:
this.indices.push({
id: doc.id,
texto: doc.texto,
embedding: gerarEmbeddingSimples(doc.texto)
})Adicione o console.log de progresso para cada documento — útil para debug quando a base é grande.
TODO 2 — VectorStore.buscar() Seção de referência: “Conceitos Fundamentais → k-NN e ANN” e “Exemplos Anotados → Exemplo 1 → método buscar”. 3 passos: (1) gerar embedding da query, (2) calcular similCosseno entre embedding da query e embedding de cada doc indexado, (3) ordenar por similaridade decrescente e retornar os primeiros topK.
TODO 3 — gerarResposta() Seção de referência: “Exemplos Anotados → Exemplo 2”. Monte o contexto como string com documentos numerados, crie o prompt com instrução de citar fontes e de dizer “não sei” quando não tiver resposta, e chame client.messages.create().
Dica para o TODO mais difícil (TODO 2): o erro mais comum é esquecer de gerar o embedding da QUERY (não usar o embedding de um documento aleatório como referência). O embedding da query e os embeddings dos documentos devem usar a mesma função gerarEmbeddingSimples().
Agora você está pronto para o lab.