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,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