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.
This commit is contained in:
omp committed 2026-11-02 13:04:14 -03:00
1 parent 8f5e098d8f
commit 816e6be159
9 files changed
+212 -26

No files matched your search

+30 -6
View File
@@ -36,18 +36,41 @@ digitalização — a referência exibida é exatamente a coordenada de onde o t
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:
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 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 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. `MIN_SIMILARITY` continua valendo como piso do lado vetorial.
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`
@@ -188,7 +211,8 @@ Todas as variáveis ficam no `.env`. As principais:
| `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 |
| `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`) |