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

+65 -6
View File
@@ -22,18 +22,77 @@ from typing import Hashable, Iterable, Sequence
# consomem memória do índice invertido.
STOPWORDS: frozenset[str] = frozenset(
"""
a ao aos aquela aquelas aquele aqueles aquilo as até com como da das de dela
delas dele deles depois do dos e ela elas ele eles em entre era eram essa
a ao aos aquela aquelas aquele aqueles aquilo as até com como contra da das de
dela delas dele deles depois do dos e ela elas ele eles em entre era eram essa
essas esse esses esta estas este estes eu foi foram há isso isto ja lhe lhes
mais mas me mesmo meu meus muito na nao nas nem no nos nossa nossas nosso
nossos num numa o os ou para pela pelas pelo pelos por qual quando que quem
se sem ser sua suas seu seus te tem tinha tu tua tuas um uma você vocês vos
nossos num numa o os ou para pela pelas pelo pelos por qual quando que quem se
sem ser sob sobre sua suas seu seus te tem tinha tu tua tuas um uma você vocês
vos
""".split()
)
_NON_WORD_RE = re.compile(r"[^a-z0-9]+")
_DIGITS_RE = re.compile(r"^\d+$")
# Sufixos em etapas, cada uma aplicada no máximo uma vez (da mesma forma que o
# RSLP). Dentro da etapa, o primeiro sufixo que casa vence — por isso vão do mais
# longo ao mais curto. `min` é o comprimento mínimo do radical resultante: sem
# ele, "deus" viraria "d" e "mar" viraria "m".
_STEM_STEPS: tuple[tuple[tuple[str, str, int], ...], ...] = (
# 1. plural
(("ões", "ão", 3), ("ães", "ão", 3), ("ais", "al", 4), ("éis", "el", 4),
("óis", "ol", 4), ("ns", "m", 3), ("s", "", 3)),
# 2. advérbio
(("mente", "", 4),),
# 3. superlativo, diminutivo
(("íssima", "", 4), ("íssimo", "", 4), ("zinha", "", 4), ("zinho", "", 4),
("inha", "", 4), ("inho", "", 4)),
# 4. sufixos nominais e adjetivais
(("adora", "", 4), ("ista", "", 4), ("ismo", "", 4), ("ável", "", 4),
("ível", "", 4), ("ença", "", 4), ("ância", "", 4), ("idade", "", 4),
("ura", "", 4), ("osa", "", 4), ("oso", "", 4), ("iva", "", 4),
("ivo", "", 4), ("ante", "", 4), ("ador", "", 4), ("eira", "", 4),
("eiro", "", 4), ("eza", "", 4), ("al", "", 4)),
# 5. verbos
(("aríamos", "", 2), ("eríamos", "", 2), ("iríamos", "", 2),
("ando", "", 2), ("endo", "", 2), ("indo", "", 2),
("aram", "", 2), ("eram", "", 2), ("iram", "", 2),
("arão", "", 2), ("erão", "", 2), ("irão", "", 2),
("aria", "", 2), ("eria", "", 2), ("iria", "", 2),
("asse", "", 2), ("esse", "", 2), ("isse", "", 2),
("aste", "", 2), ("este", "", 2), ("iste", "", 2),
("arei", "", 2), ("erei", "", 2), ("irei", "", 2),
("amos", "", 2), ("emos", "", 2), ("imos", "", 2),
("avam", "", 2), ("ado", "", 2), ("ido", "", 2), ("ada", "", 2),
("ida", "", 2), ("ara", "", 2), ("era", "", 2), ("ira", "", 2),
("ava", "", 2), ("iam", "", 2),
("ar", "", 2), ("er", "", 2), ("ir", "", 2),
("ou", "", 2), ("ei", "", 2), ("eu", "", 2), ("iu", "", 2),
("am", "", 2), ("em", "", 2), ("as", "", 2), ("es", "", 2),
("ia", "", 2), ("is", "", 2), ("ai", "", 2), ("ais", "", 2),
("eis", "", 2), ("a", "", 2), ("e", "", 2), ("o", "", 2)),
)
def stem(word: str) -> str:
"""Reduz uma palavra já normalizada ao seu radical.
O português é muito flexionado: sem isto, "amar" na pergunta nunca casa com
"amai" ou "amarás" no versículo, e o BM25 passa a pontuar por palavras
incidentais. Medido no corpus real, isso deixava Mateus 5:44 em 434º lugar
para a pergunta "o que Jesus disse sobre amar os inimigos?".
"""
for step in _STEM_STEPS:
for suffix, replacement, minimum in step:
if not word.endswith(suffix):
continue
candidate = word[: len(word) - len(suffix)] + replacement
if len(candidate) >= minimum:
word = candidate
break
return word
def normalize_token(word: str) -> str:
"""Minúsculas e sem acentos, para que "coração" e "coracao" coincidam."""
@@ -42,14 +101,14 @@ def normalize_token(word: str) -> str:
def tokenize(text: str) -> list[str]:
"""Separa o texto em termos indexáveis."""
"""Separa o texto em termos indexáveis, normalizados e reduzidos ao radical."""
tokens: list[str] = []
for raw in _NON_WORD_RE.split(normalize_token(text or "")):
if not raw or _DIGITS_RE.match(raw):
continue
if raw in STOPWORDS:
continue
tokens.append(raw)
tokens.append(stem(raw))
return tokens