· 14 min de leitura · ...

RAG na prática: como construir sistemas que realmente funcionam

Um guia técnico e prático sobre Retrieval-Augmented Generation, cobrindo arquitetura, estratégias de chunking, modelos de embedding, bancos vetoriais e os desafios reais que enfrentei em produção.

IARAGLLMsengenharia de softwarearquitetura
Indice

Introdução

Se você trabalha com IA generativa, provavelmente já ouviu falar de RAG. O conceito parece simples: em vez de depender apenas do conhecimento interno de um LLM, você busca informações relevantes em uma base de dados e injeta no contexto do modelo antes de gerar a resposta. Na teoria, é elegante. Na prática, é onde a maioria dos projetos falha.

Nos últimos meses, construí e coloquei em produção três sistemas baseados em RAG. Um deles para suporte ao cliente, outro para documentação técnica interna e um terceiro para análise de contratos. Em cada um desses projetos, descobri que a distância entre um protótipo funcional e um sistema confiável em produção é enorme. Este post é sobre essa distância e o que aprendi ao percorrê-la.

Não vou ficar no nível conceitual. Vou compartilhar decisões de arquitetura, código real, erros que cometi e as soluções que funcionaram. Se você está planejando construir algo com RAG, espero que este texto economize semanas de trabalho.

O que é RAG e por que importa

RAG, ou Retrieval-Augmented Generation, é uma técnica que combina busca de informação com geração de texto. O fluxo básico funciona assim:

  1. O usuário faz uma pergunta
  2. O sistema busca documentos relevantes em uma base de conhecimento
  3. Os documentos encontrados são inseridos no prompt do LLM
  4. O modelo gera uma resposta baseada nesse contexto

A motivação principal é resolver um problema fundamental dos LLMs: eles não sabem tudo. Eles foram treinados com dados até uma data de corte, não conhecem informações proprietárias da sua empresa e podem alucinar quando não têm certeza de algo.

Com RAG, você dá ao modelo acesso a informações atualizadas e específicas do seu domínio. Isso reduz alucinações, permite rastreabilidade (você sabe de onde veio a informação) e torna o sistema muito mais confiável.

Mas aqui vai o primeiro aprendizado importante: RAG não é uma solução mágica. Ele é tão bom quanto a qualidade da sua pipeline de dados. Se seus documentos estão mal estruturados, se o chunking é ruim, se o modelo de embedding não captura bem a semântica do seu domínio, o resultado final vai ser decepcionante.

Arquitetura de um sistema RAG

Antes de entrar nos detalhes, vale entender a arquitetura completa. Um sistema RAG em produção tem mais componentes do que você imagina.

Pipeline de ingestão

Este é o lado “offline” do sistema. Ele processa seus documentos e os prepara para busca:

from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain.embeddings import OpenAIEmbeddings
from langchain.vectorstores import Chroma

# 1. Carregar documentos
documents = load_documents_from_sources()

# 2. Chunking
text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=1000,
    chunk_overlap=200,
    separators=["\n\n", "\n", ". ", " ", ""]
)
chunks = text_splitter.split_documents(documents)

# 3. Gerar embeddings e armazenar
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
vectorstore = Chroma.from_documents(
    documents=chunks,
    embedding=embeddings,
    persist_directory="./chroma_db"
)

Pipeline de consulta

Este é o lado “online”, que responde às perguntas dos usuários:

from langchain.chat_models import ChatOpenAI
from langchain.chains import RetrievalQA

# 1. Configurar o retriever
retriever = vectorstore.as_retriever(
    search_type="mmr",
    search_kwargs={"k": 5, "fetch_k": 20}
)

# 2. Configurar o LLM
llm = ChatOpenAI(model="gpt-4o", temperature=0)

# 3. Criar a chain
qa_chain = RetrievalQA.from_chain_type(
    llm=llm,
    chain_type="stuff",
    retriever=retriever,
    return_source_documents=True
)

# 4. Responder
result = qa_chain.invoke({"query": "Qual é a política de reembolso?"})

Componentes adicionais em produção

