Pipeline RAG com embeddings vetoriais - código colorido em tela de monitor representando busca semântica

Pipeline RAG do Zero: Como Fazer IA Buscar Documentos (Sem Inventar Respostas)

Eu gastei 3 semanas tentando fazer um chatbot inteligente responder perguntas sobre a documentação do meu projeto. Três semanas. No final, ele inventava metade das respostas e citava funções que nunca existiram.

Foi quando descobri que RAG (Retrieval-Augmented Generation) não é só “jogar documentos num banco e rezar”. É um pipeline com pelo menos 5 pontos de falha — e cada um deles pode transformar seu chatbot genial num gerador de mentiras convictas.

Neste artigo, vou montar um pipeline RAG funcional do zero, mostrar onde eu quebrei a cara em cada etapa, e te dar o código que realmente funciona — não o código do tutorial bonito que nunca roda na sua máquina.

Tela de debug mostrando resultados de busca semântica em pipeline RAG com embeddings vetoriais

O Que É RAG (E Por Que Você Precisa Antes do Próximo Tutorial de Fine-Tuning)

RAG é a sigla para Retrieval-Augmented Generation. Em português simples: em vez de depender só da memória do modelo de IA, você busca documentos relevantes e os entrega como contexto antes de gerar a resposta.

Pense assim:

  • Sem RAG: Você pergunta ao ChatGPT sobre a política de reembolso da sua empresa. Ele inventa algo plausível.
  • Com RAG: O sistema busca o documento de política, entrega ao modelo, e ele responde com base no texto real.

Se você já brincou com engenharia de prompts e sentiu que o modelo “esquece” coisas importantes, RAG é o próximo passo lógico. Não fine-tuning. Não um modelo maior. RAG.

Por Que Fine-Tuning Não Resolve Isso

Fine-tuning ensina comportamento, não conhecimento. Você pode treinar um modelo para responder no tom da sua empresa, mas não para memorizar os 500 documentos internos que mudam toda semana. RAG resolve isso porque consulta a fonte em tempo real.

Os 5 Componentes do Pipeline RAG (E Onde Cada Um Quebra)

Um pipeline RAG tem 5 estágios. Cada um é um ponto de falha. Vou mostrar todos.

  1. Ingestão de documentos — PDF, Markdown, HTML, DOCX… cada formato é um pesadelo diferente.
  2. Chunking (fragmentação) — cortar o texto em pedaços que faça sentido. Spoiler: tamanho fixo quase nunca funciona.
  3. Geração de embeddings — transformar texto em vetores numéricos. O modelo de embedding muda tudo.
  4. Armazenamento vetorial — guardar esses vetores num banco otimizado para busca por similaridade.
  5. Retrieval + Geração — buscar os chunks relevantes e montar o prompt final.

Passo 1: Ingestão — O Pesadelo Dos PDFs Mal-Formatados

Meu primeiro erro: assumir que todo PDF é texto limpo. Na prática, metade dos PDFs corporativos tem tabelas quebradas, imagens com texto embutido, cabeçalhos repetidos em cada página e rodapés com números de página misturados ao conteúdo.

Aqui está o código que funciona para a maioria dos casos:

import fitz  # PyMuPDF
from pathlib import Path

def extract_text_from_pdf(pdf_path: str) -> list[dict]:
    """Extrai texto limpo de PDF, separando por página."""
    doc = fitz.open(pdf_path)
    pages = []
    for i, page in enumerate(doc):
        text = page.get_text("text")
        # Remove linhas que são só números (números de página)
        lines = [l for l in text.split('\n') if not l.strip().isdigit()]
        clean = '\n'.join(lines).strip()
        if clean:
            pages.append({
                'page': i + 1,
                'content': clean,
                'source': Path(pdf_path).name
            })
    doc.close()
    return pages

# Para Markdown (muito mais fácil)
def extract_markdown(md_path: str) -> dict:
    with open(md_path, 'r', encoding='utf-8') as f:
        return {
            'content': f.read(),
            'source': Path(md_path).name
        }

