Files
pastor-online/backend/tests/test_keyword.py
T
omp 957c73b09e 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.
2026-11-02 11:57:19 -03:00

175 lines
5.3 KiB
Python

"""Testes da busca por palavras-chave (BM25) e da fusão de rankings (RRF).
A busca vetorial sozinha erra quando a pergunta compartilha palavras com o
versículo mas não a estrutura da frase — "o que Jesus disse sobre amar os
inimigos?" não achava Mateus 5:44 com nenhum dos três modelos medidos. O BM25
cobre esse caso, e a fusão RRF combina os dois rankings sem exigir que cosseno e
BM25 (escalas incomparáveis) sejam normalizados um contra o outro.
"""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from app.keyword import BM25Index, reciprocal_rank_fusion, tokenize # noqa: E402
# --------------------------------------------------------------------------
# Tokenizador
# --------------------------------------------------------------------------
def test_tokenize_lowercases_and_strips_accents():
assert tokenize("Coração") == ["coracao"]
def test_tokenize_splits_on_punctuation():
assert tokenize("Deus é amor; crê.") == ["deus", "amor", "cre"]
def test_tokenize_drops_portuguese_stopwords():
"""Stopwords em todo versículo não discriminam nada e só incham o índice."""
tokens = tokenize("O amor de Deus é grande para com o mundo")
assert "o" not in tokens
assert "de" not in tokens
assert "e" not in tokens
assert "amor" in tokens and "mundo" in tokens
def test_tokenize_drops_pure_numbers():
assert tokenize("Salmo 23 de Davi") == ["salmo", "davi"]
def test_tokenize_handles_empty_and_punctuation_only_input():
assert tokenize("") == []
assert tokenize("... — !!!") == []
# --------------------------------------------------------------------------
# BM25
# --------------------------------------------------------------------------
DOCS = [
"Eu, porém, vos digo: Amai aos vossos inimigos, e orai pelos que vos perseguem",
"O Senhor é o meu pastor; nada me faltará",
"Deitar-me faz em pastos verdejantes; guia-me mansamente a águas tranquilas",
"Porque Deus amou o mundo de tal maneira que deu o seu Filho unigênito",
"E tirará toda a gordura do novilho da oferta pelo pecado",
]
@pytest.fixture
def index() -> BM25Index:
built = BM25Index()
built.build(DOCS)
return built
def test_bm25_finds_the_lexical_match(index):
"""O caso que a busca vetorial perdeu: a palavra exata está no versículo."""
hits = index.search("amar os inimigos", k=3)
assert hits
assert hits[0][0] == 0
def test_bm25_ranks_by_term_specificity(index):
"""Termo raro pesa mais: 'pastor' aparece em 1 doc, 'Deus' em 2."""
hits = dict(index.search("pastor", k=5))
assert hits.get(1, 0) > 0
assert 4 not in hits
def test_bm25_returns_empty_when_no_term_matches(index):
assert index.search("astronomia quântica", k=5) == []
def test_bm25_ignores_stopwords_only_query(index):
assert index.search("o de que e", k=5) == []
def test_bm25_empty_index_returns_nothing():
empty = BM25Index()
empty.build([])
assert empty.search("qualquer coisa", k=5) == []
assert len(empty) == 0
def test_bm25_respects_k(index):
hits = index.search("Deus amor mundo pastor", k=2)
assert len(hits) <= 2
def test_bm25_scores_are_descending(index):
hits = index.search("Deus amor mundo", k=5)
scores = [score for _, score in hits]
assert scores == sorted(scores, reverse=True)
def test_bm25_rebuild_replaces_the_previous_documents():
"""Reindexar não pode deixar documentos antigos pontuando."""
index = BM25Index()
index.build(["texto antigo sobre gordura do novilho"])
index.build(["texto novo sobre o bom pastor"])
assert index.search("gordura novilho", k=3) == []
assert index.search("bom pastor", k=3)
# --------------------------------------------------------------------------
# Fusão RRF
# --------------------------------------------------------------------------
def test_rrf_prefers_items_present_in_both_rankings():
fused = reciprocal_rank_fusion([["a", "b", "c"], ["b", "c", "d"]], limit=4)
assert fused[0][0] == "b", "item nos dois rankings deve liderar"
def test_rrf_orders_by_rank_within_a_single_list():
fused = reciprocal_rank_fusion([["a", "b", "c"]], limit=3)
assert [item for item, _ in fused] == ["a", "b", "c"]
def test_rrf_first_position_beats_second_across_lists():
"""Posição é o que conta: rank 1 numa lista vale o mesmo que rank 1 noutra."""
fused = reciprocal_rank_fusion([["a"], ["b", "c"]], limit=3, k=60)
scores = dict(fused)
assert scores["a"] == pytest.approx(1 / 61)
assert scores["b"] == pytest.approx(1 / 61), "ambos são rank 1 em suas listas"
assert scores["a"] > scores["c"]
assert scores["c"] == pytest.approx(1 / 62)
def test_rrf_respects_limit():
fused = reciprocal_rank_fusion([["a", "b", "c", "d"]], limit=2)
assert len(fused) == 2
def test_rrf_handles_empty_rankings():
assert reciprocal_rank_fusion([], limit=5) == []
assert reciprocal_rank_fusion([[], []], limit=5) == []
def test_rrf_is_deterministic_on_ties():
"""Empate precisa de desempate estável, senão a ordem varia entre execuções."""
first = reciprocal_rank_fusion([["a", "b"], ["b", "a"]], limit=2)
second = reciprocal_rank_fusion([["a", "b"], ["b", "a"]], limit=2)
assert first == second
assert [item for item, _ in first] == ["a", "b"]