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

+16 -8
View File
@@ -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
+3
View File
@@ -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
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)
+9 -3
View File
@@ -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)
+147
View File
@@ -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
View File
@@ -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.
+147
View File
@@ -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
+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)