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

+14 -3
View File
@@ -40,12 +40,23 @@ EMBEDDING_MODEL=paraphrase-multilingual-MiniLM-L12-v2
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Busca # Busca
# A recuperação é híbrida: similaridade vetorial + BM25, fundidos por posição # A recuperação é híbrida: similaridade vetorial + BM25 (com stemming), fundidos
# (RRF). MIN_SIMILARITY é o piso do lado vetorial; abaixo dele o trecho só entra # por posição (RRF).
# se o BM25 o encontrar. #
# 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 TOP_K=6
MIN_SIMILARITY=0.35 MIN_SIMILARITY=0.35
MAX_KEYWORD_ONLY=2
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Ingestão do corpus # Ingestão do corpus
+30 -6
View File
@@ -36,18 +36,41 @@ digitalização — a referência exibida é exatamente a coordenada de onde o t
lido. lido.
**Busca híbrida.** Vetores e palavras erram de modos diferentes, então os dois lados **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 - 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 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. 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 - A mesma busca acerta quando há vocabulário em comum, e o BM25 cobre o resto:
sobre amar os inimigos?" → Mateus 5:44 no top-1 (0,683). "ansioso e não consigo dormir" → Jó 3:13 *"teria dormido"*; "não sei que decisão
- O BM25 cobre o lado lexical e ainda pega termos que o embedding dilui. 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: 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 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 **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` 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` | | `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 | | `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 | | `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) | | `CONTEXT_WINDOW` | `2` | Vizinhos indexados com cada versículo (0 desliga a janela) |
| `INDEX_BATCH_SIZE` | `512` | Versículos gravados no banco por lote | | `INDEX_BATCH_SIZE` | `512` | Versículos gravados no banco por lote |
| `CORPUS_DIR` | `/data/corpus` | Diretório dos arquivos USFX (montado de `./corpus`) | | `CORPUS_DIR` | `/data/corpus` | Diretório dos arquivos USFX (montado de `./corpus`) |
+4
View File
@@ -42,6 +42,10 @@ class Settings(BaseSettings):
# Busca # Busca
top_k: int = 6 top_k: int = 6
min_similarity: float = 0.35 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" collection_name: str = "biblia"
# Ingestão # Ingestão
+1
View File
@@ -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. - 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: 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. - 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. - 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.""" - 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."""
+1
View File
@@ -124,6 +124,7 @@ def create_app(
embedder=embedder, embedder=embedder,
top_k=settings.top_k, top_k=settings.top_k,
min_similarity=settings.min_similarity, min_similarity=settings.min_similarity,
max_keyword_only=settings.max_keyword_only,
) )
chat = ChatService( chat = ChatService(
+64 -16
View File
@@ -13,7 +13,7 @@ não tem teto, então normalizar um contra o outro seria arbitrário.
from __future__ import annotations from __future__ import annotations
import logging import logging
from typing import Protocol from typing import Protocol, Sequence
from .embedding import Embedder from .embedding import Embedder
from .keyword import BM25Index, reciprocal_rank_fusion 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): class HybridRetriever(VectorRetriever):
"""Vetorial + BM25 fundidos por RRF.""" """Vetorial + BM25 fundidos por RRF."""
@@ -76,6 +131,7 @@ class HybridRetriever(VectorRetriever):
keyword_index: BM25Index | None = None, keyword_index: BM25Index | None = None,
candidate_multiplier: int = 4, candidate_multiplier: int = 4,
rrf_k: int = 60, rrf_k: int = 60,
max_keyword_only: int = 2,
): ):
super().__init__( super().__init__(
store=store, store=store,
@@ -86,6 +142,7 @@ class HybridRetriever(VectorRetriever):
self.keyword_index = keyword_index or BM25Index() self.keyword_index = keyword_index or BM25Index()
self.candidate_multiplier = max(1, candidate_multiplier) self.candidate_multiplier = max(1, candidate_multiplier)
self.rrf_k = rrf_k self.rrf_k = rrf_k
self.max_keyword_only = max(0, max_keyword_only)
self._entries: dict[CandidateKey, SearchResult] = {} self._entries: dict[CandidateKey, SearchResult] = {}
self._order: list[CandidateKey] = [] self._order: list[CandidateKey] = []
@@ -123,25 +180,16 @@ class HybridRetriever(VectorRetriever):
if not keyword_hits: if not keyword_hits:
return vector_hits[: self.top_k] return vector_hits[: self.top_k]
if not vector_hits: 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( return fuse_rankings(
[[_key(hit) for hit in vector_hits], [_key(hit) for hit in keyword_hits]], vector_hits,
keyword_hits,
limit=self.top_k, 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]: def _keyword_search(self, message: str, limit: int) -> list[SearchResult]:
if not len(self.keyword_index): if not len(self.keyword_index):
return [] return []
+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 "") assert "sk-super-secreta" not in str(exc.value.__cause__ or "")
finally: finally:
provider._client = monkeypatched 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.corpus import VerseChunk # noqa: E402
from app.embedding import HashEmbedder # noqa: E402 from app.embedding import HashEmbedder # noqa: E402
from app.keyword import BM25Index # 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 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 retriever.search("pastor"), "palavra do versículo tem de ser encontrada"
assert not retriever.search("simeonitas"), "palavra só da janela não pode contar" 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"]
+1
View File
@@ -24,6 +24,7 @@ services:
EMBEDDING_MODEL: ${EMBEDDING_MODEL:-paraphrase-multilingual-MiniLM-L12-v2} EMBEDDING_MODEL: ${EMBEDDING_MODEL:-paraphrase-multilingual-MiniLM-L12-v2}
TOP_K: ${TOP_K:-6} TOP_K: ${TOP_K:-6}
MIN_SIMILARITY: ${MIN_SIMILARITY:-0.35} MIN_SIMILARITY: ${MIN_SIMILARITY:-0.35}
MAX_KEYWORD_ONLY: ${MAX_KEYWORD_ONLY:-2}
INDEX_BATCH_SIZE: ${INDEX_BATCH_SIZE:-512} INDEX_BATCH_SIZE: ${INDEX_BATCH_SIZE:-512}
CONTEXT_WINDOW: ${CONTEXT_WINDOW:-2} CONTEXT_WINDOW: ${CONTEXT_WINDOW:-2}
CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:8080,http://127.0.0.1:8080} CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:8080,http://127.0.0.1:8080}