Lição aprendida: Se seus documentos são scaneados (imagens), você precisa de OCR antes. O tesseract + pdf2image resolve, mas adiciona 10x no tempo de processamento.

Passo 2: Chunking — Onde a Magia Vira Desastre

A maioria dos tutoriais faz chunking por tamanho fixo (ex: 500 tokens com overlap de 50). Isso funciona na demo e quebra na produção.

Por quê? Porque cortar no meio de uma explicação técnica destrói o contexto. Imagine cortar esta frase ao meio: “Para configurar o SSL, você precisa gerar o certificado com o comando openssl req -new -x509 e então copiar o arquivo .pem para…” — e o chunk termina aí. O próximo chunk começa com “…o diretório /etc/nginx/ssl/“. Inútil.

import re

def chunk_by_semantic_blocks(text: str, max_tokens: int = 500) -> list[str]:
    """
    Chunking inteligente: quebra por seções (## headers),
    depois por parágrafos, mantendo contexto.
    """
    # Primeiro, quebra por headers markdown
    sections = re.split(r'(?=^#{1,3}\s)', text, flags=re.MULTILINE)
    
    chunks = []
    for section in sections:
        if not section.strip():
            continue
        
        # Se a seção é menor que o limite, guarda inteira
        tokens = section.split()
        if len(tokens) <= max_tokens:
            chunks.append(section.strip())
        else:
            # Quebra por parágrafos, agrupando
            paragraphs = section.split('\n\n')
            current_chunk = []
            current_len = 0
            
            for para in paragraphs:
                para_tokens = len(para.split())
                if current_len + para_tokens > max_tokens and current_chunk:
                    chunks.append('\n\n'.join(current_chunk))
                    current_chunk = [para]
                    current_len = para_tokens
                else:
                    current_chunk.append(para)
                    current_len += para_tokens
            
            if current_chunk:
                chunks.append('\n\n'.join(current_chunk))
    
    return chunks

Repara que esse código tenta manter seções inteiras. Se a seção é grande demais, ele cai para agrupamento por parágrafos. Não é perfeito, mas está leagues ahead de cortar no caractere 500.

Laptop executando ferramentas de debug e validação de chunking em pipeline RAG

Métricas Para Validar Seu Chunking

Como saber se seu chunking está bom? Use estas métricas:

  • Cobertura semântica: Cada chunk contém uma ideia completa?
  • Tamanho médio: 200-800 tokens é o sweet spot para a maioria dos casos.
  • Redundância: Se chunks diferentes dizem a mesma coisa, seu overlap está alto demais.

Passo 3: Embeddings — O Modelo Escondido Que Define Tudo

Embeddings são representações numéricas do significado do texto. Textos com significado similar ficam “pertos” no espaço vetorial. O modelo que você escolhe define a qualidade da busca.

Testei 4 modelos. Estes são os resultados reais:

  • text-embedding-3-small (OpenAI): Rápido, barato, bom o suficiente para 80% dos casos. 1536 dimensões.
  • text-embedding-3-large (OpenAI): Mais preciso, 2x mais caro. Use quando precisão importa.
  • mxbai-embed-large (local, via Ollama): Grátis, roda local, surpreendentemente bom para português.
  • multilingual-e5-large: O melhor que testei para documentos em português. Pesado, mas preciso.
from openai import OpenAI

client = OpenAI()

def generate_embeddings(chunks: list[str], model="text-embedding-3-small") -> list[list[float]]:
    """Gera embeddings para uma lista de chunks de texto."""
    response = client.embeddings.create(
        input=chunks,
        model=model
    )
    return [item.embedding for item in response.data]

# Dica: batch de até 100 chunks por chamada para economizar
def batch_embed(chunks: list[str], batch_size=100) -> list[list[float]]:
    all_embeddings = []
    for i in range(0, len(chunks), batch_size):
        batch = chunks[i:i + batch_size]
        embeddings = generate_embeddings(batch)
        all_embeddings.extend(embeddings)
        print(f"  Embeddings: {len(all_embeddings)}/{len(chunks)}")
    return all_embeddings

