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.
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).
- Python 3.11+
- Docker e Docker Compose
- Uma chave de API da OpenAI
-
Crie o
.enva partir do exemplo e preenchaOPENAI_API_KEY(o exemplo já traz modelo de chat, modelo de embeddings e URL do banco):cp .env.example .env
-
Suba o Postgres com pgvector:
docker compose up -d
-
Crie o ambiente virtual e instale as dependências:
python3 -m venv .venv source .venv/bin/activate pip install -r requirements.txt -
Valide ambiente, conexão e extensão pgvector:
python -m app.main
python -m app.ingest # lê, valida e gera os chunks (data/chunks.jsonl)
python -m app.index # gera embeddings e indexa no pgvectorO 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.
O índice do pgvector se mantém consistente quando documentos mudam, sem nunca reindexar tudo:
-
python -m app.ingestgeradata/chunks.jsonlcom umdocument_hash(SHA256 do arquivo completo, front matter incluído) em cada chunk. Ochunk_idé determinístico —arquivo+ 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.indexcompara os chunks atuais com o manifest da última indexação (data/index_manifest.json) e classifica cada documento:Estado Ação newindexa os chunks do documento changeddeleta os chunks antigos (ids do manifest) e indexa os novos removeddeleta os chunks antigos do índice unchangednão faz nada (nenhuma chamada de embedding) -
Ao final, o manifest é regravado com o estado atual (hash, metadados e
chunk_idspor documento). Rodarindexduas vezes seguidas não duplica nada e não gasta embeddings; alterar um documento substitui só os chunks dele; remover um documento (e rodaringest+index) limpa os chunks órfãos do índice. -
python -m app.index --forcedeleta 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.
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_typesincluisla(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
O mesmo pipeline do terminal é exposto como uma API HTTP com FastAPI:
uvicorn app.api:app --reloadA 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.indexEndpoints:
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.
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=trueretorna as informações de diagnóstico:request_id,query_plan,retrieved_chunks,selected_chunk_ids,timingsemodel_usage. Comdebug=falsea resposta continua enxuta, sem nada disso. - No terminal,
--debugmostra o query plan, os chunks recuperados, os selecionados e um blocoPipeline debugcom orequest_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,timingsemodel_usage(tokens por modelo) — o suficiente para auditar o pipeline sem expor prompts, contexto completo, documentos ou credenciais. Em caso de erro, a linha trazerror_typeeerror_message, e a API responde500com 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.
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.
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 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.
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=nulle a resposta cobre todos.
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).
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.
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:
--debugmostra os chunks recuperados (candidatos) e, em seguida, os selecionados;--show-promptmostra os três prompts, na ordem: planner, reranker e resposta final;--no-rerankpula o reranking e envia os 4 primeiros do retrieval direto ao prompt final — útil para comparar os dois fluxos com a mesma pergunta.
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 allAceita --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.
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.
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.
- Crie o
.mdemknowledge_base/com o front matter completo. - Se o documento introduz um
doc_typeouplannovo, atualize osLiteralsDocType/Plane o catálogo emPLANNER_SYSTEM_PROMPT(app/query_planner.py) — a validação de startup aponta exatamente isso se você esquecer. - Regere e reindexe:
python -m app.ingest && python -m app.index— só o documento novo/alterado é indexado (veja Incremental indexing).
- Assistente de suporte interno sobre a documentação de uma empresa ou produto —
troque os documentos de
knowledge_base/, ajuste os metadados e osSAFE_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.
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