Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

16 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

RAG Knowledge Chat

Knowledge Chat com RAG usando Python, LangChain (v1) e Postgres com pgvector.

O projeto implementa um pipeline completo de RAG em módulos pequenos e independentes: ingestão e chunking de documentos Markdown, indexação vetorial no Postgres, query planner (o modelo interpreta a pergunta e planeja a busca antes do retrieval), busca vetorial filtrada por metadados e resposta com fontes. A base de exemplo é a documentação interna de uma empresa SaaS fictícia (FCAI), mas a estrutura serve para qualquer base de documentos Markdown com metadados.

Como funciona

Fluxo de uma pergunta no chat:

pergunta do usuário
   │
   ▼
query planner (modelo, structured output)
   │        └── pergunta ambígua? → devolve uma pergunta de esclarecimento e para
   ▼
busca vetorial no pgvector
   (pergunta normalizada + termos exatos, com filtros de metadados)
   │
   ▼
reranking (modelo, structured output)
   │        └── nenhum chunk sustenta a resposta? → responde "não encontrei" e para
   ▼
modelo de resposta (structured output)
   │
   ▼
Answer + Sources (as fontes são montadas pela aplicação, não pelo modelo)

O modelo de chat é chamado em exatamente três pontos: no query planner, no reranking e na resposta final. Ingestão e indexação não chamam o modelo de chat — apenas o modelo de embeddings.

Esse fluxo vive em app/rag_pipeline.py e é o mesmo nos dois modos de uso: o chat de terminal (app/rag_chat.py) e a API HTTP (app/api.py).

Requisitos

  • Python 3.11+
  • Docker e Docker Compose
  • Uma chave de API da OpenAI

Setup

  1. Crie o .env a partir do exemplo e preencha OPENAI_API_KEY (o exemplo já traz modelo de chat, modelo de embeddings e URL do banco):

    cp .env.example .env
  2. Suba o Postgres com pgvector:

    docker compose up -d
  3. Crie o ambiente virtual e instale as dependências:

    python3 -m venv .venv
    source .venv/bin/activate
    pip install -r requirements.txt
  4. Valide ambiente, conexão e extensão pgvector:

    python -m app.main

Preparando a base

python -m app.ingest   # lê, valida e gera os chunks (data/chunks.jsonl)
python -m app.index    # gera embeddings e indexa no pgvector

O ingest valida o front matter de cada documento (metadados obrigatórios: title, tenant, product, plan, doc_type, version, status, visibility) e falha com erro claro quando algo está faltando. O chunking preserva a estrutura do Markdown (MarkdownHeaderTextSplitter por seções, depois RecursiveCharacterTextSplitter com chunk_size=900 e chunk_overlap=150), e cada chunk carrega os metadados do documento mais os seus próprios (chunk_id, source_file, chunk_index, section, content_length, document_hash).

Cada chunk é prefixado com um cabeçalho de contexto (título, tipo, produto, plano, versão e seção) antes de ser indexado. Metadados não entram no embedding — só no filtro — então é esse cabeçalho que torna um chunk tirado do meio de um documento autoexplicativo para a busca e para o prompt final. A flag --no-context-header gera os chunks sem o cabeçalho, útil para comparar a qualidade do retrieval com e sem ele.

O index grava os chunks na tabela fcai_knowledge_base usando chunk_id como id (PGEngine + PGVectorStore, API atual do langchain-postgres) e reindexa apenas o que mudou desde a última execução — veja a seção a seguir.

Incremental indexing

O índice do pgvector se mantém consistente quando documentos mudam, sem nunca reindexar tudo:

  • python -m app.ingest gera data/chunks.jsonl com um document_hash (SHA256 do arquivo completo, front matter incluído) em cada chunk. O chunk_id é determinísticoarquivo + prefixo do hash + posição, ex.: product-support-sla-8f3a91c2b4d0-0001 — então o mesmo conteúdo sempre produz os mesmos ids, e um documento editado produz ids novos.

  • python -m app.index compara os chunks atuais com o manifest da última indexação (data/index_manifest.json) e classifica cada documento:

    Estado Ação
    new indexa os chunks do documento
    changed deleta os chunks antigos (ids do manifest) e indexa os novos
    removed deleta os chunks antigos do índice
    unchanged não faz nada (nenhuma chamada de embedding)
  • Ao final, o manifest é regravado com o estado atual (hash, metadados e chunk_ids por documento). Rodar index duas vezes seguidas não duplica nada e não gasta embeddings; alterar um documento substitui só os chunks dele; remover um documento (e rodar ingest + index) limpa os chunks órfãos do índice.

  • python -m app.index --force deleta todos os chunks registrados no manifest e reindexa todos os chunks atuais, recriando o índice do zero.

  • Sem manifest (primeira indexação) a tabela é recriada do zero, pois não há como saber o que existe nela.

  • O chat nunca reindexa documentos em runtime: indexação acontece só via app.ingest + app.index.

