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:
omp committed 2026-11-02 12:39:54 -03:00
1 parent 957c73b09e
commit 8f5e098d8f
5 files changed
+226 -15

No files matched your search

+89 -8
View File
@@ -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'"
+28
View File
@@ -213,3 +213,31 @@ def test_rebuild_after_new_chunks_picks_them_up(store, embedder):
assert len(retriever.keyword_index) == 4
assert retriever.search("Deus amou o mundo")[0].reference == "João 3:16"
def test_keyword_index_covers_the_verse_not_the_context_window(embedder):
"""A janela ajuda o vetor e atrapalha o BM25.
Cinco versículos têm cinco vezes mais termos incidentais, e o BM25 passa a
pontuar por palavras que não estão no trecho citado. Medido no corpus real
para "o que Jesus disse sobre amar os inimigos?": com a janela, Mateus 5:44
ficava em 12º no BM25; sobre o versículo isolado, vai a 1º.
"""
store = InMemoryVectorStore(embedder=embedder)
alvo = VerseChunk(
book="Salmos",
chapter=23,
verse=1,
file="almeida.usfx.xml",
text="O Senhor é o meu pastor.",
context="Estas são as famílias dos simeonitas. O Senhor é o meu pastor. Bete-Peor.",
)
store.upsert([alvo], embedder.encode([alvo.embedding_text]))
retriever = HybridRetriever(
store=store, embedder=embedder, top_k=3, min_similarity=0.99
)
retriever.rebuild()
assert retriever.search("pastor"), "palavra do versículo tem de ser encontrada"
assert not retriever.search("simeonitas"), "palavra só da janela não pode contar"