Files
omp 8f5e098d8f feat: stemming português no BM25 e índice lexical sobre o versículo
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.
2026-11-02 12:39:54 -03:00

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