Pipeline RAG caseiro com busca semantica e embeddings locais para inteligencia artificial

RAG Que Funciona: Como Re-Ranking e Chunking Semântico Reduziram Minhas Alucinações em 86%

Eu tinha um problema: meu chatbot IA respondia perguntas sobre meus próprios documentos com a confiança de um político, mas com a precisão de um meteorologista. “Sim, isso está no documento” — dizia. Não estava.

O culpado? Um pipeline RAG (Retrieval-Augmented Generation) preguiçoso que jogava documentos inteiros no prompt e torcia. Funcionava pra perguntas simples. Pra tudo mais, alucinava como se não houvesse amanhã.

Depois de 3 semanas quebrando a cabeça, reescrevi o pipeline do zero com re-ranking, chunking semântico e avaliação automatizada. O resultado: respostas erradas caíram de 34% pra menos de 5%. E o código todo tem 200 linhas de Python.

Neste post, vou mostrar cada etapa — incluindo os erros que cometi no caminho, porque pipeline RAG que funciona de primeira é lenda urbana.

O Problema Com RAG “Simples”

Se você já construiu um pipeline RAG básico, conhece o fluxo: pega documentos, transforma em embeddings, salva num banco vetorial, busca por similaridade, manda pro LLM. Funciona. Até não funcionar.

Os problemas surgem quando:

  • Chunks grandes demais — o modelo se perde em ruído dentro de um pedaço de 1000 tokens
  • Chunks pequenos demais — perde contexto, retorna fragmentos sem sentido
  • Sem re-ranking — os 5 resultados “mais similares” pelo cosseno nem sempre são os mais relevantes
  • Sem avaliação — você não sabe se está melhorando ou piorando com cada mudança

Meu pipeline antigo sofria de todos esses males. Vamos resolver cada um.

A Arquitetura Que Funciona

Antes de código, entenda a arquitetura completa:

  1. Ingestão: Documentos → Chunking semântico → Embeddings → Vector store
  2. Busca: Query → Embedding → Busca ampla (top-20) → Re-ranking (top-5) → Contexto
  3. Geração: Contexto + Query → LLM → Resposta + Citações
  4. Avaliação: Resposta + Contexto → Scorer → Métricas automáticas

A diferença pro RAG básico está nos passos 1 (chunking inteligente), 2 (re-ranking) e 4 (avaliação). Vamos em cada um.

Chunking Semântico: Pare de Cortar Pelo Tamanho

O erro número 1 que eu cometia: cortar documentos em chunks de N caracteres. O problema é que tamanho não é significado. Um chunk de 500 caracteres pode conter três ideias diferentes ou meia ideia incompleta.

Chunking semântico corta por mudanças de tópico, não por contagem de caracteres. A ideia é simples: embeddings de sentenças adjacentes tendem a ser similares. Quando a similaridade cai, é porque o tópico mudou.

import numpy as np
from sentence_transformers import SentenceTransformer

model = SentenceTransformer('all-MiniLM-L6-v2')

def semantic_chunk(text: str, threshold: float = 0.5) -> list[dict]:
    """
    Divide texto em chunks baseado em mudanças semânticas.
    threshold controla a sensibilidade — menor = mais chunks.
    """
    # Primeiro, divide em sentenças
    sentences = [s.strip() for s in text.split('.') if len(s.strip()) > 20]
    
    if len(sentences) < 2:
        return [{"content": text, "token_count": len(text.split())}]
    
    # Embeddings de todas as sentenças
    embeddings = model.encode(sentences)
    
    # Similaridade coseno entre sentenças adjacentes
    chunks = []
    current_chunk = [sentences[0]]
    current_tokens = len(sentences[0].split())
    
    for i in range(1, len(sentences)):
        sim = np.dot(embeddings[i], embeddings[i-1]) / (
            np.linalg.norm(embeddings[i]) * np.linalg.norm(embeddings[i-1])
        )
        
        # Se similaridade caiu abaixo do threshold, fecha o chunk
        if sim < threshold and current_tokens > 50:
            chunks.append({
                "content": ". ".join(current_chunk) + ".",
                "token_count": current_tokens
            })
            current_chunk = [sentences[i]]
            current_tokens = len(sentences[i].split())
        else:
            current_chunk.append(sentences[i])
            current_tokens += len(sentences[i].split())
    
    # Último chunk
    if current_chunk:
        chunks.append({
            "content": ". ".join(current_chunk) + ".",
            "token_count": current_tokens
        })
    
    return chunks

