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:
- Ingestão: Documentos → Chunking semântico → Embeddings → Vector store
- Busca: Query → Embedding → Busca ampla (top-20) → Re-ranking (top-5) → Contexto
- Geração: Contexto + Query → LLM → Resposta + Citações
- 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.5e 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
tesseractoumarker-pdfantes 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:
- HyDE (Hypothetical Document Embeddings): Gere uma resposta hipotética pra query e use como embedding de busca. Melhora recall em 5-10%.
- Query expansion: Reformule a pergunta de 3 formas diferentes e busque todas. Combina os resultados.
- Feedback loop: Colete 👍/👎 dos usuários e use pra ajustar o re-ranking.
- 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.
