O problema real
Você descobriu RAG e achou genial: em vez de fazer fine-tuning de um LLM com mil documentos, você simplesmente recupera os trechos relevantes na hora e joga no prompt. Economiza semanas. Mas aí na primeira semana em produção seu retriever começa a devolver documentos que não têm nada a ver. O usuário pergunta "qual é a política de devolução?" e o sistema retorna um guia sobre política fiscal de 2019. Não é mágica: é que ninguém olhou para como o documento foi cortado, qual embedding estava sendo usado, ou qual era a métrica de similaridade.
RAG (Retrieval-Augmented Generation) é simples na teoria: indexe seus documentos, recupere os mais parecidos com a pergunta, monte um prompt com eles, envie pro LLM. Na prática, cada etapa tem armadilhas que colapsam a qualidade. Este artigo mostra como montar um pipeline que funciona — números reais, com código que roda.
Como montar um pipeline RAG que recupera documentos relevantes
RAG tem cinco etapas: divisão do texto (chunking), geração de embeddings, armazenamento vetorial, busca e montagem do prompt. Vamos em cada uma.
Etapa 1: Chunking
Você não pode jogar o documento inteiro num embedding — seria muito grande e perdia o contexto fino. Precisa dividir em pedaços (chunks). A pergunta é: qual é o tamanho certo?
256 tokens é muito pequeno: você perde contexto. 1.024 é grande demais: mistura assuntos. 512 com 20% de sobreposição é o doce spot em 90% dos casos. Por quê? Porque chunks de 512 tokens cabem direto no contexto de um LLM, e a sobreposição garante que se uma sentença importante cair no meio de dois chunks, ela aparece inteira em um deles.
import tiktoken
from typing import List
def chunk_text(text: str, chunk_size: int = 512, overlap: int = 0.2) -> List[str]:
"""Divide um texto em chunks com sobreposição.
Args:
text: Texto bruto a dividir
chunk_size: Tamanho de cada chunk em tokens
overlap: Sobreposição como percentual (0.2 = 20%)
Returns:
Lista de chunks de texto
"""
encoding = tiktoken.get_encoding("cl100k_base")
tokens = encoding.encode(text)
overlap_tokens = int(chunk_size * overlap)
stride = chunk_size - overlap_tokens
chunks = []
for i in range(0, len(tokens), stride):
chunk_tokens = tokens[i : i + chunk_size]
chunk_text = encoding.decode(chunk_tokens)
chunks.append(chunk_text)
if i + chunk_size >= len(tokens):
break
return chunks
# Teste com um documento real
sample_doc = """
A política de devolução de produtos permite que clientes devolvam itens em até 30 dias.
O reembolso é integral se o produto estiver em bom estado. Não aceitamos devoluções
de itens que foram usados ou danificados pelo cliente. Para iniciar uma devolução,
entre em contato com nosso suporte através do formulário no site.
"""
chunks = chunk_text(sample_doc)
for i, chunk in enumerate(chunks):
print(f"Chunk {i}: {len(chunk)} caracteres\n{chunk}\n---")
Etapa 2: Embeddings
Um embedding é um vetor que representa o significado do texto. "Qual é a política de devolução?" tem um embedding parecido com "como faço para devolver?" porque significam a mesma coisa. Para gerar embeddings, você pode usar:
- OpenAI
text-embedding-3-small: USD 0,02 / 1M tokens. 1.536 dimensões. Ótimo custo-benefício. - Ollama local com
nomic-embed-text: Grátis, roda local, mas mais lento. - Hugging Face
all-MiniLM-L6-v2: Grátis, código aberto, suficiente para português.
Para 10 mil documentos em chunks de 512 tokens, usando OpenAI: 5 milhões de tokens × USD 0,02 / 1M = USD 0,10. Barato. Custa mais enviar para a API 10 mil vezes que o embedding em si.
from openai import OpenAI
def generate_embeddings(texts: List[str], model: str = "text-embedding-3-small") -> List[List[float]]:
"""Gera embeddings para uma lista de textos usando OpenAI.
Args:
texts: Lista de strings
model: Modelo de embedding
Returns:
Lista de vetores (embedding de cada texto)
"""
client = OpenAI()
response = client.embeddings.create(
model=model,
input=texts
)
# Resposta vem como List[dict], cada dict tem 'embedding'
embeddings = [item.embedding for item in response.data]
return embeddings
# Gera embeddings dos chunks
chunks = ["Qual é a política de devolução?", "Devoluções em até 30 dias"]
embeddings = generate_embeddings(chunks)
print(f"Embedding do primeiro chunk: {len(embeddings[0])} dimensões")
Etapa 3: Armazenamento vetorial
Você precisa guardar os embeddings de forma que possa buscar rapidamente. Um banco vetorial índexiza pelo vetor, não por string. Opções:
- Chroma (em memória ou disco): Ótimo para começar, 0 config.
- Weaviate (nuvem ou local): Mais robusto, pronto para produção.
- Pinecone (serverless): Sem gerenciar infra, USD 0,04 por 100K embeddings armazenados/mês.
- NumPy + similitude de cosseno: Funciona para datasets pequenos (< 10k vetores), é didático.
Para este exemplo, usaremos Chroma:
import chromadb
from chromadb.config import Settings
def store_embeddings_chroma(chunks: List[str], embeddings: List[List[float]]) -> chromadb.Collection:
"""Armazena chunks e embeddings em Chroma (em memória).
Args:
chunks: Lista de textos originais
embeddings: Lista de vetores (já gerados)
Returns:
Coleção do Chroma para fazer buscas
"""
client = chromadb.Client()
collection = client.create_collection(name="documents")
# Chroma precisa de um ID único para cada item
ids = [f"doc_{i}" for i in range(len(chunks))]
collection.add(
ids=ids,
embeddings=embeddings,
metadatas=[{"source": "policy"} for _ in chunks],
documents=chunks
)
return collection
# Exemplo:
doc_chunks = [
"A política de devolução permite até 30 dias",
"Reembolso integral em bom estado"
]
doc_embeddings = generate_embeddings(doc_chunks)
collection = store_embeddings_chroma(doc_chunks, doc_embeddings)
Etapa 4: Busca
A busca recupera os K chunks mais parecidos com a pergunta (típico K=3 ou K=4). A similaridade é medida usando distância de cosseno: se o ângulo entre dois vetores é pequeno, eles são parecidos.
def retrieve(query: str, collection: chromadb.Collection, k: int = 3) -> List[str]:
"""Recupera os K chunks mais relevantes para uma query.
Args:
query: Pergunta do usuário
collection: Coleção do Chroma
k: Número de chunks a recuperar
Returns:
Lista com os chunks mais relevantes
"""
# Gera embedding da pergunta
query_embedding = generate_embeddings([query])[0]
# Busca no Chroma
results = collection.query(
query_embeddings=[query_embedding],
n_results=k,
include=["documents", "distances"]
)
# Chroma retorna documentos em ordem de relevância
retrieved_docs = results["documents"][0] # [0] porque retorna lista de listas
distances = results["distances"][0]
print(f"Pergunta: {query}")
for i, (doc, distance) in enumerate(zip(retrieved_docs, distances)):
print(f" [{i+1}] (distância: {distance:.3f}) {doc[:80]}...")
return retrieved_docs
# Teste
query = "Como faço para devolver um produto?"
relevant_chunks = retrieve(query, collection, k=2)
Etapa 5: Montagem do prompt
Aqui você pega os chunks recuperados e faz um prompt estruturado:
def build_rag_prompt(query: str, context_chunks: List[str]) -> str:
"""Monta o prompt final para o LLM com contexto recuperado.
Args:
query: Pergunta original
context_chunks: Chunks recuperados
Returns:
Prompt completo
"""
context = "\n".join(context_chunks)
prompt = f"""Você é um assistente de atendimento ao cliente.
Use apenas as informações do contexto para responder. Se a resposta não está no contexto, diga "não encontrei essa informação".
CONTEXTO:
{context}
PERGUNTA: {query}
RESPOSTA:"""
return prompt
# Junta tudo
query = "Como faço para devolver um produto?"
chunks = retrieve(query, collection, k=3)
prompt = build_rag_prompt(query, chunks)
print(prompt)
Armadilhas comuns
Armadilha 1: Chunking mal feito
Se você cortar no meio de uma sentença importante, o chunk não faz sentido sozinho. Use sempre sobreposição. Se estiver processando código, não corte no meio de uma função — considere usar um splitter que entende sintaxe.
Armadilha 2: Pergunta não se parece com o documento
Você indexa "política de devolução" mas o usuário pergunta "como faço pra mandar de volta?". O embedding de ambos é diferente. Solução: expanda a pergunta antes de buscar, ou use um reranker para reordenar os resultados.
def rerank_results(query: str, chunks: List[str]) -> List[str]:
"""Reordena chunks usando um modelo de reranking.
Aqui usamos um modelo open-source de cross-encoder.
"""
from sentence_transformers import CrossEncoder
model = CrossEncoder('cross-encoder/mmarco-mMiniLMv2-L12-H384-v1')
pairs = [[query, chunk] for chunk in chunks]
scores = model.predict(pairs)
# Ordena por score decrescente
ranked = sorted(zip(chunks, scores), key=lambda x: x[1], reverse=True)
return [chunk for chunk, score in ranked]
# Teste
chunks = retrieve(query, collection, k=5)
best_chunks = rerank_results(query, chunks)
Armadilha 3: Não validar a qualidade
Recuperar documentos não garante que o LLM vai usar. Mede: qual percentual dos casos o top-1 resultado é realmente relevante? (acurácia em rank-1). Mede também: o LLM dá a resposta correta mesmo com o documento certo no contexto?
Armadilha 4: Overhead de custos
Se você estiver gerando embeddings a cada busca, está desperdiçando. Pre-calcule todos os embeddings uma vez. Se estiver com Pinecone e faz buscas muito frequentes, tenha cuidado: cada busca custa.
Quando não usar RAG
- Você quer controlar exatamente o que o LLM vê: fine-tuning garante coerência em escala; RAG pode aluciná-lo.
- O conhecimento muda a cada minuto: RAG é ótimo aqui, mas o custo de reindexar tudo é alto.
- Você precisa de respostas em < 100ms no Acre: RAG adiciona latência da busca (tipicamente 200-500ms). Melhor fazer cache ou fine-tuning.
- Você tem < 1 MB de contexto total: Indexar vale a pena a partir de 5 MB.
Próximos passos
- Leia sobre FinOps para IA para otimizar o custo de embeddings em escala.
- Combine RAG com engenharia de prompt em produção para estruturar a saída.
- Mergulhe em RAG, fine-tuning ou prompt? Guia de decisão para saber quando evitar RAG.
- Jogue Caça-Tokens para entender como tokens influenciam custo.
Resumo prático
| Etapa | Ferramenta | Custo (10k docs) | Tempo |
|---|---|---|---|
| Chunking | Python puro | Grátis | < 1s |
| Embeddings | OpenAI | USD 0,10 | 30-60s |
| Armazenamento | Chroma local | Grátis | < 1s |
| Busca | Cosine similarity | Grátis | 10-50ms |
| Reranking | Sentence-Transformers | Grátis (local) | 50-200ms |
Com esse pipeline, você consegue montar um retriever robusto em uma sexta-feira. Teste com seus documentos reais antes de mandar para produção — cada domínio tem suas surpresas.