Recuperação: - keyword.py: tokenizador PT (acentos, stopwords, dígitos), BM25 Okapi com índice invertido, e reciprocal_rank_fusion com desempate determinístico - retrieval.py: VectorRetriever e HybridRetriever; fusão por posição, não por nota (cosseno 0..1 e BM25 sem teto são escalas incomparáveis) - identidade de fusão é a coordenada (arquivo,livro,capítulo,versículo): o mesmo versículo chega das duas buscas com distance diferente e o dataclass nunca os igualaria, então o RRF não premiaria o que ambas confirmam - ChatService recebe retriever injetado; referência explícita mantém precedência Contexto: - VerseChunk ganha context (janela de CONTEXT_WINDOW vizinhos por lado, dentro do livro, atravessando capítulos) e embedding_text - store grava a janela como documento e o versículo em verse_text; a UI mostra o versículo citado e o modelo recebe a janela - snapshot() nos dois stores: o BM25 é em memória e o Chroma persiste, então o arranque precisa reconstruir o índice lexical ou a busca degrada em silêncio Motivação medida em 5703 versículos reais: a busca vetorial não liga 'estou com medo do futuro' a 'não temas' (0,19, nível de par sem relação), e e5-small tem faixa de similaridade de só 0,02 — inutilizaria o piso de evidência.
11 KiB
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), 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 |
Detalhes e o SHA-256 do arquivo em 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
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
# 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.
docker compose logs -f backend
Comandos úteis
# 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 <book>. 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.