Causa do 'Provedor de LLM indisponível' no ambiente com Ollama local foi externa: OLLAMA_BASE_URL exportada no shell (http://localhost:11434/v1) vence o .env na interpolação do docker-compose, e localhost dentro do container é o próprio container. Nada no app indicava a URL em uso. Dois defeitos de código que tornavam isso indetectável — e um deles produziria a mesma mensagem mesmo com a URL correta: - _BaseProvider criava o httpx.AsyncClient com o atributo de classe (120 s) e as subclasses atribuíam self.timeout depois. O httpx congela o timeout na construção, então os 300 s pedidos pelo Ollama nunca valeram: um primeiro atendimento com modelo frio era cortado em 120 s com exatamente a mensagem 'provedor indisponível'. O timeout agora é passado ao super(). - LLMProviderError de conexão não citava o endereço tentado; o log dizia só 'All connection attempts failed'. Passa a incluir a endpoint, com credenciais user:pass@ redigidas. /api/health expõe llm_endpoint para comparação direta com o .env. README e .env.example documentam a precedência shell > .env, o fato de /v1 ser a superfície OpenAI-compatível (o adaptador usa /api/chat nativo) e a latência medida do primeiro atendimento.
366 lines
16 KiB
Markdown
366 lines
16 KiB
Markdown
# 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. 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](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:
|
|
|
|
> ### Cuidado: variável do shell vence o `.env`
|
|
>
|
|
> O docker-compose resolve `${VAR:-padrão}` olhando **primeiro o ambiente do
|
|
> shell**, e só depois o arquivo `.env`. Uma variável exportada na sua sessão —
|
|
> por outra ferramenta, um `export` no perfil, um `direnv` — sobrescreve o `.env`
|
|
> **sem nenhum aviso**.
|
|
>
|
|
> Foi exatamente assim que o chat quebrou com "Provedor de LLM indisponível": um
|
|
> `OLLAMA_BASE_URL=http://localhost:11434/v1` herdado do shell fez o backend
|
|
> tentar conectar em `localhost` **dentro do próprio container**, onde não há
|
|
> nada na porta 11434. O `.env` dizia `http://ollama:11434` e continuou dizendo,
|
|
> sem efeito.
|
|
>
|
|
> Confira o valor que será realmente aplicado:
|
|
>
|
|
> ```bash
|
|
> docker compose --profile local config | grep -A2 'ollama:' # ou:
|
|
> docker compose --profile local config | grep OLLAMA_BASE_URL
|
|
> curl -s localhost:8030/api/health | grep llm_endpoint # o que está em uso
|
|
> ```
|
|
>
|
|
> Para o valor do `.env` valer, rode limpo: `env -u OLLAMA_BASE_URL docker compose --profile local up -d`.
|
|
>
|
|
> Nota: `/v1` é a superfície compatível com OpenAI. O adaptador Ollama usa a API
|
|
> nativa (`/api/chat`), então a URL **não** deve terminar em `/v1`.
|
|
|
|
| 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.
|
|
|
|
**"Provedor de LLM indisponível" no chat.**
|
|
Três causas possíveis, nesta ordem de probabilidade:
|
|
|
|
1. **A URL não é a que você acha.** Veja `curl -s localhost:8030/api/health | grep llm_endpoint`
|
|
e compare com o `.env`. Variável exportada no shell sobrescreve o `.env` em
|
|
silêncio (ver aviso na seção Configuração). `localhost` dentro do container é o
|
|
próprio container — para falar com o Ollama do Compose o nome é `ollama`.
|
|
2. **O serviço não está no ar.** Com `LLM_PROVIDER=ollama`, o Ollama só sobe com o
|
|
profile: `docker compose --profile local up -d`. Confira com
|
|
`docker compose exec -T backend curl -s http://ollama:11434/api/tags`.
|
|
3. **O modelo não está no volume.** `ollama run` no host não alimenta o container;
|
|
o volume é `ollama:/root/.ollama`. Liste os modelos de dentro do container:
|
|
`docker compose exec ollama ollama list`.
|
|
|
|
O log do backend traz a endpoint tentada: `docker compose logs backend | grep -i provedor`.
|
|
|
|
**A primeira resposta demora muito.**
|
|
Espera mesmo. O modelo precisa ser carregado na RAM e o prompt tem ~1.800 tokens
|
|
(system + 6 trechos com janela de contexto), tudo em CPU. Medido com `llama3.2:1b`:
|
|
~70 s com o modelo quente, bem mais no primeiro atendimento após o arranque. Como a
|
|
validação de citação exige o texto completo antes de exibir, não há saída
|
|
parcial. Modelos menores respondem mais rápido; `TOP_K` menor também reduz o
|
|
prompt.
|
|
|
|
**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.
|