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

+39 -9
View File
@@ -160,19 +160,32 @@ def canonical_book(name: str) -> str | None:
@dataclass(frozen=True)
class VerseChunk:
"""Um versículo indexável com seus metadados de origem."""
"""Um versículo indexável com seus metadados de origem.
`text` é o versículo citado — é o que aparece ao usuário. `context` é a janela
de versículos vizinhos usada na busca: um versículo isolado é curto demais
para o embedding captar o tema (medidos: "estou com medo" contra "não temas"
dava 0,19 de similaridade). A referência continua apontando para o versículo
central, então a citação exibida não perde precisão.
"""
book: str
chapter: int
verse: int
file: str
text: str
context: str = ""
@property
def reference(self) -> str:
"""Referência no formato `Livro Capítulo:Versículo`."""
return f"{self.book} {self.chapter}:{self.verse}"
@property
def embedding_text(self) -> str:
"""Texto usado para embeddar e para a busca por palavras-chave."""
return self.context or self.text
@property
def chunk_id(self) -> str:
"""ID determinístico do chunk no banco vetorial.
@@ -211,9 +224,15 @@ def _parse_id(raw: str | None) -> int | None:
return int(match.group(1)) if match else None
def parse_usfx(path: Path, file_name: str | None = None) -> Iterator[VerseChunk]:
def parse_usfx(
path: Path, file_name: str | None = None, context_window: int = 2
) -> Iterator[VerseChunk]:
"""Percorre um arquivo USFX produzindo um chunk por versículo.
Cada chunk carrega o versículo (`text`) e uma janela de `context_window`
vizinhos para cada lado (`context`). A janela é montada **dentro do livro** —
atravessa capítulos, porque a narrativa continua, mas nunca mistura livros.
Levanta `CorpusParseError` se o arquivo não puder ser lido ou não contiver
nenhum livro: um corpus vazio indexado em silêncio deixaria o assistente sem
evidência e respondendo a partir do nada.
@@ -234,6 +253,7 @@ def parse_usfx(path: Path, file_name: str | None = None) -> Iterator[VerseChunk]
raise CorpusParseError(f"{file_name}: nenhum livro encontrado no corpus")
skipped = 0
window = max(0, context_window)
for book in books:
header = book.find("h")
book_name = (header.text or "").strip() if header is not None else ""
@@ -241,6 +261,7 @@ def parse_usfx(path: Path, file_name: str | None = None) -> Iterator[VerseChunk]
book_id = book.get("id") or ""
book_name = canonical_book(book_id) or book_id or "Desconhecido"
entries: list[tuple[int, int, str]] = []
chapter = 0
for element in book:
if element.tag == "c":
@@ -251,13 +272,22 @@ def parse_usfx(path: Path, file_name: str | None = None) -> Iterator[VerseChunk]
if chapter < 1 or verse < 1 or not _is_indexable(text):
skipped += 1
continue
yield VerseChunk(
book=book_name,
chapter=chapter,
verse=verse,
file=file_name,
text=text,
)
entries.append((chapter, verse, text))
for index, (verse_chapter, verse_number, text) in enumerate(entries):
if window:
neighbours = entries[max(0, index - window) : index + window + 1]
context = " ".join(neighbour[2] for neighbour in neighbours)
else:
context = text
yield VerseChunk(
book=book_name,
chapter=verse_chapter,
verse=verse_number,
file=file_name,
text=text,
context=context,
)
if skipped:
logger.info("%s: %s versículo(s) ignorado(s) sem texto", file_name, skipped)