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.