Recuperação: - keyword.py: tokenizador PT (acentos, stopwords, dígitos), BM25 Okapi com índice invertido, e reciprocal_rank_fusion com desempate determinístico - retrieval.py: VectorRetriever e HybridRetriever; fusão por posição, não por nota (cosseno 0..1 e BM25 sem teto são escalas incomparáveis) - identidade de fusão é a coordenada (arquivo,livro,capítulo,versículo): o mesmo versículo chega das duas buscas com distance diferente e o dataclass nunca os igualaria, então o RRF não premiaria o que ambas confirmam - ChatService recebe retriever injetado; referência explícita mantém precedência Contexto: - VerseChunk ganha context (janela de CONTEXT_WINDOW vizinhos por lado, dentro do livro, atravessando capítulos) e embedding_text - store grava a janela como documento e o versículo em verse_text; a UI mostra o versículo citado e o modelo recebe a janela - snapshot() nos dois stores: o BM25 é em memória e o Chroma persiste, então o arranque precisa reconstruir o índice lexical ou a busca degrada em silêncio Motivação medida em 5703 versículos reais: a busca vetorial não liga 'estou com medo do futuro' a 'não temas' (0,19, nível de par sem relação), e e5-small tem faixa de similaridade de só 0,02 — inutilizaria o piso de evidência.
175 lines
5.3 KiB
Python
175 lines
5.3 KiB
Python
"""Testes da busca por palavras-chave (BM25) e da fusão de rankings (RRF).
|
|
|
|
A busca vetorial sozinha erra quando a pergunta compartilha palavras com o
|
|
versículo mas não a estrutura da frase — "o que Jesus disse sobre amar os
|
|
inimigos?" não achava Mateus 5:44 com nenhum dos três modelos medidos. O BM25
|
|
cobre esse caso, e a fusão RRF combina os dois rankings sem exigir que cosseno e
|
|
BM25 (escalas incomparáveis) sejam normalizados um contra o outro.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
|
|
from app.keyword import BM25Index, reciprocal_rank_fusion, tokenize # noqa: E402
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Tokenizador
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
def test_tokenize_lowercases_and_strips_accents():
|
|
assert tokenize("Coração") == ["coracao"]
|
|
|
|
|
|
def test_tokenize_splits_on_punctuation():
|
|
assert tokenize("Deus é amor; crê.") == ["deus", "amor", "cre"]
|
|
|
|
|
|
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
|
|
|
|
|
|
def test_tokenize_drops_pure_numbers():
|
|
assert tokenize("Salmo 23 de Davi") == ["salmo", "davi"]
|
|
|
|
|
|
def test_tokenize_handles_empty_and_punctuation_only_input():
|
|
assert tokenize("") == []
|
|
assert tokenize("... — !!!") == []
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# BM25
|
|
# --------------------------------------------------------------------------
|
|
|
|
DOCS = [
|
|
"Eu, porém, vos digo: Amai aos vossos inimigos, e orai pelos que vos perseguem",
|
|
"O Senhor é o meu pastor; nada me faltará",
|
|
"Deitar-me faz em pastos verdejantes; guia-me mansamente a águas tranquilas",
|
|
"Porque Deus amou o mundo de tal maneira que deu o seu Filho unigênito",
|
|
"E tirará toda a gordura do novilho da oferta pelo pecado",
|
|
]
|
|
|
|
|
|
@pytest.fixture
|
|
def index() -> BM25Index:
|
|
built = BM25Index()
|
|
built.build(DOCS)
|
|
return built
|
|
|
|
|
|
def test_bm25_finds_the_lexical_match(index):
|
|
"""O caso que a busca vetorial perdeu: a palavra exata está no versículo."""
|
|
hits = index.search("amar os inimigos", k=3)
|
|
|
|
assert hits
|
|
assert hits[0][0] == 0
|
|
|
|
|
|
def test_bm25_ranks_by_term_specificity(index):
|
|
"""Termo raro pesa mais: 'pastor' aparece em 1 doc, 'Deus' em 2."""
|
|
hits = dict(index.search("pastor", k=5))
|
|
|
|
assert hits.get(1, 0) > 0
|
|
assert 4 not in hits
|
|
|
|
|
|
def test_bm25_returns_empty_when_no_term_matches(index):
|
|
assert index.search("astronomia quântica", k=5) == []
|
|
|
|
|
|
def test_bm25_ignores_stopwords_only_query(index):
|
|
assert index.search("o de que e", k=5) == []
|
|
|
|
|
|
def test_bm25_empty_index_returns_nothing():
|
|
empty = BM25Index()
|
|
empty.build([])
|
|
|
|
assert empty.search("qualquer coisa", k=5) == []
|
|
assert len(empty) == 0
|
|
|
|
|
|
def test_bm25_respects_k(index):
|
|
hits = index.search("Deus amor mundo pastor", k=2)
|
|
|
|
assert len(hits) <= 2
|
|
|
|
|
|
def test_bm25_scores_are_descending(index):
|
|
hits = index.search("Deus amor mundo", k=5)
|
|
scores = [score for _, score in hits]
|
|
|
|
assert scores == sorted(scores, reverse=True)
|
|
|
|
|
|
def test_bm25_rebuild_replaces_the_previous_documents():
|
|
"""Reindexar não pode deixar documentos antigos pontuando."""
|
|
index = BM25Index()
|
|
index.build(["texto antigo sobre gordura do novilho"])
|
|
index.build(["texto novo sobre o bom pastor"])
|
|
|
|
assert index.search("gordura novilho", k=3) == []
|
|
assert index.search("bom pastor", k=3)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Fusão RRF
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
def test_rrf_prefers_items_present_in_both_rankings():
|
|
fused = reciprocal_rank_fusion([["a", "b", "c"], ["b", "c", "d"]], limit=4)
|
|
|
|
assert fused[0][0] == "b", "item nos dois rankings deve liderar"
|
|
|
|
|
|
def test_rrf_orders_by_rank_within_a_single_list():
|
|
fused = reciprocal_rank_fusion([["a", "b", "c"]], limit=3)
|
|
|
|
assert [item for item, _ in fused] == ["a", "b", "c"]
|
|
|
|
|
|
def test_rrf_first_position_beats_second_across_lists():
|
|
"""Posição é o que conta: rank 1 numa lista vale o mesmo que rank 1 noutra."""
|
|
fused = reciprocal_rank_fusion([["a"], ["b", "c"]], limit=3, k=60)
|
|
scores = dict(fused)
|
|
|
|
assert scores["a"] == pytest.approx(1 / 61)
|
|
assert scores["b"] == pytest.approx(1 / 61), "ambos são rank 1 em suas listas"
|
|
assert scores["a"] > scores["c"]
|
|
assert scores["c"] == pytest.approx(1 / 62)
|
|
|
|
|
|
def test_rrf_respects_limit():
|
|
fused = reciprocal_rank_fusion([["a", "b", "c", "d"]], limit=2)
|
|
|
|
assert len(fused) == 2
|
|
|
|
|
|
def test_rrf_handles_empty_rankings():
|
|
assert reciprocal_rank_fusion([], limit=5) == []
|
|
assert reciprocal_rank_fusion([[], []], limit=5) == []
|
|
|
|
|
|
def test_rrf_is_deterministic_on_ties():
|
|
"""Empate precisa de desempate estável, senão a ordem varia entre execuções."""
|
|
first = reciprocal_rank_fusion([["a", "b"], ["b", "a"]], limit=2)
|
|
second = reciprocal_rank_fusion([["a", "b"], ["b", "a"]], limit=2)
|
|
|
|
assert first == second
|
|
assert [item for item, _ in first] == ["a", "b"]
|