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
+8 -1
View File
@@ -98,7 +98,14 @@ class HybridRetriever(VectorRetriever):
entries = self.store.snapshot()
self._order = [_key(entry) for entry in entries]
self._entries = {_key(entry): entry for entry in entries}
self.keyword_index.build([entry.text for entry in entries])
# BM25 sobre o versículo, não sobre 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. Medido no corpus
# real, para "o que Jesus disse sobre amar os inimigos?" Mateus 5:44 fica
# em 12º indexando a janela e em 1º indexando o versículo.
self.keyword_index.build(
[entry.verse_text or entry.text for entry in entries]
)
logger.info("Índice de palavras-chave construído com %s trechos", len(entries))
def search(self, message: str) -> list[SearchResult]:
+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"
@@ -3,6 +3,42 @@
**Data:** 2026-11-01
**Status:** Proposed
> ## Atualização (2026-11-02): ingestão por corpus estruturado
>
> As seções que descrevem **PDF → OCR → parser de índice → reconstrução de
> capítulo/versículo** foram substituídas. O resto do desenho (FastAPI, ChromaDB,
> validação de citações, SSE, Docker Compose, provedores de LLM) segue válido.
>
> **O que mudou:** a entrada passou a ser XML [USFX](https://ebible.org/usfx/) em
> domínio público (`corpus/por-almeida.usfx.xml`, Almeida, 66 livros, 31.098
> versículos), versionado no repositório.
>
> **Por quê:** o PDF exigia OCR das 3.364 páginas e a camada textual é corrompida,
> então cada referência era *reconstruída* a partir de texto ruidoso. Com o corpus
> estruturado a referência é a coordenada de onde o texto foi lido. Medido:
> indexação de 31.098 versículos em ~30 min contra 17–67 min de OCR, sem Tesseract,
> sem PyMuPDF e sem cache de páginas.
>
> **Duas falhas encontradas no caminho**, ambas silenciadas por projeto anterior:
> 1. `create_app` criava o `VectorStore` antes do embedder; como o store default
> para `HashEmbedder`, o modelo configurado **nunca** era carregado em produção
> e a busca rodava sem semântica nenhuma.
> 2. `transformers` não estava pinado: a versão 5.x exige `torch>=2.6`, e o
> Dockerfile pinava 2.5.1 — o `sentence-transformers` desativava o PyTorch e o
> fallback silencioso caía no mesmo `HashEmbedder`.
>
> `/api/status` agora reporta o embedder **em uso** (não o do `.env`) e qualquer
> fallback aparece em `errors`.
>
> **Busca:** passou a híbrida (vetorial + BM25, fusão por RRF) e cada chunk indexa o
> versículo com uma janela de vizinhos. Medido em 5.703 versículos reais: a busca
> puramente vetorial não liga "estou com medo do futuro" a "não temas, porque eu sou
> contigo" (similaridade 0,19, no nível de um par sem relação), enquanto acerta
> "amar os inimigos" → Mateus 5:44 (0,683). Os dois lados erram de modos diferentes.
> `intfloat/multilingual-e5-small` foi descartado apesar de treinado para
> recuperação: a faixa de similaridade dele tem 0,02 de largura (0,853–0,873), o que
> inutilizaria o piso de evidência do qual a garantia de fidelidade depende.
## 1. Intenção
O Pastor Online é uma aplicação web local, containerizada, que lê PDFs da Bíblia,