From 8f5e098d8f673cd3fef2db5d8ac1d72d99b5c78c Mon Sep 17 00:00:00 2001 From: omp Date: Mon, 2 Nov 2026 12:39:54 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20stemming=20portugu=C3=AAs=20no=20BM25?= =?UTF-8?q?=20e=20=C3=ADndice=20lexical=20sobre=20o=20vers=C3=ADculo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- backend/app/keyword.py | 71 ++++++++++++-- backend/app/retrieval.py | 9 +- backend/tests/test_keyword.py | 97 +++++++++++++++++-- backend/tests/test_retrieval.py | 28 ++++++ .../specs/2026-11-01-pastor-online-design.md | 36 +++++++ 5 files changed, 226 insertions(+), 15 deletions(-) diff --git a/backend/app/keyword.py b/backend/app/keyword.py index d7ef334..5662394 100644 --- a/backend/app/keyword.py +++ b/backend/app/keyword.py @@ -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 diff --git a/backend/app/retrieval.py b/backend/app/retrieval.py index 82e5824..3ccf40a 100644 --- a/backend/app/retrieval.py +++ b/backend/app/retrieval.py @@ -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]: diff --git a/backend/tests/test_keyword.py b/backend/tests/test_keyword.py index e36534c..3f2b501 100644 --- a/backend/tests/test_keyword.py +++ b/backend/tests/test_keyword.py @@ -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'" diff --git a/backend/tests/test_retrieval.py b/backend/tests/test_retrieval.py index 799062b..d661b5a 100644 --- a/backend/tests/test_retrieval.py +++ b/backend/tests/test_retrieval.py @@ -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" diff --git a/docs/superpowers/specs/2026-11-01-pastor-online-design.md b/docs/superpowers/specs/2026-11-01-pastor-online-design.md index 7842b3f..6f2837d 100644 --- a/docs/superpowers/specs/2026-11-01-pastor-online-design.md +++ b/docs/superpowers/specs/2026-11-01-pastor-online-design.md @@ -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,