Na prática, um sistema RAG robusto precisa de mais do que isso:

  • Cache de consultas: para evitar reprocessamento de perguntas frequentes
  • Reranking: um segundo modelo que reordena os resultados da busca vetorial
  • Monitoramento: métricas de qualidade das respostas, latência, custo
  • Feedback loop: mecanismo para usuários avaliarem respostas e alimentar melhorias
  • Fallback: o que acontece quando o sistema não encontra contexto relevante

Estratégias de chunking: onde a maioria erra

Chunking é o processo de dividir seus documentos em pedaços menores para armazenamento e busca. Parece trivial, mas é provavelmente a decisão mais impactante em todo o pipeline.

O problema do tamanho

Chunks muito pequenos perdem contexto. Chunks muito grandes diluem a relevância e consomem tokens desnecessários. Não existe um tamanho universal, mas depois de muitos experimentos, cheguei a algumas heurísticas:

  • Documentação técnica: 800 a 1200 tokens, com overlap de 200
  • Contratos e documentos legais: 500 a 800 tokens, com overlap de 150
  • FAQs e suporte: 300 a 500 tokens, overlap mínimo
  • Código-fonte: por função ou classe, não por número de tokens

Chunking semântico vs. por tamanho fixo

O approach mais comum é dividir por tamanho fixo com algum overlap, como no exemplo acima. Funciona razoavelmente bem, mas tem problemas sérios. Ele pode cortar uma ideia no meio, separar uma pergunta da sua resposta, ou misturar temas diferentes no mesmo chunk.

Uma abordagem melhor é o chunking semântico, que tenta identificar fronteiras naturais no texto:

from langchain_experimental.text_splitter import SemanticChunker
from langchain.embeddings import OpenAIEmbeddings

embeddings = OpenAIEmbeddings(model="text-embedding-3-small")

semantic_splitter = SemanticChunker(
    embeddings=embeddings,
    breakpoint_threshold_type="percentile",
    breakpoint_threshold_amount=95
)

semantic_chunks = semantic_splitter.split_documents(documents)

O chunking semântico analisa a similaridade entre sentenças consecutivas e cria quebras onde há mudança significativa de tema. Na minha experiência, isso melhorou a qualidade das respostas em cerca de 15 a 20% nos benchmarks internos.

Metadados são essenciais

Um erro que cometi no início foi ignorar metadados nos chunks. Cada pedaço de texto precisa carregar informações sobre sua origem: de qual documento veio, qual seção, qual data, qual versão. Isso é crucial por dois motivos.

Primeiro, permite filtrar a busca. Se o usuário pergunta sobre a política de 2025, você pode filtrar chunks por data antes da busca vetorial. Segundo, permite rastreabilidade. Quando o sistema responde, você pode mostrar exatamente de onde veio a informação.

for chunk in chunks:
    chunk.metadata.update({
        "source_file": document.metadata["filename"],
        "section": extract_section_header(chunk),
        "doc_type": classify_document(document),
        "last_updated": document.metadata["modified_date"],
        "chunk_index": idx
    })

Modelos de embedding: escolhas que importam

O modelo de embedding transforma texto em vetores numéricos que capturam significado semântico. A escolha do modelo afeta diretamente a qualidade da busca.

Comparação prática

Testei vários modelos em datasets reais dos meus projetos. Os resultados variaram bastante dependendo do domínio:

ModeloDimensõesRecall@5 (doc técnica)Recall@5 (contratos)Custo
text-embedding-3-small153678%72%Baixo
text-embedding-3-large307285%81%Médio
voyage-2102482%84%Médio
BGE-large-en-v1.5102476%68%Grátis (local)

Algumas observações importantes:

O modelo da OpenAI (text-embedding-3-large) é consistentemente bom, mas o custo se acumula rápido quando você tem milhões de chunks. O Voyage AI surpreendeu positivamente em textos jurídicos. Modelos open-source como BGE funcionam bem para prototipagem, mas ficaram atrás em domínios especializados.

Fine-tuning de embeddings

Para o projeto de contratos, fiz fine-tuning do modelo de embedding com pares de pergunta-resposta específicos do domínio jurídico. O processo é relativamente simples:

from sentence_transformers import SentenceTransformer, InputExample, losses
from torch.utils.data import DataLoader

