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
+65
-6
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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'"
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user