Dois defeitos medidos no índice real (31.098 versículos), ambos na metade
lexical da busca híbrida:
1. Sem normalização morfológica, 'amar' da pergunta nunca casava com 'amai' ou
'amarás' do versículo. Para 'o que Jesus disse sobre amar os inimigos?',
Mateus 5:44 ficava em 434º no BM25 enquanto Marcos 12:34 liderava por casar
apenas 'jesus'+'disse'. keyword.py ganha um stemmer de sufixos em etapas
(estilo RSLP, sem dependência externa) com guarda de comprimento mínimo do
radical — sem ela 'deus' viraria 'd' e 'mar' viraria 'm'. Rank de Mt 5:44: 12º.
2. O BM25 indexava a janela de contexto. Os dois lados querem granularidades
opostas: a janela dá tema ao embedding, mas multiplica os termos incidentais
que o BM25 pontua. Indexando o versículo isolado, Mt 5:44 vai a 1º e os
achados só-lexicais deixam de ser aleatórios ('ansioso e não consigo dormir'
-> Jó 3:13 'teria dormido'; 'não sei que decisão tomar' -> Joel 3:14 'vale da
decisão').
Colisão conhecida do stemming: 'luto' e 'lutavam' reduzem ao mesmo radical.
207 lines
8.4 KiB
Python
207 lines
8.4 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 contra 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 sob sobre 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+$")
|
|
|
|
# Sufixos em etapas, cada uma aplicada no máximo uma vez (da mesma forma que o
|
|
# RSLP). Dentro da etapa, o primeiro sufixo que casa vence — por isso vão do mais
|
|
# longo ao mais curto. `min` é o comprimento mínimo do radical resultante: sem
|
|
# ele, "deus" viraria "d" e "mar" viraria "m".
|
|
_STEM_STEPS: tuple[tuple[tuple[str, str, int], ...], ...] = (
|
|
# 1. plural
|
|
(("ões", "ão", 3), ("ães", "ão", 3), ("ais", "al", 4), ("éis", "el", 4),
|
|
("óis", "ol", 4), ("ns", "m", 3), ("s", "", 3)),
|
|
# 2. advérbio
|
|
(("mente", "", 4),),
|
|
# 3. superlativo, diminutivo
|
|
(("íssima", "", 4), ("íssimo", "", 4), ("zinha", "", 4), ("zinho", "", 4),
|
|
("inha", "", 4), ("inho", "", 4)),
|
|
# 4. sufixos nominais e adjetivais
|
|
(("adora", "", 4), ("ista", "", 4), ("ismo", "", 4), ("ável", "", 4),
|
|
("ível", "", 4), ("ença", "", 4), ("ância", "", 4), ("idade", "", 4),
|
|
("ura", "", 4), ("osa", "", 4), ("oso", "", 4), ("iva", "", 4),
|
|
("ivo", "", 4), ("ante", "", 4), ("ador", "", 4), ("eira", "", 4),
|
|
("eiro", "", 4), ("eza", "", 4), ("al", "", 4)),
|
|
# 5. verbos
|
|
(("aríamos", "", 2), ("eríamos", "", 2), ("iríamos", "", 2),
|
|
("ando", "", 2), ("endo", "", 2), ("indo", "", 2),
|
|
("aram", "", 2), ("eram", "", 2), ("iram", "", 2),
|
|
("arão", "", 2), ("erão", "", 2), ("irão", "", 2),
|
|
("aria", "", 2), ("eria", "", 2), ("iria", "", 2),
|
|
("asse", "", 2), ("esse", "", 2), ("isse", "", 2),
|
|
("aste", "", 2), ("este", "", 2), ("iste", "", 2),
|
|
("arei", "", 2), ("erei", "", 2), ("irei", "", 2),
|
|
("amos", "", 2), ("emos", "", 2), ("imos", "", 2),
|
|
("avam", "", 2), ("ado", "", 2), ("ido", "", 2), ("ada", "", 2),
|
|
("ida", "", 2), ("ara", "", 2), ("era", "", 2), ("ira", "", 2),
|
|
("ava", "", 2), ("iam", "", 2),
|
|
("ar", "", 2), ("er", "", 2), ("ir", "", 2),
|
|
("ou", "", 2), ("ei", "", 2), ("eu", "", 2), ("iu", "", 2),
|
|
("am", "", 2), ("em", "", 2), ("as", "", 2), ("es", "", 2),
|
|
("ia", "", 2), ("is", "", 2), ("ai", "", 2), ("ais", "", 2),
|
|
("eis", "", 2), ("a", "", 2), ("e", "", 2), ("o", "", 2)),
|
|
)
|
|
|
|
|
|
def stem(word: str) -> str:
|
|
"""Reduz uma palavra já normalizada ao seu radical.
|
|
|
|
O português é muito flexionado: sem isto, "amar" na pergunta nunca casa com
|
|
"amai" ou "amarás" no versículo, e o BM25 passa a pontuar por palavras
|
|
incidentais. Medido no corpus real, isso deixava Mateus 5:44 em 434º lugar
|
|
para a pergunta "o que Jesus disse sobre amar os inimigos?".
|
|
"""
|
|
for step in _STEM_STEPS:
|
|
for suffix, replacement, minimum in step:
|
|
if not word.endswith(suffix):
|
|
continue
|
|
candidate = word[: len(word) - len(suffix)] + replacement
|
|
if len(candidate) >= minimum:
|
|
word = candidate
|
|
break
|
|
return word
|
|
|
|
|
|
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, normalizados e reduzidos ao radical."""
|
|
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(stem(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)]
|