feat: busca híbrida (vetorial + BM25 com fusão RRF) e janela de contexto
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.
This commit is contained in:
1 parent
2043d5ddcf
commit
957c73b09e
16 files changed
+1085
-45
No files matched your search
@@ -295,3 +295,64 @@ def test_history_is_limited(settings, store):
|
||||
|
||||
sent = provider.calls[0]
|
||||
assert len(sent) <= settings.max_history_turns + 1
|
||||
|
||||
|
||||
def test_explicit_reference_bypasses_similarity_search(settings, store):
|
||||
"""Quem digita `João 3:16` quer aquele versículo, não um parecido.
|
||||
|
||||
A busca determinística por metadados tem precedência e não depende de limiar
|
||||
de similaridade nenhum.
|
||||
"""
|
||||
embedder = HashEmbedder(dimensions=64)
|
||||
target = make_chunk(book="João", chapter=3, verse=16, text="Porque Deus amou o mundo.")
|
||||
distractor = make_chunk(book="Salmos", chapter=23, verse=1, text="O Senhor é o meu pastor.")
|
||||
store.upsert(
|
||||
[target, distractor],
|
||||
embedder.encode([chunk.embedding_text for chunk in (target, distractor)]),
|
||||
)
|
||||
|
||||
class NeverCalled:
|
||||
def search(self, message):
|
||||
raise AssertionError("a busca por similaridade não deveria rodar")
|
||||
|
||||
service = ChatService(
|
||||
settings=settings,
|
||||
store=store,
|
||||
embedder=embedder,
|
||||
provider=RecordingProvider(),
|
||||
retriever=NeverCalled(),
|
||||
)
|
||||
|
||||
results = service.retrieve("o que diz João 3:16?")
|
||||
|
||||
assert [result.reference for result in results] == ["João 3:16"]
|
||||
|
||||
|
||||
def test_source_shows_the_cited_verse_not_the_context_window(settings):
|
||||
"""A referência exibida tem de corresponder ao texto exibido.
|
||||
|
||||
A busca usa uma janela de versículos vizinhos, mas o usuário vê a citação:
|
||||
mostrar a janela inteira rotulada como um único versículo seria mentira.
|
||||
"""
|
||||
from app.chat import Source
|
||||
|
||||
store = InMemoryVectorStore(embedder=HashEmbedder(dimensions=64))
|
||||
embedder = HashEmbedder(dimensions=64)
|
||||
chunk = VerseChunk(
|
||||
book="Salmos",
|
||||
chapter=23,
|
||||
verse=1,
|
||||
file="almeida.usfx.xml",
|
||||
text="O Senhor é o meu pastor.",
|
||||
context="Versículo anterior. O Senhor é o meu pastor. Versículo seguinte.",
|
||||
)
|
||||
store.upsert([chunk], embedder.encode([chunk.embedding_text]))
|
||||
|
||||
settings.min_similarity = -1.0
|
||||
service = build_service(settings, store, RecordingProvider())
|
||||
result = service.retrieve("pastor")[0]
|
||||
source = Source.from_result(result)
|
||||
|
||||
assert source.reference == "Salmos 23:1"
|
||||
assert source.text == "O Senhor é o meu pastor."
|
||||
assert "Versículo anterior" in result.text, "o modelo recebe a janela completa"
|
||||
@@ -333,3 +333,107 @@ def test_real_corpus_declares_every_book_as_canonical():
|
||||
books = {chunk.book for chunk in parse_usfx(REAL_CORPUS)}
|
||||
|
||||
assert books <= set(BOOKS)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Janela de contexto
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
CONTEXT_BOOK = book_xml = """
|
||||
<book id="PSA">
|
||||
<h>Salmos</h>
|
||||
<c id="23"/>
|
||||
<v id="1"/>O Senhor é o meu pastor; nada me faltará. <ve/>
|
||||
<v id="2"/>Deitar-me faz em verdes pastos. <ve/>
|
||||
<v id="3"/>Guia-me mansamente a águas tranquilas. <ve/>
|
||||
<v id="4"/>Refrigera a minha alma. <ve/>
|
||||
<c id="24"/>
|
||||
<v id="1"/>Do Senhor é a terra e a sua plenitude. <ve/>
|
||||
</book>
|
||||
"""
|
||||
|
||||
|
||||
def test_text_stays_the_single_verse_and_context_holds_the_window(tmp_path):
|
||||
"""A citação continua apontando para um versículo; o contexto é só para busca."""
|
||||
path = write_usfx(tmp_path, CONTEXT_BOOK)
|
||||
|
||||
chunks = list(parse_usfx(path, context_window=1))
|
||||
|
||||
first = chunks[0]
|
||||
assert first.text == "O Senhor é o meu pastor; nada me faltará."
|
||||
assert first.reference == "Salmos 23:1"
|
||||
assert first.context.startswith("O Senhor é o meu pastor")
|
||||
assert "Deitar-me faz em verdes pastos." in first.context
|
||||
|
||||
|
||||
def test_context_is_clipped_at_the_start_of_the_book(tmp_path):
|
||||
path = write_usfx(tmp_path, CONTEXT_BOOK)
|
||||
|
||||
chunks = list(parse_usfx(path, context_window=2))
|
||||
|
||||
assert chunks[0].context.startswith("O Senhor é o meu pastor")
|
||||
|
||||
|
||||
def test_context_does_not_cross_books(tmp_path):
|
||||
"""Contexto de outro livro misturaria evidência de obras diferentes."""
|
||||
path = write_usfx(
|
||||
tmp_path,
|
||||
"""
|
||||
<book id="PSA">
|
||||
<h>Salmos</h>
|
||||
<c id="1"/>
|
||||
<v id="1"/>Bem-aventurado o varão. <ve/>
|
||||
<v id="2"/>Antes tem o seu prazer na lei. <ve/>
|
||||
</book>
|
||||
<book id="PRO">
|
||||
<h>Provérbios</h>
|
||||
<c id="1"/>
|
||||
<v id="1"/>Provérbios de Salomão. <ve/>
|
||||
<v id="2"/>Para se conhecer a sabedoria. <ve/>
|
||||
</book>
|
||||
""",
|
||||
)
|
||||
|
||||
chunks = list(parse_usfx(path, context_window=2))
|
||||
salmos = [chunk for chunk in chunks if chunk.book == "Salmos"]
|
||||
|
||||
assert salmos, "esperava chunks de Salmos"
|
||||
assert all("Provérbios de Salomão" not in chunk.context for chunk in salmos)
|
||||
|
||||
|
||||
def test_context_window_zero_falls_back_to_the_verse(tmp_path):
|
||||
path = write_usfx(tmp_path, CONTEXT_BOOK)
|
||||
|
||||
chunks = list(parse_usfx(path, context_window=0))
|
||||
|
||||
assert chunks[0].context == chunks[0].text
|
||||
|
||||
|
||||
def test_embedding_text_prefers_the_context_window(tmp_path):
|
||||
path = write_usfx(tmp_path, CONTEXT_BOOK)
|
||||
|
||||
chunk = parse_usfx(path, context_window=1).__next__()
|
||||
|
||||
assert chunk.embedding_text == chunk.context
|
||||
assert chunk.embedding_text != chunk.text
|
||||
|
||||
|
||||
def test_context_window_crosses_chapters_inside_the_same_book(tmp_path):
|
||||
"""A narrativa continua entre capítulos; cortar ali perderia sentido."""
|
||||
path = write_usfx(tmp_path, CONTEXT_BOOK)
|
||||
|
||||
chunks = {chunk.reference: chunk for chunk in parse_usfx(path, context_window=1)}
|
||||
|
||||
assert "Do Senhor é a terra" in chunks["Salmos 23:4"].context
|
||||
assert "Refrigera a minha alma" in chunks["Salmos 24:1"].context
|
||||
|
||||
|
||||
@pytest.mark.skipif(not REAL_CORPUS.exists(), reason="corpus real não versionado")
|
||||
def test_real_corpus_chunks_keep_verse_and_context_consistent():
|
||||
chunks = list(parse_usfx(REAL_CORPUS, context_window=2))
|
||||
|
||||
assert len(chunks) == 31098
|
||||
sample = next(c for c in chunks if c.reference == "Salmos 23:1")
|
||||
assert sample.text == "O Senhor é o meu pastor; nada me faltará."
|
||||
assert "Deitar-me faz em pastos verdejantes" in sample.context
|
||||
assert all(chunk.text in chunk.context for chunk in chunks[:500])
|
||||
@@ -177,9 +177,9 @@ def test_reindex_is_incremental_when_hash_is_unchanged(settings, monkeypatch):
|
||||
|
||||
real_parse = indexer_module.parse_usfx
|
||||
|
||||
def tracked_parse(path, file_name=None):
|
||||
def tracked_parse(path, file_name=None, **kwargs):
|
||||
calls.append(Path(path).name)
|
||||
return real_parse(path, file_name)
|
||||
return real_parse(path, file_name, **kwargs)
|
||||
|
||||
monkeypatch.setattr(indexer_module, "parse_usfx", tracked_parse)
|
||||
indexer.reindex()
|
||||
@@ -197,9 +197,9 @@ def test_reindex_reprocesses_when_content_changes(settings, monkeypatch):
|
||||
|
||||
real_parse = indexer_module.parse_usfx
|
||||
|
||||
def tracked_parse(p, file_name=None):
|
||||
def tracked_parse(p, file_name=None, **kwargs):
|
||||
calls.append(Path(p).name)
|
||||
return real_parse(p, file_name)
|
||||
return real_parse(p, file_name, **kwargs)
|
||||
|
||||
monkeypatch.setattr(indexer_module, "parse_usfx", tracked_parse)
|
||||
|
||||
@@ -220,9 +220,9 @@ def test_force_reprocesses_everything(settings, monkeypatch):
|
||||
|
||||
real_parse = indexer_module.parse_usfx
|
||||
|
||||
def tracked_parse(path, file_name=None):
|
||||
def tracked_parse(path, file_name=None, **kwargs):
|
||||
calls.append(Path(path).name)
|
||||
return real_parse(path, file_name)
|
||||
return real_parse(path, file_name, **kwargs)
|
||||
|
||||
monkeypatch.setattr(indexer_module, "parse_usfx", tracked_parse)
|
||||
indexer.reindex(force=True)
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
"""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"]
|
||||
@@ -0,0 +1,215 @@
|
||||
"""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"
|
||||
Reference in new issue
Block a user