✅ Artigo atualizado em Setembro 2026 Nova resposta rápida, um RAG mínimo que roda offline em Python puro, tabela RAG vs fine-tuning vs contexto longo, como avaliar as respostas, erros comuns com traceback e 6 exercícios resolvidos.
Resposta rápida
RAG (Retrieval-Augmented Generation) é buscar os trechos mais relevantes nos seus documentos e colocá-los no prompt antes de o LLM responder. Assim o modelo responde com base nos seus dados, e não só no que viu no treino. São 4 etapas:
- Carregar e dividir os documentos em pedaços (chunks).
- Gerar embeddings: transformar cada chunk em um vetor de números.
- Buscar por similaridade os chunks mais parecidos com a pergunta.
- Gerar a resposta: o LLM recebe a pergunta + os chunks encontrados.
1
2
3
4
5
6
7
8
from math import hypot
def cosseno(a, b): # similaridade de cosseno em Python puro
return sum(x * y for x, y in zip(a, b)) / (hypot(*a) * hypot(*b))
pergunta = [0.9, 0.1, 0.0] # embedding (de brinquedo) da pergunta
print(round(cosseno(pergunta, [0.8, 0.2, 0.1]), 2)) # 0.98 -> trecho sobre devolução
print(round(cosseno(pergunta, [0.1, 0.9, 0.3]), 2)) # 0.21 -> trecho sobre entrega
Resumo em 30 segundos:
- RAG = Retrieval (buscar os trechos) + Augmented Generation (gerar a resposta com eles).
- RAG não treina o modelo: atualizar o conhecimento é só reindexar os documentos.
- Documentos e perguntas precisam usar o mesmo modelo de embedding.
- A similaridade de cosseno vai de -1 a 1: quanto mais perto de 1, mais parecidos os textos.
- Mandar responder só com o contexto e citar a fonte reduz a alucinação, mas não a zera.
Salve salve, Pythonista! ![]()
Você já perguntou algo específico do seu negócio para o ChatGPT e levou uma resposta inventada com a maior cara de certeza? Modelo de linguagem não conhece os seus documentos: ele só sabe o que viu no treino. Quando você precisa de respostas sobre o seu manual, suas políticas ou a sua base de conhecimento, ele chuta. E chute em produção vira dor de cabeça.
A solução tem nome: RAG (Retrieval-Augmented Generation). É a técnica que dá ao LLM acesso aos seus dados na hora de responder. E o melhor: dá pra montar um RAG funcional em Python com pouco código.
Neste artigo você vai construir, do zero, um chatbot que responde apenas com base nos documentos que você fornecer.
Vá Direto ao Assunto…
- O que é RAG?
- Como o RAG funciona por baixo dos panos
- RAG mínimo em Python puro (sem API e sem instalar nada)
- Preparando o ambiente
- Passo 1: a base de conhecimento
- Passo 2: dividir em chunks
- Passo 3: gerar embeddings
- Passo 4: indexar no banco vetorial
- Passo 5: buscar os trechos relevantes (Retrieval)
- Passo 6: gerar a resposta (Generation)
- Juntando tudo e rodando
- Como avaliar a qualidade das respostas do RAG
- Quando NÃO usar RAG
- RAG vs fine-tuning vs contexto longo: qual escolher?
- Erros comuns
- Exercícios resolvidos
- Conclusão
O que é RAG?
RAG é uma técnica que conecta um LLM à sua base de conhecimento: antes de responder, o sistema busca os trechos mais relevantes dos seus documentos e os entrega ao modelo como contexto. Em vez de confiar na memória do treino, o LLM responde olhando para os dados que você deu.
Isso resolve dois problemas de uma vez:
- Alucinação: o modelo para de inventar, porque a resposta vem do contexto fornecido.
- Dados privados e atuais: ele passa a responder sobre o que ele nunca viu no treino - o seu manual, a documentação interna, os tickets de suporte.
A sigla traduz bem o fluxo: Retrieval (buscar os trechos certos) + Augmented Generation (gerar a resposta enriquecida com esses trechos).
Como o RAG funciona por baixo dos panos
O processo tem duas fases. Uma acontece uma vez (preparar os dados); a outra, a cada pergunta:
1
2
3
4
5
6
Indexação (uma vez):
documentos → chunks → embeddings → banco vetorial
Consulta (a cada pergunta):
pergunta → embedding → busca por similaridade → chunks relevantes
→ LLM (pergunta + chunks) → resposta fundamentada
A peça-chave são os embeddings: vetores de números que representam o significado de um texto. Textos com sentido parecido geram vetores próximos. É assim que o sistema acha o trecho certo mesmo quando a pergunta usa palavras diferentes das do documento.
| Etapa | O que faz |
|---|---|
| Chunking | Quebra documentos longos em pedaços menores |
| Embedding | Converte cada pedaço em um vetor numérico |
| Indexação | Guarda os vetores em um banco vetorial |
| Retrieval | Busca os pedaços mais parecidos com a pergunta |
| Generation | O LLM responde usando os pedaços como contexto |
RAG mínimo em Python puro (sem API e sem instalar nada)
Antes de chamar API e instalar banco vetorial, vale ver as quatro etapas funcionando com Python puro. O código abaixo roda offline, só com a biblioteca padrão. O “embedding” aqui é de brinquedo: um vetor que conta quantas vezes cada palavra do vocabulário aparece no texto. A similaridade de cosseno usa math.hypot para calcular o tamanho (norma) de cada vetor:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
import re
from math import hypot
DOCUMENTOS = {
"devolucao.txt": "Política de devolução: o cliente pode devolver qualquer produto "
"em até 30 dias corridos após o recebimento.",
"entrega.txt": "Prazos de entrega: pedidos para a região Sudeste chegam em 3 a 5 "
"dias úteis. Para as demais regiões, o prazo é de 7 a 12 dias úteis.",
"pagamento.txt": "Formas de pagamento: aceitamos cartão de crédito em até 12x, "
"Pix com 5% de desconto e boleto bancário.",
}
PALAVRAS_VAZIAS = {"a", "o", "e", "é", "de", "da", "do", "em", "para", "com", "as", "os", "que", "qual", "quais"}
def tokenizar(texto):
return [p for p in re.findall(r"\w+", texto.lower()) if p not in PALAVRAS_VAZIAS]
# 1) Indexação: vocabulário + um vetor por documento
VOCABULARIO = sorted({p for texto in DOCUMENTOS.values() for p in tokenizar(texto)})
def embedding(texto):
"""'Embedding' de brinquedo: conta cada palavra do vocabulário no texto."""
palavras = tokenizar(texto)
return [palavras.count(termo) for termo in VOCABULARIO]
def cosseno(a, b):
normas = hypot(*a) * hypot(*b)
return sum(x * y for x, y in zip(a, b)) / normas if normas else 0.0
INDICE = [(fonte, texto, embedding(texto)) for fonte, texto in DOCUMENTOS.items()]
# 2) Busca: top-k com limiar mínimo de similaridade
def buscar(pergunta, k=2, limiar=0.2):
vetor = embedding(pergunta)
pontuados = sorted(
((cosseno(vetor, emb), fonte, texto) for fonte, texto, emb in INDICE),
reverse=True,
)
return [(round(nota, 2), fonte, texto) for nota, fonte, texto in pontuados[:k] if nota >= limiar]
# 3) Prompt: contexto recuperado + instrução de citar a fonte
def montar_prompt(pergunta, trechos):
if not trechos:
return None
contexto = "\n".join(f"[{fonte}] {texto}" for _, fonte, texto in trechos)
return (
"Responda usando APENAS o contexto e cite a fonte entre colchetes.\n"
f"Contexto:\n{contexto}\n\nPergunta: {pergunta}"
)
for pergunta in ["Qual o prazo de entrega para o Sudeste?", "Vocês têm loja física?"]:
trechos = buscar(pergunta)
print(pergunta, "->", [(nota, fonte) for nota, fonte, _ in trechos])
print()
print(montar_prompt("Qual o prazo de entrega para o Sudeste?", buscar("Qual o prazo de entrega para o Sudeste?")))
Saída:
1
2
3
4
5
6
7
8
Qual o prazo de entrega para o Sudeste? -> [(0.38, 'entrega.txt')]
Vocês têm loja física? -> []
Responda usando APENAS o contexto e cite a fonte entre colchetes.
Contexto:
[entrega.txt] Prazos de entrega: pedidos para a região Sudeste chegam em 3 a 5 dias úteis. Para as demais regiões, o prazo é de 7 a 12 dias úteis.
Pergunta: Qual o prazo de entrega para o Sudeste?
Repare em três detalhes que valem para qualquer RAG de verdade:
- A pergunta sobre loja física não trouxe nada, porque nenhum trecho passou do limiar. Melhor responder “não sei” do que mandar lixo para o LLM.
- O prompt leva o nome da fonte junto de cada trecho, para o modelo citar de onde tirou a resposta.
- A última etapa (gerar a resposta) é só mandar esse texto para um LLM. É o que faremos no Passo 6.
A limitação do embedding de brinquedo: ele compara palavras, não significado. “Trocar um item” e “devolver um produto” não têm nenhuma palavra em comum, então ele não liga uma coisa à outra. Um modelo de embedding de verdade existe justamente para capturar esse sentido. Nos próximos passos trocamos a função embedding() pela API da OpenAI e a lista INDICE por um banco vetorial.
Preparando o ambiente
Vamos usar duas bibliotecas: a openai (para embeddings e geração) e a chromadb (um banco vetorial local, que roda na sua máquina sem servidor). Aqui montamos cada peça na mão para você entender o que acontece; frameworks como o LangChain empacotam essas mesmas etapas em componentes prontos.
Se ainda não usa ambientes virtuais, vale ler este artigo sobre virtualenv antes - mantém o projeto isolado e organizado.
1
pip install openai chromadb
Configure sua chave da OpenAI como variável de ambiente:
1
export OPENAI_API_KEY="sua-chave-aqui"
Quer rodar 100% local, sem custo de API? Dá pra trocar a OpenAI por modelos via Ollama - a arquitetura do RAG é a mesma. Fica de dica para o próximo passo.
Passo 1: a base de conhecimento
Na vida real, os documentos viriam de PDFs, páginas web ou um banco de dados. Para o exemplo ser executável de ponta a ponta, vamos usar uma pequena base de suporte em memória, guardada numa lista de strings:
1
2
3
4
5
6
7
8
9
10
# Na prática, isto viria de PDFs, sites, um banco de dados...
DOCUMENTOS = [
"""Política de devolução: o cliente pode devolver qualquer produto em até
30 dias corridos após o recebimento. O reembolso é feito no mesmo meio de
pagamento em até 10 dias úteis.""",
"""Prazos de entrega: pedidos para a região Sudeste chegam em 3 a 5 dias
úteis. Para as demais regiões, o prazo é de 7 a 12 dias úteis.""",
"""Formas de pagamento: aceitamos cartão de crédito em até 12x, Pix com 5%
de desconto e boleto bancário. O Pix é aprovado na hora.""",
]
Passo 2: dividir em chunks
Documentos longos não cabem (e não devem caber) inteiros no contexto do LLM. Quebramos cada um em chunks menores. A sobreposição (overlap) evita perder o sentido de uma frase cortada na borda de um pedaço:
1
2
3
4
5
6
7
8
9
10
11
12
def dividir_em_chunks(texto: str, tamanho: int = 120, sobreposicao: int = 20) -> list[str]:
"""Quebra um texto em pedaços menores (em palavras), com sobreposição entre eles."""
palavras = texto.split()
if not palavras:
return []
passo = tamanho - sobreposicao
# para antes de criar um último pedaço que só repetiria a sobreposição
ultimo_inicio = max(len(palavras) - sobreposicao, 1)
return [
" ".join(palavras[inicio:inicio + tamanho])
for inicio in range(0, ultimo_inicio, passo)
]
Cada chunk é um fatiamento palavras[inicio:inicio + tamanho], e o range anda de passo em passo palavras. O ultimo_inicio evita um bug sutil: sem ele, um texto de 110 palavras geraria um segundo chunk com as 10 últimas palavras, que já estão inteiras dentro do primeiro.
Como nossos documentos de exemplo são curtos (entre 24 e 31 palavras), cada um vira um único chunk. Para um manual de 50 páginas, essa mesma função produziria dezenas deles.
Está curtindo esse conteúdo? ![]()
Que tal receber 30 dias de conteúdo direto na sua Caixa de Entrada?
Passo 3: gerar embeddings
Aqui transformamos texto em vetor. O modelo text-embedding-3-small é barato e mais que suficiente:
1
2
3
4
5
6
7
8
9
10
11
from openai import OpenAI
cliente = OpenAI() # lê a chave automaticamente de OPENAI_API_KEY
def gerar_embeddings(textos: list[str]) -> list[list[float]]:
"""Converte uma lista de textos em uma lista de vetores (embeddings)."""
resposta = cliente.embeddings.create(
model="text-embedding-3-small",
input=textos,
)
return [item.embedding for item in resposta.data]
Use esta mesma função para os documentos e para as perguntas. Misturar modelos de embedding é um dos erros mais comuns em RAG.
Passo 4: indexar no banco vetorial
O ChromaDB guarda os vetores e cuida da busca por similaridade. Usamos upsert para a indexação ser idempotente - rodar de novo não duplica os dados:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
import chromadb
chroma = chromadb.PersistentClient(path="./base_vetorial")
colecao = chroma.get_or_create_collection("conhecimento")
def indexar(documentos: list[str]) -> None:
"""Quebra os documentos em chunks, gera os embeddings e salva no banco vetorial."""
chunks = [chunk for doc in documentos for chunk in dividir_em_chunks(doc)]
colecao.upsert(
ids=[f"chunk-{i}" for i in range(len(chunks))],
documents=chunks,
embeddings=gerar_embeddings(chunks),
)
print(f"{len(chunks)} chunks indexados.")
Repare no list comprehension aninhado: ele percorre cada documento e cada chunk dele numa linha só. É o jeito pythônico de achatar essa lista de listas.
Um cuidado: o upsert só sobrescreve ids que já existem. Se um documento atualizado passar a gerar menos chunks, os antigos continuam no banco. A correção está em Erros comuns.
O Chroma é ótimo para começar, mas não é a única opção:
| Banco vetorial | Onde roda | Quando faz sentido |
|---|---|---|
| ChromaDB | Local, dentro do seu processo Python | Protótipos, projetos pequenos, estudo |
| pgvector | Extensão do PostgreSQL | Você já usa Postgres/Django e não quer mais uma peça |
| Qdrant | Servidor próprio (open source) ou nuvem | Volume maior, com um serviço dedicado à busca |
| Pinecone | Serviço gerenciado na nuvem | Você não quer operar a infraestrutura |
Construir uma base sólida em Python, de comprehensions a POO, é o que separa quem só copia código de quem entende o que está fazendo em integrações como esta. É essa base, do zero ao avançado, que ensinamos no curso de Python completo da Jornada Python:
Passo 5: buscar os trechos relevantes (Retrieval)
Dada uma pergunta, geramos o embedding dela e pedimos ao Chroma os k chunks mais próximos:
1
2
3
4
5
def buscar(pergunta: str, k: int = 3) -> list[str]:
"""Retorna os k chunks mais relevantes para a pergunta."""
embedding_pergunta = gerar_embeddings([pergunta])[0]
resultado = colecao.query(query_embeddings=[embedding_pergunta], n_results=k)
return resultado["documents"][0]
Esta versão é propositalmente enxuta. Em produção você vai querer também a fonte de cada trecho e um limite de distância para descartar resultados fracos - as duas melhorias estão em Erros comuns.
Passo 6: gerar a resposta (Generation)
A última peça: montamos um prompt que entrega o contexto e proíbe o modelo de inventar. temperature=0 deixa a resposta mais determinística e fiel aos dados:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
PROMPT = """Você é um assistente de suporte. Responda à pergunta usando APENAS o
contexto abaixo. Se a informação não estiver no contexto, diga que não a possui -
não invente.
Contexto:
{contexto}
Pergunta: {pergunta}
Resposta:"""
def responder(pergunta: str) -> str:
contexto = "\n\n".join(buscar(pergunta))
resposta = cliente.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": PROMPT.format(contexto=contexto, pergunta=pergunta)}],
temperature=0,
)
return resposta.choices[0].message.content
Juntando tudo e rodando
Some os blocos acima em um arquivo rag.py e adicione o ponto de entrada:
1
2
3
4
5
6
if __name__ == "__main__":
indexar(DOCUMENTOS)
pergunta = "Posso devolver um produto depois de 2 semanas?"
print(f"\nPergunta: {pergunta}")
print(f"Resposta: {responder(pergunta)}")
Rodando com python rag.py, a saída é parecida com:
1
2
3
4
5
6
3 chunks indexados.
Pergunta: Posso devolver um produto depois de 2 semanas?
Resposta: Sim. Você pode devolver qualquer produto em até 30 dias corridos após o
recebimento, então duas semanas está dentro do prazo. O reembolso é feito no mesmo
meio de pagamento em até 10 dias úteis.
Repare no detalhe: a pergunta fala em “2 semanas”, o documento fala em “30 dias”, e o modelo conectou os dois - usando o seu dado, não a memória dele. Pergunte algo fora da base (“vocês têm loja física?”) e ele vai dizer que não tem essa informação, em vez de inventar. Isso é RAG funcionando.
A resposta do LLM varia um pouco de execução para execução; a linha
3 chunks indexados.é sempre a mesma para esta base.
Como avaliar a qualidade das respostas do RAG
“Parece que está respondendo bem” não é métrica. Um RAG pode errar em dois lugares diferentes, e cada um se avalia de um jeito:
- Na busca (retrieval): o trecho certo nem chegou ao LLM.
- Na geração: o trecho certo chegou, mas o modelo ignorou, distorceu ou inventou.
Comece pela busca, que dá para medir sem gastar nada com LLM. Monte uma lista de perguntas de teste com o documento que deveria ser encontrado e calcule a taxa de acerto no top-k. Usando o buscar do RAG mínimo lá de cima:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
TESTES = [
("Em quantos dias posso devolver um produto?", "devolucao.txt"),
("Aceitam boleto?", "pagamento.txt"),
("Qual o prazo de entrega no Nordeste?", "entrega.txt"),
("Posso parcelar no cartão?", "pagamento.txt"),
("Tem desconto no Pix?", "pagamento.txt"),
("Posso trocar um item com defeito?", "devolucao.txt"),
]
acertos = 0
for pergunta, esperado in TESTES:
fontes = [fonte for _, fonte, _ in buscar(pergunta, k=1)]
acertou = esperado in fontes
acertos += acertou
print(f"{'OK ' if acertou else 'FALHOU'} {pergunta} -> {fontes}")
print(f"Taxa de acerto no top-1: {acertos}/{len(TESTES)} = {acertos / len(TESTES):.0%}")
Saída:
1
2
3
4
5
6
7
OK Em quantos dias posso devolver um produto? -> ['devolucao.txt']
OK Aceitam boleto? -> ['pagamento.txt']
OK Qual o prazo de entrega no Nordeste? -> ['entrega.txt']
OK Posso parcelar no cartão? -> ['pagamento.txt']
OK Tem desconto no Pix? -> ['pagamento.txt']
FALHOU Posso trocar um item com defeito? -> []
Taxa de acerto no top-1: 5/6 = 83%
A falha é justamente o limite do embedding de brinquedo (“trocar” não aparece em nenhum documento). É esse tipo de pergunta que mostra se vale trocar o modelo de embedding, o tamanho do chunk ou o k. Guarde as perguntas de teste num arquivo (um JSON simples serve) e rode a avaliação a cada mudança.
Para a geração, confira em cada resposta:
- Fidelidade: tudo o que foi dito está no contexto recuperado?
- Citação: a resposta aponta a fonte certa?
- Recusa correta: em perguntas fora da base, o bot diz que não sabe?
No começo, revisar 20 a 30 respostas à mão já ensina muito. Depois dá para automatizar com outro LLM atuando como avaliador (“esta resposta é sustentada por este contexto? sim ou não”) ou com bibliotecas como Ragas, que calculam métricas como fidelidade (faithfulness).
Quando NÃO usar RAG
RAG é poderoso, mas não é bala de prata. Evite quando:
- A resposta cabe no prompt: se a sua base é um parágrafo, mande direto no contexto. Não precisa de banco vetorial.
- Você precisa de raciocínio, não de fatos: RAG busca trechos existentes; ele não substitui lógica ou cálculo.
- Os dados mudam a cada segundo: para informação em tempo real (preço de ação, estoque ao vivo), uma chamada de API direta serve melhor.
- Conhecimento muito específico e estável: aí fine-tuning pode valer mais que recuperar contexto toda vez.
RAG vs fine-tuning vs contexto longo: qual escolher?
Com modelos aceitando janelas de contexto cada vez maiores, surge a dúvida: por que não colar todos os documentos no prompt? Às vezes essa é mesmo a melhor resposta. A tabela ajuda a decidir:
| Critério | RAG | Fine-tuning | Contexto longo (tudo no prompt) |
|---|---|---|---|
| O que muda | O prompt: só os trechos relevantes, a cada pergunta | Os pesos do modelo | O prompt: a base inteira, em toda pergunta |
| Melhor para | Base grande ou que muda com frequência | Estilo, formato, tom, tarefa repetitiva | Base pequena, que cabe na janela de contexto |
| Atualizar os dados | Reindexar os documentos alterados | Treinar de novo | Trocar o texto enviado |
| Custo por pergunta | Baixo: poucos trechos no prompt | Baixo na inferência, alto no treino | Alto: você paga os tokens da base inteira toda vez |
| Citar a fonte | Fácil: cada chunk guarda a origem | Difícil: o conhecimento fica “dentro” do modelo | Possível, se as seções estiverem marcadas |
| Complexidade | Média: embeddings + banco vetorial | Alta: dataset, treino, avaliação | Baixa |
Regra prática: base pequena e consultas pontuais, comece colando tudo no contexto. Base grande, que muda ou precisa de citação, use RAG. Precisa que o modelo se comporte de um jeito específico (formato, tom), considere fine-tuning - e nada impede combinar fine-tuning com RAG.
Erros comuns
1. Chunks grandes ou pequenos demais
Chunk pequeno demais corta a ideia no meio, e nenhum pedaço sozinho responde “em quantos dias posso devolver?”. Usando o dividir_em_chunks do Passo 2:
1
2
texto = "O cliente pode devolver qualquer produto em até 30 dias corridos após o recebimento."
print(dividir_em_chunks(texto, tamanho=4, sobreposicao=0))
1
['O cliente pode devolver', 'qualquer produto em até', '30 dias corridos após', 'o recebimento.']
“Devolver” ficou num chunk e “30 dias” em outro. O oposto também é ruim: um chunk do tamanho do documento inteiro mistura vários assuntos, a busca fica imprecisa e você paga tokens por texto que não ajuda na resposta. Correção: use um tamanho que mantenha a ideia inteira e uma sobreposição entre vizinhos (em documentos reais, comece com 200 a 500 palavras e 10-20% de sobreposição):
1
print(dividir_em_chunks(texto, tamanho=8, sobreposicao=3))
1
['O cliente pode devolver qualquer produto em até', 'produto em até 30 dias corridos após o', 'corridos após o recebimento.']
2. Embeddings de modelos diferentes na indexação e na consulta
Você indexou com um modelo (o text-embedding-3-small gera vetores de 1536 dimensões) e, para economizar, passou a gerar o embedding da pergunta com outro modelo, de 384 dimensões. Em Python puro, o pior acontece: nenhum erro, porque o zip para no vetor mais curto e devolve um número sem sentido:
1
2
3
4
5
6
7
8
from math import hypot
def cosseno(a, b):
return sum(x * y for x, y in zip(a, b)) / (hypot(*a) * hypot(*b))
doc = [0.1] * 1536 # indexado com um modelo de 1536 dimensões
pergunta = [0.1] * 384 # consultado com outro modelo, de 384 dimensões
print(round(cosseno(doc, pergunta), 2))
1
0.5
Com zip(a, b, strict=True) (Python 3.10+), o problema aparece na hora:
1
ValueError: zip() argument 2 is shorter than argument 1
Com NumPy (np.dot(doc, pergunta)), o erro é:
1
ValueError: shapes (1536,) and (384,) not aligned: 1536 (dim 0) != 384 (dim 0)
E no ChromaDB, ao consultar uma coleção indexada com 1536 dimensões usando um vetor de 384:
1
chromadb.errors.InvalidArgumentError: Collection expecting embedding with dimension of 1536, got 384
Correção: use a mesma função gerar_embeddings nas duas pontas e guarde o nome do modelo junto com a coleção. Trocou de modelo? Reindexe tudo. E cuidado: dois modelos diferentes com o mesmo número de dimensões não dão erro nenhum, só resultados ruins, porque cada modelo organiza os vetores do seu próprio jeito.
3. Não citar a fonte
O buscar do Passo 5 devolve só o texto dos chunks. O LLM responde, mas ninguém consegue conferir de onde veio a informação, e quando a resposta sai errada você não sabe qual documento culpar. Correção: guarde a fonte como metadado na indexação (metadatas=[{"fonte": fonte} for _ in chunks] no upsert, como no erro 5) e devolva-a junto com o texto:
1
2
3
4
5
6
7
8
9
def buscar_com_fonte(pergunta: str, k: int = 3, distancia_maxima: float = 1.0) -> list[dict]:
"""Retorna texto, fonte e distância dos chunks próximos o bastante da pergunta."""
resultado = colecao.query(query_embeddings=gerar_embeddings([pergunta]), n_results=k)
trechos = zip(resultado["documents"][0], resultado["metadatas"][0], resultado["distances"][0])
return [
{"texto": texto, "fonte": meta["fonte"], "distancia": dist}
for texto, meta, dist in trechos
if dist <= distancia_maxima
]
No prompt, coloque cada trecho com a sua fonte ([manual.pdf] texto...) e peça ao modelo para citá-la, como no montar_prompt do RAG mínimo.
4. Recuperar trechos irrelevantes (sem limiar de similaridade)
A busca top-k sempre devolve k resultados, mesmo quando nenhum tem a ver com a pergunta. Com o RAG mínimo e o limiar desligado:
1
print(buscar("Vocês têm loja física?", k=2, limiar=0))
1
[(0.0, 'pagamento.txt', 'Formas de pagamento: aceitamos cartão de crédito em até 12x, Pix com 5% de desconto e boleto bancário.'), (0.0, 'entrega.txt', 'Prazos de entrega: pedidos para a região Sudeste chegam em 3 a 5 dias úteis. Para as demais regiões, o prazo é de 7 a 12 dias úteis.')]
Dois trechos com similaridade zero iriam para o prompt, e o LLM ficaria tentado a “aproveitar” o que recebeu. Correção: filtre por um limiar (com limiar=0.2, o resultado é []) e, sem trechos, responda “não encontrei isso na base” sem chamar o LLM, o que ainda economiza tokens. No Chroma, a métrica padrão é distância (quanto menor, mais parecido), por isso o buscar_com_fonte do erro 3 usa distancia_maxima. O valor certo depende do modelo e dos seus dados: calibre com as perguntas de teste da seção de avaliação.
5. Esquecer de reindexar documentos atualizados
O RAG só sabe o que está no banco vetorial. Atualizou a política de devolução e não reindexou? O bot continua respondendo a regra antiga, com toda a confiança. E há uma armadilha mais sutil: se a versão nova gera menos chunks, o upsert sobrescreve só os ids que existem e os chunks velhos ficam lá. O exemplo abaixo usa vetores falsos, então roda sem API:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import chromadb
colecao = chromadb.EphemeralClient().get_or_create_collection("manual")
def indexar(fonte, chunks):
colecao.upsert(
ids=[f"{fonte}-{i}" for i in range(len(chunks))],
documents=chunks,
embeddings=[[float(len(c)), 1.0, 2.0] for c in chunks], # vetores falsos, só para o exemplo
metadatas=[{"fonte": fonte} for _ in chunks],
)
indexar("manual.pdf", ["v1 a", "v1 b", "v1 c", "v1 d"]) # versão 1: 4 chunks
indexar("manual.pdf", ["v2 a", "v2 b"]) # versão 2: só 2 chunks
print(colecao.count(), sorted(colecao.get()["documents"]))
1
4 ['v1 c', 'v1 d', 'v2 a', 'v2 b']
Correção: antes de indexar a versão nova, apague os chunks daquela fonte:
1
2
3
4
5
6
def reindexar(fonte, chunks):
colecao.delete(where={"fonte": fonte}) # remove todos os chunks da versão anterior
indexar(fonte, chunks)
reindexar("manual.pdf", ["v2 a", "v2 b"])
print(colecao.count(), sorted(colecao.get()["documents"]))
1
2 ['v2 a', 'v2 b']
Automatize: rode a reindexação sempre que um arquivo mudar (comparar a data de modificação ou um hash do conteúdo resolve).
Exercícios resolvidos
Todos rodam offline, sem chave de API.
Exercício 1. Escreva dividir_em_chunks(texto, tamanho, sobreposicao) que divida um texto em chunks de tamanho palavras, repetindo sobreposicao palavras entre chunks vizinhos. Teste com "um dois três quatro cinco seis sete oito nove dez", tamanho=4 e sobreposicao=2.
Ver solução
1
2
3
4
5
6
7
8
def dividir_em_chunks(texto, tamanho, sobreposicao):
palavras = texto.split()
passo = tamanho - sobreposicao
ultimo_inicio = max(len(palavras) - sobreposicao, 1)
return [" ".join(palavras[i:i + tamanho]) for i in range(0, ultimo_inicio, passo)]
texto = "um dois três quatro cinco seis sete oito nove dez"
print(dividir_em_chunks(texto, tamanho=4, sobreposicao=2))
Saída: ['um dois três quatro', 'três quatro cinco seis', 'cinco seis sete oito', 'sete oito nove dez']
O passo é tamanho - sobreposicao = 2, então cada chunk começa 2 palavras depois do anterior e repete as 2 últimas dele. O ultimo_inicio impede um quinto chunk 'nove dez', que só repetiria palavras.
Exercício 2. Implemente a similaridade de cosseno em Python puro (sem NumPy), levantando ValueError se os vetores tiverem dimensões diferentes. Teste com vetores na mesma direção, perpendiculares e opostos.
Ver solução
1
2
3
4
5
6
7
8
9
10
11
12
13
from math import sqrt
def cosseno(a, b):
if len(a) != len(b):
raise ValueError("vetores com dimensões diferentes")
produto = sum(x * y for x, y in zip(a, b))
norma_a = sqrt(sum(x * x for x in a))
norma_b = sqrt(sum(y * y for y in b))
return produto / (norma_a * norma_b)
print(cosseno([1, 2, 3], [2, 4, 6])) # mesma direção
print(cosseno([1, 0], [0, 1])) # perpendiculares
print(cosseno([1, 0], [-1, 0])) # opostos
Saída: 1.0, 0.0 e -1.0 (uma por linha).
Cosseno é o produto escalar dividido pelo produto das normas. Ele mede a direção dos vetores, não o tamanho: [2, 4, 6] é o dobro de [1, 2, 3] e mesmo assim a similaridade é máxima.
Exercício 3. Dado um dicionário {fonte: nota_de_similaridade}, escreva top_k(notas, k=2, limiar=0.3) que devolva as k fontes com maior nota, descartando as que ficarem abaixo do limiar.
Ver solução
1
2
3
4
5
6
7
def top_k(notas, k=2, limiar=0.3):
ordenados = sorted(notas.items(), key=lambda item: item[1], reverse=True)
return [(fonte, nota) for fonte, nota in ordenados[:k] if nota >= limiar]
notas = {"devolucao.txt": 0.82, "entrega.txt": 0.15, "pagamento.txt": 0.47, "faq.txt": 0.33}
print(top_k(notas))
print(top_k(notas, k=3, limiar=0.4))
Saída:
1
2
[('devolucao.txt', 0.82), ('pagamento.txt', 0.47)]
[('devolucao.txt', 0.82), ('pagamento.txt', 0.47)]
Na segunda chamada pedimos 3, mas faq.txt (0.33) ficou abaixo do limiar de 0.4. É o limiar que impede trechos fracos de chegarem ao LLM. A função lambda no key ordena pela nota, não pelo nome.
Exercício 4. Escreva montar_prompt(pergunta, trechos) que receba uma lista de dicionários {"fonte": ..., "texto": ...} e monte um prompt com os trechos numerados ([1], [2]…), a fonte de cada um e a instrução de responder só com o contexto.
Ver solução
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def montar_prompt(pergunta, trechos):
contexto = "\n".join(
f"[{i}] ({t['fonte']}) {t['texto']}" for i, t in enumerate(trechos, start=1)
)
return (
"Responda usando APENAS o contexto abaixo e cite as fontes como [1], [2].\n"
"Se a resposta não estiver no contexto, diga que não sabe.\n\n"
f"Contexto:\n{contexto}\n\n"
f"Pergunta: {pergunta}"
)
trechos = [
{"fonte": "devolucao.txt", "texto": "Devoluções em até 30 dias corridos."},
{"fonte": "pagamento.txt", "texto": "Reembolso no mesmo meio de pagamento."},
]
print(montar_prompt("Como funciona a devolução?", trechos))
Saída:
1
2
3
4
5
6
7
8
Responda usando APENAS o contexto abaixo e cite as fontes como [1], [2].
Se a resposta não estiver no contexto, diga que não sabe.
Contexto:
[1] (devolucao.txt) Devoluções em até 30 dias corridos.
[2] (pagamento.txt) Reembolso no mesmo meio de pagamento.
Pergunta: Como funciona a devolução?
enumerate(..., start=1) numera os trechos a partir de 1, e a numeração permite que a resposta cite [1] e você saiba exatamente qual documento embasou cada frase.
Exercício 5. (Questão de prova) Um chatbot RAG indexou os documentos com um modelo de embedding de 1536 dimensões. Para economizar, a equipe passou a gerar o embedding das perguntas com outro modelo, de 384 dimensões. O que acontece?
a) A busca fica mais rápida e igualmente precisa.
b) Dá erro de dimensão ou, se o código truncar os vetores, notas sem sentido; é preciso usar o mesmo modelo nas duas pontas.
c) O LLM percebe a diferença e corrige a busca sozinho.
d) Nada muda, porque a similaridade de cosseno ignora o tamanho dos vetores.
Ver solução
Resposta: b). Vetores de modelos diferentes vivem em “espaços” diferentes e nem sequer têm o mesmo tamanho. O Chroma e o NumPy levantam erro de dimensão; um zip sem strict=True trunca o vetor maior em silêncio e devolve uma nota sem significado. A alternativa d) confunde as coisas: o cosseno ignora a norma (comprimento) do vetor, não o número de dimensões.
Exercício 6. (Questão de prova) Uma empresa altera a tabela de preços toda semana e quer um chatbot que responda sobre ela citando a tabela vigente. Qual abordagem é a mais adequada?
a) Fazer fine-tuning do modelo toda semana com a tabela nova.
b) RAG, reindexando a tabela sempre que ela mudar.
c) Aumentar a temperature para o modelo ser mais criativo com os preços.
d) Confiar no conhecimento que o modelo já tem do treino.
Ver solução
Resposta: b). Dados que mudam com frequência e precisam de citação são o caso clássico de RAG: basta reindexar a tabela, sem treinar nada. Fine-tuning semanal (a) é caro, lento e dificulta citar a fonte; temperature alta (c) aumenta a variação, o oposto do que se quer com preços; e o treino (d) não conhece a tabela desta semana.
Conclusão
Você acabou de construir um RAG funcional: indexação, busca por similaridade e geração fundamentada, com um punhado de funções pythônicas. Esse mesmo esqueleto - trocando a base de exemplo pelos seus PDFs e o Chroma por um banco vetorial mais robusto - é o que está por trás da maioria dos “chatbots que conhecem a empresa”.
Próximos passos naturais: transformar o buscar em uma ferramenta que o modelo decide quando usar, como mostramos em como criar um agente de IA em Python, ou expor a sua base de conhecimento para qualquer assistente compatível via MCP (Model Context Protocol).
E é exatamente esse tipo de arquitetura que move o Ebookr.ai, minha plataforma que gera ebooks profissionais com IA: recuperar o contexto certo e gerar conteúdo fiel a ele, em escala. Se curte ver IA aplicada de verdade, dá uma conferida:
E você, vai plugar esse RAG em qual base de conhecimento? Conta aqui nos comentários! ![]()
Nos vemos no próximo artigo! ![]()
Perguntas frequentes
O que é RAG (Retrieval-Augmented Generation)?
RAG é uma técnica que busca os trechos mais relevantes nos seus documentos e os coloca no prompt antes de o LLM responder. Assim o modelo responde com base nos seus dados (manuais, políticas, base de conhecimento) e não só no que aprendeu no treino. O fluxo tem 4 etapas, nesta ordem: carregar e dividir os documentos em chunks, gerar embeddings, buscar os trechos por similaridade e gerar a resposta com o contexto recuperado.
Qual a diferença entre RAG e fine-tuning?
Fine-tuning treina o modelo de novo com exemplos para mudar o comportamento dele (estilo, formato, tom). RAG não treina nada: busca o conhecimento nos seus documentos na hora da pergunta e o injeta no prompt. Para responder sobre documentos que mudam com frequência, RAG costuma ser mais barato e flexível, porque basta reindexar os arquivos alterados. Fine-tuning compensa quando você precisa de um comportamento ou formato consistente.
O que é um embedding?
Embedding é uma lista de números (um vetor) que representa o significado de um texto. Textos com sentido parecido geram vetores próximos, mesmo usando palavras diferentes. No RAG, cada chunk e cada pergunta viram embeddings, e a busca compara esses vetores, normalmente pela similaridade de cosseno, que vai de -1 a 1. O modelo text-embedding-3-small da OpenAI, por exemplo, gera vetores de 1536 dimensões.
O que é um banco vetorial? Dá para usar PostgreSQL em vez do Chroma?
Banco vetorial é um banco de dados feito para guardar embeddings e encontrar rapidamente os vetores mais parecidos com o vetor da pergunta (busca por similaridade). Exemplos: ChromaDB (roda local, bom para começar), Qdrant e Pinecone. E sim, dá para usar PostgreSQL: a extensão pgvector transforma o Postgres em banco vetorial, ótimo para quem já usa Django e Postgres e não quer mais uma peça na infraestrutura. Para poucos documentos, uma lista em memória com similaridade de cosseno já resolve.
Qual o tamanho ideal de chunk e de sobreposição em um RAG?
Não existe número mágico, mas chunks de 200 a 500 palavras com 10-20% de sobreposição são um bom ponto de partida. Chunk grande demais traz ruído e gasta contexto; pequeno demais corta a ideia no meio. A sobreposição repete algumas palavras entre pedaços vizinhos para não perder a frase cortada na borda. Ajuste testando com perguntas reais sobre os seus dados.
Como reduzir alucinação em um RAG?
Instrua o modelo a responder apenas com base no contexto e a dizer que não sabe quando a informação não estiver lá, use temperature=0, aplique um limiar de similaridade para não mandar trechos irrelevantes, peça que ele cite a fonte de cada afirmação e mantenha a base reindexada. RAG reduz a alucinação, mas não a elimina, por isso vale avaliar as respostas com um conjunto de perguntas de teste.
Como avaliar a qualidade das respostas de um RAG?
Avalie as duas metades separadamente. Na busca, monte perguntas de teste com o documento esperado e meça a taxa de acerto no top-k (o trecho certo veio entre os k primeiros?). Na geração, confira se a resposta é fiel ao contexto, se cita a fonte e se diz que não sabe em perguntas fora da base. Dá para fazer isso à mão, com outro LLM como avaliador ou com bibliotecas como Ragas.
Preciso pagar pela API da OpenAI para fazer RAG?
Não. A OpenAI é só uma opção. Dá para gerar embeddings localmente com sentence-transformers, rodar o LLM na sua máquina com Ollama e usar um banco vetorial local, como ChromaDB ou pgvector. A arquitetura do RAG continua a mesma: dividir, gerar embeddings, buscar por similaridade e gerar a resposta.
"Porque o Senhor dá a sabedoria, e da sua boca vem a inteligência e o entendimento" Pv 2:6