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
+39
-9
@@ -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)
|
||||
Reference in new issue
Block a user