Construa um pipeline de RAG
Construa um pipeline de RAG do zero: quebre um corpus, gere embeddings, armazene vetores, faça busca semântica com filtro de acesso e gere respostas fundamentadas com citações das fontes.
Faça fork ou clone (Ruby/Rails, Python/FastAPI ou TypeScript) e faça os testes que falham passarem.
Problema
Um LLM só sabe o que estava nos dados de treino. Pergunte a ele sobre a documentação interna da sua empresa, uma base de conhecimento privada ou qualquer coisa posterior ao corte de treino, e ele chuta — com confiança e errado (uma "alucinação"). O **RAG (Retrieval-Augmented Generation)** resolve isso entregando ao modelo o contexto certo *na hora da pergunta*. Em vez de depender da memória do modelo, você recupera os trechos mais relevantes do seu próprio corpus e os injeta no prompt. O modelo responde *a partir da evidência que você forneceu* e cita as fontes. Neste lab você constrói o pipeline inteiro à mão — sem framework fazendo mágica atrás de um único `.query()`. Você vai quebrar um corpus, gerar embeddings, armazenar os vetores, fazer busca semântica, aplicar um filtro de acesso para que conteúdo restrito nunca vaze e, por fim, gerar uma resposta fundamentada e com citações.
Objetivos
- Quebrar um corpus de texto em chunks com overlap que preservam o contexto.
- Gerar um vetor de embedding por chunk e cachear por hash do conteúdo para não re-embeddar.
- Fazer upsert dos vetores mais um payload de metadados num vector store com distância de cosseno.
- Implementar a busca semântica: embeddar a pergunta, recuperar top-k, aplicar threshold de score.
- Aplicar um filtro de acesso/premium dentro da query para que conteúdo restrito nunca vaze.
- Montar um prompt de contexto com os trechos recuperados e gerar uma resposta com citações das fontes.
- Tornar a reindexação idempotente removendo os pontos antigos do documento antes de reinserir.
Pré-requisitos
- Uma chave de API de um provider de embeddings (ex.:
text-embedding-3-small, 1536 dims) ou um modelo de embeddings local. - Um vector store rodando localmente — Qdrant ou pgvector via Docker servem bem.
- Um LLM que você possa chamar para o passo final de geração (qualquer modelo de chat/completions).
- A linguagem de programação da sua escolha; o lab é stack-agnóstico e usa pseudocódigo.
- Familiaridade básica com APIs HTTP/JSON e com subir um container via
docker run.
O que é RAG e por que construir você mesmo?
O RAG tem quatro partes móveis, e entender cada uma é o objetivo deste lab:
-
Indexação (offline): transformar seu corpus em vetores buscáveis.
Chunk → embed → armazenar. -
Recuperação (por pergunta): achar os trechos mais parecidos em significado
com a pergunta. Embeddar a pergunta → buscar por similaridade de cosseno → top-k. - Aumento (augmentation): injetar esses trechos no prompt como contexto.
- Geração: o LLM responde usando esse contexto e cita as fontes.
A palavra mágica é embeddings: um modelo mapeia texto para um vetor (uma
lista de números, ex.: 1536 deles) de forma que textos semanticamente parecidos
ficam próximos nesse espaço. "Como reseto minha senha?" e "passos para recuperar
acesso à conta" são palavras diferentes, mas vetores próximos. É por isso que a
busca semântica ganha da busca por palavra-chave para perguntas.
Um sistema de RAG de produção não é só "chamar uma biblioteca". As partes que
você precisa acertar à mão — tamanho do chunk, cache, o payload de metadados, o
threshold de score e, acima de tudo, o filtro de acesso — são exatamente o
que este lab aprofunda.
Você vai construir um pipeline pequeno sobre um corpus de ~10–30 documentos
curtos e terminar com um programa que responde a uma pergunta sobre esse corpus,
fundamentado no texto recuperado e citando quais documentos usou.
Passos
-
Prepare um corpus e faça chunking com overlap
Objetivo
Reúna um corpus pequeno e quebre cada documento em chunks com overlap.
Por que fazer chunking? Modelos de embedding têm janela de entrada
limitada e — mais importante — um vetor para um documento inteiro de 20
páginas é uma média borrada que não casa bem com nada. Chunks menores geram
vetores mais nítidos e recuperáveis.Por que overlap? Se você corta numa fronteira rígida, uma frase que
explica um conceito pode ficar separada do próprio conceito, e nenhum dos
chunks responde à pergunta. O overlap repete o final de um chunk no começo
do próximo para que o contexto sobreviva ao corte.Corpus
Junte 10–30 documentos curtos (Markdown ou texto puro serve): itens de FAQ,
docs internas, posts de blog. Marque pelo menos uns dois como restritos
(premium: true) — você vai precisar deles no Passo 5.Chunking
Use ~3000 caracteres (~800 tokens) por chunk com ~400 caracteres de
overlap. Quebre em fronteiras de parágrafo/frase quando possível, em vez de
no meio de uma palavra.Documento (9000 chars) |--------- chunk 0: chars 0..3000 ---------| |--- overlap ---| |--------- chunk 1: chars 2600..5600 ---------| |--------- chunk 2 ---------|def chunk(text, size=3000, overlap=400): step = size - overlap # 2600 chunks = [] start = 0 while start < len(text): piece = text[start:start + size] chunks.append(piece) start += step return chunksCada chunk carrega a identidade do documento pai para você poder citá-lo
depois. Modele um registro de chunk assim:{ "chunk_id": "doc-42:2", "doc_id": "doc-42", "title": "Política de reset de senha", "premium": false, "text": "…os ~3000 chars do chunk…" }Pronto quando: rodar seu chunker sobre o corpus produz uma lista plana de
registros de chunk, cada um com ≤ ~3000 chars, com overlap visível entre
chunks consecutivos do mesmo documento. -
Gere embeddings (com cache)
Objetivo
Transforme o texto de cada chunk em um vetor e cacheie por hash do conteúdo
para nunca pagar para embeddar o mesmo texto duas vezes.Embeddar
Chame seu modelo de embeddings no
textde cada chunk. Com
text-embedding-3-smallvocê recebe um vetor de 1536 dimensões por chunk.
Anote a dimensão — a coleção do vector store precisa ser criada com exatamente
esse número.vector = embed(chunk["text"]) # -> [0.013, -0.220, ..., 0.008] (len 1536)Cache por hash do conteúdo
Embeddar custa dinheiro e tempo. Se um documento não mudou, seus chunks não
mudaram, então os vetores deles não mudaram. Use como chave de cache um hash
do texto exato:import hashlib def embed_cached(text, cache): key = hashlib.sha256(text.encode()).hexdigest() if key in cache: return cache[key] # hit — sem chamada de API vec = embed(text) # miss — chama o modelo cache[key] = vec return vecO cache pode ser um dict em memória para o lab, ou uma tabelinha/arquivo para
uso real. O ponto é que a chave é o hash do texto — mude um caractere e você
gera uma nova chave, então vetores obsoletos não sobrevivem.Batch
A maioria das APIs de embedding aceita um lote de entradas numa única
requisição. Envie os chunks em lotes (ex.: 64 por vez) para reduzir latência
e custo.Pronto quando: todo registro de chunk tem um
vectorda dimensão esperada,
re-rodar o passo é quase de graça (tudo cache hit), e editar um documento
re-embeda apenas os chunks dele. -
Suba um vector store e faça upsert com payload
Objetivo
Rode um vector store, crie uma coleção com distância de cosseno e faça upsert
dos seus vetores junto com um payload de metadados.Rode
# Qdrant docker run -p 6333:6333 qdrant/qdrant # ou pgvector docker run -p 5432:5432 -e POSTGRES_PASSWORD=pw pgvector/pgvector:pg16Crie a coleção
A coleção precisa bater com a dimensão do seu embedding e usar distância de
cosseno — a métrica de similaridade padrão para embeddings de texto.{ "collection": "kb", "size": 1536, "distance": "Cosine" }Upsert dos pontos com payload
Um "ponto" é um vetor mais um payload — os metadados nos quais você vai
filtrar e a partir dos quais vai citar. Armazene exatamente o que a recuperação
e a geração precisam: a identidade da fonte, o título de exibição, a flag de
acesso e o próprio texto.{ "id": "doc-42:2", "vector": [0.013, -0.220, "…", 0.008], "payload": { "type": "doc", "doc_id": "doc-42", "title": "Política de reset de senha", "premium": false, "text": "…o texto do chunk, para você citar e exibir…" } }Indexe os campos de payload nos quais você filtra (
premium,type) se seu
store suportar índices de payload — a busca filtrada fica muito mais rápida
com eles.Reindexação idempotente
Quando um documento muda e você reindexa, remova os pontos antigos primeiro
e só então insira os novos. Senão os chunks obsoletos se acumulam e continuam
sendo recuperados para sempre.def reindex(doc): store.delete(collection="kb", filter={"doc_id": doc.id}) # remove os antigos store.upsert(collection="kb", points=points_for(doc)) # insere os novosPronto quando: a coleção reporta a contagem certa de vetores, e reindexar
um documento duas vezes deixa o mesmo número de pontos (sem duplicatas). -
Implemente a busca semântica (embed, top-k, threshold)
Objetivo
Dada uma pergunta em linguagem natural, recupere os chunks mais relevantes.
O fluxo
-
Embedde a pergunta com o mesmo modelo que você usou nos chunks.
Isso é inegociável — vetores de dois modelos diferentes não compartilham o
mesmo espaço e as distâncias entre eles não têm significado. -
Busque top-k por similaridade de cosseno — peça ao store os
kpontos
mais próximos (comece comk = 5). -
Aplique um threshold de score para descartar matches fracos, para que
uma pergunta fora do tema retorne nada em vez do chunk menos irrelevante.
def search(question, k=5, min_score=0.35): qvec = embed(question) # mesmo modelo da indexação! hits = store.search( collection="kb", vector=qvec, limit=k, ) return [h for h in hits if h.score >= min_score]Exemplo de query e o que volta:
Pergunta: "Em quanto tempo o link de reset expira?" hits: 0.71 doc-42:2 "Política de reset de senha" 0.63 doc-42:1 "Política de reset de senha" 0.41 doc-17:0 "Visão geral de segurança da conta" 0.22 doc-03:4 "FAQ de faturamento" <- abaixo do threshold, descartadoNotas de tuning
-
k baixo demais → você perde contexto que a resposta precisava. k alto
demais → você dilui o prompt com ruído e paga por mais tokens.k = 4–8
é um começo sensato. -
Scores de cosseno vão de 0..1 para embeddings normalizados; um bom
threshold depende do corpus, então meça algumas perguntas reais e escolha
um valor que mantenha os bons hits e descarte o lixo.
Pronto quando: uma pergunta dentro do tema retorna uma lista curta de
chunks claramente relevantes (maior score primeiro), e uma pergunta sem sentido
retorna uma lista vazia. -
Embedde a pergunta com o mesmo modelo que você usou nos chunks.
-
Aplique o filtro de acesso na query
Objetivo
Garanta que um usuário sem acesso nunca consiga recuperar conteúdo restrito —
filtrando dentro da query do vector store, não depois.O filtro
Empurre um filtro de payload para dentro da própria busca. Para um usuário
gratuito, restrinja apremium = false:def search(question, user, k=5, min_score=0.35): qvec = embed(question) payload_filter = None if user.premium else {"premium": False} hits = store.search( collection="kb", vector=qvec, limit=k, filter=payload_filter, # <- aplicado pelo store, antes do ranking ) return [h for h in hits if h.score >= min_score]No Qdrant isso é uma condição
mustno payload; no pgvector é um
WHERE premium = falseao lado doORDER BY embedding <=> $q— de qualquer
forma, o store nunca retorna os pontos restritos.Nota de segurança — por que este é o ponto central
Nunca recupere tudo e filtre no código da aplicação depois. Dois jeitos
de isso vazar:- Chunks restritos ocupam suas vagas de top-k, então mesmo depois de
descartá-los o usuário recebe uma resposta pior — uma negação sutil do
conteúdo que ele deveria ver. - Um único ramo esquecido — um caminho de erro, um log de debug, uma camada
de cache que guarda os hits crus — e o texto restrito já está no seu
processo, a um erro de distância da resposta.
O filtro pertence à query para que vetores restritos nem sejam pontuados.
O banco é sua fronteira de enforcement. Trate a flag de acesso como entrada
não confiável vinda da sessão do usuário, não do corpo da requisição.Verifique que o vazamento está fechado
Faça uma pergunta cuja melhor resposta esteja só num documento
premium: true:- Como usuário premium → o chunk restrito é recuperado e citado.
- Como usuário gratuito → esse chunk nunca aparece; a resposta recorre a
conteúdo público ou diz que não sabe.
Pronto quando: a mesma pergunta retorna resultados diferentes e corretamente
escopados para um usuário premium vs. um gratuito, e o texto restrito está
comprovadamente ausente dos hits recuperados do usuário gratuito. - Chunks restritos ocupam suas vagas de top-k, então mesmo depois de
-
Monte o prompt de contexto e gere a resposta com citações
Objetivo
Transforme os chunks recuperados numa resposta fundamentada e com citações
vinda do LLM — e entregue.Monte o prompt de contexto
Concatene os chunks recuperados, cada um marcado com o título da sua fonte
para o modelo poder citá-lo. Dê ao modelo uma instrução firme para responder
apenas a partir do contexto e admitir quando o contexto não cobre a pergunta.def answer(question, hits): context = "\n\n".join( f"[Fonte: {h.payload['title']}]\n{h.payload['text']}" for h in hits ) prompt = f"""Responda à pergunta usando APENAS o contexto abaixo. Se o contexto não contiver a resposta, diga que não sabe. Cite as fontes que você usou pelos títulos. Contexto: {context} Pergunta: {question} """ return llm(prompt)Retorne as fontes junto com a resposta
Não dependa só do modelo para citar — você já sabe quais documentos
alimentaram o prompt, então retorne-os também como dado estruturado:{ "answer": "O link de reset expira após 30 minutos. [Política de reset de senha]", "sources": [ { "doc_id": "doc-42", "title": "Política de reset de senha" } ] }Por que "responda apenas a partir do contexto"
Essa instrução mais a evidência recuperada é o que transforma uma máquina de
alucinação num assistente fundamentado. Se a recuperação não retornou nada
(vazio após o threshold do Passo 4), não chame o LLM com contexto vazio e
deixe ele improvisar — retorne "Não tenho informação sobre isso."Critério de submissão
Seu pipeline responde a uma pergunta real sobre o seu corpus, fundamentado nos
chunks recuperados e citando os títulos das fontes — e respeita o filtro de
acesso do Passo 5 (um usuário gratuito não consegue uma resposta construída a
partir de conteúdo restrito).Entregue o repositório, incluindo:
- O corpus (ou um script que o baixe) com pelo menos um documento
premium: true. - O pipeline completo: chunk → embed (com cache) → upsert → busca → filtro → geração.
- Um README curto com o comando para indexar o corpus e o comando para fazer uma pergunta.
- Um transcript (ou teste) mostrando: (a) uma boa pergunta respondida com citações,
(b) a mesma pergunta que só tem resposta em conteúdo premium respondida para um
usuário premium mas recusada/redirecionada para um usuário gratuito.
- O corpus (ou um script que o baixe) com pelo menos um documento