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

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