Usando o chat

python -m app.rag_chat                # o chat com RAG
python -m app.rag_chat --debug        # imprime o QueryPlan, os chunks recuperados e os selecionados
python -m app.rag_chat --show-prompt  # imprime os prompts do planner, do reranker e da resposta
python -m app.rag_chat --no-rerank    # pula o reranking (usa os 4 primeiros do retrieval)

Digite exit ou quit para sair.

O modelo de resposta retorna saída estruturada (answer, has_answer, used_chunk_ids) e é instruído a responder somente com base no contexto recuperado. As fontes não são escritas pelo modelo: a aplicação as monta cruzando os used_chunk_ids com os metadados dos chunks realmente recuperados (source_file, title, section, version) — IDs inventados são descartados. Quando o contexto não sustenta a resposta, o chat diz que não há informação suficiente e imprime Sources: No sources..

Perguntas para ver o planner trabalhando (com --debug):

  • E se der problema grave no Enterprise? → normaliza para P1 + SLA, plan=enterprise
  • Como funciona o suporte no plano da empresa? → ambígua, pede esclarecimento
  • Qual é o e-mail de suporte? → doc_types inclui sla (o e-mail mora no doc de SLA)
  • O que acontece se eu ultrapassar a cota de ingestão? → plan=null, resposta cobre os três planos

API

O mesmo pipeline do terminal é exposto como uma API HTTP com FastAPI:

uvicorn app.api:app --reload

A API não roda ingestão nem indexação — o /chat apenas consulta o índice já criado no Postgres. Antes de usá-la, prepare a base:

python -m app.ingest
python -m app.index

Endpoints:

  • GET /health{"status": "ok"}
  • POST /chat → executa o pipeline completo (planner → retrieval → reranking → resposta)
curl -X POST http://127.0.0.1:8000/chat \
  -H 'Content-Type: application/json' \
  -d '{"question": "Qual é o SLA para incidentes P1 no plano Enterprise?"}'

Campos opcionais do request: debug (default false) inclui na resposta o query plan, os chunks recuperados e os selected_chunk_ids; use_rerank (default true) permite pular o reranking. Pergunta vazia retorna 400; pergunta ambígua retorna a pergunta de esclarecimento com needs_clarification=true. As fontes são montadas pela aplicação, nunca pelo modelo, e a API não expõe prompts nem segredos.

O arquivo test.http na raiz tem todos esses requests prontos para a extensão REST Client do VS Code.

Pipeline observability

Cada execução do pipeline gera um request_id (UUID), mede o tempo de cada etapa (query_planning_ms, retrieval_ms, reranking_ms, answer_generation_ms, total_ms) e agrega os tokens consumidos por modelo (model_usage, via UsageMetadataCallbackHandler do LangChain — o custo não é calculado porque o LangChain não fornece preços, apenas tokens).

  • Na API, debug=true retorna as informações de diagnóstico: request_id, query_plan, retrieved_chunks, selected_chunk_ids, timings e model_usage. Com debug=false a resposta continua enxuta, sem nada disso.
  • No terminal, --debug mostra o query plan, os chunks recuperados, os selecionados e um bloco Pipeline debug com o request_id, os tempos e os tokens por modelo.
  • Logs estruturados: toda execução emite uma linha JSON no stdout com request_id, has_answer, needs_clarification, used_rerank, contagens de chunks, source_files, timings e model_usage (tokens por modelo) — o suficiente para auditar o pipeline sem expor prompts, contexto completo, documentos ou credenciais. Em caso de erro, a linha traz error_type e error_message, e a API responde 500 com uma mensagem genérica (sem stack trace).
  • Ingestão e indexação continuam separadas do runtime: observabilidade não muda o fato de que o chat e a API apenas consultam o índice existente.

O query planner

A pergunta que o usuário digita quase nunca é a melhor pergunta para buscar no banco. Antes do retrieval, o chat faz uma chamada ao modelo com structured output (QueryPlan, em app/query_planner.py) que interpreta a pergunta e planeja a busca. Cada campo do plano tem um efeito concreto:

