Todo mundo consegue fazer um RAG funcionar numa demonstração. Você joga 20 PDFs num banco vetorial, faz três perguntas, funciona, e o projeto é aprovado. Aí entram 40 mil documentos reais — contratos digitalizados, planilhas viradas em PDF, manuais com tabela — e a qualidade despenca.
Este guia é sobre a distância entre esses dois momentos. Ele monta um RAG que aguenta os documentos de verdade da sua empresa, com as decisões que mais afetam qualidade e a conta de quanto custa.
Sete etapas: extrair → fatiar → indexar → buscar (híbrido) → reordenar → gerar → avaliar. As duas que mais mudam o resultado são a extração e a busca híbrida — e são justamente as que os tutoriais pulam.
Por que o RAG ingênuo falha
O caminho "PDF → split(1000) → embedding → busca por similaridade" falha por motivos previsíveis:
- A extração perdeu a informação. Se o extrator transformou a tabela de preços em texto embaralhado, nenhuma busca vai recuperá-la. Lixo indexado é lixo recuperado.
- O trecho cortou no meio da resposta. Fatia de tamanho fixo separa a pergunta da resposta, o cabeçalho da tabela dos dados.
- Busca vetorial não acha código nem número. Perguntou pela "cláusula 7.3" ou pelo SKU "AX-4410"? Similaridade semântica é ruim nisso. Busca por palavra-chave é ótima.
- O modelo respondeu com o que ele já sabia. Sem instrução firme e sem citação, ele completa a lacuna com conhecimento geral — que pode contradizer a sua política interna.
- Ninguém mediu. Sem conjunto de avaliação, "melhorou" é opinião.
Etapa 1: extração — onde o projeto se ganha ou se perde
Se você só puder caprichar em uma etapa, é esta. Um PDF pode ser três coisas muito diferentes, e o tratamento muda:
| Tipo | Como identificar | Ferramenta |
|---|---|---|
| PDF nativo | Dá para selecionar o texto | Extrator simples resolve |
| PDF com tabela/layout | Colunas, tabelas, formulários | Marker — converte para Markdown preservando estrutura |
| Digitalizado (imagem) | Não seleciona nada | Surya OCR — funciona bem em português |
Ambos estão disponíveis como template de 1 clique. E ambos usam GPU: é o primeiro lugar onde uma placa se paga, porque OCR em CPU para 40 mil páginas leva dias.
def extrair(caminho: Path) -> str:
if caminho.suffix == ".pdf":
texto = texto_selecionavel(caminho)
if len(texto.strip()) < 100: # praticamente vazio → é digitalizado
return ocr(caminho) # Surya
return markdown_estruturado(caminho) # Marker
if caminho.suffix in (".docx", ".xlsx"):
return converter_office(caminho)
return caminho.read_text(encoding="utf-8", errors="ignore")
Antes de indexar 40 mil documentos, abra 20 arquivos extraídos e leia. Sempre — sempre — há uma família de documentos que sai errada: o contrato antigo com duas colunas, a planilha exportada com as colunas coladas, o fax digitalizado torto.
Achar isso na inspeção custa uma tarde. Achar depois, pela reclamação de um usuário que recebeu uma resposta errada, custa a confiança no projeto.
Etapa 2: fatiar respeitando a estrutura
Corte em fronteiras de significado, não a cada N caracteres. Como o Marker devolve Markdown, você tem os títulos — use-os:
def fatiar(markdown: str, doc: dict, alvo=800, sobreposicao=120):
secoes, atual, titulo = [], [], "(início)"
for linha in markdown.split("\n"):
if linha.startswith("#"):
if atual: secoes.append((titulo, "\n".join(atual)))
titulo, atual = linha.lstrip("# ").strip(), []
else:
atual.append(linha)
if atual: secoes.append((titulo, "\n".join(atual)))
trechos = []
for titulo, corpo in secoes:
for pedaco in quebrar_por_paragrafo(corpo, alvo, sobreposicao):
trechos.append({
# o CABEÇALHO viaja junto: sozinho, o trecho perde o contexto
"texto": f"[{doc['titulo']} — {titulo}]\n\n{pedaco}",
"doc_id": doc["id"], "titulo": doc["titulo"],
"secao": titulo, "pagina": doc.get("pagina"),
"url": doc["url"], "atualizado_em": doc["atualizado_em"],
})
return trechos
Duas decisões dentro desse código valem mais que a escolha do banco vetorial:
- O cabeçalho no texto indexado. Um trecho que diz "o prazo é de 5 dias úteis" não responde nada. "[Política de Trocas — Prazo de devolução] o prazo é de 5 dias úteis" responde. E melhora a busca, porque o termo entra no índice.
- Metadados desde o começo. Documento, seção, página, URL e data. Sem eles você não consegue citar a fonte nem filtrar por versão — e vai ter que reindexar tudo para adicionar depois.
Etapa 3: embeddings na sua GPU
Aqui há uma escolha de arquitetura. Modelos de embedding rodam bem em GPU modesta, e indexar é trabalho intenso e pontual — o caso clássico de subir uma máquina, processar e desligar.
Para português, prefira modelo multilíngue de qualidade (a família BGE-M3 é uma escolha sólida e aberta). Modelo só de inglês degrada bastante em documento em português.
from sentence_transformers import SentenceTransformer
modelo = SentenceTransformer("BAAI/bge-m3", device="cuda")
def indexar(trechos, lote=256):
vetores = modelo.encode(
[t["texto"] for t in trechos],
batch_size=lote,
normalize_embeddings=True, # normalize e use produto interno
show_progress_bar=True,
)
return vetores
100 mil trechos numa RTX A4000 (R$ 1,07/h): a indexação leva menos de uma hora, incluindo o tempo de montar o ambiente. Cerca de R$ 1 pela base inteira. Você sobe, roda e desliga.
Para reindexação incremental (documentos novos do dia), o mesmo script roda em minutos — ou você mantém uma instância pequena ligada só no horário do lote.
Etapa 4: busca híbrida — o maior ganho de qualidade
Se você adotar uma única recomendação deste artigo, que seja esta. Busca vetorial sozinha não basta. Ela é ótima em paráfrase e péssima em termo exato — e usuário corporativo pergunta por termo exato o tempo todo: número de cláusula, código de produto, sigla interna, nome de norma.
A solução é rodar as duas buscas e fundir os resultados. A fusão recíproca de postos (RRF) é simples e funciona bem sem precisar calibrar peso:
def buscar(pergunta: str, k=20):
vet = qdrant.search(colecao, modelo.encode(pergunta,
normalize_embeddings=True), limit=k) # semântica
kw = bm25.search(pergunta, limit=k) # palavra-chave
K, pontos = 60, {}
for lista in (vet, kw):
for posicao, item in enumerate(lista):
pontos[item.id] = pontos.get(item.id, 0) + 1 / (K + posicao + 1)
return [por_id(i) for i, _ in
sorted(pontos.items(), key=lambda x: -x[1])[:k]]
O Qdrant — disponível como template de 1 clique — suporta vetor denso e esparso na mesma coleção, então você não precisa manter dois bancos.
Reordenação: a segunda maior melhoria
Busque com folga (20 candidatos) e reordene com um modelo cross-encoder, que lê pergunta e trecho juntos e é muito mais preciso que similaridade de vetor. Depois mande só os 5 melhores para o LLM:
from sentence_transformers import CrossEncoder
reordenador = CrossEncoder("BAAI/bge-reranker-v2-m3", device="cuda")
def melhores(pergunta, candidatos, n=5):
notas = reordenador.predict([(pergunta, c["texto"]) for c in candidatos])
ordenados = [c for _, c in sorted(zip(notas, candidatos), key=lambda x: -x[0])]
return ordenados[:n]
Isso melhora a precisão e reduz custo: você envia 5 trechos em vez de 20 ao modelo de geração. Menos contexto, menos token, menos ruído para o modelo se distrair.
Etapa 5: gerar com citação obrigatória
O prompt de geração tem um trabalho: fazer o modelo responder só com o que está nos trechos, e admitir quando não sabe.
PROMPT = """Você responde perguntas usando APENAS os trechos fornecidos.
Regras:
- Se a resposta não estiver nos trechos, diga exatamente:
"Não encontrei essa informação nos documentos disponíveis."
- Nunca use conhecimento geral seu para completar lacuna.
- Cite a fonte de cada afirmação com [1], [2] etc.
- Se os trechos se contradisserem, aponte a contradição e cite as duas fontes.
- Responda em português do Brasil, direto.
TRECHOS:
{trechos}
PERGUNTA: {pergunta}"""
def responder(pergunta: str):
trechos = melhores(pergunta, buscar(pergunta))
if not trechos:
return {"resposta": "Não encontrei nada relacionado.", "fontes": []}
bloco = "\n\n".join(
f"[{i}] ({t['titulo']} — {t['secao']}, p. {t['pagina']})\n{t['texto']}"
for i, t in enumerate(trechos, 1))
r = llm.chat.completions.create(
model="gpub-plus",
messages=[{"role": "user",
"content": PROMPT.format(trechos=bloco, pergunta=pergunta)}],
temperature=0, # RAG não é lugar para criatividade
)
return {"resposta": r.choices[0].message.content,
"fontes": [{"titulo": t["titulo"], "secao": t["secao"],
"pagina": t["pagina"], "url": t["url"]} for t in trechos],
"custo": custo(r)}
A frase de escape ("Não encontrei essa informação") precisa ser literal e exata. Isso permite que o seu código a detecte, registre e transforme em métrica: a taxa de "não encontrei" é o melhor indicador de buraco na base de conhecimento que existe.
O modelo pode citar [3] para uma afirmação que veio do trecho 1. Se a resposta é usada em decisão séria, valide: para cada citação, confira se o número existe e — no nível mais rigoroso — se a afirmação tem respaldo naquele trecho específico. Um segundo modelo barato consegue fazer essa conferência por uma fração de centavo.
Etapa 6: medir, ou você está adivinhando
Monte um conjunto de avaliação antes de otimizar qualquer coisa: 50 perguntas reais (peça ao time que atende), cada uma com a resposta correta e o documento que a contém. Custa uma tarde e é o que transforma "acho que melhorou" em número.
| Métrica | O que mede | Alvo |
|---|---|---|
| Recall@20 | O trecho certo apareceu na busca? | Acima de 90% |
| Precision@5 | Depois de reordenar, o certo está no topo? | Acima de 80% |
| Fidelidade | A resposta está apoiada nos trechos? | Acima de 95% |
| Taxa de "não encontrei" | Buracos na base | Acompanhar a tendência |
Diagnóstico rápido: recall baixo é problema de busca ou de extração (o trecho não está indexado direito). Recall alto e fidelidade baixa é problema de prompt (o modelo está inventando com a informação na mão). São consertos completamente diferentes — e sem as duas métricas você não sabe qual fazer.
A conta completa
Base de 100 mil trechos, 10 mil perguntas por mês:
| Item | Como | Custo/mês |
|---|---|---|
| Indexação inicial | RTX A4000, menos de 1 h | ~R$ 1 (uma vez) |
| Reindexação diária | Mesma máquina, ~20 min/dia | ~R$ 11 |
| Embedding das perguntas + reordenação | Instância pequena ligada no horário comercial | ~R$ 235 |
Geração (gpub-plus) | 10 mil × ~R$ 0,0033 | ~R$ 33 |
| Banco vetorial | Qdrant em instância de CPU | Baixo |
Duas formas de cortar a maior linha, que é a máquina ligada para atender pergunta:
- Processe em lote quando puder. Se as perguntas não são interativas (relatório noturno, classificação), acumule e rode uma vez por dia — a máquina fica ligada minutos em vez de horas.
- Desligue fora do expediente. RAG corporativo é usado em horário comercial. 24×7 custa três vezes mais e atende o mesmo.
Sete melhorias, em ordem de retorno
- Busca híbrida. Maior ganho isolado. Faça primeiro.
- Reordenação. Segundo maior. Melhora a precisão e ainda barateia a geração.
- Extração melhor nos documentos que falham. Encontre a família problemática e trate só ela.
- Cabeçalho no trecho. Uma linha de código, ganho desproporcional.
- Reescrita da pergunta. Um modelo barato transforma "e o prazo?" numa pergunta autocontida antes de buscar — resolve boa parte das falhas em conversa de várias trocas.
- Filtro por metadados. Deixe o usuário restringir por área, ano ou tipo. Reduz o espaço de busca e melhora tudo.
- Só então trocar o modelo de geração. É o que todo mundo tenta primeiro e o que menos resolve.
Documento corporativo costuma ter dado pessoal. Dois pontos que aparecem em toda análise jurídica:
Permissão na busca, não na resposta. Filtre por permissão do usuário antes de recuperar. Se um trecho restrito entrou no contexto, ele já vazou — pedir ao modelo para "não mencionar" não é controle de acesso.
Controle sobre o processamento. Numa instância dedicada, o modelo, os pesos, os logs e os prompts estão sob a sua gestão, sem API de terceiro no caminho. É um argumento de controle — o que a análise de LGPD examina é quem processa o quê e sob qual base legal.
Monte o RAG da sua empresa
Templates de 1 clique para extração, banco vetorial e servidor de modelo, GPUs por hora e API por token — tudo no mesmo saldo, cobrado em reais.
Criar conta →Um plano de duas semanas
- Dias 1–2: junte 200 documentos representativos (não os mais fáceis) e escreva 50 perguntas reais com a resposta certa.
- Dias 3–5: extração. Leia a saída. Conserte a família que sai errada.
- Dias 6–7: fatiar com cabeçalho, indexar, medir recall. Só siga acima de 90%.
- Dias 8–9: busca híbrida e reordenação. Meça de novo.
- Dias 10–11: geração com citação. Meça fidelidade.
- Dias 12–14: permissões, registro de consultas e piloto com 10 usuários reais.
Repare que a geração — a parte "de IA" — aparece só no dia 10. É proposital: se a recuperação está ruim, nenhum modelo salva a resposta.
Conclusão
RAG de produção é um problema de engenharia de dados com um LLM no fim. As decisões que definem a qualidade — como extrair, onde cortar, como buscar, como reordenar — acontecem todas antes de o modelo entrar em cena, e são exatamente as que os tutoriais de demonstração pulam.
Comece pelo conjunto de avaliação. Com ele, cada mudança vira um número e você para de discutir por impressão. Sem ele, você vai trocar de modelo três vezes achando que o problema é o modelo — quando na maioria das vezes o problema é que o trecho certo nunca foi recuperado.
Continue: Qdrant em 1 clique · Marker para PDFs · Surya OCR em português · agente com ferramentas