Dois defeitos medidos no índice real (31.098 versículos), ambos na metade
lexical da busca híbrida:
1. Sem normalização morfológica, 'amar' da pergunta nunca casava com 'amai' ou
'amarás' do versículo. Para 'o que Jesus disse sobre amar os inimigos?',
Mateus 5:44 ficava em 434º no BM25 enquanto Marcos 12:34 liderava por casar
apenas 'jesus'+'disse'. keyword.py ganha um stemmer de sufixos em etapas
(estilo RSLP, sem dependência externa) com guarda de comprimento mínimo do
radical — sem ela 'deus' viraria 'd' e 'mar' viraria 'm'. Rank de Mt 5:44: 12º.
2. O BM25 indexava a janela de contexto. Os dois lados querem granularidades
opostas: a janela dá tema ao embedding, mas multiplica os termos incidentais
que o BM25 pontua. Indexando o versículo isolado, Mt 5:44 vai a 1º e os
achados só-lexicais deixam de ser aleatórios ('ansioso e não consigo dormir'
-> Jó 3:13 'teria dormido'; 'não sei que decisão tomar' -> Joel 3:14 'vale da
decisão').
Colisão conhecida do stemming: 'luto' e 'lutavam' reduzem ao mesmo radical.
155 lines
5.5 KiB
Python
155 lines
5.5 KiB
Python
"""Estratégias de recuperação: vetorial e híbrida (vetorial + BM25).
|
|
|
|
A recuperação híbrida existe porque as duas buscas erram de modos diferentes. A
|
|
vetorial acha "não temas" para "estou com medo" só se o embedding captar a
|
|
paráfrase — medido: não capta. A lexical acha Mateus 5:44 para "amar os
|
|
inimigos" pela palavra, mas não entende sentimento. Fundir os dois rankings
|
|
cobre os dois casos.
|
|
|
|
A fusão é por **posição** (RRF), não por pontuação: cosseno vive em 0..1 e BM25
|
|
não tem teto, então normalizar um contra o outro seria arbitrário.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import Protocol
|
|
|
|
from .embedding import Embedder
|
|
from .keyword import BM25Index, reciprocal_rank_fusion
|
|
from .vector_store import SearchResult
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Identidade de um trecho para fusão. Não pode ser o `SearchResult` inteiro: o
|
|
# mesmo versículo chega das duas buscas com `distance` diferente, e como o
|
|
# dataclass compara todos os campos as duas ocorrências pareceriam itens
|
|
# distintos — o RRF nunca premiaria o que ambas confirmam.
|
|
CandidateKey = tuple[str, str, int, int]
|
|
|
|
|
|
def _key(result: SearchResult) -> CandidateKey:
|
|
return (result.file, result.book, result.chapter, result.verse)
|
|
|
|
|
|
class Retriever(Protocol):
|
|
"""Interface de recuperação de trechos."""
|
|
|
|
def search(self, message: str) -> list[SearchResult]:
|
|
"""Trechos que sustentam a resposta, em ordem de relevância."""
|
|
...
|
|
|
|
|
|
class VectorRetriever:
|
|
"""Recuperação só por similaridade vetorial."""
|
|
|
|
def __init__(
|
|
self,
|
|
store,
|
|
embedder: Embedder,
|
|
top_k: int = 6,
|
|
min_similarity: float = 0.35,
|
|
):
|
|
self.store = store
|
|
self.embedder = embedder
|
|
self.top_k = top_k
|
|
self.min_similarity = min_similarity
|
|
|
|
def search(self, message: str) -> list[SearchResult]:
|
|
if not message or not message.strip():
|
|
return []
|
|
vector = self.embedder.encode([message])[0]
|
|
return self.store.query(
|
|
vector, n_results=self.top_k, min_similarity=self.min_similarity
|
|
)
|
|
|
|
|
|
class HybridRetriever(VectorRetriever):
|
|
"""Vetorial + BM25 fundidos por RRF."""
|
|
|
|
def __init__(
|
|
self,
|
|
store,
|
|
embedder: Embedder,
|
|
top_k: int = 6,
|
|
min_similarity: float = 0.35,
|
|
keyword_index: BM25Index | None = None,
|
|
candidate_multiplier: int = 4,
|
|
rrf_k: int = 60,
|
|
):
|
|
super().__init__(
|
|
store=store,
|
|
embedder=embedder,
|
|
top_k=top_k,
|
|
min_similarity=min_similarity,
|
|
)
|
|
self.keyword_index = keyword_index or BM25Index()
|
|
self.candidate_multiplier = max(1, candidate_multiplier)
|
|
self.rrf_k = rrf_k
|
|
self._entries: dict[CandidateKey, SearchResult] = {}
|
|
self._order: list[CandidateKey] = []
|
|
|
|
def rebuild(self) -> None:
|
|
"""Reconstrói o índice lexical a partir do que está no banco vetorial.
|
|
|
|
Chamado após cada indexação e no arranque: o Chroma persiste em disco,
|
|
mas o BM25 vive em memória e nasce vazio a cada processo novo.
|
|
"""
|
|
entries = self.store.snapshot()
|
|
self._order = [_key(entry) for entry in entries]
|
|
self._entries = {_key(entry): entry for entry in entries}
|
|
# BM25 sobre o versículo, não sobre a janela de contexto. Os dois lados
|
|
# querem granularidades opostas: a janela dá tema ao embedding, mas
|
|
# multiplica os termos incidentais que o BM25 pontua. Medido no corpus
|
|
# real, para "o que Jesus disse sobre amar os inimigos?" Mateus 5:44 fica
|
|
# em 12º indexando a janela e em 1º indexando o versículo.
|
|
self.keyword_index.build(
|
|
[entry.verse_text or entry.text for entry in entries]
|
|
)
|
|
logger.info("Índice de palavras-chave construído com %s trechos", len(entries))
|
|
|
|
def search(self, message: str) -> list[SearchResult]:
|
|
if not message or not message.strip():
|
|
return []
|
|
|
|
candidates = self.top_k * self.candidate_multiplier
|
|
vector_hits = self.store.query(
|
|
self.embedder.encode([message])[0],
|
|
n_results=candidates,
|
|
min_similarity=self.min_similarity,
|
|
)
|
|
keyword_hits = self._keyword_search(message, candidates)
|
|
|
|
if not keyword_hits:
|
|
return vector_hits[: self.top_k]
|
|
if not vector_hits:
|
|
return keyword_hits[: self.top_k]
|
|
|
|
fused = reciprocal_rank_fusion(
|
|
[[_key(hit) for hit in vector_hits], [_key(hit) for hit in keyword_hits]],
|
|
limit=self.top_k,
|
|
k=self.rrf_k,
|
|
)
|
|
|
|
# O lado vetorial tem precedência: ele traz a similaridade real. Um
|
|
# trecho achado só por palavra-chave fica com score 0, que é honesto —
|
|
# nenhuma similaridade vetorial foi medida para ele.
|
|
best: dict[CandidateKey, SearchResult] = {}
|
|
for hit in vector_hits:
|
|
best.setdefault(_key(hit), hit)
|
|
for hit in keyword_hits:
|
|
best.setdefault(_key(hit), hit)
|
|
|
|
return [best[key] for key, _ in fused if key in best]
|
|
|
|
def _keyword_search(self, message: str, limit: int) -> list[SearchResult]:
|
|
if not len(self.keyword_index):
|
|
return []
|
|
hits: list[SearchResult] = []
|
|
for doc_index, _score in self.keyword_index.search(message, k=limit):
|
|
if 0 <= doc_index < len(self._order):
|
|
entry = self._entries.get(self._order[doc_index])
|
|
if entry is not None:
|
|
hits.append(entry)
|
|
return hits
|