Files
pastor-online/backend/app/keyword.py
T
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

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)]