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:
omp committed 2026-11-02 11:57:19 -03:00
1 parent 2043d5ddcf
commit 957c73b09e
16 files changed
+1085 -45

No files matched your search

+61
View File
@@ -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"