Erro que me custou 2 dias: Misturar modelos de embedding. Se você gera embeddings com um modelo e busca com outro, os vetores são incompatíveis. Parece óbvio, mas quando você atualiza o modelo esquecendo de reprocessar os documentos… o chatbot não quebra. Ele só erra silenciosamente. Muito pior.

Passo 4: Banco Vetorial — ChromaDB Para Começar, Qdrant Para Valer

Para prototipar, ChromaDB é suficiente. Para produção com volume real, Qdrant ou Pinecone.

Vou mostrar com ChromaDB porque é o mais simples para começar:

import chromadb
from chromadb.config import Settings

# Inicializa o cliente (persiste em disco)
client = chromadb.PersistentClient(path="./chroma_db")

# Cria ou carrega a coleção
collection = client.get_or_create_collection(
    name="meus_documentos",
    metadata={"hnsw:space": "cosine"}
)

def index_documents(chunks: list[str], embeddings: list[list[float]], 
                    metadatas: list[dict]):
    """Indexa chunks com seus embeddings e metadados."""
    ids = [f"chunk_{i}" for i in range(len(chunks))]
    
    # ChromaDB limita batch a 5000
    batch_size = 500
    for i in range(0, len(chunks), batch_size):
        collection.add(
            ids=ids[i:i + batch_size],
            documents=chunks[i:i + batch_size],
            embeddings=embeddings[i:i + batch_size],
            metadatas=metadatas[i:i + batch_size]
        )
    print(f"Indexados {len(chunks)} chunks")

def search(query_embedding: list[float], top_k: int = 5) -> list[dict]:
    """Busca os chunks mais similares ao embedding da query."""
    results = collection.query(
        query_embeddings=[query_embedding],
        n_results=top_k,
        include=["documents", "metadatas", "distances"]
    )
    return results

Se você curtiu a ideia de rodar coisas localmente, como mostrei no post sobre como meu pipeline de IA gerou dados falsos, vai gostar de saber que o ChromaDB roda 100% local, sem API externa.

Passo 5: Retrieval + Geração — Onde Tudo Se Conecta

Agora a parte que importa: buscar contexto relevante e gerar a resposta.

from openai import OpenAI

client = OpenAI()

def rag_query(question: str, collection, top_k: int = 5) -> str:
    """Pipeline completo: pergunta → busca → contexto → resposta."""
    
    # 1. Gera embedding da pergunta
    query_embedding = generate_embeddings([question])[0]
    
    # 2. Busca chunks relevantes
    results = search(query_embedding, top_k)
    
    # 3. Monta o contexto
    context_chunks = []
    for doc, meta in zip(results['documents'][0], results['metadatas'][0]):
        source = meta.get('source', 'desconhecido')
        page = meta.get('page', '')
        context_chunks.append(f"[Fonte: {source}, pág. {page}]\n{doc}")
    
    context = '\n\n---\n\n'.join(context_chunks)
    
    # 4. Gera a resposta com contexto
    prompt = f"""Baseado APENAS no contexto abaixo, responda a pergunta.
Se a resposta não está no contexto, diga "Não encontrei essa informação nos documentos."

CONTEXTO:
{context}

PERGUNTA: {question}

Responda em português, citando as fontes quando relevante."""
    
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {"role": "system", "content": "Você é um assistente técnico preciso. Responda apenas com base no contexto fornecido."},
            {"role": "user", "content": prompt}
        ],
        temperature=0.1  # Baixa temperatura = menos alucinação
    )
    
    return response.choices[0].message.content

O Perrengue: Meu RAG Respondia Tudo Certo — Até Que Não Respondia

🔍 Box Perrengue: O Bug Invisível

Implementei o pipeline RAG acima. Testei com 20 perguntas: 18 corretas, 2 “não sei”. Perfeito, né?

Então um usuário perguntou: “Qual o prazo de entrega para São Paulo?”

O sistema respondeu com a política de devolução. Prazo de devolução: 30 dias. Parecia correto. Estava completamente errado.

O problema? O chunking cortou o documento de “Política de Entrega” e o documento de “Política de Devolução” no mesmo bloco. O embedding era quase idêntico. O sistema achava que eram a mesma coisa.

