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.

⚡ Resumo

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:

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:

TipoComo identificarFerramenta
PDF nativoDá para selecionar o textoExtrator simples resolve
PDF com tabela/layoutColunas, tabelas, formuláriosMarker — converte para Markdown preservando estrutura
Digitalizado (imagem)Não seleciona nadaSurya 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")
⚠️ Leia a saída da extração com os próprios olhos

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:

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
💰 Quanto custa indexar

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 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.

⚠️ Citação sem verificação é decoração

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étricaO que medeAlvo
Recall@20O trecho certo apareceu na busca?Acima de 90%
Precision@5Depois de reordenar, o certo está no topo?Acima de 80%
FidelidadeA resposta está apoiada nos trechos?Acima de 95%
Taxa de "não encontrei"Buracos na baseAcompanhar 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:

ItemComoCusto/mês
Indexação inicialRTX A4000, menos de 1 h~R$ 1 (uma vez)
Reindexação diáriaMesma máquina, ~20 min/dia~R$ 11
Embedding das perguntas + reordenaçãoInstância pequena ligada no horário comercial~R$ 235
Geração (gpub-plus)10 mil × ~R$ 0,0033~R$ 33
Banco vetorialQdrant em instância de CPUBaixo

Duas formas de cortar a maior linha, que é a máquina ligada para atender pergunta:

Sete melhorias, em ordem de retorno

  1. Busca híbrida. Maior ganho isolado. Faça primeiro.
  2. Reordenação. Segundo maior. Melhora a precisão e ainda barateia a geração.
  3. Extração melhor nos documentos que falham. Encontre a família problemática e trate só ela.
  4. Cabeçalho no trecho. Uma linha de código, ganho desproporcional.
  5. 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.
  6. Filtro por metadados. Deixe o usuário restringir por área, ano ou tipo. Reduz o espaço de busca e melhora tudo.
  7. Só então trocar o modelo de geração. É o que todo mundo tenta primeiro e o que menos resolve.
🔒 Governança e LGPD

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

  1. Dias 1–2: junte 200 documentos representativos (não os mais fáceis) e escreva 50 perguntas reais com a resposta certa.
  2. Dias 3–5: extração. Leia a saída. Conserte a família que sai errada.
  3. Dias 6–7: fatiar com cabeçalho, indexar, medir recall. Só siga acima de 90%.
  4. Dias 8–9: busca híbrida e reordenação. Meça de novo.
  5. Dias 10–11: geração com citação. Meça fidelidade.
  6. 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