"""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 ( # noqa: E402 HybridRetriever, VectorRetriever, fuse_rankings, ) 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" # -------------------------------------------------------------------------- # Cota de achados só-lexicais # -------------------------------------------------------------------------- def make_result(reference: str, score: float) -> SearchResult: book, rest = reference.split(" ", 1) chapter, verse = rest.split(":") return SearchResult( book=book, chapter=int(chapter), verse=int(verse), file="a.usfx.xml", text=f"texto de {reference}", verse_text=f"texto de {reference}", distance=1.0 - score, ) VECTOR_HITS = [make_result(f"Salmos {n}:1", 0.60 - n * 0.01) for n in range(1, 7)] KEYWORD_ONLY = [ make_result("Jó 3:13", 0.0), make_result("Joel 3:14", 0.0), make_result("Gênesis 25:22", 0.0), make_result("Deuteronômio 28:27", 0.0), ] def test_keyword_only_hits_are_capped(): """O lado lexical contribui, mas não pode tomar a resposta inteira. Medido: a nota BM25 não separa acerto de ruído (Dt 28:27 pontua 1.000 da razão do topo para 'como posso perdoar', enquanto o correto Lc 7:47 fica em 0.716). Um piso de nota cortaria o versículo certo e manteria o errado, então o controle é por cota de posições, não por magnitude. """ fused = fuse_rankings(VECTOR_HITS, KEYWORD_ONLY, limit=6, max_keyword_only=2) keyword_refs = {"Jó 3:13", "Joel 3:14", "Gênesis 25:22", "Deuteronômio 28:27"} included = [r.reference for r in fused if r.reference in keyword_refs] assert len(included) == 2 assert included == ["Jó 3:13", "Joel 3:14"], "os mais bem ranqueados no BM25" def test_dual_confirmed_hits_never_count_against_the_quota(): """O que os dois lados confirmam é a evidência mais forte que existe.""" shared = make_result("Salmos 1:1", 0.60) vector = [shared] + VECTOR_HITS[1:] keyword = [shared] + KEYWORD_ONLY fused = fuse_rankings(vector, keyword, limit=6, max_keyword_only=1) assert fused[0].reference == "Salmos 1:1" def test_quota_zero_disables_keyword_only_hits(): fused = fuse_rankings(VECTOR_HITS, KEYWORD_ONLY, limit=6, max_keyword_only=0) assert all(r.score > 0 for r in fused) assert len(fused) == 6 def test_quota_larger_than_limit_keeps_everything(): fused = fuse_rankings(VECTOR_HITS, KEYWORD_ONLY, limit=4, max_keyword_only=4) assert len(fused) == 4 def test_fusion_without_vector_hits_still_respects_the_quota(): """Sem nenhum vetor acima do piso, o lado lexical ainda responde — limitado.""" fused = fuse_rankings([], KEYWORD_ONLY, limit=6, max_keyword_only=2) assert [r.reference for r in fused] == ["Jó 3:13", "Joel 3:14"]