Todo problema de RAG parece problema do modelo. Quase nunca é.
Demorei pra aceitar isso. Ficava trocando modelo de embedding, mexendo no prompt, achando que o Claude tava inventando coisa. O que estava quebrado era o que eu entregava pra ele, e só ficou óbvio quando comecei a logar o que voltava da recuperação.
Depois que instrumentei, os erros se separaram em quatro tipos bem diferentes.
A pergunta simplesmente não tem resposta na base. Quase ninguém checa. Um piso resolve:
q_emb = vo.embed([question], model="voyage-3.5", input_type="query").embeddings[0]
cur.execute(
"SELECT content, 1 - (embedding <=> %s) AS score "
"FROM documents ORDER BY embedding <=> %s LIMIT %s",
(q_emb, q_emb, top_k)
)
rows = cur.fetchall()
if not rows or rows[0][1] < 0.7:
return "Não tenho essa informação na base."
Feio, mas corta a maior parte das respostas inventadas com cara de certeza. O 0.7 não é sagrado: é o ponto onde, na minha base, o chunk deixava de ter relação com a pergunta. Loga o score por uma semana e você acha o teu.
"como resetar senha" trazendo "como resetar produto". Vetorialmente colado, na prática inútil.
Esse foi o que mais me irritou, porque parece bug do modelo e não é: busca vetorial pura não separa as duas coisas. Os dois chunks falam de resetar algo, com estrutura de frase quase idêntica, e a diferença inteira mora numa palavra que o vetor dilui.
Hybrid search (vetor + BM25) rendeu mais aqui do que qualquer troca de embedding que eu tinha tentado antes, porque o BM25 dá peso ao token literal "senha" — justamente o que o vetor perdeu. Reranker com cross-encoder na frente ataca o mesmo problema por outro lado.
O retrieval acertou e o modelo extrapolou mesmo assim. Isso é instrução, não recuperação: o system prompt precisa proibir na cara dura e pedir citação do trecho.
SYSTEM = (
"Responda APENAS com base no contexto fornecido. "
"Cite o trecho que embasa cada afirmação. "
"Se a resposta não estiver no contexto, diga que não sabe."
)
Óbvio depois de escrito. Passei semanas culpando o retrieval por isso.
Tabela partida, bloco de código sem fechamento. Chunking por contagem de token faz isso o tempo todo:
def chunk_text(text, size=500, overlap=50):
words = text.split()
return [" ".join(words[i:i + size])
for i in range(0, len(words), size - overlap)]
Funciona pra prosa corrida e destrói documentação técnica. E o estrago é silencioso: o modelo responde bonito em cima de um pedaço mutilado, sem sinal nenhum de que faltou metade da tabela. Respeitar a estrutura do documento (header de markdown, seção do PDF) dá mais trabalho pra escrever e se paga na primeira semana.
Se fosse começar de novo, a primeira coisa que eu escreveria não era o pipeline, era o log. Score de cada chunk, quais entraram, tamanho de cada um. Sem isso você fica trocando peça no escuro, e trocar modelo de embedding é caro, demorado e quase nunca era o problema.
Escrevi o passo a passo completo no blog, com o pipeline em Python usando pgvector, Voyage e Claude, as 4 estratégias de chunking e a comparação com fine-tuning: https://www.techknow.com.br/post/o-que-e-rag
Quem já tem isso em produção: o ganho maior veio de mexer no chunking ou de botar um reranker na frente?