← Blog

RAG: como funciona na prática

O RAG devolve o trecho errado porque o chunk cortou a resposta ao meio e o top_k nunca volta vazio. Mecanismo, código testável e limites.

A política de reembolso da empresa entrou no RAG na sexta. Na segunda, alguém do suporte pergunta "Qual o prazo do reembolso?" e o assistente responde que o documento não informa prazo. O prazo está lá, na terceira frase: 7 dias úteis. Na mesma tarde, outra pessoa pergunta a senha do wifi e recebe uma resposta confiante sobre o horário do chat.

O que a busca devolveu

O primeiro passo é olhar o que chegou para o modelo, antes de culpá-lo. O código abaixo reproduz um pipeline comum: corta o texto a cada N caracteres, gera um embedding para cada pedaço e devolve os top_k mais parecidos com a pergunta. O embedding roda local, sem chave de API, com um modelo multilíngue pequeno.

# rag.py  (pip install sentence-transformers)
import re

from sentence_transformers import SentenceTransformer

MODELO = SentenceTransformer("sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2")

DOC = (
    "Política de pagamentos. Todo pedido aprovado gera uma nota fiscal enviada por e-mail. "
    "Pagamentos por boleto compensam em até três dias úteis. "
    "Reembolso. Quando o cliente cancela a assinatura dentro do período de garantia, "
    "o valor pago é devolvido integralmente. O prazo para o estorno aparecer na fatura "
    "é de até 7 dias úteis após a confirmação do cancelamento. "
    "Pedidos cancelados depois da garantia não geram devolução. "
    "Suporte. O atendimento funciona de segunda a sexta, das 9h às 18h, pelo chat do painel."
)


def chunk_fixo(texto: str, chunk_size: int = 120) -> list[str]:
    # Corta a cada N caracteres, sem olhar para o conteúdo
    return [texto[i:i + chunk_size] for i in range(0, len(texto), chunk_size)]


def buscar(pergunta: str, chunks: list[str], top_k: int = 2,
           score_minimo: float = -1.0) -> list[tuple[float, str]]:
    # Vetores normalizados: o produto escalar é a similaridade de cosseno
    emb_chunks = MODELO.encode(chunks, normalize_embeddings=True)
    emb_pergunta = MODELO.encode([pergunta], normalize_embeddings=True)[0]
    scores = emb_chunks @ emb_pergunta
    ranking = sorted(zip(scores.tolist(), chunks), reverse=True)[:top_k]
    # Com o piso padrão, top_k sempre devolve k chunks, mesmo que nenhum sirva
    return [(s, c) for s, c in ranking if s >= score_minimo]


if __name__ == "__main__":
    for pergunta in ["Qual o prazo do reembolso?", "Qual a senha do wifi do escritório?"]:
        print(pergunta)
        for score, chunk in buscar(pergunta, chunk_fixo(DOC)):
            print(f"  {score:.3f} {chunk!r}")

Saída, com sentence-transformers 6.0.1 e Python 3.14:

Qual o prazo do reembolso?
  0.695 ' até três dias úteis. Reembolso. Quando o cliente cancela a assinatura dentro do período de garantia, o valor pago é dev'
  0.658 'olvido integralmente. O prazo para o estorno aparecer na fatura é de até 7 dias úteis após a confirmação do cancelamento'
Qual a senha do wifi do escritório?
  0.317 'às 18h, pelo chat do painel.'
  0.230 'Política de pagamentos. Todo pedido aprovado gera uma nota fiscal enviada por e-mail. Pagamentos por boleto compensam em'

O chunk que vence a pergunta do reembolso contém a palavra "Reembolso" e termina em "é dev". O prazo ficou no chunk seguinte, em segundo lugar. Se o pipeline usa top_k=1, o LLM recebe o assunto sem a resposta e diz, com razão, que o trecho não informa prazo. A pergunta sobre wifi, que o documento não responde, recebe dois chunks mesmo assim. Com quatro perguntas respondíveis, esse corte coloca o chunk errado em primeiro lugar em três delas.

Por que o corte e o ranking enganam

RAG são três etapas em sequência: recuperar, aumentar o prompt, gerar. O LLM só vê o que a recuperação entregou. Qualquer erro na primeira etapa chega intacto na última.

O chunk vira um vetor só, e esse vetor é uma média. Este modelo gera um vetor de 384 dimensões para cada token e depois tira a média de todos eles (pooling_mode: mean). O embedding do chunk representa o assunto dominante do pedaço inteiro. Quando o corte cai no meio da frase, "Reembolso" vai para um vetor e "7 dias úteis" vai para outro. O primeiro pedaço fala de reembolso sem prazo. O segundo fala de estorno e prazo sem dizer reembolso. A pergunta menciona reembolso, então o primeiro vence. O ranking fez exatamente o que devia com o que recebeu. Overlap de caracteres atenua, mas continua cortando em ponto arbitrário.

A média também dilui. Juntar frases sem relação com a pergunta puxa o vetor para longe dela. No mesmo documento, "Vocês emitem nota fiscal?" pontua 0.560 contra um chunk de 120 caracteres e 0.476 contra um de 152 caracteres com a mesma frase. O tamanho do chunk é uma troca entre contexto completo e foco.

Similaridade nunca volta zero. Textos no mesmo idioma e no mesmo registro ocupam uma região próxima do espaço de embeddings. Três perguntas que o documento não responde (wifi, estacionamento, férias) pontuaram entre 0.206 e 0.348. Não existe "nenhuma relação" na escala do cosseno: existe "menos parecido que o resto".

