mozak.tech Engenharia de IA Corporativa 53%

Parte I — Alicerces da Inteligência Artificial Moderna

1.9 — Memória Privada para LLMs: Recuperação Aumentada por Geração (RAG & Embeddings)

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 chunks

Simples 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 melhor
Fundamento: 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 lento

ANN (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 similares

Para 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 melhorar

Answer 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 relevance

RAGAS é 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.

⚗ Laboratório prático — mozak.tech
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.