Files
pastor-online/backend/tests/test_retrieval.py
T
omp 8f5e098d8f 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.
2026-11-02 12:39:54 -03:00

244 lines
7.8 KiB
Python

"""Testes da recuperação híbrida (vetorial + BM25 com fusão RRF).
O ponto central: o mesmo versículo chega das duas buscas com `distance`
diferente, então a identidade para fusão não pode ser o objeto — tem de ser a
coordenada (arquivo, livro, capítulo, versículo). Sem isso o RRF trata as duas
ocorrências como itens distintos e nunca premia o que as duas buscas confirmam.
"""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from app.corpus import VerseChunk # noqa: E402
from app.embedding import HashEmbedder # noqa: E402
from app.keyword import BM25Index # noqa: E402
from app.retrieval import HybridRetriever, VectorRetriever # noqa: E402
from app.vector_store import InMemoryVectorStore, SearchResult # noqa: E402
def chunk(book="Mateus", chapter=5, verse=44, text="Amai aos vossos inimigos.", **kwargs):
return VerseChunk(
book=book,
chapter=chapter,
verse=verse,
file="almeida.usfx.xml",
text=text,
context=text,
**kwargs,
)
@pytest.fixture
def embedder():
return HashEmbedder(dimensions=32)
@pytest.fixture
def store(embedder):
populated = InMemoryVectorStore(embedder=embedder)
chunks = [
chunk(verse=44, text="Amai aos vossos inimigos, e orai pelos que vos perseguem"),
chunk(chapter=6, verse=14, text="Se perdoardes aos homens as suas ofensas"),
chunk(book="Salmos", chapter=23, verse=1, text="O Senhor é o meu pastor"),
]
populated.upsert(chunks, embedder.encode([c.embedding_text for c in chunks]))
return populated
# --------------------------------------------------------------------------
# Snapshot do banco
# --------------------------------------------------------------------------
def test_snapshot_returns_every_indexed_chunk(store):
entries = store.snapshot()
assert len(entries) == 3
assert {entry.reference for entry in entries} == {
"Mateus 5:44",
"Mateus 6:14",
"Salmos 23:1",
}
def test_snapshot_carries_both_the_context_and_the_cited_verse(store):
entry = next(e for e in store.snapshot() if e.reference == "Salmos 23:1")
assert entry.verse_text == "O Senhor é o meu pastor"
assert entry.text == entry.verse_text
def test_snapshot_entries_are_unscored(store):
"""Snapshot é inventário, não ranking: não pode fingir similaridade."""
assert all(entry.score == 0.0 for entry in store.snapshot())
# --------------------------------------------------------------------------
# Recuperação vetorial (comportamento anterior, preservado)
# --------------------------------------------------------------------------
def test_vector_retriever_respects_min_similarity(store, embedder):
retriever = VectorRetriever(
store=store, embedder=embedder, top_k=3, min_similarity=0.99
)
assert retriever.search("pastor") == []
def test_vector_retriever_returns_hits_above_the_threshold(store, embedder):
retriever = VectorRetriever(
store=store, embedder=embedder, top_k=3, min_similarity=-1.0
)
assert len(retriever.search("pastor")) == 3
# --------------------------------------------------------------------------
# Recuperação híbrida
# --------------------------------------------------------------------------
def test_hybrid_rebuild_indexes_every_chunk_from_the_store(store, embedder):
retriever = HybridRetriever(
store=store, embedder=embedder, top_k=3, min_similarity=0.99
)
retriever.rebuild()
assert len(retriever.keyword_index) == 3
def test_hybrid_finds_a_lexical_match_the_vector_side_missed(store, embedder):
"""min_similarity alto derruba o lado vetorial; o BM25 ainda acha o versículo."""
retriever = HybridRetriever(
store=store, embedder=embedder, top_k=3, min_similarity=0.99
)
retriever.rebuild()
results = retriever.search("Amai aos vossos inimigos")
assert [r.reference for r in results] == ["Mateus 5:44"]
def test_hybrid_prefers_a_result_confirmed_by_both_sides(store, embedder):
retriever = HybridRetriever(
store=store, embedder=embedder, top_k=3, min_similarity=-1.0
)
retriever.rebuild()
results = retriever.search("O Senhor é o meu pastor")
assert results[0].reference == "Salmos 23:1"
def test_hybrid_merges_the_same_verse_coming_from_both_sides(store, embedder):
"""O mesmo versículo não pode aparecer duas vezes na resposta."""
retriever = HybridRetriever(
store=store, embedder=embedder, top_k=3, min_similarity=-1.0
)
retriever.rebuild()
results = retriever.search("pastor inimigos")
references = [result.reference for result in results]
assert len(references) == len(set(references))
def test_hybrid_returns_the_cited_verse_text(store, embedder):
retriever = HybridRetriever(
store=store, embedder=embedder, top_k=3, min_similarity=0.99
)
retriever.rebuild()
result = retriever.search("Amai aos vossos inimigos")[0]
assert result.cited_text.startswith("Amai aos vossos inimigos")
def test_hybrid_returns_nothing_when_neither_side_finds_anything(store, embedder):
retriever = HybridRetriever(
store=store, embedder=embedder, top_k=3, min_similarity=0.99
)
retriever.rebuild()
assert retriever.search("astronomia quântica") == []
def test_hybrid_on_empty_index_returns_nothing(embedder):
empty = InMemoryVectorStore(embedder=embedder)
retriever = HybridRetriever(
store=empty, embedder=embedder, top_k=3, min_similarity=0.3
)
retriever.rebuild()
assert retriever.search("qualquer coisa") == []
def test_hybrid_respects_top_k(store, embedder):
retriever = HybridRetriever(
store=store, embedder=embedder, top_k=1, min_similarity=-1.0
)
retriever.rebuild()
assert len(retriever.search("pastor inimigos ofensas")) == 1
def test_keyword_only_hit_is_not_reported_as_a_vector_match(store, embedder):
"""Score é similaridade vetorial; achado só por palavra não pode fingir 1.0."""
retriever = HybridRetriever(
store=store, embedder=embedder, top_k=3, min_similarity=0.99
)
retriever.rebuild()
result = retriever.search("Amai aos vossos inimigos")[0]
assert result.score == 0.0
def test_rebuild_after_new_chunks_picks_them_up(store, embedder):
retriever = HybridRetriever(
store=store, embedder=embedder, top_k=5, min_similarity=-1.0
)
retriever.rebuild()
extra = chunk(book="João", chapter=3, verse=16, text="Porque Deus amou o mundo")
store.upsert([extra], embedder.encode([extra.embedding_text]))
retriever.rebuild()
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"