O threshold é o parâmetro crítico. Comece com 0.5 e ajuste. Muito baixo = chunks enormes. Muito alto = fragmentos sem contexto.

🧨 Box do Perrengue

O perrengue: Na primeira tentativa, usei threshold de 0.3 achando que “mais chunks = mais preciso”. Resultado: um documento de 5 páginas virou 47 chunks, a maioria com uma frase só. O LLM recebia 47 fragmentos desconexos e alucinava mais que antes.

A lição: Chunking não é “mais é melhor”. Cada chunk precisa ter informação suficiente pra ser útil sozinho. Teste com threshold=0.5 e avalie manualmente os 10 primeiros chunks.

O desafio: Pegue um documento técnico seu e compare chunks por tamanho fixo vs. semântico. Me conte nos comentários qual teve menos “cortes no meio da ideia”.

Embeddings Locais: Performance Sem Depender de API

Usei o all-MiniLM-L6-v2 acima por um motivo: roda local, é rápido (80ms por batch de 32), e custo zero. Pra português, funciona surpreendentemente bem — melhor que vários modelos multilíngues pesados que testei.

Mas se seus documentos são todos em português e você quer mais precisão, teste o paraphrase-multilingual-MiniLM-L12-v2:

# Comparação prática que fiz com 200 queries em português
models = {
    'all-MiniLM-L6-v2': {
        'speed': '82ms/batch',
        'recall@5': 0.71,
        'latency_p99': '120ms'
    },
    'paraphrase-multilingual-MiniLM-L12-v2': {
        'speed': '140ms/batch', 
        'recall@5': 0.78,  # +7% em português
        'latency_p99': '200ms'
    },
    'text-embedding-3-small (OpenAI)': {
        'speed': '450ms/batch (API)',
        'recall@5': 0.82,
        'latency_p99': '800ms'
    }
}

# Meu veredito: all-MiniLM pra protótipo, multilingual pra produção local
# OpenAI só se você já está usando a API pra geração de qualquer forma

Para armazenamento, eu uso SQLite com a extensão sqlite-vss. Leve, sem servidor, portátil:

import sqlite3
import sqlite_vss

db = sqlite3.connect('rag_store.db')
db.enable_load_extension(True)
sqlite_vss.load(db)

# Criar tabela de vetores (384 dimensões pro MiniLM)
db.execute("""
    CREATE VIRTUAL TABLE IF NOT EXISTS doc_vectors 
    USING vss0(embedding(384))
""")

# Tabela de metadados
db.execute("""
    CREATE TABLE IF NOT EXISTS chunks (
        id INTEGER PRIMARY KEY,
        content TEXT NOT NULL,
        source TEXT NOT NULL,
        token_count INTEGER,
        chunk_index INTEGER
    )
""")

def index_chunks(chunks: list[dict], source: str):
    """Indexa uma lista de chunks no vector store."""
    embeddings = model.encode([c['content'] for c in chunks])
    
    for i, (chunk, emb) in enumerate(zip(chunks, embeddings)):
        cursor = db.execute(
            "INSERT INTO chunks (content, source, token_count, chunk_index) VALUES (?,?,?,?)",
            (chunk['content'], source, chunk['token_count'], i)
        )
        chunk_id = cursor.lastrowid
        db.execute(
            "INSERT INTO doc_vectors (rowid, embedding) VALUES (?,?)",
            (chunk_id, emb.tolist())
        )
    db.commit()

Busca com Re-Ranking: O Segredo dos 5%

Essa é a mudança que mais impactou a qualidade. Busca vetorial sozinha é só o primeiro passo. Ela é boa pra encontrar candidatos, péssima pra ordenar por relevância real.

O problema: similaridade por cosseno mede proximidade no espaço vetorial, mas “próximo” não significa “responde à pergunta”. Dois chunks podem falar do mesmo tema, mas só um contém a resposta.

Solução: busca ampla (top-20) seguida de re-ranking com um modelo cross-encoder:

from sentence_transformers import CrossEncoder

# Cross-encoder é mais lento mas muito mais preciso pra ranking
reranker = CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2')

