Construa um Assistente de Docs com RAG
Um capstone guiado: aplique o método DARE de ponta a ponta no seu próprio repo para entregar um assistente de docs que ingere documentos, gera embeddings e os indexa, e responde perguntas com retrieval, gating de acesso e fontes citadas.
O que você vai construir
Este é um projeto capstone, não um starter que você clona. Você vai
construir um assistente de docs com RAG a partir de um repositório vazio,
aplicando o método DARE — Design, Blueprint, Tasks,
Run/Execute — da primeira à última linha. O lab Construa um Pipeline
de RAG te ensinou as partes móveis; o playbook Popule um Corpus de RAG te
ensinou a operar uma. Aqui você junta as duas num sistema real e verificável
que você desenhou.
O assistente pronto faz uma coisa bem: um usuário faz uma pergunta em
linguagem natural sobre um conjunto de documentos, e o assistente responde
fundamentado nesses documentos, citando quais usou — nunca inventando
fatos, e nunca vazando conteúdo que o usuário não tem permissão de ver.
Por baixo dos panos isso significa seis capacidades funcionando como um
pipeline:
documentos ──▶ ingestão ──▶ chunking ──▶ embeddings ──▶ vector store
│
pergunta ──▶ embed ──▶ retrieval (top-k, com gate) ──▶ prompt ──▶ LLM ──▶ resposta + fontes
Por que este projeto importa
RAG é a arquitetura padrão para colocar o conhecimento privado de uma empresa
atrás de uma interface de chat: bots de suporte, wikis internas, docs de
produto, assistentes jurídicos e de políticas. Qualquer um liga o .query()
de um framework numa tarde. O que separa uma demo de algo que você poria na
frente de usuários é justamente a parte que este projeto aprofunda: um
schema a partir do qual você cita, um filtro de acesso que
comprovadamente não vaza, um passo de retrieval que você consegue medir,
e testes que seguram a linha. Essas são decisões de engenharia, e o DARE é
como você as toma de forma deliberada, não por acaso.
O que você terá ao final
- Um repositório seu, estruturado pelos artefatos DARE (um doc de design, um
blueprint de arquitetura, um DAG de tarefas e um log de execução). - Um endpoint
POST /askfuncionando (ou equivalente em CLI) que retorna uma
resposta fundamentada mais uma lista estruturada de fontes. - Um caminho de ingestão que faz chunk dos documentos com overlap, os embedda
e os indexa num vector store — de forma idempotente. - Gating de acesso aplicado dentro da query de retrieval, verificado por
um teste que prova que um usuário gratuito nunca consegue recuperar conteúdo
premium. - Uma suíte de testes cobrindo relevância, gating e citação, mais um critério
de submissão escrito que define o "pronto".
Como o DARE guia a construção
Você não começa escrevendo código de pipeline. Você começa pensando, e o
DARE dá forma a esse pensamento. Cada fase produz um artefato que a fase
seguinte consome:
-
Design — enquadre o problema, os usuários e o escopo. O que é este
assistente e, tão importante quanto, o que ele não é? -
Blueprint — transforme o design em arquitetura: os componentes, o schema
de chunk/payload, os endpoints e o contrato do retriever. -
Tasks — decomponha o blueprint em tarefas pequenas e atômicas e ordene-as
num grafo de dependências (um DAG) para sempre saber o que construir a
seguir. -
Run / Execute — implemente as tarefas em duas ondas (ingestão+index,
depois retrieval+resposta), e então endureça e verifique até os testes
provarem que funciona.
Os seis milestones abaixo mapeiam um-para-um nesse fluxo. Faça-os em ordem —
cada um tem um critério concreto de "pronto quando", e o ponto central é
que você nunca escreve código que não consiga justificar a partir do artefato
acima dele.
Arquitetura
Arquitetura de referência
Mantenha o sistema como um conjunto de componentes pequenos com costuras
claras, para que cada um possa ser construído e testado isoladamente. Seis
peças, dois fluxos.
Componentes
-
Ingestão — lê os documentos-fonte (Markdown/texto/HTML), normaliza-os e
quebra cada um em chunks com overlap. É dona das fronteiras de chunk e da
identidade do documento pai. -
Embedder — transforma o texto de um chunk em um vetor usando um modelo de
embedding. Cacheia por hash do conteúdo para que texto inalterado nunca seja
re-embeddado. A dimensão que ele emite (ex.: 1536) é um contrato rígido com o
vector store. -
Vector store — guarda pontos
{id, vector, payload}e roda a busca por
similaridade de cosseno com um filtro de payload. Esta é a sua fronteira de
enforcement para o controle de acesso. -
Retriever — embedda uma pergunta com o mesmo modelo, aplica o filtro de
acesso, pede ao store os top-k, e descarta os matches abaixo de um threshold
de score. Retorna hits rankeados, não uma resposta. -
LLM caller — monta um prompt de contexto a partir dos chunks recuperados,
instrui o modelo a responder apenas a partir do contexto, e chama um modelo
de chat (separado). -
API / UI — expõe
POST /ask, orquestra retriever → LLM caller, e retorna
{ answer, sources }. Deriva o acesso do usuário a partir da sessão.
Fluxo de dados
INDEXAÇÃO (offline, ao publicar ou em backfill)
documento ─▶ chunk(overlap) ─▶ embed(cache) ─▶ upsert{id,vector,payload}
RESPOSTA (por requisição)
pergunta ─▶ embed ─▶ search(top-k, filter=acesso) ─▶ threshold
│
prompt(contexto + citações) ─▶ LLM ─▶ {resposta, fontes}
Diagrama do pipeline
flowchart TD
subgraph Indexacao
D[Documentos] --> C[Chunker com overlap]
C --> E[Embedder cache por hash]
E --> V[(Vector store cosseno)]
end
subgraph Resposta
Q[Pergunta] --> QE[Embeddar pergunta]
QE --> R[Retriever top-k]
R -- filtro de acesso na query --> V
V --> TH{score >= threshold?}
TH -- nao --> EMPTY[Sem contexto relevante]
TH -- sim --> P[Montar prompt contexto + fontes]
P --> L[LLM caller]
L --> A[Resposta + fontes citadas]
EMPTY --> IDK[Nao sei responder]
end
Decisões-chave e trade-offs
-
Tamanho de chunk e overlap. Chunks menores geram vetores mais nítidos mas
perdem o contexto ao redor; o overlap recompra esse contexto ao custo de um
pouco de duplicação. Comece com ~3000 chars / ~400 de overlap e ajuste contra
queries reais. -
top-k. Baixo demais e você deixa o prompt sem o trecho que continha a
resposta; alto demais e você o dilui com ruído e paga por tokens.k = 4–8é
uma faixa inicial sensata; um threshold de score te protege de retornar o
chunk "menos irrelevante" em perguntas fora do tema. -
Gating no payload, filtrado na query. A flag de acesso vive no payload de
cada ponto e o filtro é empurrado para dentro da busca, para que vetores
restritos nem sejam pontuados. Filtrar depois do retrieval é um vazamento
(chunks restritos roubam vagas de top-k, e o texto cru já está no seu
processo). Derive a flag da sessão, nunca do corpo da requisição. -
Dois provedores, mantidos separados. O modelo de embedding e o modelo de
chat são serviços diferentes com chaves diferentes, para você trocar cada um
de forma independente. -
Indexação idempotente. Reindexar deleta os pontos antigos de um documento
antes de inserir os novos, para que callbacks de publicação e backfills possam
rodar quantas vezes for e caírem no mesmo estado limpo. -
O contrato do retriever. Congele a assinatura do retriever cedo —
search(pergunta, usuario, k, min_score) -> [Hit{score, payload}]— para que
a API e o LLM caller possam ser construídos contra ela antes de ela estar
totalmente implementada.
Marcos
-
Design — enquadre o problema, os usuários e o escopo
Aplique o D do DARE
Antes de qualquer arquitetura, escreva um breve documento de DESIGN
(uma página basta). É aqui que você decide o que está construindo e para
quem — e, tão importante quanto, o que está deixando de fora. Um assistente
de RAG com escopo vago é impossível de testar porque "correto" nunca foi
definido.Responda a estas perguntas por escrito
-
Usuários e níveis de acesso. Quem faz perguntas? Existe mais de um
nível de acesso (ex.: gratuito vs. premium)? Essa decisão guia toda a
história de gating depois — nomeie os níveis agora. -
Fontes de documentos. O que entra no corpus? Escolha um conjunto
concreto e limitado para o capstone: 10–30 documentos curtos (docs de
produto, itens de FAQ, políticas). Marque pelo menos dois como
restritos para ter algo a proteger. -
Tipos de pergunta. Que tipos de pergunta ele precisa responder bem
("como eu faço…", "qual é a política sobre…")? Escreva 5–8 perguntas de
exemplo reais — elas viram seu conjunto de avaliação no Milestone 6. -
Contrato de fundamentação. Diga a regra em voz alta: o assistente
responde apenas a partir do contexto recuperado e diz "não sei" quando
o corpus não cobre a pergunta. Sem conhecimento livre de mundo. -
Não-objetivos. Escreva o que isto não é: não é um chatbot geral,
não é busca na web, não é um sumarizador de documentos que ele não
recuperou, não é memória multi-turno (a não ser que você a inclua no
escopo). Não-objetivos são o que tornam o projeto finalizável.
Entregável
Um
DESIGN.mdno seu repo capturando usuários, níveis, fontes, tipos de
pergunta, o contrato de fundamentação e não-objetivos explícitos.Pronto quando: um leitor que nunca viu o projeto consegue afirmar, só a
partir do seu DESIGN, quem usa o assistente, o que ele vai e não vai
responder, e o que significa "uma resposta correta" — incluindo que
conteúdo restrito tem acesso controlado. -
Usuários e níveis de acesso. Quem faz perguntas? Existe mais de um
-
Blueprint — arquitetura, schema, endpoints e o contrato do retriever
Aplique o B do DARE
Transforme o DESIGN aprovado num BLUEPRINT: a arquitetura concreta
contra a qual o resto do projeto é construído. É aqui que as escolhas
técnicas ficam travadas para que as tarefas possam avançar em paralelo sem
pisar umas nas outras.Escolha seus provedores
-
Embedder + vector store. Escolha um modelo de embedding (anote a
dimensão dele, ex.: 1536) e um vector store (Qdrant e pgvector ambos
rodam localmente via Docker). A coleção precisa ser criada com essa
dimensão exata e distância de cosseno — divergência é a falha
silenciosa nº 1. -
Modelo de chat. Um provedor separado para a geração, com a própria
chave.
Defina o schema de chunk / payload
Este schema é o contrato que todo componente compartilha — a ingestão o
escreve, o retriever filtra por ele, o LLM caller cita a partir dele. Fixe-o
agora:{ "id": "doc-42:2", "vector": [0.013, -0.220, "…"], "payload": { "doc_id": "doc-42", "title": "Política de reset de senha", "premium": false, "text": "…o texto do chunk, para você citar e exibir…" } }Cada campo ganha seu lugar:
doc_idpara reindex idempotente e citação,
titlepara a fonte citada,premium(a flag de acesso) para o filtro da
query,textpara você montar o prompt e mostrar a evidência.Especifique os endpoints e o contrato do retriever
-
POST /ask— requisição{ pergunta }(o acesso vem da sessão,
nunca do corpo); resposta{ resposta, fontes: [{doc_id, title}] }. -
(Opcional) endpoint ou task de indexação — como um documento entra no
corpus. -
Contrato do retriever — congele a assinatura para que a API e o LLM
caller possam ser construídos contra ela:
search(pergunta, usuario, k=5, min_score=0.35) -> [Hit{ score, payload }] - embedda `pergunta` com o MESMO modelo da indexação - empurra um filtro de acesso para a query do store quando o usuario nao e premium - retorna no maximo k hits com score >= min_score, rankeados descEntregável
Um
BLUEPRINT.mdcom a lista de componentes, provedores escolhidos +
dimensão, o schema de chunk/payload, os contratos de endpoint e a assinatura
do retriever.Pronto quando: o schema, o formato de request/response do
POST /aske
a assinatura do retriever estão escritos e estáveis o bastante para que duas
pessoas pudessem construir ingestão e resposta separadamente e ainda assim
se encaixarem. -
Embedder + vector store. Escolha um modelo de embedding (anote a
-
Tasks — decomponha num DAG de tarefas atômicas
Aplique o (t)A do DARE
Quebre o blueprint em tarefas pequenas e atômicas e organize-as num
DAG (grafo acíclico direcionado) — um grafo de dependências que te diz o
que pode ser construído agora, o que precisa esperar e o que pode rodar em
paralelo. Uma boa tarefa é uma que você consegue implementar e testar numa
sentada, com um sinal claro de pronto.Decomposição sugerida
Cada uma destas é uma tarefa; as setas são dependências:
T1 chunker(text, size, overlap) -> [chunk] T2 embedder(text) -> vetor (cache por hash do conteudo) T3 cliente do vector store: criar colecao, upsert, delete, search T4 job de ingestao: doc -> chunks -> embed -> upsert (precisa de T1, T2, T3) T5 retriever.search(pergunta, usuario, k, min_score) (precisa de T2, T3) T6 prompt builder: hits -> prompt de contexto T7 endpoint de resposta POST /ask: retrieve -> prompt -> LLM -> {resposta, fontes} (precisa de T5, T6)O DAG
flowchart LR T1[T1 chunker] --> T4[T4 job de ingestao] T2[T2 embedder] --> T4 T3[T3 cliente vector store] --> T4 T2 --> T5[T5 retriever] T3 --> T5 T5 --> T7[T7 endpoint de resposta] T6[T6 prompt builder] --> T7
Ordem e paralelismo
-
Rank 0 (paralelo): T1, T2, T3, T6 — nenhuma depende da outra, então
podem ser construídas e testadas em unidade de forma independente. T6 só
molda strings. -
Rank 1: T4 (ingestão) e T5 (retriever) — cada uma precisa das suas
dependências de rank 0 mas não da outra, então também podem avançar em
paralelo. -
Rank 2: T7 (o endpoint de resposta) — o ponto de junção; precisa do
retriever e do prompt builder.
Essa ordenação é o motivo de os Milestones 4 e 5 se dividirem como se
dividem: tudo o que a onda de ingestão+index precisa (T1–T4) é
independente da onda de retrieval+resposta (T5–T7), exceto pelo vector
store e pelo embedder, que são contratos compartilhados e congelados.Entregável
Um
TASKS.md(ou um arquivo de DAG) listando cada tarefa atômica, suas
dependências e seu critério de pronto.Pronto quando: toda capacidade do blueprint mapeia para pelo menos uma
tarefa, toda tarefa lista suas dependências, e o grafo não tem ciclos — você
consegue ler uma ordem de build válida e ver quais tarefas são
paralelizáveis. -
Rank 0 (paralelo): T1, T2, T3, T6 — nenhuma depende da outra, então
-
Execute — ingestão + index
Rode a primeira onda de execução
Implemente as tarefas T1–T4: coloque os documentos no vector store como
pontos limpos, idempotentes e com gate. Construa cada peça contra o schema
congelado, teste-a isoladamente, e então conecte-as num único caminho de
ingestão.Chunk com overlap (T1)
Quebre cada documento em chunks com overlap que preservam o contexto através
do corte. Anexe a identidade do pai a todo chunk para poder citá-lo depois.step = size - overlap # ex.: 3000 - 400 = 2600 para start em 0, step, 2*step, …: emita text[start : start + size] com {doc_id, title, premium}Embed com cache (T2)
Embedde o texto de cada chunk com o modelo escolhido. Use como chave de
cachesha256(text)para que re-rodar seja quase de graça e editar um
documento só re-embedde os chunks dele. Faça batch das requisições (ex.: 64
por vez) para reduzir custo e latência. Garanta que todo vetor tem a
dimensão esperada.Indexe no vector store (T3, T4)
Crie a coleção com a dimensão exata do embedding e distância de cosseno.
Faça upsert dos pontos carregando o payload completo (doc_id,title,
premium,text). Torne o reindex idempotente — delete os pontos
existentes de um documento antes de inserir os novos:reindex(doc): store.delete(filter={doc_id: doc.id}) # remove os antigos store.upsert(points_for(doc)) # insere os novosCarimbe a flag de acesso corretamente na hora de indexar: sub-conteúdo
herda o gating do pai (uma aula num curso premium é premium), então
resolva opremiumefetivo e nunca o deixe null.Backfill do corpus
Rode uma passada única sobre todo documento para popular o store, reusando o
mesmo caminho de ingestão. Como o reindex é idempotente, um backfill que
morre no meio pode ser rodado de novo sem duplicatas.Pronto quando: rodar o backfill produz uma coleção cuja contagem de
pontos bate com sua expectativa (≈ documentos × média de chunks),
reindexar um documento duas vezes deixa essa contagem inalterada (sem
duplicatas), e todo ponto carrega uma flagpremiumcorreta. Confirme a
contagem — "o job não deu erro" não é prova; uma coleção vazia também não dá
erro. -
Execute — retrieval + resposta (com gating)
Rode a segunda onda de execução
Implemente as tarefas T5–T7: dada uma pergunta, recupere os chunks certos
com gate, monte um prompt fundamentado, chame o LLM e retorne uma resposta
com fontes.Busca por similaridade, top-k, threshold (T5)
Embedde a pergunta com o mesmo modelo usado na indexação — vetores de
dois modelos diferentes não compartilham o mesmo espaço. Peça ao store os
top-k mais próximos por cosseno, e então descarte qualquer coisa abaixo de
um threshold de score para que uma pergunta fora do tema retorne nada em
vez do chunk menos irrelevante.Gating: filtre premium ANTES do top-k (a parte crítica)
Empurre o filtro de acesso para dentro da query do store, para que
vetores restritos nem sejam pontuados:search(pergunta, usuario, k=5, min_score=0.35): qvec = embed(pergunta) # mesmo modelo da indexacao filtro = None se usuario.premium senao {premium: False} hits = store.search(vector=qvec, limit=k, filter=filtro) # antes do ranking return [h for h in hits se h.score >= min_score]Por que antes do top-k, não depois. Se você recupera tudo e descarta
os hits restritos no código da aplicação, duas coisas quebram: chunks
restritos roubam vagas de top-k (o usuário gratuito recebe uma resposta
pior — uma negação sutil de conteúdo que ele deveria ver), e o texto
restrito já está no seu processo, a um ramo esquecido de distância da
resposta. O banco é a fronteira de enforcement. Deriveusuario.premium
da sessão, nunca do corpo da requisição.Monte o prompt de contexto e gere (T6, T7)
Concatene os chunks recuperados, cada um marcado com o título da sua fonte,
e instrua o modelo a responder apenas a partir do contexto e a admitir
quando o contexto não cobre a pergunta. Se a recuperação não retornou nada
após o threshold, não chame o LLM com contexto vazio — retorne "Não
tenho informação sobre isso."Retorne as fontes como dado estruturado, não só texto inline — você já sabe
quais documentos alimentaram o prompt:{ "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" } ] }Pronto quando:
POST /askresponde a uma pergunta dentro do tema
fundamentado nos chunks recuperados e retorna as fontes que usou; uma
pergunta fora do tema resulta em "não sei" em vez de uma alucinação; e a
mesma pergunta cuja melhor resposta está só num documento premium é
respondida para um usuário premium mas não para um gratuito — com o texto
restrito comprovadamente ausente dos hits recuperados do usuário gratuito. -
Endureça & verifique — testes, limites e o critério de submissão
Rode até o verde, depois prove
O pipeline funciona no caminho feliz — agora torne-o confiável. Este
milestone é o fechamento R/verify do DARE: testes que seguram a linha,
atenção aos limites, e uma definição escrita de "pronto".Escreva os testes que importam
Três propriedades, cada uma um teste que falharia em alto e bom som se uma
mudança futura a quebrasse:-
Relevância do retrieval. Para cada pergunta de exemplo do seu DESIGN
(Milestone 1), o documento esperado aparece nos primeiros hits. Uma
pergunta sem sentido retorna resultado vazio (o threshold funciona). -
O gating não vaza. A mesma pergunta cuja resposta está só num
documento premium: um usuário premium a recupera e cita; os hits de um
usuário gratuito comprovadamente não contêm o texto restrito. Este é o
teste que mais importa — ele codifica uma fronteira de segurança, não uma
gentileza. -
A resposta cita a fonte. Uma resposta bem-sucedida retorna um array
sourcesnão vazio cujosdoc_ids de fato alimentaram o prompt, e o
texto da resposta os referencia.
Cuide dos limites
-
Latência.
POST /askfaz um embed + uma busca vetorial + uma chamada
ao LLM. Meça; a chamada ao LLM domina. Não embedde inline na ingestão
dentro de uma requisição web — esse caminho é assíncrono. -
Caminhos vazio e de erro. Sem hits após o threshold → "não sei", nunca
uma chamada ao LLM com contexto vazio. Um timeout do provedor → um erro
limpo, não um stack trace vazado. -
Custo. Embeddar é um custo real e medido. O cache por hash de conteúdo
e o batching (do Milestone 4) mantêm a reindexação barata; confirme que
re-publicar conteúdo inalterado gera cache hits, não novas cobranças. -
Dimensão divergente. Re-afirme que a dimensão da coleção é igual à do
embedder — é a falha silenciosa nº 1 e vale um health check no startup.
Critério de submissão — o que "pronto" significa
Entregue o repositório, contendo:
- Os artefatos DARE:
DESIGN.md,BLUEPRINT.md,TASKS.md(+ DAG). - O corpus (ou um script que o baixe) com pelo menos um documento restrito
(premium: true). - O pipeline completo: chunk → embed (com cache) → upsert → busca → gate →
geração, conectado a umPOST /askfuncionando. - Um README curto com o comando para indexar/backfill o corpus e o comando
(ou requisição) para fazer uma pergunta. - A suíte de testes passando, mais um transcript 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.
Pronto quando: os três testes passam, a contagem de pontos e uma query
ao vivo verificam que o corpus está populado e recuperável, o gate muda
visivelmente os resultados entre um usuário gratuito e um premium, e um
revisor consegue indexar o corpus e fazer uma pergunta usando apenas o seu
README. -
Relevância do retrieval. Para cada pergunta de exemplo do seu DESIGN