model = SentenceTransformer("BAAI/bge-large-en-v1.5")

# Pares de treino: (query, documento_relevante)
train_examples = [
    InputExample(texts=[
        "Qual o prazo de rescisão?",
        "O contrato pode ser rescindido por qualquer parte mediante aviso prévio de 30 dias..."
    ]),
    # ... mais exemplos
]

train_dataloader = DataLoader(train_examples, shuffle=True, batch_size=16)
train_loss = losses.MultipleNegativesRankingLoss(model)

model.fit(
    train_objectives=[(train_dataloader, train_loss)],
    epochs=3,
    warmup_steps=100
)

O fine-tuning melhorou o Recall@5 de 68% para 83% no domínio de contratos. Vale o esforço quando você tem dados suficientes (recomendo pelo menos 500 pares de treino).

Bancos de dados vetoriais: qual escolher

A escolha do banco vetorial depende muito do seu cenário. Não existe “o melhor”, existe o mais adequado para o seu caso.

Opções que testei em produção

Chroma: ótimo para desenvolvimento e projetos menores. É fácil de configurar, roda localmente e tem uma API simples. Mas não escala bem para milhões de documentos e não tem features avançadas de filtragem.

Pinecone: managed service que facilita muito a operação. Escalabilidade automática, filtros por metadados eficientes e boa latência. O custo pode ser alto para volumes grandes, mas a redução de complexidade operacional compensa em muitos casos.

Qdrant: minha escolha preferida para projetos sérios. Open-source, pode ser hospedado localmente ou na nuvem, tem filtros poderosos e suporta múltiplos vetores por documento. A API é bem desenhada:

from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams, PointStruct

client = QdrantClient(host="localhost", port=6333)

# Criar collection
client.create_collection(
    collection_name="documentos",
    vectors_config=VectorParams(
        size=1536,
        distance=Distance.COSINE
    )
)

# Inserir documentos
client.upsert(
    collection_name="documentos",
    points=[
        PointStruct(
            id=idx,
            vector=embedding,
            payload={
                "text": chunk.text,
                "source": chunk.metadata["source_file"],
                "doc_type": chunk.metadata["doc_type"],
                "date": chunk.metadata["last_updated"]
            }
        )
        for idx, (chunk, embedding) in enumerate(zip(chunks, embeddings))
    ]
)

# Busca com filtros
results = client.search(
    collection_name="documentos",
    query_vector=query_embedding,
    query_filter={
        "must": [
            {"key": "doc_type", "match": {"value": "contrato"}},
            {"key": "date", "range": {"gte": "2025-01-01"}}
        ]
    },
    limit=5
)

pgvector: se você já usa PostgreSQL, pode ser a escolha mais pragmática. Não é o mais performático para busca vetorial pura, mas simplifica a infraestrutura e permite queries SQL tradicionais junto com busca semântica.

Desafios reais em produção

Agora vamos falar sobre o que dá errado quando você coloca RAG em produção. Porque muita coisa dá errado.

Problema 1: qualidade inconsistente das respostas

O maior desafio que enfrentei foi a inconsistência. O sistema respondia perfeitamente 80% das vezes e completamente errado nos outros 20%. O problema estava em três frentes.

Primeiro, queries ambíguas. Quando o usuário pergunta “como funciona?”, o sistema não sabe o que buscar. Implementei um passo de reformulação da query usando o próprio LLM:

def reformulate_query(original_query: str, chat_history: list) -> str:
    prompt = f"""Dada a conversa abaixo e a última pergunta do usuário,
    reformule a pergunta para ser autocontida e específica.

    Histórico: {chat_history}
    Pergunta: {original_query}

    Pergunta reformulada:"""

    return llm.invoke(prompt).content

Segundo, chunks irrelevantes nos resultados. A busca vetorial retorna os chunks mais similares, mas similaridade semântica nem sempre significa relevância. Implementei um reranker usando o Cohere Rerank:

from cohere import Client

cohere_client = Client(api_key="...")

def rerank_results(query: str, documents: list, top_n: int = 3):
    results = cohere_client.rerank(
        query=query,
        documents=[doc.page_content for doc in documents],
        top_n=top_n,
        model="rerank-english-v3.0"
    )
    return [documents[r.index] for r in results.results]

