"""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"]