Dois defeitos medidos no índice real (31.098 versículos), ambos na metade
lexical da busca híbrida:
1. Sem normalização morfológica, 'amar' da pergunta nunca casava com 'amai' ou
'amarás' do versículo. Para 'o que Jesus disse sobre amar os inimigos?',
Mateus 5:44 ficava em 434º no BM25 enquanto Marcos 12:34 liderava por casar
apenas 'jesus'+'disse'. keyword.py ganha um stemmer de sufixos em etapas
(estilo RSLP, sem dependência externa) com guarda de comprimento mínimo do
radical — sem ela 'deus' viraria 'd' e 'mar' viraria 'm'. Rank de Mt 5:44: 12º.
2. O BM25 indexava 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 o versículo isolado, Mt 5:44 vai a 1º e os
achados só-lexicais deixam de ser aleatórios ('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').
Colisão conhecida do stemming: 'luto' e 'lutavam' reduzem ao mesmo radical.
256 lines
7.9 KiB
Python
256 lines
7.9 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 ( # noqa: E402
|
|
BM25Index,
|
|
normalize_token,
|
|
reciprocal_rank_fusion,
|
|
stem,
|
|
tokenize,
|
|
)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Tokenizador
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
def test_tokenize_lowercases_and_strips_accents():
|
|
tokens = tokenize("Coração Coração")
|
|
|
|
assert len(set(tokens)) == 1, "a mesma palavra não pode virar dois termos"
|
|
assert all(token == token.lower() for token in tokens)
|
|
assert not any(ch in token for token in tokens for ch in "ãçáéíóúÃÇÁÉÍÓÚ")
|
|
|
|
|
|
def test_tokenize_splits_on_punctuation():
|
|
assert tokenize("Deus é amor; crê.") == ["deu", "amor", "cr"]
|
|
|
|
|
|
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")
|
|
|
|
for stopword in ("o", "de", "e", "para", "com"):
|
|
assert stopword not in tokens
|
|
assert "amor" in tokens
|
|
assert "mund" in tokens, "'mundo' deve aparecer como radical"
|
|
|
|
|
|
def test_tokenize_drops_pure_numbers():
|
|
assert tokenize("Salmo 23 de Davi") == ["salm", "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"]
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Stemmer
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"words",
|
|
[
|
|
["amar", "amai", "amarás", "amado", "amam", "amo"],
|
|
["inimigo", "inimigos"],
|
|
["família", "famílias"],
|
|
["perdoar", "perdoa", "perdoai", "perdoas"],
|
|
["morreu", "morrer", "morre"],
|
|
["Senhor", "Senhora"],
|
|
],
|
|
)
|
|
def test_stem_unifies_inflections(words):
|
|
"""Sem isso, 'amar' da pergunta nunca casa com 'amai' do versículo."""
|
|
stems = {stem(normalize_token(word)) for word in words}
|
|
|
|
assert len(stems) == 1, f"{words} -> {stems}"
|
|
|
|
|
|
@pytest.mark.parametrize("word", ["Deus", "mar", "dar", "ver", "pôr", "rei", "luz"])
|
|
def test_stem_does_not_collapse_short_words(word):
|
|
"""Stem agressivo demais destruiria palavras curtas e comuns."""
|
|
|
|
stemmed = stem(normalize_token(word))
|
|
|
|
assert len(stemmed) >= 2, f"{word} -> {stemmed!r}"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"left,right",
|
|
[
|
|
("vida", "morte"),
|
|
("amor", "temor"),
|
|
("Deus", "homem"),
|
|
("justo", "injusto"),
|
|
("fé", "lei"),
|
|
],
|
|
)
|
|
def test_stem_keeps_distinct_words_distinct(left, right):
|
|
"""Agrupar flexões não pode fundir palavras de sentido diferente."""
|
|
assert stem(normalize_token(left)) != stem(normalize_token(right))
|
|
|
|
|
|
def test_tokenize_applies_stemming():
|
|
"""Conjugações diferentes têm de produzir os mesmos termos de busca."""
|
|
shared = {"am", "inimig"}
|
|
|
|
assert shared <= set(tokenize("Amai aos vossos inimigos"))
|
|
assert shared <= set(tokenize("amar o inimigo"))
|
|
|
|
|
|
def test_bm25_finds_the_verse_by_its_inflected_form():
|
|
"""O caso real que falhou: a pergunta diz 'amar', o texto diz 'Amai'."""
|
|
index = BM25Index()
|
|
index.build(
|
|
[
|
|
"Eu, porém, vos digo: Amai aos vossos inimigos, e orai pelos que vos perseguem",
|
|
"E Jesus, vendo que havia respondido sabiamente, disse-lhe: Não estás longe",
|
|
"O Senhor é o meu pastor; nada me faltará",
|
|
]
|
|
)
|
|
|
|
hits = index.search("O que Jesus disse sobre amar os inimigos?", k=3)
|
|
|
|
assert hits[0][0] == 0, "Mateus 5:44 deve liderar, não o versículo com 'Jesus disse'"
|