# Pastor Online Sistema web RAG para consulta bíblica. Indexa um corpus bíblico em um banco vetorial e responde em chat com um agente pastoral **ancorado exclusivamente nos versículos recuperados** — com as referências sempre à vista. Containerizado com Docker Compose: frontend React, backend FastAPI, ChromaDB e (opcionalmente) Ollama local. --- ## Como funciona ``` ./corpus/*.usfx.xml ──▶ parser USFX ──▶ versículo + janela de contexto │ ┌───────────────┴───────────────┐ embeddings (MiniLM) BM25 (palavras) │ │ ChromaDB índice em memória └───────────────┬───────────────┘ fusão RRF │ navegador ◀── SSE ── FastAPI ◀────┴── validação de citações ``` **Fidelidade ao texto.** A resposta só é gerada quando há evidência recuperada do corpus. As citações são conferidas no servidor contra as fontes consultadas: uma referência que o modelo inventou é removida **antes** de chegar à tela. Sem evidência, o sistema responde com uma mensagem fixa e não chama o LLM. **Corpus estruturado.** O texto vem de XML ([USFX](https://ebible.org/usfx/)), não de PDF: cada versículo já chega com livro, capítulo e número próprios. Não há OCR, nem reconstrução de capítulos a partir de marcadores de página, nem ruído de digitalização — a referência exibida é exatamente a coordenada de onde o texto foi lido. **Busca híbrida.** Vetores e palavras erram de modos diferentes, então os dois lados são combinados. Medido no corpus real, com 5.703 versículos e 6 perguntas pastorais: - A busca vetorial não liga "estou com medo do futuro" a "não temas, porque eu sou contigo" (similaridade 0,19 — no nível de um par sem relação alguma). Modelos de paráfrase casam formulações parecidas, não pergunta e resposta. - A mesma busca acerta em cheio quando há vocabulário em comum: "o que Jesus disse sobre amar os inimigos?" → Mateus 5:44 no top-1 (0,683). - O BM25 cobre o lado lexical e ainda pega termos que o embedding dilui. A fusão é por **posição** nos dois rankings (Reciprocal Rank Fusion), não por nota: cosseno vive em 0..1 e BM25 não tem teto, então normalizar um contra o outro seria arbitrário. `MIN_SIMILARITY` continua valendo como piso do lado vetorial. **Janela de contexto.** Um versículo isolado tem ~20 palavras — curto demais para o embedding captar o tema. Cada chunk indexa o versículo junto com `CONTEXT_WINDOW` vizinhos de cada lado (dentro do mesmo livro, atravessando capítulos). A referência exibida continua sendo a do versículo central, e é o texto **dele** que aparece na interface; a janela inteira é o que vai para o modelo como evidência. --- ## Corpus Vem versionado em **`./corpus`**, pronto para uso: | | | |---|---| | Tradução | João Ferreira de Almeida | | Licença | **Domínio público** — sem atribuição nem restrição | | Conteúdo | 66 livros · 1.189 capítulos · 31.098 versículos | | Proveniência | [seven1m/open-bibles](https://github.com/seven1m/open-bibles) | Detalhes e o SHA-256 do arquivo em [`corpus/README.md`](corpus/README.md). Para usar outra tradução, coloque um arquivo USFX em `./corpus` e reinicie o backend (ou chame `POST /api/reindex`). **Verifique a licença antes**: NVI, NTLH, NAA, ACF, ARA, ARC e KJA pertencem às editoras e não podem ser redistribuídas. --- ## Requisitos - Docker com Compose v2 (ou Podman com `podman-compose`) - ~2 GB de RAM livres para o backend - ~1,5 GB de disco para o modelo de embeddings e o banco vetorial --- ## Início rápido ### 1. Configure o provedor de LLM ```bash cp .env.example .env ``` Edite o `.env`. Quatro caminhos possíveis: | Provedor | Configuração | Observação | |---|---|---| | **Ollama** (local) | `LLM_PROVIDER=ollama` | Subir com `--profile local`; o modelo é baixado na primeira execução | | **OpenAI** | `LLM_PROVIDER=openai` + `OPENAI_API_KEY` | | | **Groq** | `LLM_PROVIDER=groq` + `GROQ_API_KEY` | | | **Anthropic** | `LLM_PROVIDER=anthropic` + `ANTHROPIC_API_KEY` | | Para testar sem nenhum provedor, use `LLM_PROVIDER=stub`: devolve resposta determinística a partir do contexto recuperado, útil para conferir a busca e as citações. ### 2. Suba o sistema ```bash # Com Ollama local: docker compose --profile local up --build -d # Sem Ollama (usando OpenAI, Groq ou Anthropic): docker compose up --build -d ``` Acesse **http://localhost:8080**. ### 3. Acompanhe a indexação A indexação começa sozinha no arranque. O trabalho é todo embedding de 31.098 versículos em CPU — o painel lateral mostra o progresso por versículo e o chat já responde enquanto ela roda. Reiniciar o container não repete o trabalho: o manifesto guarda o SHA-256 de cada arquivo e só reprocessa o que mudou. ```bash docker compose logs -f backend ``` --- ## Comandos úteis ```bash # Reiniciar só o frontend (após mudanças no React) docker compose up -d --build frontend # Forçar a reindexação do corpus curl -X POST http://localhost:8080/api/reindex # Ver o estado da indexação curl http://localhost:8080/api/status # Rodar os testes do backend docker compose exec backend pytest tests/ -v # Rodar os testes do frontend docker compose exec frontend npm test ``` --- ## API | Rota | Método | Descrição | |---|---|---| | `/api/health` | GET | Saúde do serviço | | `/api/status` | GET | Fontes lidas, versículos indexados, progresso, erros | | `/api/reindex` | POST | Força a reindexação (202). 409 se já em andamento | | `/api/chat` | POST | Chat em streaming (SSE) | | `/api/sources` | GET | Trechos consultados na última resposta | O chat responde em Server-Sent Events: ``` event: sources data: {"sources":[{"reference":"Gênesis 1:1","text":"...","book":"Gênesis","chapter":1,"verse":1}]} event: token data: {"text":"No princípio criou Deus os céus e a terra..."} event: done data: {"citations":["Gênesis 1:1"]} ``` O frontend consome o stream com `fetch` + `ReadableStream`, porque `EventSource` não aceita `POST`. --- ## Configuração Todas as variáveis ficam no `.env`. As principais: | Variável | Padrão | Efeito | |---|---|---| | `LLM_PROVIDER` | `ollama` | `ollama`, `openai`, `groq`, `anthropic` ou `stub` | | `EMBEDDING_MODEL` | `paraphrase-multilingual-MiniLM-L12-v2` | `all-MiniLM-L6-v2` é menor e mais rápido | | `TOP_K` | `6` | Quantos trechos são recuperados por pergunta | | `MIN_SIMILARITY` | `0.35` | Piso do lado vetorial; abaixo disso o trecho só entra se o BM25 o encontrar | | `CONTEXT_WINDOW` | `2` | Vizinhos indexados com cada versículo (0 desliga a janela) | | `INDEX_BATCH_SIZE` | `512` | Versículos gravados no banco por lote | | `CORPUS_DIR` | `/data/corpus` | Diretório dos arquivos USFX (montado de `./corpus`) | | `REINDEX_TOKEN` | vazio | Se preenchido, `/api/reindex` exige o header `X-Reindex-Token` | | `CORS_ORIGINS` | `http://localhost:8080` | Origens permitidas | | `AUTO_INDEX` | `true` | Indexa automaticamente na inicialização | --- ## Solução de problemas **O chat responde que o texto "não aborda diretamente o assunto".** Nenhum dos dois lados da busca encontrou nada acima do piso. Confira `/api/status` (índice vazio?), reduza `MIN_SIMILARITY` para 0.25 ou aumente `TOP_K` para 10. Se a pergunta citar uma referência explícita (`João 3:16`), a busca é determinística por metadados e não depende de limiar nenhum. **As respostas citam versículos que parecem fora do tema.** Verifique em `/api/status` o campo `model`. Se aparecer `hash-determinístico (sem semântica)` em vez do nome do modelo configurado, o embedding **não** está usando o modelo real — o campo `errors` traz o motivo. Isso acontece quando `torch`/`transformers`/`sentence-transformers` saem de alinhamento; as versões estão pinadas em `backend/requirements.txt` e no `Dockerfile` exatamente por isso. Depois de corrigir, reindexe: os vetores gravados são do embedder antigo. **A busca por palavra não está funcionando.** O índice BM25 vive em memória e é reconstruído no arranque e após cada indexação. Confira no log a linha `Índice de palavras-chave construído com N trechos`. Sem ela, a recuperação degrada para só-vetorial. **"nenhum versículo pôde ser lido" em `/api/status`.** O arquivo em `./corpus` não é um USFX válido, ou não contém ``. Confira com: `docker compose exec backend python -c "from app.corpus import parse_usfx; print(sum(1 for _ in parse_usfx('/data/corpus/NOME.xml')))"`. **A reindexação não pega um arquivo novo.** Só `*.xml` é descoberto. Confira o nome e a permissão de leitura em `./corpus`. **Quero reindexar do zero.** `docker compose down && docker volume rm biblia-online_vector_db && docker compose up -d`. **Ollama não sobe.** `docker compose --profile local up -d`. O serviço `ollama-pull` baixa o modelo indicado em `OLLAMA_MODEL` na primeira execução. Para outro modelo: `OLLAMA_MODEL=llama3.2:3b docker compose --profile local up -d`. **Porta 8080 já em uso.** `FRONTEND_PORT=8090 docker compose up -d`. Atualize também `CORS_ORIGINS`. --- ## Estrutura do projeto ``` . ├── docker-compose.yml ├── .env.example ├── corpus/ # texto bíblico indexado (USFX, domínio público) ├── backend/ │ ├── Dockerfile │ ├── requirements.txt │ ├── app/ │ │ ├── config.py # settings via pydantic-settings │ │ ├── models.py # schemas da API │ │ ├── corpus.py # USFX → versículos + janela de contexto + nomes canônicos │ │ ├── embedding.py # sentence-transformers │ │ ├── keyword.py # tokenizador PT, BM25 e fusão RRF │ │ ├── retrieval.py # recuperadores vetorial e híbrido │ │ ├── vector_store.py # ChromaDB (+ versão em memória para testes) │ │ ├── indexer.py # descoberta, manifesto incremental, progresso │ │ ├── llm.py # adaptadores Ollama/OpenAI/Groq/Anthropic/stub │ │ ├── chat.py # contexto, validação de citações │ │ └── main.py # rotas FastAPI │ └── tests/ ├── frontend/ │ ├── Dockerfile │ ├── nginx.conf │ └── src/ │ ├── App.tsx │ ├── api.ts │ ├── lib/sse.ts │ └── components/ └── docs/superpowers/ # design e plano de implementação ``` --- ## Segurança - As chaves de API vivem **apenas** no backend, via variáveis de ambiente. Nunca chegam ao bundle do navegador. - O corpus é montado somente-leitura; a escrita fica restrita ao volume `/data`. - O conteúdo indexado é entregue ao modelo como **dado delimitado**, nunca como instrução — o que neutraliza tentativas de prompt injection via corpus. - Mensagens acima de 4.000 caracteres são recusadas; o histórico é limitado às últimas 10 trocas. - O endpoint de reindexação rejeita chamadas concorrentes (409) e aceita um token opcional (`REINDEX_TOKEN`). A aplicação é local, sem autenticação. Não a exponha à internet sem colocar um proxy com autenticação à frente.