def search_with_rerank(query: str, top_k: int = 5) -> list[dict]:
    """
    Busca em duas etapas:
    1. Busca vetorial ampla (top-20 candidatos)
    2. Re-ranking com cross-encoder (top-k finais)
    """
    # ETAPA 1: Busca vetorial
    query_emb = model.encode(query)
    
    results = db.execute("""
        SELECT c.id, c.content, c.source, c.token_count,
               v.distance
        FROM doc_vectors v
        JOIN chunks c ON c.id = v.rowid
        WHERE v.embedding MATCH ?
        ORDER BY v.distance
        LIMIT 20
    """, (query_emb.tolist(),)).fetchall()
    
    candidates = [
        {"id": r[0], "content": r[1], "source": r[2], 
         "token_count": r[3], "vec_distance": r[4]}
        for r in results
    ]
    
    # ETAPA 2: Re-ranking
    pairs = [(query, c['content']) for c in candidates]
    scores = reranker.predict(pairs)
    
    # Combinar scores e ordenar
    for c, score in zip(candidates, scores):
        c['rerank_score'] = float(score)
    
    candidates.sort(key=lambda x: x['rerank_score'], reverse=True)
    
    return candidates[:top_k]

O re-ranking adiciona ~50ms por query. Vale cada milissegundo — minha taxa de “chunk correto no top-3” subiu de 71% pra 89%.

Por Que Cross-Encoder é Melhor Pra Ranking

Bi-encoders (como o MiniLM que uso pra indexação) codificam query e documento separadamente. Isso é rápido, mas perde a interação entre os dois. Cross-encoders processam query e documento juntos, capturando nuances como “este documento afirma isso ou apenas menciona?”

A desvantagem: cross-encoders são lentos pra indexação (processam cada par individualmente). Por isso, usamos bi-encoder pra busca ampla e cross-encoder só pros candidatos finais. O melhor dos dois mundos.

Construção do Prompt com Citações

Agora que temos os chunks certos, vamos construir o prompt que força o LLM a citar fontes:

def build_rag_prompt(query: str, chunks: list[dict]) -> str:
    """Constrói prompt com contexto e instruções de citação."""
    
    context_parts = []
    for i, chunk in enumerate(chunks, 1):
        context_parts.append(
            f"[Fonte {i}: {chunk['source']}]\n"
            f"{chunk['content']}\n"
        )
    
    context = "\n---\n".join(context_parts)
    
    prompt = f"""Baseado APENAS no contexto abaixo, responda à pergunta.
Se a resposta não está no contexto, diga "Não encontrei essa informação nos documentos disponíveis."
Cada afirmação deve citar a fonte entre colchetes, ex: [Fonte 1].

CONTEXTO:
{context}

PERGUNTA: {query}

RESPOSTA:"""
    
    return prompt

A instrução “Baseado APENAS no contexto” é crucial. Sem ela, o LLM vai misturar conhecimento interno com o contexto, e você perde o controle sobre alucinações. Eu aprendi isso da forma difícil — depois de estruturar outputs, percebi que restringir o que o modelo pode dizer é mais eficaz que tentar formatar como ele diz.

Avaliação Automatizada: Como Saber Se Você Melhorou

Essa é a parte que 90% dos tutoriais de RAG pulam. E é a mais importante.

Sem avaliação, você está otimizando no escuro. Mudou o chunking? Não sabe se melhorou. Trocou o modelo de embedding? Intuição não conta.

Criei um evaluator simples com 3 métricas:

class RAGEvaluator:
    """Avalia qualidade de respostas RAG automaticamente."""
    
    def __init__(self):
        self.judge = CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2')
    
    def evaluate(self, query: str, response: str, 
                 expected_answer: str, chunks: list[dict]) -> dict:
        
        # 1. FAITHFULNESS: a resposta é fiel aos chunks?
        faithfulness = float(self.judge.predict([(response, c['content']) 
                                                  for c in chunks]).max())
        
        # 2. RELEVANCE: a resposta responde à pergunta?
        relevance = float(self.judge.predict([(query, response)]))
        
        # 3. COMPLETENESS: contém info da resposta esperada?
        completeness = float(self.judge.predict(
            [(expected_answer, response)]
        ))
        
        return {
            'faithfulness': round(faithfulness, 3),
            'relevance': round(relevance, 3),
            'completeness': round(completeness, 3),
            'avg_score': round(
                (faithfulness + relevance + completeness) / 3, 3
            )
        }

