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
+16
-8
@@ -20,6 +20,7 @@ from typing import Iterable, Sequence
|
||||
from .corpus import canonical_book
|
||||
from .config import Settings
|
||||
from .embedding import Embedder
|
||||
from .retrieval import Retriever, VectorRetriever
|
||||
from .llm import SYSTEM_PROMPT, LLMProvider
|
||||
from .vector_store import SearchResult, VectorStore
|
||||
|
||||
@@ -94,7 +95,9 @@ class Source:
|
||||
def from_result(cls, result: SearchResult) -> "Source":
|
||||
return cls(
|
||||
reference=result.reference,
|
||||
text=result.text,
|
||||
# O usuário vê o versículo citado, não a janela de contexto que foi
|
||||
# para o modelo: a referência exibida precisa corresponder ao texto.
|
||||
text=result.cited_text,
|
||||
book=result.book,
|
||||
chapter=result.chapter,
|
||||
verse=result.verse,
|
||||
@@ -178,11 +181,20 @@ class ChatService:
|
||||
store: VectorStore,
|
||||
embedder: Embedder,
|
||||
provider: LLMProvider,
|
||||
retriever: Retriever | None = None,
|
||||
):
|
||||
self.settings = settings
|
||||
self.store = store
|
||||
self.embedder = embedder
|
||||
self.provider = provider
|
||||
# Sem retriever injetado, mantém o comportamento vetorial simples — os
|
||||
# testes existentes e qualquer uso embutido seguem iguais.
|
||||
self.retriever: Retriever = retriever or VectorRetriever(
|
||||
store=store,
|
||||
embedder=embedder,
|
||||
top_k=settings.top_k,
|
||||
min_similarity=settings.min_similarity,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Recuperação
|
||||
@@ -192,7 +204,8 @@ class ChatService:
|
||||
"""Recupera os trechos que sustentam a resposta.
|
||||
|
||||
Perguntas com referência explícita (`João 3:16`) usam busca determinística
|
||||
por metadados; as demais usam similaridade semântica.
|
||||
por metadados; as demais passam pelo retriever configurado (híbrido em
|
||||
produção: vetorial + BM25).
|
||||
"""
|
||||
reference = extract_reference(message)
|
||||
if reference is not None:
|
||||
@@ -201,12 +214,7 @@ class ChatService:
|
||||
if exact:
|
||||
return exact
|
||||
|
||||
vector = self.embedder.encode([message])[0]
|
||||
return self.store.query(
|
||||
vector,
|
||||
n_results=self.settings.top_k,
|
||||
min_similarity=self.settings.min_similarity,
|
||||
)
|
||||
return self.retriever.search(message)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Contexto
|
||||
|
||||
Reference in new issue
Block a user