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
+14
-3
@@ -40,12 +40,23 @@ EMBEDDING_MODEL=paraphrase-multilingual-MiniLM-L12-v2
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Busca
|
||||
# A recuperação é híbrida: similaridade vetorial + BM25, fundidos por posição
|
||||
# (RRF). MIN_SIMILARITY é o piso do lado vetorial; abaixo dele o trecho só entra
|
||||
# se o BM25 o encontrar.
|
||||
# A recuperação é híbrida: similaridade vetorial + BM25 (com stemming), fundidos
|
||||
# por posição (RRF).
|
||||
#
|
||||
# MIN_SIMILARITY é filtro de ruído do lado vetorial, NÃO um detector de assunto
|
||||
# fora do tema. Medido no corpus completo: nenhum valor entre 0,30 e 0,50
|
||||
# bloqueou uma pergunta off-topic ("como fazer um bolo de chocolate" bate 0,544
|
||||
# em Levítico 7:14). Num corpus de 31 mil versículos sempre há algum acima de
|
||||
# qualquer limiar; a recusa off-topic é instruída ao modelo no SYSTEM_PROMPT.
|
||||
#
|
||||
# MAX_KEYWORD_ONLY: posições da resposta que podem ser ocupadas por trechos que
|
||||
# só o BM25 achou. A nota BM25 não separa acerto de ruído (Deuteronômio 28:27
|
||||
# pontua 1,000 do topo para "como posso perdoar", e o correto Lucas 7:47 fica em
|
||||
# 0,716), então o controle é por cota de posições. 0 desliga o lado lexical.
|
||||
# ---------------------------------------------------------------------------
|
||||
TOP_K=6
|
||||
MIN_SIMILARITY=0.35
|
||||
MAX_KEYWORD_ONLY=2
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ingestão do corpus
|
||||
|
||||
@@ -36,18 +36,41 @@ digitalização — a referência exibida é exatamente a coordenada de onde o t
|
||||
lido.
|
||||
|
||||
**Busca híbrida.** Vetores e palavras erram de modos diferentes, então os dois lados
|
||||
são combinados. Medido no corpus real, com 5.703 versículos e 6 perguntas pastorais:
|
||||
são combinados. Tudo abaixo foi medido no corpus real (31.098 versículos indexados):
|
||||
|
||||
- A busca vetorial não liga "estou com medo do futuro" a "não temas, porque eu sou
|
||||
contigo" (similaridade 0,19 — no nível de um par sem relação alguma). Modelos de
|
||||
paráfrase casam formulações parecidas, não pergunta e resposta.
|
||||
- A mesma busca acerta em cheio quando há vocabulário em comum: "o que Jesus disse
|
||||
sobre amar os inimigos?" → Mateus 5:44 no top-1 (0,683).
|
||||
- O BM25 cobre o lado lexical e ainda pega termos que o embedding dilui.
|
||||
- A mesma busca acerta quando há vocabulário em comum, e o BM25 cobre o resto:
|
||||
"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"*.
|
||||
- **Stemming é obrigatório.** Sem ele, "amar" da pergunta não casa com "amai" do
|
||||
versículo e Mateus 5:44 ficava em **434º** no BM25 para "o que Jesus disse sobre
|
||||
amar os inimigos?", enquanto Marcos 12:34 liderava apenas por conter "Jesus" e
|
||||
"disse". Com o stemmer de sufixos, vai a **1º**.
|
||||
- **BM25 indexa o versículo, não 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 a janela, Mateus 5:44 caía para 12º.
|
||||
|
||||
A fusão é por **posição** nos dois rankings (Reciprocal Rank Fusion), não por nota:
|
||||
cosseno vive em 0..1 e BM25 não tem teto, então normalizar um contra o outro seria
|
||||
arbitrário. `MIN_SIMILARITY` continua valendo como piso do lado vetorial.
|
||||
arbitrário.
|
||||
|
||||
**Dois limites medidos, e o que foi feito a respeito:**
|
||||
|
||||
1. `MIN_SIMILARITY` **não detecta assunto fora do tema**. Nenhum valor entre 0,30 e
|
||||
0,50 bloqueou uma única pergunta off-topic: "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. O valor serve
|
||||
como filtro de ruído; a recusa off-topic é instruída ao modelo no `SYSTEM_PROMPT`.
|
||||
2. **A nota BM25 não separa acerto de ruído.** Para "como posso perdoar quem me
|
||||
feriu?", Deuteronômio 28:27 (*"o Senhor te ferirá"*) pontua 1,000 da razão do
|
||||
topo enquanto o correto Lucas 7:47 fica em 0,716 — um piso de nota cortaria o
|
||||
certo e manteria o errado. Por isso o controle é uma **cota de posições**
|
||||
(`MAX_KEYWORD_ONLY`, padrão 2): o lado lexical sempre contribui com seus melhores
|
||||
achados, sem poder dominar a resposta.
|
||||
|
||||
Colisão conhecida do stemming: "luto" e "lutavam" reduzem ao mesmo radical.
|
||||
|
||||
**Janela de contexto.** Um versículo isolado tem ~20 palavras — curto demais para o
|
||||
embedding captar o tema. Cada chunk indexa o versículo junto com `CONTEXT_WINDOW`
|
||||
@@ -188,7 +211,8 @@ Todas as variáveis ficam no `.env`. As principais:
|
||||
| `LLM_PROVIDER` | `ollama` | `ollama`, `openai`, `groq`, `anthropic` ou `stub` |
|
||||
| `EMBEDDING_MODEL` | `paraphrase-multilingual-MiniLM-L12-v2` | `all-MiniLM-L6-v2` é menor e mais rápido |
|
||||
| `TOP_K` | `6` | Quantos trechos são recuperados por pergunta |
|
||||
| `MIN_SIMILARITY` | `0.35` | Piso do lado vetorial; abaixo disso o trecho só entra se o BM25 o encontrar |
|
||||
| `MIN_SIMILARITY` | `0.35` | Filtro de ruído do lado vetorial — **não** detecta off-topic (ver acima) |
|
||||
| `MAX_KEYWORD_ONLY` | `2` | Posições que podem ser ocupadas por trechos achados só pelo BM25; 0 desliga o lado lexical |
|
||||
| `CONTEXT_WINDOW` | `2` | Vizinhos indexados com cada versículo (0 desliga a janela) |
|
||||
| `INDEX_BATCH_SIZE` | `512` | Versículos gravados no banco por lote |
|
||||
| `CORPUS_DIR` | `/data/corpus` | Diretório dos arquivos USFX (montado de `./corpus`) |
|
||||
|
||||
@@ -42,6 +42,10 @@ class Settings(BaseSettings):
|
||||
# Busca
|
||||
top_k: int = 6
|
||||
min_similarity: float = 0.35
|
||||
# Quantas posições da resposta podem ser ocupadas por trechos que só o BM25
|
||||
# encontrou. A nota BM25 não separa acerto de ruído (medido), então o
|
||||
# controle é por cota de posições. 0 desliga o lado lexical.
|
||||
max_keyword_only: int = 2
|
||||
collection_name: str = "biblia"
|
||||
|
||||
# Ingestão
|
||||
|
||||
@@ -32,6 +32,7 @@ Regras adicionais de fidelidade:
|
||||
- Se os trechos recuperados não sustentarem a resposta, diga com humildade que o texto bíblico indexado não aborda diretamente o assunto.
|
||||
|
||||
Limites do aconselhamento:
|
||||
- Se o pedido não for de natureza espiritual, bíblica ou pastoral — receita, código de programa, cotação, esporte, tarefa doméstica, opinião política — diga com cordialidade que o Pastor Online trata apenas do texto bíblico e convide a pessoa a trazer sua dúvida de fé. NÃO use os trechos recuperados para atender a esses pedidos, mesmo que alguma palavra em comum apareça neles.
|
||||
- Ofereça consolo, oração, reflexão e orientação ancorada nos versículos recuperados.
|
||||
- Não emita diagnósticos, prescrições médicas, aconselhamento jurídico ou orientações financeiras.
|
||||
- Em situações de crise (desespero, risco de autoagressão, dependência química, crise de saúde mental), exorte o cuidado com a segurança imediata, oriente procurar ajuda profissional e de confiança, ofereça versículos de conforto e remeta aos serviços públicos de emergência do local do usuário, sem inventar números de telefone."""
|
||||
|
||||
@@ -124,6 +124,7 @@ def create_app(
|
||||
embedder=embedder,
|
||||
top_k=settings.top_k,
|
||||
min_similarity=settings.min_similarity,
|
||||
max_keyword_only=settings.max_keyword_only,
|
||||
)
|
||||
|
||||
chat = ChatService(
|
||||
|
||||
+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 []
|
||||
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -24,6 +24,7 @@ services:
|
||||
EMBEDDING_MODEL: ${EMBEDDING_MODEL:-paraphrase-multilingual-MiniLM-L12-v2}
|
||||
TOP_K: ${TOP_K:-6}
|
||||
MIN_SIMILARITY: ${MIN_SIMILARITY:-0.35}
|
||||
MAX_KEYWORD_ONLY: ${MAX_KEYWORD_ONLY:-2}
|
||||
INDEX_BATCH_SIZE: ${INDEX_BATCH_SIZE:-512}
|
||||
CONTEXT_WINDOW: ${CONTEXT_WINDOW:-2}
|
||||
CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:8080,http://127.0.0.1:8080}
|
||||
|
||||
Reference in new issue
Block a user