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.
This commit is contained in:
1 parent
957c73b09e
commit
8f5e098d8f
5 files changed
+226
-15
No files matched your search
@@ -16,7 +16,13 @@ import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from app.keyword import BM25Index, reciprocal_rank_fusion, tokenize # noqa: E402
|
||||
from app.keyword import ( # noqa: E402
|
||||
BM25Index,
|
||||
normalize_token,
|
||||
reciprocal_rank_fusion,
|
||||
stem,
|
||||
tokenize,
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -25,25 +31,29 @@ from app.keyword import BM25Index, reciprocal_rank_fusion, tokenize # noqa: E40
|
||||
|
||||
|
||||
def test_tokenize_lowercases_and_strips_accents():
|
||||
assert tokenize("Coração") == ["coracao"]
|
||||
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ê.") == ["deus", "amor", "cre"]
|
||||
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")
|
||||
|
||||
assert "o" not in tokens
|
||||
assert "de" not in tokens
|
||||
assert "e" not in tokens
|
||||
assert "amor" in tokens and "mundo" in tokens
|
||||
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") == ["salmo", "davi"]
|
||||
assert tokenize("Salmo 23 de Davi") == ["salm", "davi"]
|
||||
|
||||
|
||||
def test_tokenize_handles_empty_and_punctuation_only_input():
|
||||
@@ -172,3 +182,74 @@ def test_rrf_is_deterministic_on_ties():
|
||||
|
||||
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'"
|
||||
Reference in new issue
Block a user