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
@@ -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)]
|
||||
Reference in new issue
Block a user