feat: busca híbrida (vetorial + BM25 com fusão RRF) e janela de contexto

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.
This commit is contained in:
omp committed 2026-11-02 11:57:19 -03:00
1 parent 2043d5ddcf
commit 957c73b09e
16 files changed
+1085 -45

No files matched your search

+6 -6
View File
@@ -177,9 +177,9 @@ def test_reindex_is_incremental_when_hash_is_unchanged(settings, monkeypatch):
real_parse = indexer_module.parse_usfx
def tracked_parse(path, file_name=None):
def tracked_parse(path, file_name=None, **kwargs):
calls.append(Path(path).name)
return real_parse(path, file_name)
return real_parse(path, file_name, **kwargs)
monkeypatch.setattr(indexer_module, "parse_usfx", tracked_parse)
indexer.reindex()
@@ -197,9 +197,9 @@ def test_reindex_reprocesses_when_content_changes(settings, monkeypatch):
real_parse = indexer_module.parse_usfx
def tracked_parse(p, file_name=None):
def tracked_parse(p, file_name=None, **kwargs):
calls.append(Path(p).name)
return real_parse(p, file_name)
return real_parse(p, file_name, **kwargs)
monkeypatch.setattr(indexer_module, "parse_usfx", tracked_parse)
@@ -220,9 +220,9 @@ def test_force_reprocesses_everything(settings, monkeypatch):
real_parse = indexer_module.parse_usfx
def tracked_parse(path, file_name=None):
def tracked_parse(path, file_name=None, **kwargs):
calls.append(Path(path).name)
return real_parse(path, file_name)
return real_parse(path, file_name, **kwargs)
monkeypatch.setattr(indexer_module, "parse_usfx", tracked_parse)
indexer.reindex(force=True)