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
@@ -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])
|
||||
Reference in new issue
Block a user