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
+42
-6
@@ -35,6 +35,7 @@ from .models import (
|
||||
SourcesResponse,
|
||||
StatusResponse,
|
||||
)
|
||||
from .retrieval import HybridRetriever
|
||||
from .vector_store import InMemoryVectorStore, VectorStore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -51,6 +52,7 @@ class Services:
|
||||
indexer: Indexer
|
||||
chat: ChatService
|
||||
last_sources: list[Source]
|
||||
retriever: HybridRetriever
|
||||
embedder_warning: str | None = None
|
||||
|
||||
|
||||
@@ -117,8 +119,19 @@ def create_app(
|
||||
if indexer is None:
|
||||
indexer = Indexer(settings=settings, embedder=embedder, store=store)
|
||||
|
||||
retriever = HybridRetriever(
|
||||
store=store,
|
||||
embedder=embedder,
|
||||
top_k=settings.top_k,
|
||||
min_similarity=settings.min_similarity,
|
||||
)
|
||||
|
||||
chat = ChatService(
|
||||
settings=settings, store=store, embedder=embedder, provider=provider
|
||||
settings=settings,
|
||||
store=store,
|
||||
embedder=embedder,
|
||||
provider=provider,
|
||||
retriever=retriever,
|
||||
)
|
||||
|
||||
services = Services(
|
||||
@@ -129,15 +142,20 @@ def create_app(
|
||||
indexer=indexer,
|
||||
chat=chat,
|
||||
last_sources=[],
|
||||
retriever=retriever,
|
||||
embedder_warning=embedder_warning,
|
||||
)
|
||||
reindex_lock = asyncio.Lock()
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
"""Dispara a indexação inicial sem bloquear o startup."""
|
||||
if settings.auto_index:
|
||||
asyncio.create_task(_run_initial_index(services))
|
||||
"""Indexa se configurado e sempre reconstrói o índice lexical.
|
||||
|
||||
O BM25 vive em memória enquanto o Chroma persiste em disco: sem a
|
||||
reconstrução no arranque, um restart deixaria a busca híbrida degradada
|
||||
em só-vetorial, em silêncio.
|
||||
"""
|
||||
asyncio.create_task(_run_initial_index(services))
|
||||
yield
|
||||
close = getattr(provider, "aclose", None)
|
||||
if close is not None:
|
||||
@@ -291,19 +309,37 @@ def _sse(event: str, data: dict) -> str:
|
||||
|
||||
|
||||
async def _run_initial_index(services: Services) -> None:
|
||||
"""Indexa o corpus na inicialização, sem bloquear o startup."""
|
||||
await asyncio.to_thread(services.indexer.reindex)
|
||||
"""Indexa o corpus (se `auto_index`) e reconstrói o índice lexical."""
|
||||
if services.settings.auto_index:
|
||||
await asyncio.to_thread(services.indexer.reindex)
|
||||
await asyncio.to_thread(_rebuild_keyword_index, services)
|
||||
|
||||
|
||||
async def _run_reindex(services: Services, lock: asyncio.Lock) -> None:
|
||||
"""Executa a reindexação em background, segurando o lock durante o trabalho."""
|
||||
try:
|
||||
await asyncio.to_thread(services.indexer.reindex, True)
|
||||
await asyncio.to_thread(_rebuild_keyword_index, services)
|
||||
finally:
|
||||
if lock.locked():
|
||||
lock.release()
|
||||
|
||||
|
||||
def _rebuild_keyword_index(services: Services) -> None:
|
||||
"""Reconstrói o BM25 a partir do banco vetorial, tolerando falha.
|
||||
|
||||
Se falhar, a busca continua funcionando no lado vetorial — degradada, mas
|
||||
disponível. Silenciar aqui seria esconder o problema, então fica no log.
|
||||
"""
|
||||
try:
|
||||
services.retriever.rebuild()
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Falha ao reconstruir o índice de palavras-chave; "
|
||||
"a busca seguirá apenas vetorial"
|
||||
)
|
||||
|
||||
|
||||
def _build_default_app() -> FastAPI:
|
||||
"""Cria a aplicação padrão, tolerando falha no carregamento do Chroma.
|
||||
|
||||
|
||||
Reference in new issue
Block a user