Campo O que ele faz
normalized_question reescrita autoexplicativa da pergunta; é o texto da busca semântica
exact_terms literais (P1, SSO, 99,9%…) anexados ao texto da busca, para o embedding não perdê-los
doc_types vira filtro doc_type IN (...)
plan vira filtro plan IN [plano, "all"]
needs_clarification interrompe o fluxo antes do retrieval e devolve uma pergunta de esclarecimento

A pergunta original continua sendo a que o modelo final responde — a normalizada serve só para buscar.

Filtros: o que é do modelo e o que é da aplicação

O modelo só sugere filtros de conteúdo (doc_types e plan). Os filtros seguros são fixos na aplicação e o modelo nunca decide sobre eles:

SAFE_FILTERS = {"tenant": "fcai", "product": "fcai-cloud", "status": "published"}

Tenant, produto, status e autorização são responsabilidade da aplicação — deixar o modelo escolher isso seria abrir mão do controle de acesso.

O filtro é eliminatório: doc_types é uma lista generosa

O filtro de metadados roda antes da busca semântica, como um WHERE: o que não passa nele não pode ser recuperado, nem que contenha exatamente a resposta. Como o filtro vem de um palpite do modelo, ele precisa ser generoso — o planner lista todos os tipos que podem conter a resposta, não um único:

Pergunta doc_types Onde a resposta mora
Quanto custa o plano Pro? plan, policy seção de preço do pro-plan.md
O Enterprise tem SSO e SCIM? plan, product enterprise-plan.md
Tempo de resposta P1 no Enterprise? plan, sla enterprise-plan.md + product-support-sla.md

Quando não dá para saber, a lista fica vazia e nenhum filtro de tipo é aplicado. Filtro aumenta a precisão e derruba o recall; na dúvida, é melhor buscar em tudo.

Pelo mesmo motivo, o catálogo de tipos no prompt do planner descreve o que cada tipo realmente contém, não o que o nome sugere — sla também cobre canais de atendimento e janelas de manutenção; company também cobre os e-mails de contato de todos os departamentos. O planner só enxerga a base por esse catálogo.

Quando o planner pede esclarecimento

Só quando a pergunta tem mais de uma leitura razoável, cada uma levando a uma resposta diferente. Dois casos que parecem iguais são tratados de forma diferente:

  • pergunta que aponta para um plano específico sem nomear ("o meu plano", "o plano da empresa") → ambígua: pede esclarecimento em vez de chutar;
  • pergunta que não menciona plano nenhum → uma leitura só, cuja resposta apenas varia por plano: plan=null e a resposta cobre todos.

O contrato entre o planner e o índice

DocType e Plan são Literals: o structured output garante que o planner nunca inventa um tipo. O outro lado dessa garantia: um doc_type novo indexado que o planner não conhece nunca pode ser escolhido — os documentos dele sumiriam de toda busca filtrada, silenciosamente. Por isso o chat valida o contrato na largada (ensure_planner_covers_index()): os valores distintos de doc_type e plan do índice são comparados com o que o planner conhece, e o chat encerra com instrução clara quando há algo desconhecido:

The index has metadata the planner does not know: faq. Update DocType/Plan and the
catalog in PLANNER_SYSTEM_PROMPT (app/query_planner.py).

Decisões de design

  • temperature=0 — o planner é um classificador: a mesma pergunta tem que produzir o mesmo plano, inclusive na decisão de pedir esclarecimento.
  • TOP_K=8 — o retrieval é generoso de propósito: traz candidatos de sobra para o reranking escolher, em vez de cortar a resposta fora antes da hora.

Reranking

O retrieval otimiza recall: traz até 8 candidatos que podem ajudar. O reranking (app/rerank.py) otimiza precisão: uma chamada ao modelo com structured output (RerankResult) recebe a pergunta original, a pergunta normalizada, os termos exatos e os candidatos, e seleciona no máximo 4 chunk_ids que ajudam diretamente a responder — preferindo chunks específicos a genéricos e chunks que contêm os termos exatos. Só os selecionados entram no prompt final e nas fontes.

O reranker também funciona como portão de segurança: quando nenhum candidato sustenta a resposta, ele devolve uma lista vazia e o chat responde "não encontrei" sem chamar o modelo final — os candidatos irrelevantes nunca chegam ao prompt de resposta. IDs fora dos candidatos são descartados pela aplicação, no mesmo padrão das fontes.

