RAG sobre Dados Próprios
Um LLM não sabe nada sobre os teus documentos. RAG é a técnica que lhe mostra o pedaço certo, no momento certo, sem o teres de retreinar.
Por Telmo Silva
Ir para o teu progresso ↓Porque escrevi este curso
A pergunta mais comum de quem já sabe chamar a API de um LLM (o Curso 1 desta série ensina isso a sério) é sempre a mesma: "como faço o modelo responder com base nos MEUS documentos, não só no que ele aprendeu no treino?" RAG, retrieval-augmented generation, é a resposta padrão da indústria a essa pergunta, e é provavelmente a técnica mais usada em produção depois de uma chamada simples com um bom prompt.
O problema é que RAG costuma ser explicado como se fosse um produto que se instala, não uma técnica que se constrói: "usa uma base de dados vetorial", "faz embeddings dos teus documentos", como se essas frases já explicassem alguma coisa. Não explicam. RAG é um pipeline com peças concretas de engenharia, cada uma com o seu próprio trade-off: como divides um documento (chunking), como transformas texto em algo comparável matematicamente (embeddings), como medes semelhança (similaridade de cosseno), como escolhes o quê devolver (retrieval), e como constróis o prompt final para o modelo responder com base nisso e não inventar. Este curso ensina cada peça, a sério, com código que testas tu mesmo antes de confiares nele.
Uma honestidade sobre o que aqui está
É o segundo curso da série "De 0 a AI Engineer". Não pressupõe teres feito o Curso 1 ao pé da letra, pressupõe as competências que ele ensina: já saberes envolver uma chamada a um LLM num serviço real, ou experiência equivalente que te dê a mesma confiança.
Este curso ensina o mecanismo de recuperação a sério, com uma estrutura simples (uma lista de dicionários em memória) em vez de instalar e operar uma base de dados vetorial. Isso é deliberado, não uma limitação: o capítulo 5 explica exatamente o que uma base de dados vetorial resolve e quando precisas mesmo de uma, mas entender o mecanismo por baixo primeiro é o que te permite usar essa ferramenta bem mais tarde, em vez de a tratares como uma caixa preta.
Os exercícios chamam a API de embeddings da OpenAI quando tens uma chave OPENAI_API_KEY tua; sem ela, corres e testas a mesma lógica na mesma, com um fallback determinístico e claramente identificado como tal. É muito barato correr os dois exercícios a sério: a API de embeddings custa uma fração do preço de uma chamada de chat normal.
O que precisas de ter pronto antes de começares
- O Curso 1 desta série feito, "Da API ao Serviço em Produção" (ou equivalente: já saber envolver uma chamada a um LLM num serviço real, não só num script solto).
- Python sólido: funções, dicionários, listas, try/except, sem surpresas.
- Já ter chamado a API de um LLM em Python pelo menos uma vez.
- Uma chave OPENAI_API_KEY tua, criada em platform.openai.com. Sem ela, corres e testas a maior parte de cada exercício na mesma; só a chamada real à API de embeddings ou de chat fica por confirmar.
Meter tudo no prompt funciona até o documento não caber. RAG não é magia: é ires buscar o pedaço certo antes de perguntares, com código que dá para testar como qualquer outro.
Por onde começar, consoante a tua situação
Índice
9 capítulos, dois módulos, com código a sério em cada um. Pressupõe o Curso 1 desta série, ou a experiência equivalente indicada acima.
01Porque RAG, e Quando Não Precisas Dele
Um LLM só sabe o que aprendeu no treino
Um modelo de linguagem não tem acesso aos teus documentos, à tua base de dados, ao manual interno da tua empresa, ou a nada que não estivesse no conjunto de dados com que foi treinado. Pergunta a um LLM qual é a política de devoluções da tua loja, e ele não vai dizer "não sei": muito provavelmente inventa uma resposta plausível, com datas e prazos que soam corretos e não têm nada a ver com a tua política real. Isto não é um bug ocasional, é uma consequência direta de como o modelo funciona: prevê a continuação mais provável de um texto, e uma resposta inventada mas fluente é, estatisticamente, uma continuação tão boa como uma verdadeira.
Há também um problema de data: o conhecimento de um modelo fica congelado no momento em que o treino terminou. Um documento que escreveste ontem, um preço que mudou esta manhã, uma decisão tomada na reunião de segunda-feira, nada disso existe para o modelo, por mais recente ou mais capaz que ele seja.
Três formas de dar contexto a um modelo
Há três formas de resolver isto, e são frequentemente confundidas como se fossem a mesma coisa com nomes diferentes. Não são.
| Abordagem | Como funciona | Quando faz sentido |
|---|---|---|
| Meter tudo no prompt | Colas o documento inteiro (ou os documentos relevantes) na mensagem enviada ao modelo, em cada pedido. | Corpus pequeno, que cabe sempre no limite de contexto sem esforço. |
| RAG | Guardas os documentos indexados à parte; em cada pergunta, vais buscar só os pedaços relevantes e metes esses no prompt. | Corpus grande ou que muda com frequência; só uma fração é relevante por pergunta. |
| Fine-tuning | Retreinas (ou ajustas) o próprio modelo com os teus dados, mudando os seus pesos internos. | Precisas de mudar o comportamento ou o estilo do modelo, não de lhe dar factos novos. |
Porque "meter tudo no prompt" não escala
Mesmo com uma janela de contexto grande (alguns modelos já aceitam centenas de milhares de tokens, ou mais), colar o documento inteiro em cada pedido tem dois custos reais. O primeiro é dinheiro: pagas por token enviado, em cada pedido, mesmo que a pergunta só precise de uma frase de um documento com cem páginas. O segundo é qualidade: um prompt muito longo, com muita informação irrelevante para a pergunta concreta, dilui o que interessa, e um modelo tende a dar menos atenção a informação enterrada no meio de um contexto extenso (o capítulo 8 explica isto em detalhe, é conhecido como "lost in the middle"). Nenhum destes problemas desaparece por teres um modelo com janela de contexto maior; só adias o momento em que deixa de caber.
Porque fine-tuning resolve um problema diferente
Fine-tuning ensina o modelo um comportamento, um estilo, um formato de resposta consistente. Não é uma forma fiável de lhe ensinar factos novos: um modelo afinado com os teus documentos não "memoriza" o conteúdo deles de forma confiável, continua a poder inventar, e sempre que um documento muda tens de repetir todo o processo de treino, que é lento e caro comparado com atualizar um índice. Se o que precisas é o modelo responder com factos atualizados e verificáveis a partir de uma fonte concreta, fine-tuning não é a ferramenta certa.
RAG: ires buscar o contexto certo, na hora
RAG (retrieval-augmented generation, geração aumentada por recuperação) não muda o modelo nem manda tudo de uma vez: em cada pergunta, procura nos teus documentos os pedaços com mais probabilidade de conterem a resposta, e só esses pedaços entram no prompt. O modelo continua a ser o mesmo, sem retreino nenhum; o que muda é que passa a receber, junto com a pergunta, exatamente o contexto de que precisa para não ter de adivinhar. Atualizar a informação é trivial: reindexas o documento que mudou, não o modelo inteiro.
Quando não precisas de RAG
É tentador tratar RAG como o próximo passo natural depois de saberes chamar um LLM, mas nem toda a pergunta com "informação própria" o justifica. Três situações onde é over-engineering:
- O corpus é pequeno e estável. Se tens cinco documentos curtos que cabem sempre confortavelmente no prompt, colá-los diretamente é mais simples, sem infraestrutura de indexação nem bugs de recuperação a debugar.
- A pergunta não depende de dados próprios. Se a resposta está no conhecimento geral que o modelo já tem ("explica o que é uma API REST"), RAG não acrescenta nada: um prompt bem escrito já chega.
- A pergunta exige raciocínio sobre o corpus inteiro, não recuperação de um pedaço. "Quantos dos nossos cem contratos têm uma cláusula de rescisão a menos de 30 dias?" não se resolve bem com top-k de pedaços; precisa de examinar tudo. Isto volta no capítulo 8.
RAG é uma ferramenta, não um objetivo
Se em algum momento deste curso a solução mais simples te parecer "esquece o retrieval, mete o documento todo no prompt", e isso resolver o teu problema real sem exceder o limite de contexto nem o orçamento, essa é a resposta certa. O resto deste curso ensina a construir RAG bem, precisamente para o usares quando faz sentido, não porque é o que se costuma fazer.
02Chunking
Porque dividir o documento em pedaços
Um manual de cem páginas não se indexa como uma peça única: se o fizesses, cada pergunta recuperaria o manual inteiro, e estarias de volta ao problema do capítulo 1 (custo e diluição de relevância). Chunking é o processo de dividir cada documento em pedaços mais pequenos e geríveis, cada um pequeno o suficiente para ser indexado e recuperado individualmente, e para caber, sozinho ou ao lado de outros poucos, dentro de um prompt razoável.
O trade-off de tamanho
Não existe um tamanho de chunk universalmente certo, só trade-offs. Um chunk demasiado pequeno perde contexto: um pedaço que diz só "até 30 dias após a compra" é inútil quando recuperado sozinho, se não disser 30 dias de quê. Um chunk demasiado grande dilui a relevância: se um chunk cobre cinco assuntos diferentes e só um interessa à pergunta feita, o embedding desse chunk (capítulo 3) representa uma média vaga dos cinco assuntos, não nenhum deles com precisão, e a recuperação piora. Também custa mais: cada chunk devolvido entra no prompt final, e um chunk maior é mais tokens pagos por resposta, relevantes ou não.
| Chunk pequeno | Chunk grande | |
|---|---|---|
| Precisão da recuperação | Alta: o embedding representa uma ideia específica | Baixa: o embedding mistura vários assuntos |
| Contexto suficiente | Risco de faltar contexto para a ideia fazer sentido sozinha | Contexto de sobra, por vezes a mais |
| Custo por resposta | Baixo por chunk, mas pode precisar de mais chunks (k maior) | Alto por chunk, mesmo que só uma parte seja relevante |
Overlap: não cortar uma ideia a meio
Se dividires um texto em pedaços exatos, sem sobreposição nenhuma, uma ideia que atravesse a fronteira entre dois chunks fica partida ao meio, e nenhum dos dois chunks sozinho a contém por inteiro. Overlap resolve isto: repetes um pequeno número de caracteres do fim de um chunk no início do seguinte, para que uma ideia perto da fronteira apareça inteira em pelo menos um dos dois.
Chunking por estrutura vs por tamanho fixo
Chunking por tamanho fixo (um número de caracteres ou tokens) é simples e funciona sempre, mas ignora completamente a estrutura do texto: pode cortar uma frase a meio, ou até uma palavra. Chunking por estrutura divide nos limites naturais do documento (fim de parágrafo, mudança de heading), o que respeita melhor o significado, mas produz chunks de tamanho imprevisível, e um parágrafo sozinho pode continuar a ser demasiado grande. Um pipeline a sério costuma combinar os dois: divide primeiro por estrutura, e qualquer pedaço que ainda fique grande demais divide-se depois por tamanho fixo, com overlap.
Este curso foca-se em chunking por tamanho fixo com overlap, a técnica base sobre a qual qualquer variante mais sofisticada se constrói. A função abaixo foi testada com casos concretos antes de aqui chegar: um texto mais curto do que o tamanho do chunk (devolve um único chunk), um texto vazio (devolve uma lista vazia), overlap real confirmado entre chunks consecutivos, e a rejeição de um overlap igual ou maior que o tamanho do chunk (nesse caso o passo entre chunks seria zero ou negativo, e o algoritmo nunca avançaria).
python
def chunk_texto(texto, tamanho=500, overlap=50):
"""Divide texto em pedaços de tamanho caracteres, repetindo overlap
caracteres entre pedaços consecutivos para não cortar uma ideia
exatamente na fronteira de um chunk."""
if tamanho <= 0:
raise ValueError("tamanho tem de ser positivo")
if overlap >= tamanho:
raise ValueError("overlap tem de ser menor que tamanho")
texto = texto.strip()
if not texto:
return []
chunks = []
inicio = 0
passo = tamanho - overlap
while inicio < len(texto):
fim = inicio + tamanho
chunks.append(texto[inicio:fim])
if fim >= len(texto):
break
inicio += passo
return chunkspasso é a distância entre o início de um chunk e o início do seguinte: tamanho menos overlap. Com tamanho=500 e overlap=50, cada chunk novo começa 450 caracteres depois do anterior, repetindo os últimos 50 caracteres do chunk anterior no início do seguinte. O ciclo para assim que fim alcança o comprimento do texto, para não gerar um último chunk vazio ou repetido.
Caracteres, não tokens
Esta função conta caracteres, não tokens (a unidade que o modelo realmente usa e pela qual pagas). Um chunk de 500 caracteres em português corresponde grosso modo a 120 a 150 tokens, mas a proporção varia com o texto. Para controlar o tamanho do chunk em tokens com precisão, seria preciso um tokenizador real (a biblioteca tiktoken, para modelos OpenAI); para os efeitos deste curso, contar caracteres é suficiente e mais simples de testar.
03Embeddings e Similaridade
O que é um embedding
Um embedding é um vetor, uma lista de números, gerado por um modelo treinado especificamente para isso, que captura o significado de um texto. Dois textos com significados parecidos ("o cão ladrou" e "o cachorro latiu") produzem vetores que apontam em direções parecidas, mesmo sem partilharem quase nenhuma palavra literal. É esta propriedade que torna possível comparar significado matematicamente, em vez de comparar palavras uma a uma.
Um embedding típico da OpenAI (text-embedding-3-small) tem 1536 números, cada um representando uma dimensão desse espaço de significado. Não há uma dimensão isolada que signifique "tema: devoluções" ou "tom: formal"; o significado está distribuído por todas as dimensões em conjunto, aprendido durante o treino do modelo, não definido à mão.
Similaridade de cosseno: medir o quão parecidos são dois vetores
Similaridade de cosseno mede o ângulo entre dois vetores, ignorando o seu tamanho (a magnitude): interessa só a direção para onde apontam. O resultado varia entre -1 e 1: 1.0 significa a mesma direção exata (textos com o mesmo significado, para o modelo), 0.0 significa perpendicular (nada em comum), -1.0 significa direções opostas. Na prática, embeddings de texto normal costumam produzir valores positivos; valores negativos são raros.
A fórmula é o produto escalar dos dois vetores, dividido pelo produto das suas normas (o "comprimento" de cada vetor). Dividir pelas normas é o que remove o efeito do tamanho: um vetor [2, 0] e um vetor [10, 0] apontam exatamente para a mesma direção, e a similaridade de cosseno entre eles é 1.0, apesar de terem magnitudes muito diferentes.
python
import math
def similaridade_cosseno(v1, v2):
"""Mede o quão parecidos são dois vetores pela direção que apontam,
ignorando o seu tamanho: 1.0 é a mesma direção (mesmo significado),
0.0 é perpendicular (nada em comum), -1.0 é direção oposta."""
if len(v1) != len(v2):
raise ValueError("vetores têm de ter o mesmo número de dimensões")
produto_escalar = sum(a * b for a, b in zip(v1, v2))
norma1 = math.sqrt(sum(a * a for a in v1))
norma2 = math.sqrt(sum(b * b for b in v2))
if norma1 == 0 or norma2 == 0:
return 0.0
return produto_escalar / (norma1 * norma2)O caso norma1 == 0 or norma2 == 0 evita uma divisão por zero se algum dos vetores for todo zeros (um caso degenerado, mas que rebentaria o programa sem esta verificação). Testado com vetores conhecidos: [1,2,3] contra si próprio dá exatamente 1.0; [1,0] contra [0,1] dá 0.0; [1,2] contra [-1,-2] dá -1.0; e [1,1,0] contra [1,0,0], calculado à mão, dá 1/√2 ≈ 0.7071, exatamente o que a função devolve.
Chamar a API de embeddings da OpenAI
text-embedding-3-small é o modelo de embeddings recomendado como ponto de partida: barato (uma fração do custo de uma chamada de chat normal) e suficientemente bom para a maior parte dos casos de RAG. Chama-se de forma parecida a client.chat.completions.create, mas devolve um vetor, não texto:
python
from openai import OpenAI
client = OpenAI()
resposta = client.embeddings.create(
model="text-embedding-3-small",
input="A política de devoluções aceita artigos até 30 dias após a compra.",
)
vetor = resposta.data[0].embedding
print(len(vetor)) # 1536input também aceita uma lista de strings, e a API devolve um embedding por cada uma, na mesma ordem: resposta.data[0].embedding, resposta.data[1].embedding, e assim por diante. Enviar vários chunks de uma vez, em vez de um pedido por chunk, é mais rápido e mais barato do que chamar a API chunk a chunk, e vale a pena sempre que estiveres a indexar mais do que um punhado de pedaços.
Um modelo de embeddings não conversa
client.embeddings.create não é uma versão mais barata de client.chat.completions.create: são famílias de modelos diferentes, com propósitos diferentes. Um modelo de embeddings não gera texto nem responde a perguntas, só transforma texto num vetor. É a peça de comparação matemática do pipeline, não a peça que fala com o utilizador; essa continua a ser um modelo de chat normal, mais à frente, no capítulo 7.
04Prática: o Teu Primeiro Índice
Antes de começares, cria uma pasta documentos/ com dois ou três ficheiros .txt curtos, de texto livre em português (um resumo de um produto, um excerto de um manual, o que tiveres à mão). Não precisam de ter nada em comum; servem só para o pipeline ter algo real para indexar.
Exercício 1: pipeline de indexação
Objetivo: Escrever indexador.py: um script que lê vários documentos de texto, faz chunk de cada um, gera o embedding de cada chunk, e guarda tudo numa lista de dicionários em memória (sem base de dados vetorial nenhuma, essa vem no capítulo 5).
- chunk_texto(texto, tamanho=500, overlap=50): a função do capítulo 2, reaproveitada tal e qual.
- gerar_embedding_falso(texto, dimensoes=8): devolve um vetor determinístico calculado a partir de um hash do texto, sem chamar nenhuma API. Serve só para testar o mecanismo do pipeline sem precisares de uma chave paga; nunca captura significado real.
- gerar_embedding(texto, client=None): se client não for None, chama a API de embeddings da OpenAI e devolve o vetor real; se for None, devolve gerar_embedding_falso(texto), com um aviso impresso a deixar claro que não é um embedding a sério.
- criar_client(): devolve um cliente OpenAI se a variável de ambiente OPENAI_API_KEY existir, ou None caso contrário. Não rebenta se a chave não existir; deixa a decisão para gerar_embedding.
- indexar_documentos(caminhos, client=None, tamanho_chunk=500, overlap=50): para cada caminho em caminhos, lê o ficheiro, faz chunk do conteúdo, gera o embedding de cada chunk, e acrescenta um dicionário à lista devolvida, com as chaves texto, embedding, fonte (o caminho do ficheiro) e chunk_index (a posição do chunk dentro desse documento, a começar em 0).
- main(): cria o cliente, indexa os documentos da pasta documentos/, e imprime quantos chunks foram indexados no total.
Dica: Para testares indexar_documentos sem nenhuma chave, chama-a com client=None: a parte determinística (ler ficheiro, fazer chunk, montar o dicionário) corre por inteiro na mesma, só o vetor de cada chunk vem do fallback em vez da API real.
Ver solução
import hashlib
import os
def chunk_texto(texto, tamanho=500, overlap=50):
if tamanho <= 0:
raise ValueError("tamanho tem de ser positivo")
if overlap >= tamanho:
raise ValueError("overlap tem de ser menor que tamanho")
texto = texto.strip()
if not texto:
return []
chunks = []
inicio = 0
passo = tamanho - overlap
while inicio < len(texto):
fim = inicio + tamanho
chunks.append(texto[inicio:fim])
if fim >= len(texto):
break
inicio += passo
return chunks
def gerar_embedding_falso(texto, dimensoes=8):
digest = hashlib.sha256(texto.encode("utf-8")).digest()
return [b / 255 for b in digest[:dimensoes]]
def gerar_embedding(texto, client=None):
if client is None:
print("AVISO: sem OPENAI_API_KEY, a usar embedding falso (só para testar o pipeline).")
return gerar_embedding_falso(texto)
resposta = client.embeddings.create(model="text-embedding-3-small", input=texto)
return resposta.data[0].embedding
def criar_client():
if not os.environ.get("OPENAI_API_KEY"):
return None
from openai import OpenAI
return OpenAI()
def indexar_documentos(caminhos, client=None, tamanho_chunk=500, overlap=50):
indice = []
for caminho in caminhos:
with open(caminho, encoding="utf-8") as ficheiro:
texto = ficheiro.read()
chunks = chunk_texto(texto, tamanho=tamanho_chunk, overlap=overlap)
for i, chunk in enumerate(chunks):
indice.append({
"texto": chunk,
"embedding": gerar_embedding(chunk, client=client),
"fonte": caminho,
"chunk_index": i,
})
return indice
def main():
client = criar_client()
pasta = "documentos"
caminhos = [
os.path.join(pasta, nome)
for nome in os.listdir(pasta)
if nome.endswith(".txt")
]
indice = indexar_documentos(caminhos, client=client)
print(f"Indexados {len(indice)} chunks a partir de {len(caminhos)} documentos.")
if __name__ == "__main__":
main()05Bases de Dados Vetoriais
O que a tua lista de dicionários não aguenta
A estrutura do exercício anterior, uma lista de dicionários em memória, resolve o problema de forma correta, mas com uma limitação clara: encontrar os chunks mais relevantes exige comparar o embedding da pergunta contra o embedding de cada chunk, um a um (o capítulo 6 mostra exatamente essa função). Com cem ou mil chunks, isto é instantâneo. Com um milhão, cada pergunta obriga a um milhão de cálculos de similaridade, e a lista inteira tem de caber em memória, no processo de um único servidor, sem sobreviver a um reinício.
O que uma base de dados vetorial resolve de verdade
Uma base de dados vetorial indexa os vetores de forma a que uma busca por similaridade não precise de comparar contra todos: algoritmos de busca aproximada por vizinhos mais próximos (ANN, approximate nearest neighbors; HNSW é o mais comum) organizam os vetores numa estrutura que torna a busca muito mais rápida do que uma comparação exaustiva, trocando uma pequena percentagem de exatidão (podes, muito raramente, não obter literalmente os k mais próximos, mas sim uma aproximação muito boa) por uma velocidade que escala a milhões de vetores. Para além disso, resolve o que uma lista em Python nunca resolveria sozinha: persistência em disco, filtrar por metadados ("só chunks deste documento"), atualizar ou remover entradas sem reconstruir tudo, e distribuir a carga por vários processos ou máquinas.
Chroma, como referência para produção
Chroma é uma base de dados vetorial open source, feita especificamente para casos de uso como este: corre embutida no teu processo Python (pip install chromadb) ou como um serviço à parte, e a API para adicionar e consultar vetores é deliberadamente simples. Não é a única opção (Pinecone, Qdrant, Weaviate e outras seguem o mesmo princípio, com trade-offs diferentes), mas é uma escolha razoável quando este curso terminar e precisares de escalar além de uma lista em memória. Não é preciso instalar nada disto para os exercícios deste curso: o objetivo aqui foi mesmo entenderes o mecanismo que estas ferramentas escondem, antes de confiares nelas às cegas.
Quando a lista em memória continua a ser a escolha certa
Para um protótipo, um corpus interno pequeno, ou até alguns milhares de chunks, uma lista em memória com uma comparação exaustiva é honestamente suficiente, e mais simples de correr, testar e depurar do que instalar e operar uma base de dados vetorial. O momento de migrar não é "sempre que fazes RAG", é quando o número de chunks ou a exigência de persistência e latência o justificarem de facto.
06Retrieval
Top-k: só os chunks mais relevantes
Retrieval é o passo que, dado o embedding de uma pergunta, percorre o índice inteiro, calcula a similaridade de cosseno contra cada chunk, e devolve só os k mais parecidos, ordenados do mais para o menos relevante. k é um número que escolhes tu (3 e 5 são pontos de partida comuns), não uma constante fixa da técnica.
python
def retrieval_top_k(pergunta_embedding, indice, k=3):
"""indice é uma lista de dicts com pelo menos a chave "embedding".
Devolve os k dicts com maior similaridade de cosseno em relação a
pergunta_embedding, ordenados do mais para o menos relevante, cada
um com uma chave "similaridade" adicionada."""
if k <= 0:
return []
pontuados = []
for item in indice:
sim = similaridade_cosseno(pergunta_embedding, item["embedding"])
pontuados.append({**item, "similaridade": sim})
pontuados.sort(key=lambda item: item["similaridade"], reverse=True)
return pontuados[:k]{**item, "similaridade": sim} cria um dicionário novo, cópia de item com uma chave a mais, em vez de alterar item diretamente: o dicionário original no índice não fica com um campo similaridade a mais depois de uma busca, o que interessaria a uma pergunta e não à seguinte. Testado com um índice de quatro itens em duas direções diferentes (uma pergunta alinhada com "gatos e cães", outra com "carros e motas"): cada busca devolve os itens certos, pela ordem certa, e k maior do que o índice devolve tudo sem rebentar.
O problema de k grande demais: chunks irrelevantes
Um k grande garante que raramente falta o chunk certo, mas cada chunk a mais que entra no prompt é mais tokens pagos e mais informação para o modelo peneirar, incluindo chunks pouco relevantes que, na melhor das hipóteses, são ruído, e na pior, confundem a resposta. Com um índice pequeno, k=5 pode devolver chunks com uma similaridade baixa só porque não há mais nada melhor no índice; vale a pena, além de k, considerar um limiar mínimo de similaridade abaixo do qual um chunk nem entra, em vez de forçar sempre exatamente k resultados.
O problema de chunks redundantes
Se um documento repete a mesma informação em vários pontos (comum em manuais e FAQs), vários chunks quase idênticos podem obter uma similaridade parecida e ocupar várias das k posições, todos a dizer essencialmente o mesmo. O resultado é um top-k que parece completo mas, na prática, cobre menos informação distinta do que o número k sugere, porque o espaço que um chunk redundante ocupa podia ter ido para um aspeto diferente e igualmente relevante da pergunta. Deduplicar ou diversificar os resultados é uma técnica real (chamada muitas vezes maximal marginal relevance), mas fica fora do âmbito deste curso; o que importa reter aqui é que "os k mais parecidos" não é o mesmo que "as k informações mais úteis", e vale a pena olhar para o que o teu retrieval está mesmo a devolver, não só confiar cegamente no número.
07Construir o Prompt Fundamentado
Montar o prompt com o contexto recuperado
Depois do retrieval, tens uma lista de chunks relevantes. O passo seguinte é montar o prompt final que vai para o modelo: a pergunta do utilizador, acompanhada do contexto recuperado, com instruções explícitas sobre como usar esse contexto.
python
def construir_prompt_fundamentado(pergunta, chunks_contexto):
contexto_formatado = "\n\n".join(f"- {c}" for c in chunks_contexto)
return f"""Responde à pergunta usando APENAS a informação no contexto abaixo.
Se o contexto não tiver a resposta, diz claramente que não tens essa
informação; não inventes nada que não esteja no contexto.
Contexto:
{contexto_formatado}
Pergunta: {pergunta}
Resposta:"""Instruir o modelo a admitir quando não sabe
A instrução "usa APENAS a informação no contexto" e "se não tiver a resposta, diz claramente que não tens essa informação" não é decoração, é a linha que separa RAG feito a sério de RAG que só parece funcionar. Sem essa instrução explícita, um modelo tende a preencher lacunas com o que aprendeu no treino, misturando silenciosamente conhecimento geral com o contexto que lhe deste; a resposta pode até soar plausível e estar errada, precisamente o tipo de erro mais difícil de apanhar, porque não parece um erro.
Se já fizeste o Curso 4 da série de Python para QA, "IA Aplicada a Testes de Software", isto liga diretamente ao conceito de deteção de alucinação (sem_alucinacao) ensinado lá: uma resposta fundamentada é uma resposta que se consegue verificar contra uma fonte concreta, não uma resposta que apenas soa correta. RAG bem construído é, em grande parte, uma forma de dar ao modelo uma fonte concreta contra a qual responder, e de o instruir a admitir os limites dessa fonte em vez de os esconder atrás de fluência.
A instrução ajuda, não garante
Pedir ao modelo para se limitar ao contexto reduz a frequência de respostas inventadas, mas não a elimina: um modelo pode, ainda assim, ignorar a instrução ocasionalmente. É por isso que o capítulo 9 constrói uma verificação determinística, em código, que confirma se a resposta partilha de facto vocabulário com o contexto recuperado, em vez de confiar só na boa vontade do prompt.
08Quando o RAG Falha
Lost in the middle
Quando um prompt inclui vários chunks de contexto, um modelo tende a dar mais peso à informação colocada no início e no fim do prompt, e a ignorar, na prática, informação enterrada algures no meio, mesmo que essa informação esteja tecnicamente dentro da janela de contexto e tenha sido recuperada corretamente. Isto chama-se lost in the middle, e é um efeito real e bem documentado em modelos de linguagem, não uma falha de implementação tua.
A implicação prática: se tens vários chunks para incluir num prompt, não os ordenes arbitrariamente pela ordem em que o retrieval os devolveu. Uma mitigação simples é colocar o chunk mais relevante no início, o segundo mais relevante no fim, e deixar os menos relevantes no meio, exatamente onde já tendem a ser menos considerados de qualquer forma.
python
def reordenar_para_evitar_lost_in_the_middle(chunks_ordenados):
"""Recebe chunks já ordenados do mais para o menos relevante (como
devolvidos por retrieval_top_k) e devolve-os reordenados para que o
mais relevante fique no início e o segundo mais relevante no fim,
as posições que um LLM tende a favorecer num prompt longo."""
if len(chunks_ordenados) <= 2:
return list(chunks_ordenados)
meio = chunks_ordenados[2:]
return [chunks_ordenados[0]] + meio + [chunks_ordenados[1]]Testado com uma lista de cinco elementos ['A','B','C','D','E'] (A o mais relevante): o resultado é ['A','C','D','E','B'], com A no início e B, o segundo mais relevante, movido para o fim. Com uma ou duas entradas, a lista devolve-se inalterada, porque não há "meio" nenhum para reorganizar.
Chunks fragmentados sem contexto suficiente
Um chunk é recortado sem saber, no momento em que é criado, que pergunta vai um dia tentar respondê-lo. Um chunk que diz "o prazo é de 5 a 8 dias úteis" pode ser recuperado corretamente para uma pergunta sobre prazos de envio, e ainda assim ser inútil sozinho, se o parágrafo anterior (que dizia "para as ilhas e para o resto da Europa") ficou de fora por causa de onde a fronteira do chunk caiu. A mitigação mais direta é guardar metadados junto de cada chunk (a fonte, um título de secção, o documento de origem, como o campo fonte do exercício 1) e reinjetá-los no chunk apresentado ao modelo, para que "5 a 8 dias úteis" venha sempre acompanhado do documento e da secção de onde saiu.
Perguntas que exigem raciocínio sobre vários documentos
RAG simples, top-k mais um prompt, é bom a responder perguntas de localização: "o que diz o documento sobre X". É mau a responder perguntas de agregação ou comparação: "quantos dos nossos documentos mencionam X", "qual é a diferença entre a política A e a política B", perguntas que exigem examinar o corpus inteiro ou combinar factos espalhados por vários documentos, não recuperar um punhado de pedaços parecidos com a pergunta. Um top-k de cinco chunks, por mais bem escolhidos que sejam, simplesmente não contém a resposta a "quantos" se a resposta depende de contar por todo o corpus.
Isto não tem uma solução simples dentro deste curso
Técnicas como decomposição de perguntas (dividir uma pergunta complexa em várias perguntas simples), retrieval em múltiplos passos, ou agentes que decidem sozinhos quantas vezes procurar, existem precisamente para atacar este limite, mas ficam fora do âmbito deste curso. O que interessa reter é a honestidade do capítulo 1: RAG resolve bem um tipo de problema (recuperação de factos localizados), e não é a ferramenta certa para todos os outros, por mais que o nome sugira uma solução universal.
09Prática: Pergunta e Resposta Fundamentada
Este exercício junta tudo: indexação, retrieval, prompt fundamentado, e uma verificação determinística, sem chamar nenhum modelo, que confirma que a resposta partilha vocabulário e factos com o contexto recuperado, não com informação vinda de fora dele. Em vez de pedires ao leitor para criar ficheiros, os documentos fictícios (uma pequena FAQ de loja) vêm já escritos abaixo, como um dicionário Python no próprio script.
python
DOCUMENTOS = {
"devolucoes.txt": (
"Política de devoluções: aceitamos devoluções até 30 dias após a data de "
"compra, desde que o artigo esteja sem uso e com a embalagem original. "
"O reembolso é feito no método de pagamento original, em até 5 dias úteis "
"depois de recebermos o artigo devolvido."
),
"envio.txt": (
"Prazos de envio: encomendas para Portugal continental chegam em 2 a 4 "
"dias úteis. Para as ilhas e para o resto da Europa, o prazo é de 5 a 8 "
"dias úteis. O envio é gratuito acima de 40 euros."
),
"garantia.txt": (
"Garantia: todos os produtos têm garantia de 2 anos contra defeitos de "
"fabrico, conforme a lei. A garantia não cobre danos causados por uso "
"indevido nem desgaste normal."
),
"contactos.txt": (
"Contactos: podes falar com o suporte por email em suporte@loja-exemplo.pt "
"ou por telefone das 9h às 18h, dias úteis. O tempo médio de resposta por "
"email é de 24 horas."
),
}A verificação determinística compara as palavras de conteúdo da resposta (ignorando artigos, preposições e outras palavras vazias de significado) com as palavras presentes nos chunks recuperados. Se uma fração suficiente das palavras da resposta aparece no contexto, a resposta é considerada fundamentada; caso contrário, é sinal de que o modelo (ou, no fallback sem chave, o próprio pipeline) trouxe informação de fora do contexto dado. Não confirma que a resposta está certa, isso exigiria compreender o texto como uma pessoa; confirma só que não parece inventada do nada, o sinal mais barato e mais determinístico de apanhar uma alucinação sem chamar nenhum modelo extra para verificar o primeiro.
Exercício 2: sistema de pergunta/resposta fundamentada
Objetivo: Escrever qa_faq.py: um sistema completo de pergunta/resposta sobre os documentos DOCUMENTOS acima, que recupera os chunks relevantes, constrói o prompt fundamentado, e verifica de forma determinística que a resposta obtida está mesmo ancorada no contexto recuperado.
- DOCUMENTOS: o dicionário {nome_ficheiro: texto} dado acima, tal e qual.
- indexar_documentos(documentos, client=None, tamanho_chunk=500, overlap=50): a mesma lógica do Exercício 1, mas recebendo o dicionário DOCUMENTOS diretamente (chave = fonte, valor = texto) em vez de caminhos de ficheiro.
- construir_prompt_fundamentado(pergunta, chunks_contexto): a função do capítulo 7, tal e qual.
- responder_pergunta(pergunta, indice, client=None, k=3): calcula o embedding da pergunta, chama retrieval_top_k, e devolve um par (resposta, chunks_texto). Com client, monta o prompt fundamentado e chama o modelo de chat; sem client, devolve como resposta o texto do próprio chunk mais relevante (um fallback extrativo, só para o mecanismo se poder testar sem nenhuma chamada de rede).
- verificar_resposta_fundamentada(resposta, chunks_contexto, minimo_sobreposicao=0.5): normaliza as palavras da resposta e dos chunks (minúsculas, sem pontuação, sem palavras vazias de significado), e devolve True só se pelo menos minimo_sobreposicao da proporção de palavras da resposta também aparecer no contexto.
- main(): indexa DOCUMENTOS, corre um pequeno conjunto de perguntas de exemplo, e para cada uma imprime a resposta e o resultado de verificar_resposta_fundamentada.
Dica: Para veres a verificação a apanhar uma alucinação a sério, chama verificar_resposta_fundamentada com uma resposta inventada à mão (por exemplo, sobre um assunto que não está em nenhum dos quatro documentos) contra os chunks reais, e confirma que devolve False.
Ver solução
import hashlib
import os
import re
DOCUMENTOS = {
"devolucoes.txt": (
"Política de devoluções: aceitamos devoluções até 30 dias após a data de "
"compra, desde que o artigo esteja sem uso e com a embalagem original. "
"O reembolso é feito no método de pagamento original, em até 5 dias úteis "
"depois de recebermos o artigo devolvido."
),
"envio.txt": (
"Prazos de envio: encomendas para Portugal continental chegam em 2 a 4 "
"dias úteis. Para as ilhas e para o resto da Europa, o prazo é de 5 a 8 "
"dias úteis. O envio é gratuito acima de 40 euros."
),
"garantia.txt": (
"Garantia: todos os produtos têm garantia de 2 anos contra defeitos de "
"fabrico, conforme a lei. A garantia não cobre danos causados por uso "
"indevido nem desgaste normal."
),
"contactos.txt": (
"Contactos: podes falar com o suporte por email em suporte@loja-exemplo.pt "
"ou por telefone das 9h às 18h, dias úteis. O tempo médio de resposta por "
"email é de 24 horas."
),
}
def chunk_texto(texto, tamanho=500, overlap=50):
if tamanho <= 0:
raise ValueError("tamanho tem de ser positivo")
if overlap >= tamanho:
raise ValueError("overlap tem de ser menor que tamanho")
texto = texto.strip()
if not texto:
return []
chunks = []
inicio = 0
passo = tamanho - overlap
while inicio < len(texto):
fim = inicio + tamanho
chunks.append(texto[inicio:fim])
if fim >= len(texto):
break
inicio += passo
return chunks
def gerar_embedding_falso(texto, dimensoes=8):
digest = hashlib.sha256(texto.encode("utf-8")).digest()
return [b / 255 for b in digest[:dimensoes]]
def gerar_embedding(texto, client=None):
if client is None:
return gerar_embedding_falso(texto)
resposta = client.embeddings.create(model="text-embedding-3-small", input=texto)
return resposta.data[0].embedding
def indexar_documentos(documentos, client=None, tamanho_chunk=500, overlap=50):
indice = []
for fonte, texto in documentos.items():
chunks = chunk_texto(texto, tamanho=tamanho_chunk, overlap=overlap)
for i, chunk in enumerate(chunks):
indice.append({
"texto": chunk,
"embedding": gerar_embedding(chunk, client=client),
"fonte": fonte,
"chunk_index": i,
})
return indice
def similaridade_cosseno(v1, v2):
import math
if len(v1) != len(v2):
raise ValueError("vetores têm de ter o mesmo número de dimensões")
produto_escalar = sum(a * b for a, b in zip(v1, v2))
norma1 = math.sqrt(sum(a * a for a in v1))
norma2 = math.sqrt(sum(b * b for b in v2))
if norma1 == 0 or norma2 == 0:
return 0.0
return produto_escalar / (norma1 * norma2)
def retrieval_top_k(pergunta_embedding, indice, k=3):
if k <= 0:
return []
pontuados = []
for item in indice:
sim = similaridade_cosseno(pergunta_embedding, item["embedding"])
pontuados.append({**item, "similaridade": sim})
pontuados.sort(key=lambda item: item["similaridade"], reverse=True)
return pontuados[:k]
def construir_prompt_fundamentado(pergunta, chunks_contexto):
contexto_formatado = "\n\n".join(f"- {c}" for c in chunks_contexto)
return f"""Responde à pergunta usando APENAS a informação no contexto abaixo.
Se o contexto não tiver a resposta, diz claramente que não tens essa
informação; não inventes nada que não esteja no contexto.
Contexto:
{contexto_formatado}
Pergunta: {pergunta}
Resposta:"""
def responder_pergunta(pergunta, indice, client=None, k=3):
embedding_pergunta = gerar_embedding(pergunta, client=client)
relevantes = retrieval_top_k(embedding_pergunta, indice, k=k)
chunks_texto = [item["texto"] for item in relevantes]
if client is None:
resposta = chunks_texto[0] if chunks_texto else ""
else:
prompt = construir_prompt_fundamentado(pergunta, chunks_texto)
resposta_llm = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
)
resposta = resposta_llm.choices[0].message.content
return resposta, chunks_texto
PALAVRAS_IRRELEVANTES = {
"o", "a", "os", "as", "de", "da", "do", "das", "dos", "e", "que", "para",
"um", "uma", "uns", "umas", "em", "com", "por", "se", "no", "na", "nos",
"nas", "ao", "aos", "ou", "sua", "seu", "suas", "seus", "é", "há",
"são", "foi", "ser", "está", "este", "esta", "isso", "isto", "não", "sim",
"após",
}
def normalizar_palavras(texto):
return set(re.findall(r"[a-zà-öø-ÿ0-9]+", texto.lower()))
def verificar_resposta_fundamentada(resposta, chunks_contexto, minimo_sobreposicao=0.5):
palavras_resposta = normalizar_palavras(resposta) - PALAVRAS_IRRELEVANTES
if not palavras_resposta:
return False
palavras_contexto = set()
for chunk in chunks_contexto:
palavras_contexto |= normalizar_palavras(chunk)
palavras_contexto -= PALAVRAS_IRRELEVANTES
sobrepostas = palavras_resposta & palavras_contexto
return len(sobrepostas) / len(palavras_resposta) >= minimo_sobreposicao
def criar_client():
if not os.environ.get("OPENAI_API_KEY"):
return None
from openai import OpenAI
return OpenAI()
def main():
client = criar_client()
indice = indexar_documentos(DOCUMENTOS, client=client)
perguntas = [
"Quantos dias tenho para devolver um artigo?",
"Quanto tempo demora a entrega para as ilhas?",
"Como contacto o suporte?",
]
for pergunta in perguntas:
resposta, chunks = responder_pergunta(pergunta, indice, client=client, k=2)
fundamentada = verificar_resposta_fundamentada(resposta, chunks)
print(f"P: {pergunta}")
print(f"R: {resposta}")
print(f"Fundamentada no contexto recuperado: {fundamentada}\n")
if __name__ == "__main__":
main()O teu progresso
Marca os exercícios à medida que os fores fazendo a sério, não só a ler a solução. Fica guardado só neste browser.
Conclusão
Em nove capítulos: quando RAG é a ferramenta certa e quando um prompt bem escrito já chega, chunking com overlap para dividir documentos sem cortar ideias a meio, embeddings e similaridade de cosseno como a matemática que torna a busca por significado possível, uma estrutura simples em memória antes de qualquer base de dados vetorial, retrieval por top-k e os seus dois problemas típicos (chunks irrelevantes e redundantes), um prompt fundamentado que instrui o modelo a admitir quando não sabe, e os limites reais da técnica: lost in the middle, chunks sem contexto suficiente, e perguntas que exigem raciocínio sobre múltiplos documentos.
Se fizeste os dois exercícios a sério, tens um pipeline que lê documentos, indexa-os em pedaços geríveis, recupera os mais relevantes para uma pergunta concreta, e verifica de forma determinística que a resposta obtida está mesmo ancorada nesse contexto, não inventada a partir daí. É a diferença entre "o meu RAG parece estar a funcionar" e um sistema que dá para confiar, porque cada peça foi testada em separado antes de as juntares.
Se ficares só com uma decisão deste curso, fica com esta: RAG não é uma caixa preta que se instala, é um pipeline com peças concretas de engenharia, cada uma com o seu próprio trade-off, e cada uma testável isoladamente antes de confiares no sistema todo. É aí que começa a diferença entre um protótipo que parece impressionante uma vez e um sistema que continua a responder bem depois de o documento errado aparecer.
Se ainda não fizeste: Curso 1.
Isto ajudou-te?
Fontes, e o que é só observação minha
O resto
- É o segundo curso da série "De 0 a AI Engineer", ancorado na experiência real de construir e operar sistemas de IA em produção (deteção de plágio por embeddings, moderação de conteúdo com LLM). Não é investigação, é o programa com que ensino isto a sério.