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"
|
||||
Reference in new issue
Block a user