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

+42 -6
View File
@@ -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.