top_k não sabe dizer "nada". Ordenar e pegar os dois primeiros sempre devolve dois itens. Sem piso de score, toda pergunta recebe contexto, e o modelo, instruído a responder com base no contexto, responde com o que tem. A resposta confiante sobre o chat do painel sai daí.

Cortar por frase e calibrar o piso

A correção ataca as duas causas: chunks que respeitam fim de frase, com uma frase repetida entre vizinhos, e um piso de score que permite responder "não encontrei". O piso sai de medição, e não de chute. Com as sete perguntas rotuladas do repositório do experimento, a menor pergunta com resposta pontuou 0.476 e a maior sem resposta, 0.348. O código usa 0.41, perto do meio.

# Continuação de rag.py

def chunk_por_frase(texto: str, max_chars: int = 220,
                    overlap_frases: int = 1) -> list[str]:
    # Quebra só em fim de frase, então nenhuma afirmação fica partida ao meio
    frases = re.split(r"(?<=[.!?])\s+", texto.strip())
    chunks: list[str] = []
    atual: list[str] = []
    for frase in frases:
        if atual and len(" ".join(atual + [frase])) > max_chars:
            chunks.append(" ".join(atual))
            # Repete a última frase no chunk seguinte para carregar o contexto
            atual = atual[-overlap_frases:] if overlap_frases else []
        atual.append(frase)
    if atual:
        chunks.append(" ".join(atual))
    return chunks


def montar_prompt(pergunta: str, chunks: list[str]) -> str | None:
    # 0.41 foi calibrado com perguntas rotuladas neste modelo, ver trade-offs
    trechos = buscar(pergunta, chunks, top_k=2, score_minimo=0.41)
    if not trechos:
        return None  # a aplicação responde "não encontrei" sem chamar o LLM
    contexto = "\n\n".join(chunk for _, chunk in trechos)
    return (
        "Responda usando APENAS os trechos abaixo. "
        "Se a resposta não estiver neles, diga que não sabe.\n\n"
        f"Trechos:\n{contexto}\n\nPergunta: {pergunta}"
    )


if __name__ == "__main__":
    chunks = chunk_por_frase(DOC)
    for pergunta in ["Qual o prazo do reembolso?", "Qual a senha do wifi do escritório?"]:
        print(pergunta)
        for score, chunk in buscar(pergunta, chunks):
            print(f"  {score:.3f} {chunk!r}")
        print("  prompt montado" if montar_prompt(pergunta, chunks) else "  sem contexto")

Saída:

Qual o prazo do reembolso?
  0.732 'Reembolso. Quando o cliente cancela a assinatura dentro do período de garantia, o valor pago é devolvido integralmente. O prazo para o estorno aparecer na fatura é de até 7 dias úteis após a confirmação do cancelamento.'
  0.654 'O prazo para o estorno aparecer na fatura é de até 7 dias úteis após a confirmação do cancelamento. Pedidos cancelados depois da garantia não geram devolução. Suporte.'
  prompt montado
Qual a senha do wifi do escritório?
  0.339 'Suporte. O atendimento funciona de segunda a sexta, das 9h às 18h, pelo chat do painel.'
  0.181 'Política de pagamentos. Todo pedido aprovado gera uma nota fiscal enviada por e-mail. Pagamentos por boleto compensam em até três dias úteis. Reembolso.'
  sem contexto

O primeiro chunk do reembolso agora traz o assunto e o prazo juntos, e sobe para 0.732. Nas quatro perguntas respondíveis, o primeiro colocado contém a resposta. A pergunta sobre wifi fica abaixo do piso e não chega ao LLM: uma chamada de API a menos e uma alucinação a menos.

Quando essa solução não serve

O piso vale para este modelo e este conjunto. Sete perguntas deram uma margem de 0.128 entre os dois grupos. Com centenas de perguntas reais, os grupos tendem a se sobrepor e nenhum número separa tudo. Trocar de modelo, de versão ou de idioma muda a escala. O que se leva daqui é o procedimento: rotular perguntas com e sem resposta, medir no modelo que vai para produção e escolher o erro que custa menos.

Frase é uma unidade ruim para alguns documentos. Tabelas, código-fonte, listas de parâmetros e contratos com cláusulas longas quebram mal por pontuação. Nesses casos, cortar pela estrutura do documento (seção Markdown, função, linha de tabela com o cabeçalho repetido) preserva mais significado.

O modelo tem limite de tokens e corta em silêncio. Este lê até 128 tokens. O maior chunk do exemplo tem 52. Com max_chars alto, o final do chunk deixa de influenciar o vetor sem nenhum aviso, e a resposta que estava ali some da busca.

Overlap custa armazenamento e duplica contexto. Com uma frase repetida, os dois chunks devolvidos para a pergunta de reembolso contêm a mesma frase do prazo. Em bases grandes, isso aumenta o índice e gasta tokens do prompt com texto repetido. Deduplicar trechos antes de montar o prompt resolve.

Piso de score troca alucinação por recusa. Um piso alto demais faz o sistema responder "não encontrei" para perguntas que o documento responde. Num assistente de suporte, recusar costuma ser mais barato do que inventar prazo. Num buscador interno, talvez não.

O que levar para amanhã

Antes de trocar de modelo, imprima os chunks e os scores da busca: se a resposta estiver partida entre dois trechos ou se uma pergunta sem resposta pontuar perto de uma com resposta, o problema está no corte e no piso, e o LLM só está repetindo o que recebeu.


A trilha AI Engineer (R$ 697) constrói um pipeline RAG completo, com busca vetorial, prompt aumentado, geração estruturada e métricas, validado por testes automatizados a cada etapa.