# Dataset de teste (crie o seu com pelo menos 30 pares)
test_cases = [
    {
        "query": "Qual o procedimento para resetar a senha do VPN?",
        "expected": "Acesse portal.empresa.com/reset, insira o token SMS, defina nova senha com mínimo 12 caracteres."
    },
    # ... mais 29 casos
]

evaluator = RAGEvaluator()
scores = []

for case in test_cases:
    chunks = search_with_rerank(case['query'])
    prompt = build_rag_prompt(case['query'], chunks)
    response = llm_generate(prompt)  # sua função de geração
    
    score = evaluator.evaluate(
        case['query'], response, case['expected'], chunks
    )
    scores.append(score)

# Relatório
avg_faith = np.mean([s['faithfulness'] for s in scores])
avg_rel = np.mean([s['relevance'] for s in scores])
avg_comp = np.mean([s['completeness'] for s in scores])

print(f"Faithfulness: {avg_faith:.3f}")
print(f"Relevance:    {avg_rel:.3f}")
print(f"Completeness: {avg_comp:.3f}")
print(f"Score médio:  {np.mean([s['avg_score'] for s in scores]):.3f}")

Antes e depois das mudanças, rodo o mesmo dataset de teste. Sempre.

Antes vs. Depois: Números Reais

Comparei o pipeline antigo (chunking por tamanho, sem re-ranking, sem avaliação) com o novo:

  • Faithfulness: 0.61 → 0.87 (+42%)
  • Relevance: 0.73 → 0.91 (+24%)
  • Completeness: 0.58 → 0.84 (+44%)
  • Respostas com alucinação: 34% → 4.7%

A mudança não foi mágica. Foi engenharia metodológica.

O Pipeline Completo Em 200 Linhas

Juntando tudo, aqui está o pipeline integrado:

#!/usr/bin/env python3
"""
rag_pipeline.py — Pipeline RAG com chunking semântico, re-ranking e avaliação.
Dependências: pip install sentence-transformers sqlite-vss numpy
"""

import sqlite3
import numpy as np
from sentence_transformers import SentenceTransformer, CrossEncoder

class RAGPipeline:
    def __init__(self, db_path: str = 'rag_store.db'):
        self.embedder = SentenceTransformer('all-MiniLM-L6-v2')
        self.reranker = CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2')
        self.db = sqlite3.connect(db_path)
        self._init_db()
    
    def _init_db(self):
        self.db.enable_load_extension(True)
        import sqlite_vss
        sqlite_vss.load(self.db)
        self.db.execute("CREATE VIRTUAL TABLE IF NOT EXISTS doc_vectors USING vss0(embedding(384))")
        self.db.execute("""CREATE TABLE IF NOT EXISTS chunks (
            id INTEGER PRIMARY KEY, content TEXT, source TEXT,
            token_count INTEGER, chunk_index INTEGER)""")
        self.db.commit()
    
    def ingest(self, text: str, source: str, threshold: float = 0.5):
        """Ingesta documento com chunking semântico."""
        chunks = self._semantic_chunk(text, threshold)
        if not chunks:
            return 0
        
        embeddings = self.embedder.encode([c['content'] for c in chunks])
        for i, (chunk, emb) in enumerate(zip(chunks, embeddings)):
            cursor = self.db.execute(
                "INSERT INTO chunks (content,source,token_count,chunk_index) VALUES (?,?,?,?)",
                (chunk['content'], source, chunk['token_count'], i))
            self.db.execute("INSERT INTO doc_vectors (rowid,embedding) VALUES (?,?)",
                          (cursor.lastrowid, emb.tolist()))
        self.db.commit()
        return len(chunks)
    
    def query(self, question: str, top_k: int = 5) -> list[dict]:
        """Busca com re-ranking."""
        q_emb = self.embedder.encode(question)
        rows = self.db.execute("""
            SELECT c.id,c.content,c.source,v.distance FROM doc_vectors v
            JOIN chunks c ON c.id=v.rowid WHERE v.embedding MATCH ?
            ORDER BY v.distance LIMIT 20""", (q_emb.tolist(),)).fetchall()
        
        candidates = [{"id":r[0],"content":r[1],"source":r[2],"vec_dist":r[3]} for r in rows]
        scores = self.reranker.predict([(question, c['content']) for c in candidates])
        for c, s in zip(candidates, scores):
            c['rerank_score'] = float(s)
        candidates.sort(key=lambda x: x['rerank_score'], reverse=True)
        return candidates[:top_k]
    
    def _semantic_chunk(self, text: str, threshold: float) -> list[dict]:
        sentences = [s.strip() for s in text.split('.') if len(s.strip()) > 20]
        if len(sentences) < 2:
            return [{"content": text, "token_count": len(text.split())}]
        embs = self.embedder.encode(sentences)
        chunks, cur, tokens = [], [sentences[0]], len(sentences[0].split())
        for i in range(1, len(sentences)):
            sim = np.dot(embs[i], embs[i-1]) / (np.linalg.norm(embs[i]) * np.linalg.norm(embs[i-1]))
            if sim < threshold and tokens > 50:
                chunks.append({"content": ". ".join(cur)+".", "token_count": tokens})
                cur, tokens = [sentences[i]], len(sentences[i].split())
            else:
                cur.append(sentences[i])
                tokens += len(sentences[i].split())
        if cur:
            chunks.append({"content": ". ".join(cur)+".", "token_count": tokens})
        return chunks

