diff --git a/.env.example b/.env.example index b56e56e..0ed3853 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index 978b14a..eb71d40 100644 --- a/README.md +++ b/README.md @@ -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`) | diff --git a/backend/app/config.py b/backend/app/config.py index 7f37e3a..cbfd456 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -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 diff --git a/backend/app/llm.py b/backend/app/llm.py index c4a27d5..7f2df40 100644 --- a/backend/app/llm.py +++ b/backend/app/llm.py @@ -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.""" diff --git a/backend/app/main.py b/backend/app/main.py index 0d7449e..74f58f2 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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( diff --git a/backend/app/retrieval.py b/backend/app/retrieval.py index 3ccf40a..29eef7b 100644 --- a/backend/app/retrieval.py +++ b/backend/app/retrieval.py @@ -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 [] diff --git a/backend/tests/test_llm.py b/backend/tests/test_llm.py index 84b7436..cbaabe4 100644 --- a/backend/tests/test_llm.py +++ b/backend/tests/test_llm.py @@ -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 diff --git a/backend/tests/test_retrieval.py b/backend/tests/test_retrieval.py index d661b5a..04d2c1c 100644 --- a/backend/tests/test_retrieval.py +++ b/backend/tests/test_retrieval.py @@ -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"] diff --git a/docker-compose.yml b/docker-compose.yml index a26b18d..d66ebf0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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}