O reranking melhorou a precisão das respostas significativamente. Em números, saímos de 80% de respostas satisfatórias para 93%.

Terceiro, o modelo às vezes ignorava o contexto fornecido e respondia com conhecimento próprio. Resolvi isso com prompts mais restritivos:

SYSTEM_PROMPT = """Você é um assistente que responde perguntas APENAS
com base no contexto fornecido. Se a informação não estiver no contexto,
diga explicitamente que não encontrou essa informação nos documentos
disponíveis. NUNCA invente ou complemente com conhecimento externo.

Ao responder, cite a fonte entre colchetes, ex: [Documento X, Seção Y]."""

Problema 2: latência

Um sistema RAG em produção tem múltiplas etapas, e cada uma adiciona latência. Na minha primeira versão, o tempo de resposta era de 8 a 12 segundos, o que é inaceitável para chat.

A otimização envolveu várias camadas:

  • Cache semântico: consultas similares retornam respostas cacheadas. Usei Redis com busca vetorial para encontrar queries similares (threshold de 0.95 de similaridade)
  • Busca assíncrona: enquanto a busca vetorial roda, já inicio o streaming da resposta parcial
  • Pré-computação de embeddings: embeddings de queries comuns são pré-calculados
  • Quantização de vetores: reduzi a precisão dos vetores de float32 para float16, cortando pela metade o consumo de memória com perda mínima de qualidade

Com essas otimizações, caí para 2 a 3 segundos de latência total, com o primeiro token aparecendo em menos de 1 segundo.

Problema 3: documentos que mudam

No projeto de documentação técnica, os documentos mudavam frequentemente. Isso criava um problema: chunks desatualizados competiam com chunks novos na busca.

A solução foi implementar versionamento nos chunks:

def update_document(doc_id: str, new_content: str):
    # 1. Marcar chunks antigos como deprecated
    vectorstore.update_metadata(
        filter={"doc_id": doc_id},
        metadata={"status": "deprecated", "deprecated_at": datetime.now()}
    )

    # 2. Processar novo conteúdo
    new_chunks = process_document(new_content, doc_id)

    # 3. Inserir novos chunks
    vectorstore.add_documents(new_chunks)

    # 4. Remover chunks deprecated após período de graça
    schedule_cleanup(doc_id, delay_hours=24)

O período de graça de 24 horas permite rollback se algo der errado com a nova versão do documento.

Problema 4: avaliação contínua

Como saber se o sistema está melhorando ou piorando ao longo do tempo? Implementei um framework de avaliação com três componentes.

Primeiro, métricas automáticas. Para cada resposta, calculo a similaridade entre a resposta gerada e a resposta esperada (quando disponível), a cobertura dos documentos fonte e a detecção de alucinações usando um modelo auxiliar.

Segundo, feedback do usuário. Botões de “útil” e “não útil” em cada resposta, com campo opcional para comentários.

Terceiro, avaliação periódica com dataset de golden questions. Um conjunto de 200 perguntas com respostas verificadas que rodo semanalmente para detectar regressões.

Padrões avançados

Além do RAG básico, existem padrões que podem elevar significativamente a qualidade do sistema.

Combinar busca vetorial com busca por keywords (BM25) dá resultados melhores do que qualquer uma isoladamente:

from langchain.retrievers import EnsembleRetriever
from langchain.retrievers import BM25Retriever

bm25_retriever = BM25Retriever.from_documents(chunks)
bm25_retriever.k = 5

vector_retriever = vectorstore.as_retriever(search_kwargs={"k": 5})

ensemble = EnsembleRetriever(
    retrievers=[bm25_retriever, vector_retriever],
    weights=[0.4, 0.6]
)

A busca vetorial captura similaridade semântica, enquanto BM25 pega correspondências exatas de termos técnicos, nomes próprios e códigos que a busca vetorial pode perder.

Multi-query retrieval

Em vez de buscar com a query original apenas, gere múltiplas variações e combine os resultados:

