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

+61 -4
View File
@@ -16,19 +16,29 @@ logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class SearchResult:
"""Trecho recuperado da busca vetorial."""
"""Trecho recuperado da busca.
`text` é a janela de contexto que a busca encontrou (e que vai para o LLM);
`verse_text` é só o versículo citado, que é o que o usuário vê.
"""
book: str
chapter: int
verse: int
file: str
text: str
verse_text: str = ""
distance: float = 0.0
@property
def reference(self) -> str:
return f"{self.book} {self.chapter}:{self.verse}"
@property
def cited_text(self) -> str:
"""Texto a exibir como citação: o versículo, ou a janela se não houver."""
return self.verse_text or self.text
@property
def score(self) -> float:
"""Similaridade em escala 0..1 (1 = idêntico)."""
@@ -84,13 +94,17 @@ class VectorStore:
self._collection.upsert(
ids=[chunk.chunk_id for chunk in chunk_list],
embeddings=[list(vector) for vector in vectors],
documents=[chunk.text for chunk in chunk_list],
# O documento gravado é a janela de contexto: é ela que foi embeddada
# e é ela que serve de evidência ao LLM. O versículo citado vai no
# metadado, para a UI mostrar a referência exata.
documents=[chunk.embedding_text for chunk in chunk_list],
metadatas=[
{
"book": chunk.book,
"chapter": chunk.chapter,
"verse": chunk.verse,
"file": chunk.file,
"verse_text": chunk.text,
"reference": f"{chunk.book} {chunk.chapter}:{chunk.verse}",
}
for chunk in chunk_list
@@ -134,6 +148,7 @@ class VectorStore:
verse=int(metadata.get("verse", 0)),
file=str(metadata.get("file", "")),
text=document or "",
verse_text=str(metadata.get("verse_text", "") or ""),
distance=float(distance),
)
)
@@ -162,11 +177,36 @@ class VectorStore:
verse=int(metadata.get("verse", verse)),
file=str(metadata.get("file", "")),
text=document or "",
verse_text=str(metadata.get("verse_text", "") or ""),
distance=0.0,
)
for document, metadata in zip(documents, metadatas)
]
def snapshot(self) -> list[SearchResult]:
"""Inventário de todos os trechos indexados, sem pontuação.
Serve para (re)construir o índice de palavras-chave depois de uma
reinicialização, quando o Chroma persistiu mas o BM25 em memória não.
`distance=1.0` deixa `score` em 0: snapshot não é ranking e não pode
fingir similaridade.
"""
raw = self._collection.get(include=["documents", "metadatas"])
documents = raw.get("documents") or []
metadatas = raw.get("metadatas") or []
return [
SearchResult(
book=str(metadata.get("book", "")),
chapter=int(metadata.get("chapter", 0)),
verse=int(metadata.get("verse", 0)),
file=str(metadata.get("file", "")),
text=document or "",
verse_text=str(metadata.get("verse_text", "") or ""),
distance=1.0,
)
for document, metadata in zip(documents, metadatas)
]
def count(self) -> int:
"""Total de chunks indexados."""
return int(self._collection.count())
@@ -212,7 +252,8 @@ class InMemoryVectorStore:
chapter=chunk.chapter,
verse=chunk.verse,
file=chunk.file,
text=chunk.text,
text=chunk.embedding_text,
verse_text=chunk.text,
distance=1.0 - similarity,
)
)
@@ -228,12 +269,28 @@ class InMemoryVectorStore:
chapter=chunk.chapter,
verse=chunk.verse,
file=chunk.file,
text=chunk.text,
text=chunk.embedding_text,
verse_text=chunk.text,
distance=0.0,
)
for chunk in self._chunks.values()
if chunk.book == book and chunk.chapter == chapter and chunk.verse == verse
]
def snapshot(self) -> list[SearchResult]:
"""Inventário de todos os trechos indexados, sem pontuação."""
return [
SearchResult(
book=chunk.book,
chapter=chunk.chapter,
verse=chunk.verse,
file=chunk.file,
text=chunk.embedding_text,
verse_text=chunk.text,
distance=1.0,
)
for chunk in self._chunks.values()
]
def count(self) -> int:
return len(self._chunks)