RAG — quando e como fazer certo
A diferença entre um chatbot inútil e um assistente real.
RAG (Retrieval-Augmented Generation) é buscar contexto relevante antes de gerar a resposta. Sem RAG, o LLM responde com conhecimento de treinamento genérico. Com RAG bem feito, responde com o seu contexto específico — documentação, base de conhecimento, histórico de decisões.
O erro mais comum: usar apenas busca semântica (vetores) ignorando busca léxica (BM25/FTS). Para a maioria dos casos de uso, uma busca híbrida (semântica + léxica) supera qualquer uma das duas isoladas.
Python
# Arquitetura RAG mínima funcional
# 1. Indexação (roda offline, quando o conteúdo muda)
def index_document(doc: str, doc_id: str):
# Chunking — blocos com overlap para não perder contexto
chunks = chunk_with_overlap(doc, size=800, overlap=100)
for i, chunk in enumerate(chunks):
embedding = embed(chunk) # via API: text-embedding-004
vector_store.upsert(
id=f"{doc_id}_{i}",
vector=embedding,
metadata={"text": chunk, "doc_id": doc_id}
)
# 2. Retrieval (em tempo real, por query do usuário)
def retrieve(query: str, top_k: int = 5) -> list[str]:
query_embedding = embed(query)
# Busca semântica (vetores)
semantic_results = vector_store.query(query_embedding, top_k=top_k)
# Busca léxica (palavras exatas) — não ignore isso
lexical_results = full_text_search(query, top_k=top_k)
# Rerank híbrido — RRF (Reciprocal Rank Fusion)
return reciprocal_rank_fusion([semantic_results, lexical_results])
# 3. Generation — contexto injetado no prompt
def answer(query: str) -> str:
context_chunks = retrieve(query)
context = "\n\n".join(context_chunks)
prompt = f"""Responda baseado APENAS no contexto abaixo.
Se a resposta não estiver no contexto, diga explicitamente.
CONTEXTO:
{context}
PERGUNTA: {query}"""
return llm.generate(prompt)
Custo de LLM — o que não te contam
Token de entrada é mais barato que token de saída. Use isso.
O custo de LLM em produção não é o que parece no playground. Em produção, o custo vem de quatro frentes: tokens de sistema (fixos por request), histórico de conversa (cresce a cada turno), contexto RAG (por request) e output do modelo.
O que funciona na prática: comprimir o histórico ao invés de mandar 10 turnos completos, rotear por complexidade usando modelo pequeno para classificação e modelo grande só para resposta final, cache de resposta para queries idênticas ou similares (por embedding distance), e sempre definir maxOutputTokens explícito porque modelos são verbosos por natureza.
Python
# Controle de custo real em produção
# 1. Compressão de histórico — não mande tudo
def compress_history(history: list[dict], max_turns: int = 4) -> str:
if len(history) <= max_turns:
return format_history(history)
# Resumo dos turnos antigos + últimas N mensagens completas
old_turns = history[:-max_turns]
recent_turns = history[-max_turns:]
summary = llm.generate(
f"Resuma em 2-3 frases os pontos principais desta conversa: {old_turns}",
config={"maxOutputTokens": 150} # resumo curto, modelo pequeno
)
return f"[Resumo anterior: {summary}]\n{format_history(recent_turns)}"
# 2. Roteamento por complexidade — modelo certo para cada tarefa
def route_to_model(query: str) -> str:
# Classificação rápida e barata
is_complex = classifier.predict(query) # modelo local ou flash
return "gemini-2.5-pro" if is_complex else "gemini-2.5-flash"
# 3. Limite explícito de output
generation_config = {
"maxOutputTokens": 600, # suficiente para resposta útil
"temperature": 0.3, # mais determinístico = menos tokens desperdiçados
}
LGPD + IA — o que é obrigatório
Dados pessoais não saem da sua infraestrutura sem consentimento explícito.
Toda vez que você manda um dado para uma API de LLM externa (OpenAI, Anthropic, Google), esse dado potencialmente alimenta treinamento futuro — a menos que você tenha acordos específicos (API tier, contratos enterprise).
Para dados pessoais (CPF, email, nome, localização), você tem três opções: modelo local (sem dados saindo), anonymization antes de mandar, ou contrato explícito com a plataforma garantindo que os dados não são usados para treinamento.
O artigo 6º da LGPD exige finalidade, adequação e necessidade. Se você está mandando um registro completo de cliente para o LLM mas só precisa de parte, já viola a necessidade.
Python
# Anonymization básica antes de enviar para LLM externo
import re
def anonymize_for_llm(text: str) -> tuple[str, dict]:
"""Remove PII antes de enviar. Retorna texto limpo + mapa para substituição."""
replacements = {}
counter = {"cpf": 0, "email": 0, "phone": 0}
# CPF
def replace_cpf(m):
key = f"[CPF_{counter['cpf']}]"
replacements[key] = m.group(0)
counter["cpf"] += 1
return key
# Email
def replace_email(m):
key = f"[EMAIL_{counter['email']}]"
replacements[key] = m.group(0)
counter["email"] += 1
return key
text = re.sub(r'\d{3}\.\d{3}\.\d{3}-\d{2}', replace_cpf, text)
text = re.sub(r'[\w.-]+@[\w.-]+\.\w+', replace_email, text)
return text, replacements
# Uso
clean_text, pii_map = anonymize_for_llm(user_message)
llm_response = llm.generate(clean_text)
# Nunca logue pii_map — ele contém os dados originais
# Use apenas se precisar reconstituir a resposta com dados reais
IA Local vs IA Cloud
Não é "ou". É saber onde cada uma faz sentido.
IA local (Ollama, vLLM, llama.cpp) roda no seu hardware. Zero dados saindo. Latência previsível. Custo fixo. Funciona para classificação, extração de entidades, moderação de conteúdo e qualquer caso onde privacidade é inegociável.
IA cloud (Vertex AI, AWS Bedrock, Azure OpenAI) entrega modelos maiores e mais capazes sem o custo de manter GPU. Funciona para geração de texto complexo, RAG com modelos de 70B+, e qualquer cenário onde a qualidade do output compensa o custo por token.
A decisão correta quase sempre é usar os dois. Modelo local pequeno para roteamento e classificação, modelo cloud grande para resposta final. O roteamento local custa centavos de eletricidade. O modelo cloud só é chamado quando o resultado precisa ser excepcional.
Para quem opera em múltiplas clouds (Azure, GCP, AWS), o padrão é abstrair o provider por interface. Troca de Vertex para Bedrock sem mexer na lógica. O mesmo princípio de Dependency Inversion que funciona em banco de dados funciona aqui.
Python
# Abstração de provider: troca de cloud sem mexer na lógica
from abc import ABC, abstractmethod
class LLMProvider(ABC):
@abstractmethod
def generate(self, prompt: str, config: dict) -> str: ...
# Google Cloud
class VertexProvider(LLMProvider):
def generate(self, prompt: str, config: dict) -> str:
from vertexai.generative_models import GenerativeModel
model = GenerativeModel(config.get("model", "gemini-2.5-flash"))
response = model.generate_content(prompt)
return response.text
# AWS
class BedrockProvider(LLMProvider):
def generate(self, prompt: str, config: dict) -> str:
import boto3
client = boto3.client("bedrock-runtime")
# Claude via Bedrock
response = client.invoke_model(
modelId=config.get("model", "anthropic.claude-3-haiku"),
body={"prompt": prompt, "max_tokens": config.get("max_tokens", 600)}
)
return response["body"].read().decode()
# Local (Ollama)
class OllamaProvider(LLMProvider):
def generate(self, prompt: str, config: dict) -> str:
import requests
r = requests.post("http://localhost:11434/api/generate", json={
"model": config.get("model", "llama3.2"),
"prompt": prompt, "stream": False
})
return r.json()["response"]
# Roteamento inteligente
def get_provider(task_type: str) -> LLMProvider:
if task_type in ("classify", "extract", "moderate"):
return OllamaProvider() # local, rápido, privado
if task_type == "generate":
return VertexProvider() # cloud, modelo grande
return BedrockProvider() # fallback multi-cloud