Fusão:
- fuse_rankings() extrai a fusão RRF e aplica MAX_KEYWORD_ONLY (padrão 2):
quantas posições podem ser ocupadas por trechos que só o BM25 achou. O que os
dois lados confirmam nunca conta contra a cota.
- O controle é por cota de posições, não por piso de nota. Medido: a razão da
nota BM25 não separa acerto de ruído — para 'como posso perdoar quem me
feriu?', Deuteronômio 28:27 pontua 1,000 do topo e o correto Lucas 7:47 fica
em 0,716 (rank 12). Um piso cortaria o certo e manteria o errado.
- Ponderar o RRF também não resolve: com peso 0,5 no BM25 os achados só-lexicais
caem abaixo do 6º vetorial (0,5/61 < 1/66) e somem, levando junto Jó 3:13 e
Joel 3:14, os melhores resultados medidos.
Portão off-topic:
- MIN_SIMILARITY não consegue implementar 'sem evidência'. Medido no índice
completo: 0/6 perguntas off-topic bloqueadas em qualquer limiar de 0,30 a
0,50 ('bolo de chocolate' bate 0,544 em Lv 7:14; 'cotação do dólar' 0,494).
A recusa passa a ser instruída no SYSTEM_PROMPT e o limiar fica documentado
como filtro de ruído.
324 lines
10 KiB
Python
324 lines
10 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 ( # 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"]
|