feat: cota lexical na fusão, recusa off-topic no prompt, docs medidas
Fusão:
- fuse_rankings() extrai a fusão RRF e aplica MAX_KEYWORD_ONLY (padrão 2):
quantas posições podem ser ocupadas por trechos que só o BM25 achou. O que os
dois lados confirmam nunca conta contra a cota.
- O controle é por cota de posições, não por piso de nota. Medido: a razão da
nota BM25 não separa acerto de ruído — para 'como posso perdoar quem me
feriu?', Deuteronômio 28:27 pontua 1,000 do topo e o correto Lucas 7:47 fica
em 0,716 (rank 12). Um piso cortaria o certo e manteria o errado.
- Ponderar o RRF também não resolve: com peso 0,5 no BM25 os achados só-lexicais
caem abaixo do 6º vetorial (0,5/61 < 1/66) e somem, levando junto Jó 3:13 e
Joel 3:14, os melhores resultados medidos.
Portão off-topic:
- MIN_SIMILARITY não consegue implementar 'sem evidência'. Medido no índice
completo: 0/6 perguntas off-topic bloqueadas em qualquer limiar de 0,30 a
0,50 ('bolo de chocolate' bate 0,544 em Lv 7:14; 'cotação do dólar' 0,494).
A recusa passa a ser instruída no SYSTEM_PROMPT e o limiar fica documentado
como filtro de ruído.
This commit is contained in:
1 parent
8f5e098d8f
commit
816e6be159
9 files changed
+212
-26
No files matched your search
+64
-16
@@ -13,7 +13,7 @@ não tem teto, então normalizar um contra o outro seria arbitrário.
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Protocol
|
||||
from typing import Protocol, Sequence
|
||||
|
||||
from .embedding import Embedder
|
||||
from .keyword import BM25Index, reciprocal_rank_fusion
|
||||
@@ -64,6 +64,61 @@ class VectorRetriever:
|
||||
)
|
||||
|
||||
|
||||
def fuse_rankings(
|
||||
vector_hits: Sequence[SearchResult],
|
||||
keyword_hits: Sequence[SearchResult],
|
||||
limit: int,
|
||||
rrf_k: int = 60,
|
||||
max_keyword_only: int = 2,
|
||||
) -> list[SearchResult]:
|
||||
"""Funde os dois rankings por RRF, com cota para achados só-lexicais.
|
||||
|
||||
A cota existe porque a **nota** BM25 não separa acerto de ruído — medido no
|
||||
corpus real: para "como posso perdoar quem me feriu?", Deuteronômio 28:27
|
||||
("o Senhor te ferirá") pontua na razão 1,000 do topo enquanto o correto
|
||||
Lucas 7:47 fica em 0,716. Um piso de nota cortaria o certo e manteria o
|
||||
errado. Limitar quantas posições o lado lexical pode ocupar sozinho preserva
|
||||
os achados fortes (Jó 3:13 para "não consigo dormir") sem deixar o ruído
|
||||
dominar a resposta.
|
||||
|
||||
O que os dois lados confirmam nunca conta contra a cota: é a evidência mais
|
||||
forte disponível.
|
||||
"""
|
||||
vector_keys = [_key(hit) for hit in vector_hits]
|
||||
keyword_keys = [_key(hit) for hit in keyword_hits]
|
||||
|
||||
# O lado vetorial tem precedência no dicionário: ele traz a similaridade
|
||||
# real. Um trecho achado só por palavra-chave fica com score 0, que é
|
||||
# honesto — nenhuma similaridade vetorial foi medida para ele.
|
||||
best: dict[CandidateKey, SearchResult] = {}
|
||||
for hit in vector_hits:
|
||||
best.setdefault(_key(hit), hit)
|
||||
for hit in keyword_hits:
|
||||
best.setdefault(_key(hit), hit)
|
||||
|
||||
confirmed = set(vector_keys)
|
||||
fused = reciprocal_rank_fusion(
|
||||
[vector_keys, keyword_keys],
|
||||
limit=len(best),
|
||||
k=rrf_k,
|
||||
)
|
||||
|
||||
results: list[SearchResult] = []
|
||||
keyword_only_used = 0
|
||||
for key, _score in fused:
|
||||
if len(results) >= limit:
|
||||
break
|
||||
hit = best.get(key)
|
||||
if hit is None:
|
||||
continue
|
||||
if key not in confirmed:
|
||||
if keyword_only_used >= max_keyword_only:
|
||||
continue
|
||||
keyword_only_used += 1
|
||||
results.append(hit)
|
||||
return results
|
||||
|
||||
|
||||
class HybridRetriever(VectorRetriever):
|
||||
"""Vetorial + BM25 fundidos por RRF."""
|
||||
|
||||
@@ -76,6 +131,7 @@ class HybridRetriever(VectorRetriever):
|
||||
keyword_index: BM25Index | None = None,
|
||||
candidate_multiplier: int = 4,
|
||||
rrf_k: int = 60,
|
||||
max_keyword_only: int = 2,
|
||||
):
|
||||
super().__init__(
|
||||
store=store,
|
||||
@@ -86,6 +142,7 @@ class HybridRetriever(VectorRetriever):
|
||||
self.keyword_index = keyword_index or BM25Index()
|
||||
self.candidate_multiplier = max(1, candidate_multiplier)
|
||||
self.rrf_k = rrf_k
|
||||
self.max_keyword_only = max(0, max_keyword_only)
|
||||
self._entries: dict[CandidateKey, SearchResult] = {}
|
||||
self._order: list[CandidateKey] = []
|
||||
|
||||
@@ -123,25 +180,16 @@ class HybridRetriever(VectorRetriever):
|
||||
if not keyword_hits:
|
||||
return vector_hits[: self.top_k]
|
||||
if not vector_hits:
|
||||
return keyword_hits[: self.top_k]
|
||||
return keyword_hits[: self.max_keyword_only or self.top_k]
|
||||
|
||||
fused = reciprocal_rank_fusion(
|
||||
[[_key(hit) for hit in vector_hits], [_key(hit) for hit in keyword_hits]],
|
||||
return fuse_rankings(
|
||||
vector_hits,
|
||||
keyword_hits,
|
||||
limit=self.top_k,
|
||||
k=self.rrf_k,
|
||||
rrf_k=self.rrf_k,
|
||||
max_keyword_only=self.max_keyword_only,
|
||||
)
|
||||
|
||||
# O lado vetorial tem precedência: ele traz a similaridade real. Um
|
||||
# trecho achado só por palavra-chave fica com score 0, que é honesto —
|
||||
# nenhuma similaridade vetorial foi medida para ele.
|
||||
best: dict[CandidateKey, SearchResult] = {}
|
||||
for hit in vector_hits:
|
||||
best.setdefault(_key(hit), hit)
|
||||
for hit in keyword_hits:
|
||||
best.setdefault(_key(hit), hit)
|
||||
|
||||
return [best[key] for key, _ in fused if key in best]
|
||||
|
||||
def _keyword_search(self, message: str, limit: int) -> list[SearchResult]:
|
||||
if not len(self.keyword_index):
|
||||
return []
|
||||
|
||||
Reference in new issue
Block a user