RAG na Prática: o que é e como criar um chatbot com seus documentos em Python

Cansado de programar?

Conheça a melhor e mais completa formação de Python e Django e sinta-se um programador verdadeiramente competente. Além de Python e Django, você também vai aprender Banco de Dados, SQL, HTML, CSS, Javascript, Bootstrap e muito mais!

Quero aprender Python e Django de Verdade! Quero aprender!
Suporte

Tire suas dúvidas diretamente com o professor

Projetos práticos

Projetos práticos voltados para o mercado de trabalho

Prática profissional

Formação moderna com foco na prática profissional

✅ 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:

  1. Carregar e dividir os documentos em pedaços (chunks).
  2. Gerar embeddings: transformar cada chunk em um vetor de números.
  3. Buscar por similaridade os chunks mais parecidos com a pergunta.
  4. 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! :wave:

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?

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? :thumbsup:

Que tal receber 30 dias de conteúdo direto na sua Caixa de Entrada?

Sua assinatura não pôde ser validada.
Você fez sua assinatura com sucesso.

Assine as PyDicas e receba 30 dias do melhor conteúdo Python na sua Caixa de Entrada: direto e sem enrolação!

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:

  1. Na busca (retrieval): o trecho certo nem chegou ao LLM.
  2. 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! :point_down:

Nos vemos no próximo artigo! :wave:

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.

Começe agora sua Jornada na Programação!

Não deixe para amanhã o sucesso que você pode começar a construir hoje!

#newsletter Olá :wave: Curtiu o artigo? Então faça parte da nossa Newsletter! Privacidade Não se preocupe, respeitamos sua privacidade. Você pode se descadastrar a qualquer momento.