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
+16
-8
@@ -20,6 +20,7 @@ from typing import Iterable, Sequence
|
||||
from .corpus import canonical_book
|
||||
from .config import Settings
|
||||
from .embedding import Embedder
|
||||
from .retrieval import Retriever, VectorRetriever
|
||||
from .llm import SYSTEM_PROMPT, LLMProvider
|
||||
from .vector_store import SearchResult, VectorStore
|
||||
|
||||
@@ -94,7 +95,9 @@ class Source:
|
||||
def from_result(cls, result: SearchResult) -> "Source":
|
||||
return cls(
|
||||
reference=result.reference,
|
||||
text=result.text,
|
||||
# O usuário vê o versículo citado, não a janela de contexto que foi
|
||||
# para o modelo: a referência exibida precisa corresponder ao texto.
|
||||
text=result.cited_text,
|
||||
book=result.book,
|
||||
chapter=result.chapter,
|
||||
verse=result.verse,
|
||||
@@ -178,11 +181,20 @@ class ChatService:
|
||||
store: VectorStore,
|
||||
embedder: Embedder,
|
||||
provider: LLMProvider,
|
||||
retriever: Retriever | None = None,
|
||||
):
|
||||
self.settings = settings
|
||||
self.store = store
|
||||
self.embedder = embedder
|
||||
self.provider = provider
|
||||
# Sem retriever injetado, mantém o comportamento vetorial simples — os
|
||||
# testes existentes e qualquer uso embutido seguem iguais.
|
||||
self.retriever: Retriever = retriever or VectorRetriever(
|
||||
store=store,
|
||||
embedder=embedder,
|
||||
top_k=settings.top_k,
|
||||
min_similarity=settings.min_similarity,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Recuperação
|
||||
@@ -192,7 +204,8 @@ class ChatService:
|
||||
"""Recupera os trechos que sustentam a resposta.
|
||||
|
||||
Perguntas com referência explícita (`João 3:16`) usam busca determinística
|
||||
por metadados; as demais usam similaridade semântica.
|
||||
por metadados; as demais passam pelo retriever configurado (híbrido em
|
||||
produção: vetorial + BM25).
|
||||
"""
|
||||
reference = extract_reference(message)
|
||||
if reference is not None:
|
||||
@@ -201,12 +214,7 @@ class ChatService:
|
||||
if exact:
|
||||
return exact
|
||||
|
||||
vector = self.embedder.encode([message])[0]
|
||||
return self.store.query(
|
||||
vector,
|
||||
n_results=self.settings.top_k,
|
||||
min_similarity=self.settings.min_similarity,
|
||||
)
|
||||
return self.retriever.search(message)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Contexto
|
||||
|
||||
@@ -48,6 +48,9 @@ class Settings(BaseSettings):
|
||||
# Tamanho do lote gravado no banco vetorial por vez: limita a memória e dá
|
||||
# granularidade ao progresso da indexação.
|
||||
index_batch_size: int = 512
|
||||
# Versículos vizinhos incluídos em cada lado do chunk indexado. Um versículo
|
||||
# isolado é curto demais para o embedding captar o tema.
|
||||
context_window: int = 2
|
||||
|
||||
# API
|
||||
cors_origins: str = "http://localhost:8080,http://127.0.0.1:8080"
|
||||
|
||||
+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)
|
||||
@@ -17,7 +17,7 @@ from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from .config import Settings
|
||||
from .corpus import CorpusParseError, VerseChunk, parse_usfx
|
||||
from .corpus import CorpusParseError, parse_usfx
|
||||
from .embedding import Embedder
|
||||
from .vector_store import VectorStore
|
||||
|
||||
@@ -198,7 +198,13 @@ class Indexer:
|
||||
self.report_progress(source.name, 0, 0)
|
||||
|
||||
try:
|
||||
chunks = list(parse_usfx(source, file_name=source.name))
|
||||
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()
|
||||
@@ -224,7 +230,7 @@ class Indexer:
|
||||
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.text for chunk in batch])
|
||||
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)
|
||||
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Busca por palavras-chave (BM25) e fusão de rankings (RRF).
|
||||
|
||||
A busca vetorial falha quando a pergunta compartilha o vocabulário do versículo
|
||||
mas não a sua forma de frase — "o que Jesus disse sobre amar os inimigos?" não
|
||||
recuperava Mateus 5:44 com nenhum dos três modelos medidos. O BM25 cobre esse
|
||||
caso pelo lado lexical.
|
||||
|
||||
As duas escalas são incomparáveis (cosseno em 0..1, BM25 sem limite superior),
|
||||
então a fusão usa **Reciprocal Rank Fusion**: só a posição em cada ranking
|
||||
importa, não a pontuação absoluta.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
import unicodedata
|
||||
from collections import defaultdict
|
||||
from typing import Hashable, Iterable, Sequence
|
||||
|
||||
# Palavras que aparecem em quase todo versículo: não discriminam nada e só
|
||||
# consomem memória do índice invertido.
|
||||
STOPWORDS: frozenset[str] = frozenset(
|
||||
"""
|
||||
a ao aos aquela aquelas aquele aqueles aquilo as até com como da das de dela
|
||||
delas dele deles depois do dos e ela elas ele eles em entre era eram essa
|
||||
essas esse esses esta estas este estes eu foi foram há isso isto ja lhe lhes
|
||||
mais mas me mesmo meu meus muito na nao nas nem no nos nossa nossas nosso
|
||||
nossos num numa o os ou para pela pelas pelo pelos por qual quando que quem
|
||||
se sem ser sua suas seu seus te tem tinha tu tua tuas um uma você vocês vos
|
||||
""".split()
|
||||
)
|
||||
|
||||
_NON_WORD_RE = re.compile(r"[^a-z0-9]+")
|
||||
_DIGITS_RE = re.compile(r"^\d+$")
|
||||
|
||||
|
||||
def normalize_token(word: str) -> str:
|
||||
"""Minúsculas e sem acentos, para que "coração" e "coracao" coincidam."""
|
||||
decomposed = unicodedata.normalize("NFKD", word)
|
||||
return "".join(ch for ch in decomposed if not unicodedata.combining(ch)).lower()
|
||||
|
||||
|
||||
def tokenize(text: str) -> list[str]:
|
||||
"""Separa o texto em termos indexáveis."""
|
||||
tokens: list[str] = []
|
||||
for raw in _NON_WORD_RE.split(normalize_token(text or "")):
|
||||
if not raw or _DIGITS_RE.match(raw):
|
||||
continue
|
||||
if raw in STOPWORDS:
|
||||
continue
|
||||
tokens.append(raw)
|
||||
return tokens
|
||||
|
||||
|
||||
class BM25Index:
|
||||
"""Índice BM25 (Okapi) sobre documentos de texto.
|
||||
|
||||
Mantido em memória: 31 mil versículos cabem em poucos MB e a reconstrução a
|
||||
partir do banco vetorial leva cerca de um segundo.
|
||||
"""
|
||||
|
||||
def __init__(self, k1: float = 1.5, b: float = 0.75):
|
||||
self.k1 = k1
|
||||
self.b = b
|
||||
self._doc_freqs: dict[str, int] = {}
|
||||
self._postings: dict[str, list[tuple[int, int]]] = {}
|
||||
self._doc_lengths: list[int] = []
|
||||
self._idf: dict[str, float] = {}
|
||||
self._avgdl: float = 0.0
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._doc_lengths)
|
||||
|
||||
def build(self, documents: Sequence[str]) -> None:
|
||||
"""(Re)constrói o índice, descartando os documentos anteriores."""
|
||||
postings: dict[str, list[tuple[int, int]]] = defaultdict(list)
|
||||
doc_lengths: list[int] = []
|
||||
doc_freqs: dict[str, int] = {}
|
||||
|
||||
for index, document in enumerate(documents):
|
||||
tokens = tokenize(document)
|
||||
doc_lengths.append(len(tokens))
|
||||
counts: dict[str, int] = defaultdict(int)
|
||||
for token in tokens:
|
||||
counts[token] += 1
|
||||
for token, count in counts.items():
|
||||
postings[token].append((index, count))
|
||||
doc_freqs[token] = doc_freqs.get(token, 0) + 1
|
||||
|
||||
total = len(doc_lengths)
|
||||
self._postings = dict(postings)
|
||||
self._doc_lengths = doc_lengths
|
||||
self._doc_freqs = doc_freqs
|
||||
self._avgdl = (sum(doc_lengths) / total) if total else 0.0
|
||||
# IDF de Robertson-Spärck Jones, na forma sempre positiva usada pelo
|
||||
# Lucene: evita peso negativo para termos presentes em quase todo doc.
|
||||
self._idf = {
|
||||
token: math.log(1.0 + (total - freq + 0.5) / (freq + 0.5))
|
||||
for token, freq in doc_freqs.items()
|
||||
}
|
||||
|
||||
def search(self, query: str, k: int = 10) -> list[tuple[int, float]]:
|
||||
"""Devolve `[(índice_do_documento, pontuação)]` em ordem decrescente."""
|
||||
if not self._doc_lengths or not self._avgdl:
|
||||
return []
|
||||
|
||||
terms = dict.fromkeys(tokenize(query))
|
||||
if not terms:
|
||||
return []
|
||||
|
||||
scores: dict[int, float] = defaultdict(float)
|
||||
for term in terms:
|
||||
idf = self._idf.get(term)
|
||||
if not idf:
|
||||
continue
|
||||
for doc_index, term_freq in self._postings.get(term, ()):
|
||||
length = self._doc_lengths[doc_index]
|
||||
denominator = term_freq + self.k1 * (
|
||||
1.0 - self.b + self.b * length / self._avgdl
|
||||
)
|
||||
scores[doc_index] += idf * (term_freq * (self.k1 + 1.0)) / denominator
|
||||
|
||||
ranked = sorted(scores.items(), key=lambda item: (-item[1], item[0]))
|
||||
return ranked[: max(0, k)]
|
||||
|
||||
|
||||
def reciprocal_rank_fusion(
|
||||
rankings: Iterable[Sequence[Hashable]], limit: int, k: int = 60
|
||||
) -> list[tuple[Hashable, float]]:
|
||||
"""Combina rankings por posição: `score = Σ 1/(k + posição)`.
|
||||
|
||||
A posição é 1-based. Itens ausentes de um ranking simplesmente não somam
|
||||
ali — é isso que faz um resultado confirmado pelas duas buscas liderar.
|
||||
Empates são desempatados pela ordem de aparição, para o resultado ser
|
||||
determinístico entre execuções.
|
||||
"""
|
||||
scores: dict[Hashable, float] = defaultdict(float)
|
||||
order: dict[Hashable, int] = {}
|
||||
|
||||
for ranking in rankings:
|
||||
for position, item in enumerate(ranking, start=1):
|
||||
scores[item] += 1.0 / (k + position)
|
||||
order.setdefault(item, len(order))
|
||||
|
||||
fused = sorted(scores.items(), key=lambda entry: (-entry[1], order[entry[0]]))
|
||||
return fused[: max(0, limit)]
|
||||
+42
-6
@@ -35,6 +35,7 @@ from .models import (
|
||||
SourcesResponse,
|
||||
StatusResponse,
|
||||
)
|
||||
from .retrieval import HybridRetriever
|
||||
from .vector_store import InMemoryVectorStore, VectorStore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -51,6 +52,7 @@ class Services:
|
||||
indexer: Indexer
|
||||
chat: ChatService
|
||||
last_sources: list[Source]
|
||||
retriever: HybridRetriever
|
||||
embedder_warning: str | None = None
|
||||
|
||||
|
||||
@@ -117,8 +119,19 @@ def create_app(
|
||||
if indexer is None:
|
||||
indexer = Indexer(settings=settings, embedder=embedder, store=store)
|
||||
|
||||
retriever = HybridRetriever(
|
||||
store=store,
|
||||
embedder=embedder,
|
||||
top_k=settings.top_k,
|
||||
min_similarity=settings.min_similarity,
|
||||
)
|
||||
|
||||
chat = ChatService(
|
||||
settings=settings, store=store, embedder=embedder, provider=provider
|
||||
settings=settings,
|
||||
store=store,
|
||||
embedder=embedder,
|
||||
provider=provider,
|
||||
retriever=retriever,
|
||||
)
|
||||
|
||||
services = Services(
|
||||
@@ -129,15 +142,20 @@ def create_app(
|
||||
indexer=indexer,
|
||||
chat=chat,
|
||||
last_sources=[],
|
||||
retriever=retriever,
|
||||
embedder_warning=embedder_warning,
|
||||
)
|
||||
reindex_lock = asyncio.Lock()
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
"""Dispara a indexação inicial sem bloquear o startup."""
|
||||
if settings.auto_index:
|
||||
asyncio.create_task(_run_initial_index(services))
|
||||
"""Indexa se configurado e sempre reconstrói o índice lexical.
|
||||
|
||||
O BM25 vive em memória enquanto o Chroma persiste em disco: sem a
|
||||
reconstrução no arranque, um restart deixaria a busca híbrida degradada
|
||||
em só-vetorial, em silêncio.
|
||||
"""
|
||||
asyncio.create_task(_run_initial_index(services))
|
||||
yield
|
||||
close = getattr(provider, "aclose", None)
|
||||
if close is not None:
|
||||
@@ -291,19 +309,37 @@ def _sse(event: str, data: dict) -> str:
|
||||
|
||||
|
||||
async def _run_initial_index(services: Services) -> None:
|
||||
"""Indexa o corpus na inicialização, sem bloquear o startup."""
|
||||
await asyncio.to_thread(services.indexer.reindex)
|
||||
"""Indexa o corpus (se `auto_index`) e reconstrói o índice lexical."""
|
||||
if services.settings.auto_index:
|
||||
await asyncio.to_thread(services.indexer.reindex)
|
||||
await asyncio.to_thread(_rebuild_keyword_index, services)
|
||||
|
||||
|
||||
async def _run_reindex(services: Services, lock: asyncio.Lock) -> None:
|
||||
"""Executa a reindexação em background, segurando o lock durante o trabalho."""
|
||||
try:
|
||||
await asyncio.to_thread(services.indexer.reindex, True)
|
||||
await asyncio.to_thread(_rebuild_keyword_index, services)
|
||||
finally:
|
||||
if lock.locked():
|
||||
lock.release()
|
||||
|
||||
|
||||
def _rebuild_keyword_index(services: Services) -> None:
|
||||
"""Reconstrói o BM25 a partir do banco vetorial, tolerando falha.
|
||||
|
||||
Se falhar, a busca continua funcionando no lado vetorial — degradada, mas
|
||||
disponível. Silenciar aqui seria esconder o problema, então fica no log.
|
||||
"""
|
||||
try:
|
||||
services.retriever.rebuild()
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Falha ao reconstruir o índice de palavras-chave; "
|
||||
"a busca seguirá apenas vetorial"
|
||||
)
|
||||
|
||||
|
||||
def _build_default_app() -> FastAPI:
|
||||
"""Cria a aplicação padrão, tolerando falha no carregamento do Chroma.
|
||||
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
"""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}
|
||||
self.keyword_index.build([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
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user