if __name__ == '__main__':
    rag = RAGPipeline()
    # rag.igest(open('documento.txt').read(), 'documento.txt')
    # results = rag.query("Como configurar VPN?")
    # for r in results: print(f"[{r['rerank_score']:.3f}] {r['content'][:100]}...")

Deploy: Do Laptop Pra Produção

Com o servidor devidamente endurecido, o deploy é direto. Empacoto tudo num container Docker leve:

# Dockerfile
FROM python:3.11-slim

WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Download modelos durante build (não em runtime)
RUN python -c "from sentence_transformers import SentenceTransformer, CrossEncoder; \
    SentenceTransformer('all-MiniLM-L6-v2'); \
    CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2')"

COPY rag_pipeline.py .
COPY server.py .

EXPOSE 8000
CMD ["python", "server.py"]

O servidor é uma API Flask/FastAPI mínima que expõe /query e /ingest. Nada de framework pesado — como disse no post sobre agentes autônomos, frameworks são ótimos até você precisar debugar. Código seu você entende.

Erros Que Eu Cometi (Pra Você Não Cometer)

  • Indexar PDFs sem OCR: Scanned PDFs viram chunks vazios. Use tesseract ou marker-pdf antes da ingestão.
  • Não normalizar embeddings: Similaridade cosseno sem normalização dá resultados inconsistentes. Use normalize_embeddings=True.
  • Ignorar overlap entre chunks: Chunks adjacentes sem overlap perdem informações na fronteira. Adicione 1-2 sentenças de overlap.
  • Re-indexar tudo a cada mudança: Ingestão incremental é essencial. Hash do conteúdo como ID permite re-indexar só o que mudou.
  • Não logar queries que falham: As perguntas que o RAG erra são ouro puro pra melhorar o pipeline. Log todas e revise semanalmente.

Quando RAG Não é a Resposta

RAG não resolve tudo. Se sua base de conhecimento muda a cada minuto (dados de mercado, logs em tempo real), você precisa de algo mais dinâmico. Se o domínio é muito técnico e o modelo base não tem conhecimento suficiente, fine-tuning pode ser mais eficaz.

Mas pra 80% dos casos — documentação interna, FAQs, base de conhecimento, suporte ao cliente — um pipeline RAG bem construído resolve. O segredo está nos detalhes: chunking inteligente, re-ranking, e avaliação contínua.

Próximos Passos

Se você quer ir além do que mostrei aqui:

  1. HyDE (Hypothetical Document Embeddings): Gere uma resposta hipotética pra query e use como embedding de busca. Melhora recall em 5-10%.
  2. Query expansion: Reformule a pergunta de 3 formas diferentes e busque todas. Combina os resultados.
  3. Feedback loop: Colete 👍/👎 dos usuários e use pra ajustar o re-ranking.
  4. Multimodal: Estenda pra imagens e tabelas com CLIP embeddings.

Comece pelo básico que mostrei. Quando estiver estável, adicione camadas. Não tente fazer tudo de uma vez — construa o hábito de iterar.


E você? Qual automação com IA você quer ver aqui no blog? Um agente que lê seus emails e prioriza? Um sistema que monitora seus serviços e alerta antes de cair? Um chatbot que realmente entende seus documentos? Comenta aí — as ideias mais pedidas viram posts.

Posts Similares