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:
omp committed 2026-11-02 13:04:14 -03:00
1 parent 8f5e098d8f
commit 816e6be159
9 files changed
+212 -26

No files matched your search

+16
View File
@@ -224,3 +224,19 @@ def test_provider_error_does_not_leak_the_api_key():
assert "sk-super-secreta" not in str(exc.value.__cause__ or "")
finally:
provider._client = monkeypatched
def test_system_prompt_declares_the_off_topic_scope():
"""O portão por similaridade não funciona: precisa estar no prompt.
Medido no corpus real, nenhum valor de MIN_SIMILARITY entre 0,30 e 0,50
bloqueou uma única pergunta fora do tema — "como fazer um bolo de chocolate"
bate 0,544 em Levítico 7:14 e "cotação do dólar" bate 0,494. Num corpus de
31 mil versículos sempre existe algum acima de qualquer limiar utilizável,
então a recusa tem de ser instruída ao modelo, não filtrada por nota.
"""
lowered = SYSTEM_PROMPT.lower()
assert "receita" in lowered
assert "apenas do texto bíblico" in lowered
assert "não use os trechos recuperados" in lowered
+81 -1
View File
@@ -18,7 +18,11 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from app.corpus import VerseChunk # noqa: E402
from app.embedding import HashEmbedder # noqa: E402
from app.keyword import BM25Index # noqa: E402
from app.retrieval import HybridRetriever, VectorRetriever # noqa: E402
from app.retrieval import ( # noqa: E402
HybridRetriever,
VectorRetriever,
fuse_rankings,
)
from app.vector_store import InMemoryVectorStore, SearchResult # noqa: E402
@@ -241,3 +245,79 @@ def test_keyword_index_covers_the_verse_not_the_context_window(embedder):
assert retriever.search("pastor"), "palavra do versículo tem de ser encontrada"
assert not retriever.search("simeonitas"), "palavra só da janela não pode contar"
# --------------------------------------------------------------------------
# Cota de achados só-lexicais
# --------------------------------------------------------------------------
def make_result(reference: str, score: float) -> SearchResult:
book, rest = reference.split(" ", 1)
chapter, verse = rest.split(":")
return SearchResult(
book=book,
chapter=int(chapter),
verse=int(verse),
file="a.usfx.xml",
text=f"texto de {reference}",
verse_text=f"texto de {reference}",
distance=1.0 - score,
)
VECTOR_HITS = [make_result(f"Salmos {n}:1", 0.60 - n * 0.01) for n in range(1, 7)]
KEYWORD_ONLY = [
make_result("Jó 3:13", 0.0),
make_result("Joel 3:14", 0.0),
make_result("Gênesis 25:22", 0.0),
make_result("Deuteronômio 28:27", 0.0),
]
def test_keyword_only_hits_are_capped():
"""O lado lexical contribui, mas não pode tomar a resposta inteira.
Medido: a nota BM25 não separa acerto de ruído (Dt 28:27 pontua 1.000 da
razão do topo para 'como posso perdoar', enquanto o correto Lc 7:47 fica em
0.716). Um piso de nota cortaria o versículo certo e manteria o errado, então
o controle é por cota de posições, não por magnitude.
"""
fused = fuse_rankings(VECTOR_HITS, KEYWORD_ONLY, limit=6, max_keyword_only=2)
keyword_refs = {"Jó 3:13", "Joel 3:14", "Gênesis 25:22", "Deuteronômio 28:27"}
included = [r.reference for r in fused if r.reference in keyword_refs]
assert len(included) == 2
assert included == ["Jó 3:13", "Joel 3:14"], "os mais bem ranqueados no BM25"
def test_dual_confirmed_hits_never_count_against_the_quota():
"""O que os dois lados confirmam é a evidência mais forte que existe."""
shared = make_result("Salmos 1:1", 0.60)
vector = [shared] + VECTOR_HITS[1:]
keyword = [shared] + KEYWORD_ONLY
fused = fuse_rankings(vector, keyword, limit=6, max_keyword_only=1)
assert fused[0].reference == "Salmos 1:1"
def test_quota_zero_disables_keyword_only_hits():
fused = fuse_rankings(VECTOR_HITS, KEYWORD_ONLY, limit=6, max_keyword_only=0)
assert all(r.score > 0 for r in fused)
assert len(fused) == 6
def test_quota_larger_than_limit_keeps_everything():
fused = fuse_rankings(VECTOR_HITS, KEYWORD_ONLY, limit=4, max_keyword_only=4)
assert len(fused) == 4
def test_fusion_without_vector_hits_still_respects_the_quota():
"""Sem nenhum vetor acima do piso, o lado lexical ainda responde — limitado."""
fused = fuse_rankings([], KEYWORD_ONLY, limit=6, max_keyword_only=2)
assert [r.reference for r in fused] == ["Jó 3:13", "Joel 3:14"]