Solução: Adicionei o tipo de documento nos metadatos e fiz filtro pré-busca. Antes de buscar “prazo”, o sistema verifica se existem chunks de “Política de Entrega” e prioriza esses.

Isso não está em nenhum tutorial. Você só aprende quando o usuário reclama.

Métricas de Avaliação — Como Saber Se Seu RAG Presta

Depois de montar o pipeline, como saber se ele funciona? Sem métricas, você está voando cego.

As 4 Métricas Que Importam

  • Precision@K: Dos K chunks recuperados, quantos são relevantes? Meta: > 80%.
  • Recall@K: Dos chunks relevantes que existem, quantos foram recuperados? Meta: > 70%.
  • Answer Faithfulness: A resposta gerada é fiel ao contexto recuperado? (GPT-4 pode avaliar isso automaticamente.)
  • Answer Relevance: A resposta realmente responde à pergunta feita?
# Avaliação simples com GPT-4 como juiz
def evaluate_faithfulness(question: str, answer: str, context: str) -> dict:
    """Usa GPT-4 para avaliar se a resposta é fiel ao contexto."""
    eval_prompt = f"""Avalie se a resposta é fiel ao contexto fornecido.

CONTEXTO: {context}
PERGUNTA: {question}
RESPOSTA: {answer}

Responda em JSON:
- "fiel": true/false
- "problemas": lista de problemas encontrados
- "nota": 1-10"""
    
    response = client.chat.completions.create(
        model="gpt-4o",
        messages=[{"role": "user", "content": eval_prompt}],
        response_format={"type": "json_object"}
    )
    return json.loads(response.choices[0].message.content)

Automatizei essa avaliação como parte do pipeline — toda noite, um shell script roda 50 perguntas de teste e gera um relatório. Se a nota média cai abaixo de 7, recebo alerta.

O Custo Real De Um Pipeline RAG

Ninguém fala sobre isso, então vou ser direto. Um pipeline RAG para ~1000 documentos, com atualização semanal:

  • Embeddings (OpenAI small): ~R$ 2,00/mês para processar tudo
  • Busca (ChromaDB local): R$ 0,00 — roda na sua máquina
  • Geração (GPT-4o-mini): ~R$ 15-30/mês dependendo do volume de queries
  • Infra (se usar Qdrant Cloud): ~R$ 80-150/mês

Total: R$ 17-180/mês dependendo da escala. Para a maioria dos projetos pessoais e small business, fica na faixa dos R$ 20-50.

Comparado ao custo de um funcionário respondendo as mesmas perguntas todo dia? É de graça.

Checklist Pronto Para Produção

Antes de colocar seu RAG em produção, verifica estes pontos:

  • ✅ Documentos sendo ingeridos sem perda de texto
  • ✅ Chunking mantém contexto semântico (não só tamanho fixo)
  • ✅ Embeddings consistentes (mesmo modelo para indexar E buscar)
  • ✅ Banco vetorial com persistência (não perde dados ao reiniciar)
  • ✅ Fallback para “não sei” quando contexto é insuficiente
  • ✅ Metadados ricos (fonte, página, tipo de documento) para filtragem
  • ✅ Avaliação automatizada rodando periodicamente
  • ✅ Monitoramento de custos de API

E Agora, Qual Automação Você Quer Ver?

Montei o pipeline RAG completo — da ingestão à avaliação. Mas a melhor parte do RAG é que ele se integra com tudo. Chatbot no Telegram? Sistema de tickets? Bot no Slack? Documentação interna da empresa?

Me conta nos comentários: qual seria o uso de RAG que mais resolveria um problema real pra você? Qual sistema ou documento você queria que “entendesse” suas perguntas?

Se curtiu esse tipo de conteúdo técnico com erro real e solução prática, acompanha a categoria Mente Binária — é aqui que eu documento meus perrengues com IA e automação.

E se você ainda está no começo da jornada com automação e IA, dá uma olhada no post sobre 10 automações do dia a dia que me devolvem 2 horas — é um bom ponto de partida antes de mergulhar em pipelines mais complexos como esse.

Posts Similares