Para inspecionar e comparar:

  • --debug mostra os chunks recuperados (candidatos) e, em seguida, os selecionados;
  • --show-prompt mostra os três prompts, na ordem: planner, reranker e resposta final;
  • --no-rerank pula o reranking e envia os 4 primeiros do retrieval direto ao prompt final — útil para comparar os dois fluxos com a mesma pergunta.

Retrieval isolado

Para inspecionar a busca sem chamar o modelo de chat:

python -m app.retrieve "Qual é o SLA para incidentes P1 no plano Enterprise?"
python -m app.retrieve "Qual é o SLA para P1?" --top-k 5 --doc-type sla --plan all

Aceita --top-k e filtros por metadados (--tenant, --product, --plan, --doc-type, --status). Para cada resultado imprime score, chunk_id, arquivo, título, seção, plano, tipo, versão, status e um trecho do conteúdo. Útil para verificar se o contexto certo está sendo recuperado e para observar o efeito dos filtros — a mesma pergunta com um --doc-type errado mostra, na prática, o filtro eliminatório escondendo a resposta.

Utilitários de comparação

  • python -m app.chat — chat sem RAG: envia a pergunta direto ao modelo, com streaming. Linha de base para comparar com as respostas do RAG.
  • python -m app.context_chat [arquivo.md] — chat com um documento inteiro no prompt (sem embeddings nem retrieval). Funciona para documentos pequenos e mostra o limite que o RAG resolve: muitos documentos, custo de tokens e busca seletiva.

Knowledge base

Os documentos ficam em knowledge_base/: oito arquivos Markdown em português sobre a FCAI, cobrindo informações da empresa (company), visão geral do produto (product), políticas comercial e de faturamento (policy), SLA de suporte (sla) e um documento por plano — Starter, Pro e Enterprise (plan).

Todo documento tem front matter YAML com os metadados obrigatórios. plan: all marca documentos válidos para qualquer plano — o filtro de plano usa plan IN [plano, "all"] para que eles nunca sejam descartados.

Adicionando documentos

  1. Crie o .md em knowledge_base/ com o front matter completo.
  2. Se o documento introduz um doc_type ou plan novo, atualize os Literals DocType/Plan e o catálogo em PLANNER_SYSTEM_PROMPT (app/query_planner.py) — a validação de startup aponta exatamente isso se você esquecer.
  3. Regere e reindexe: python -m app.ingest && python -m app.index — só o documento novo/alterado é indexado (veja Incremental indexing).

Possibilidades de uso

  • Assistente de suporte interno sobre a documentação de uma empresa ou produto — troque os documentos de knowledge_base/, ajuste os metadados e os SAFE_FILTERS.
  • Bancada de experimentação de RAG — cada módulo roda isolado (retrieval sem LLM, chat sem RAG, documento inteiro no prompt, RAG completo), o que permite comparar abordagens e medir o efeito de cada peça (cabeçalho de contexto, filtros, query planner) com as flags de debug.
  • Base para evoluções — busca híbrida (BM25), frontend, histórico de conversa e streaming da resposta são extensões naturais sobre esta estrutura; nenhuma está implementada.

Estrutura

app/
  config.py        # carrega e valida variáveis de ambiente
  db.py            # conexão com Postgres e extensão pgvector
  main.py          # script de validação do ambiente
  chat.py          # chat sem RAG (linha de base)
  context_chat.py  # chat com um documento inteiro no prompt (sem RAG)
  ingest.py        # lê, valida e gera chunks da knowledge_base (com document_hash)
  index.py         # indexa no pgvector só o que mudou (new/changed/removed)
  manifest.py      # hash de documentos e leitura/escrita do index_manifest.json
  retrieve.py      # busca isolada no pgvector, com filtros (sem resposta RAG)
  query_planner.py # planeja a busca: entende a pergunta, monta os filtros e busca
  rerank.py        # seleciona quais chunks recuperados entram no prompt final
  rag_pipeline.py  # o pipeline RAG completo, compartilhado pelo terminal e pela API
  rag_chat.py      # o chat no terminal, por cima do pipeline
  api.py           # a API HTTP (FastAPI), por cima do pipeline
knowledge_base/    # documentos Markdown com front matter de metadados
test.http          # requests prontos para a extensão REST Client do VS Code
docker-compose.yml
requirements.txt
.env.example

About

No description, website, or topics provided.

Resources

Stars

4 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages