omp 816e6be159 feat: cota lexical na fusão, recusa off-topic no prompt, docs medidas
Fusão:
- fuse_rankings() extrai a fusão RRF e aplica MAX_KEYWORD_ONLY (padrão 2):
  quantas posições podem ser ocupadas por trechos que só o BM25 achou. O que os
  dois lados confirmam nunca conta contra a cota.
- O controle é por cota de posições, não por piso de nota. Medido: a razão da
  nota BM25 não separa acerto de ruído — para 'como posso perdoar quem me
  feriu?', Deuteronômio 28:27 pontua 1,000 do topo e o correto Lucas 7:47 fica
  em 0,716 (rank 12). Um piso cortaria o certo e manteria o errado.
- Ponderar o RRF também não resolve: com peso 0,5 no BM25 os achados só-lexicais
  caem abaixo do 6º vetorial (0,5/61 < 1/66) e somem, levando junto Jó 3:13 e
  Joel 3:14, os melhores resultados medidos.

Portão off-topic:
- MIN_SIMILARITY não consegue implementar 'sem evidência'. Medido no índice
  completo: 0/6 perguntas off-topic bloqueadas em qualquer limiar de 0,30 a
  0,50 ('bolo de chocolate' bate 0,544 em Lv 7:14; 'cotação do dólar' 0,494).
  A recusa passa a ser instruída no SYSTEM_PROMPT e o limiar fica documentado
  como filtro de ruído.
2026-11-02 13:04:14 -03:00

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. Tudo abaixo foi medido no corpus real (31.098 versículos indexados):

  • 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 quando há vocabulário em comum, e o BM25 cobre o resto: "ansioso e não consigo dormir" → Jó 3:13 "teria dormido"; "não sei que decisão tomar" → Joel 3:14 "vale da decisão".
  • Stemming é obrigatório. Sem ele, "amar" da pergunta não casa com "amai" do versículo e Mateus 5:44 ficava em 434º no BM25 para "o que Jesus disse sobre amar os inimigos?", enquanto Marcos 12:34 liderava apenas por conter "Jesus" e "disse". Com o stemmer de sufixos, vai a 1º.
  • BM25 indexa o versículo, não a janela de contexto. Os dois lados querem granularidades opostas: a janela dá tema ao embedding, mas multiplica os termos incidentais que o BM25 pontua. Indexando a janela, Mateus 5:44 caía para 12º.

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.

Dois limites medidos, e o que foi feito a respeito:

  1. MIN_SIMILARITY não detecta assunto fora do tema. Nenhum valor entre 0,30 e 0,50 bloqueou uma única pergunta off-topic: "como fazer um bolo de chocolate" bate 0,544 em Levítico 7:14 e "cotação do dólar" bate 0,494. Num corpus de 31 mil versículos sempre existe algum acima de qualquer limiar utilizável. O valor serve como filtro de ruído; a recusa off-topic é instruída ao modelo no SYSTEM_PROMPT.
  2. A nota BM25 não separa acerto de ruído. Para "como posso perdoar quem me feriu?", Deuteronômio 28:27 ("o Senhor te ferirá") pontua 1,000 da razão do topo enquanto o correto Lucas 7:47 fica em 0,716 — um piso de nota cortaria o certo e manteria o errado. Por isso o controle é uma cota de posições (MAX_KEYWORD_ONLY, padrão 2): o lado lexical sempre contribui com seus melhores achados, sem poder dominar a resposta.

Colisão conhecida do stemming: "luto" e "lutavam" reduzem ao mesmo radical.

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 Filtro de ruído do lado vetorial — não detecta off-topic (ver acima)
MAX_KEYWORD_ONLY 2 Posições que podem ser ocupadas por trechos achados só pelo BM25; 0 desliga o lado lexical
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.

S
Description
Sistema web RAG para consulta bíblica: corpus USFX em domínio público, busca híbrida (vetorial + BM25) e agente pastoral ancorado exclusivamente nos versículos recuperados. FastAPI + ChromaDB + React, containerizado.
Readme
1.5 MiB
0 Stars 1 Watchers 0 Forks
Languages
Python 83.7%
TypeScript 10.1%
CSS 5.2%
Dockerfile 0.8%
HTML 0.2%