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.
148 lines
5.6 KiB
Python
148 lines
5.6 KiB
Python
"""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)]
|