Files
omp 957c73b09e 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.
2026-11-02 11:57:19 -03:00

273 lines
9.2 KiB
Python

"""Indexador: orquestra leitura do corpus, embeddings e persistência vetorial.
O manifesto (`manifest.json`) registra, por arquivo, o SHA-256 do conteúdo, o
estado (`complete` ou `error`) e a contagem de chunks. Arquivos cujo hash não
mudou não são reprocessados, então reiniciar o container não repete o trabalho de
embedding.
"""
from __future__ import annotations
import hashlib
import json
import logging
import threading
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from .config import Settings
from .corpus import CorpusParseError, parse_usfx
from .embedding import Embedder
from .vector_store import VectorStore
logger = logging.getLogger(__name__)
STATE_COMPLETE = "complete"
STATE_ERROR = "error"
CORPUS_SUFFIXES = (".xml",)
def file_sha256(path: Path, chunk_size: int = 1024 * 1024) -> str:
"""SHA-256 do conteúdo do arquivo, lido em blocos."""
digest = hashlib.sha256()
with Path(path).open("rb") as handle:
while chunk := handle.read(chunk_size):
digest.update(chunk)
return digest.hexdigest()
@dataclass
class IndexStatus:
"""Estado público da indexação."""
state: str = "ready"
sources_total: int = 0
sources_indexed: int = 0
chunks: int = 0
model: str = ""
last_indexed_at: str | None = None
errors: list[str] = field(default_factory=list)
verses_indexed: int = 0
verses_total: int = 0
current_file: str | None = None
@dataclass
class _ManifestEntry:
sha256: str
state: str
chunks: int = 0
indexed_at: str = ""
def to_dict(self) -> dict:
return {
"sha256": self.sha256,
"state": self.state,
"chunks": self.chunks,
"indexed_at": self.indexed_at,
}
class Indexer:
"""Coordena a indexação dos corpora presentes em `settings.corpus_dir`."""
def __init__(
self,
settings: Settings,
embedder: Embedder,
store: VectorStore,
):
self.settings = settings
self.embedder = embedder
self.store = store
self._manifest: dict[str, _ManifestEntry] = {}
self._lock = threading.Lock()
self._last_indexed_at: str | None = None
self._errors: list[str] = []
self._indexing = False
self._verses_indexed = 0
self._verses_total = 0
self._current_file: str | None = None
self._load_manifest()
@property
def reindex_lock(self) -> threading.Lock:
"""Lock que serializa reindexações concorrentes."""
return self._lock
# ------------------------------------------------------------------ #
# Manifesto
# ------------------------------------------------------------------ #
def _load_manifest(self) -> None:
path = self.settings.manifest_path
if not path.exists():
return
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
logger.warning("Manifesto ilegível em %s: %s", path, exc)
return
for name, entry in raw.items():
self._manifest[name] = _ManifestEntry(
sha256=entry.get("sha256", ""),
state=entry.get("state", STATE_ERROR),
chunks=int(entry.get("chunks", 0)),
indexed_at=entry.get("indexed_at", ""),
)
def _save_manifest(self) -> None:
path = self.settings.manifest_path
path.parent.mkdir(parents=True, exist_ok=True)
payload = {name: entry.to_dict() for name, entry in self._manifest.items()}
temporary = path.with_suffix(".json.tmp")
temporary.write_text(
json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8"
)
temporary.replace(path)
# ------------------------------------------------------------------ #
# Progresso
# ------------------------------------------------------------------ #
def mark_indexing(self) -> None:
"""Sinaliza que uma indexação está em andamento, zerando contadores."""
self._indexing = True
self._verses_indexed = 0
self._verses_total = 0
self._current_file = None
def report_progress(self, file_name: str, verses_done: int, verses_total: int) -> None:
"""Registra o progresso da indexação de um arquivo."""
self._current_file = file_name
self._verses_indexed = verses_done
self._verses_total = verses_total
# ------------------------------------------------------------------ #
# Indexação
# ------------------------------------------------------------------ #
def discover_sources(self) -> list[Path]:
"""Lista os arquivos de corpus do diretório configurado, por nome."""
directory = self.settings.corpus_dir
if not directory.exists():
return []
return sorted(
path
for suffix in CORPUS_SUFFIXES
for path in directory.glob(f"*{suffix}")
if path.is_file()
)
def reindex(self, force: bool = False) -> IndexStatus:
"""Indexa os corpora novos ou alterados."""
with self._lock:
self._errors = []
self.mark_indexing()
sources = self.discover_sources()
try:
for source in sources:
try:
self._index_file(source, force=force)
except Exception as exc: # pragma: no cover - falha inesperada
logger.exception("Falha ao indexar %s", source.name)
self._manifest[source.name] = _ManifestEntry(
sha256="", state=STATE_ERROR, indexed_at=_now()
)
self._errors.append(f"{source.name}: {exc}")
self._save_manifest()
self._last_indexed_at = _now()
finally:
self._indexing = False
return self.status()
def _index_file(self, source: Path, force: bool) -> None:
file_hash = file_sha256(source)
entry = self._manifest.get(source.name)
if entry is not None and entry.sha256 == file_hash and entry.state == STATE_COMPLETE and not force:
logger.info("%s já está indexado; pulando", source.name)
return
logger.info("Indexando %s", source.name)
self.report_progress(source.name, 0, 0)
try:
chunks = list(
parse_usfx(
source,
file_name=source.name,
context_window=self.settings.context_window,
)
)
except CorpusParseError as exc:
self._manifest[source.name] = _ManifestEntry(
sha256=file_hash, state=STATE_ERROR, indexed_at=_now()
)
self._errors.append(str(exc))
return
if not chunks:
self._manifest[source.name] = _ManifestEntry(
sha256=file_hash, state=STATE_ERROR, indexed_at=_now()
)
self._errors.append(f"{source.name}: nenhum versículo pôde ser lido")
return
# Remove os chunks anteriores deste arquivo antes de gravar os novos.
self.store.delete_file(source.name)
total = len(chunks)
# O total é publicado antes do primeiro lote: a barra de progresso do
# frontend precisa do denominador assim que a leitura termina, e não só
# depois do primeiro embedding (que é a etapa mais lenta).
self.report_progress(source.name, 0, total)
batch_size = max(1, self.settings.index_batch_size)
for start in range(0, total, batch_size):
batch = chunks[start : start + batch_size]
vectors = self.embedder.encode([chunk.embedding_text for chunk in batch])
self.store.upsert(batch, vectors)
self.report_progress(source.name, min(start + batch_size, total), total)
self._manifest[source.name] = _ManifestEntry(
sha256=file_hash,
state=STATE_COMPLETE,
chunks=total,
indexed_at=_now(),
)
def status(self) -> IndexStatus:
"""Estado atual da indexação."""
indexed = sum(
1 for entry in self._manifest.values() if entry.state == STATE_COMPLETE
)
if self._indexing:
state = "indexing"
elif self._errors and indexed == 0:
state = "error"
else:
state = "ready"
return IndexStatus(
state=state,
sources_total=len(self.discover_sources()),
sources_indexed=indexed,
chunks=self.store.count(),
# Reporta o embedder *em uso*, não o configurado: se o modelo falhou
# ao carregar e houve fallback, o operador precisa ver isso aqui.
model=getattr(self.embedder, "name", self.settings.embedding_model),
last_indexed_at=self._last_indexed_at,
errors=list(self._errors),
verses_indexed=self._verses_indexed,
verses_total=self._verses_total,
current_file=self._current_file,
)
def _now() -> str:
return datetime.now(timezone.utc).isoformat()