Applied AI

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

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:

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

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

Marcos

  1. 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.md no 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.

  2. 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_id para reindex idempotente e citação,
    title para a fonte citada, premium (a flag de acesso) para o filtro da
    query, text para 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 desc
    

    Entregável

    Um BLUEPRINT.md com 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 /ask e
    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.

  3. 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.

  4. 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
    cache sha256(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 novos
    

    Carimbe 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 o premium efetivo 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 flag premium correta. Confirme a
    contagem — "o job não deu erro" não é prova; uma coleção vazia também não dá
    erro.

  5. 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. Derive usuario.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 /ask responde 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.

  6. 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
      sources não vazio cujos doc_ids de fato alimentaram o prompt, e o
      texto da resposta os referencia.

    Cuide dos limites

    • Latência. POST /ask faz 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 um POST /ask funcionando.
    • 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.