def multi_query_retrieve(original_query: str, retriever, llm):
    # Gerar variações da query
    variations_prompt = f"""Gere 3 variações da seguinte pergunta,
    cada uma abordando de um ângulo diferente:
    Pergunta: {original_query}"""

    variations = llm.invoke(variations_prompt).content.split("\n")

    # Buscar com cada variação
    all_docs = set()
    for query in [original_query] + variations:
        docs = retriever.get_relevant_documents(query)
        all_docs.update(docs)

    return list(all_docs)

Contextual compression

Nem todo o conteúdo de um chunk é relevante para a pergunta. A compressão contextual extrai apenas as partes pertinentes:

from langchain.retrievers import ContextualCompressionRetriever
from langchain.retrievers.document_compressors import LLMChainExtractor

compressor = LLMChainExtractor.from_llm(llm)
compression_retriever = ContextualCompressionRetriever(
    base_compressor=compressor,
    base_retriever=retriever
)

Isso reduz o número de tokens no prompt e foca a atenção do modelo nas informações mais relevantes.

Custos e otimização

Em produção, o custo é uma preocupação real. O custo de um sistema RAG vem de três fontes: embeddings, armazenamento vetorial e chamadas ao LLM.

Para o projeto de suporte ao cliente, que processava cerca de 5.000 consultas por dia, o custo mensal ficou em torno de 800 dólares. A maior parte (60%) vinha das chamadas ao GPT-4o. Migrando as consultas mais simples para GPT-4o-mini e usando cache agressivo, reduzi para 350 dólares por mês sem perda perceptível de qualidade.

A regra que sigo é: comece com o modelo mais capaz para validar a solução, depois otimize custos quando tiver dados de uso real. Otimizar prematuramente pode levar a decisões ruins.

Lições aprendidas

Depois de três projetos RAG em produção, estas são as lições mais importantes:

Comece pelos dados, não pela arquitetura. A qualidade dos seus documentos fonte é o fator mais importante. Invista tempo em limpeza, estruturação e enriquecimento antes de pensar em modelos e frameworks.

Chunking é mais arte do que ciência. Não existe receita universal. Teste diferentes abordagens com dados reais do seu domínio e meça os resultados.

Reranking é quase obrigatório. A melhoria de qualidade justifica o custo e a latência adicionais. Se eu pudesse adicionar apenas uma coisa a um sistema RAG básico, seria um reranker.

Avaliação contínua é essencial. Sem métricas, você está no escuro. Configure avaliação automatizada desde o início, não como algo para fazer depois.

Não subestime a engenharia de prompt. O prompt do sistema que instrui o LLM sobre como usar o contexto faz uma diferença enorme. Pequenos ajustes no prompt podem melhorar a qualidade mais do que trocar o modelo de embedding.

Planeje para documentos que mudam. Se seus dados não são estáticos (e provavelmente não são), versionamento e atualização incremental precisam estar no design desde o início.

Conclusão

RAG é uma técnica poderosa, mas exige muito mais do que colar um tutorial junto. Os desafios reais estão nos detalhes: como você divide seus documentos, como avalia a qualidade, como lida com edge cases, como otimiza custos e latência.

Se você está começando, minha recomendação é: construa um protótipo rápido para validar a ideia, depois invista pesado na qualidade dos dados e na avaliação. O protótipo vai funcionar em uma demo. Fazer funcionar em produção, com milhares de consultas por dia e expectativas reais de qualidade, é um desafio completamente diferente.

Os frameworks e ferramentas estão amadurecendo rapidamente. LangChain, LlamaIndex, Haystack, todos facilitam muito o início. Mas a diferença entre um sistema RAG medíocre e um excelente está nas decisões de engenharia que você toma ao longo do caminho, especialmente nas áreas que parecem menos empolgantes: limpeza de dados, chunking, avaliação e monitoramento.

Se quiser trocar ideias sobre RAG ou compartilhar experiências, me procure nas redes. Estou sempre interessado em aprender como outras pessoas estão resolvendo esses mesmos problemas.

Comentários

Carregando comentários...

Deixe um comentário

Posts relacionados

Fique por dentro

Receba artigos sobre arquitetura de software, IA e projetos open source direto no seu e-mail.