From 957c73b09e27fe5a1dea0f78acd9e9c348e4e884 Mon Sep 17 00:00:00 2001 From: omp Date: Mon, 2 Nov 2026 11:57:19 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20busca=20h=C3=ADbrida=20(vetorial=20+=20?= =?UTF-8?q?BM25=20com=20fus=C3=A3o=20RRF)=20e=20janela=20de=20contexto?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recuperação: - keyword.py: tokenizador PT (acentos, stopwords, dígitos), BM25 Okapi com índice invertido, e reciprocal_rank_fusion com desempate determinístico - retrieval.py: VectorRetriever e HybridRetriever; fusão por posição, não por nota (cosseno 0..1 e BM25 sem teto são escalas incomparáveis) - identidade de fusão é a coordenada (arquivo,livro,capítulo,versículo): o mesmo versículo chega das duas buscas com distance diferente e o dataclass nunca os igualaria, então o RRF não premiaria o que ambas confirmam - ChatService recebe retriever injetado; referência explícita mantém precedência Contexto: - VerseChunk ganha context (janela de CONTEXT_WINDOW vizinhos por lado, dentro do livro, atravessando capítulos) e embedding_text - store grava a janela como documento e o versículo em verse_text; a UI mostra o versículo citado e o modelo recebe a janela - snapshot() nos dois stores: o BM25 é em memória e o Chroma persiste, então o arranque precisa reconstruir o índice lexical ou a busca degrada em silêncio Motivação medida em 5703 versículos reais: a busca vetorial não liga 'estou com medo do futuro' a 'não temas' (0,19, nível de par sem relação), e e5-small tem faixa de similaridade de só 0,02 — inutilizaria o piso de evidência. --- .env.example | 9 +- README.md | 60 +++++++-- backend/app/chat.py | 24 ++-- backend/app/config.py | 3 + backend/app/corpus.py | 48 +++++-- backend/app/indexer.py | 12 +- backend/app/keyword.py | 147 ++++++++++++++++++++++ backend/app/main.py | 48 ++++++- backend/app/retrieval.py | 147 ++++++++++++++++++++++ backend/app/vector_store.py | 65 +++++++++- backend/tests/test_chat.py | 61 +++++++++ backend/tests/test_corpus.py | 104 +++++++++++++++ backend/tests/test_indexer.py | 12 +- backend/tests/test_keyword.py | 174 ++++++++++++++++++++++++++ backend/tests/test_retrieval.py | 215 ++++++++++++++++++++++++++++++++ docker-compose.yml | 1 + 16 files changed, 1085 insertions(+), 45 deletions(-) create mode 100644 backend/app/keyword.py create mode 100644 backend/app/retrieval.py create mode 100644 backend/tests/test_keyword.py create mode 100644 backend/tests/test_retrieval.py diff --git a/.env.example b/.env.example index 6e4bc85..b56e56e 100644 --- a/.env.example +++ b/.env.example @@ -40,6 +40,9 @@ 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. # --------------------------------------------------------------------------- TOP_K=6 MIN_SIMILARITY=0.35 @@ -47,10 +50,14 @@ MIN_SIMILARITY=0.35 # --------------------------------------------------------------------------- # Ingestão do corpus # Diretório com os arquivos USFX (*.xml), montado somente-leitura em /data/corpus. -# Tamanho do lote gravado no banco vetorial por vez. +# INDEX_BATCH_SIZE: versículos gravados no banco vetorial por lote. +# CONTEXT_WINDOW: vizinhos indexados junto com cada versículo. Um versículo +# isolado é curto demais para o embedding captar o tema; a referência exibida +# continua sendo a do versículo central. # --------------------------------------------------------------------------- # CORPUS_DIR=/data/corpus INDEX_BATCH_SIZE=512 +CONTEXT_WINDOW=2 # --------------------------------------------------------------------------- # API diff --git a/README.md b/README.md index 4f4e614..978b14a 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,16 @@ Containerizado com Docker Compose: frontend React, backend FastAPI, ChromaDB e ## Como funciona ``` -./corpus/*.usfx.xml ──▶ parser USFX ──▶ embeddings ──▶ ChromaDB - │ - navegador ◀── SSE ── FastAPI ◀── busca + validação de citações +./corpus/*.usfx.xml ──▶ parser USFX ──▶ versículo + janela de contexto + │ + ┌───────────────┴───────────────┐ + embeddings (MiniLM) BM25 (palavras) + │ │ + ChromaDB índice em memória + └───────────────┬───────────────┘ + fusão RRF + │ + navegador ◀── SSE ── FastAPI ◀────┴── validação de citações ``` **Fidelidade ao texto.** A resposta só é gerada quando há evidência recuperada do @@ -28,6 +35,26 @@ reconstrução de capítulos a partir de marcadores de página, nem ruído de digitalização — a referência exibida é exatamente a coordenada de onde o texto foi 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: + +- 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 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. + +**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` +vizinhos de cada lado (dentro do mesmo livro, atravessando capítulos). A referência +exibida continua sendo a do versículo central, e é o texto **dele** que aparece na +interface; a janela inteira é o que vai para o modelo como evidência. + --- ## Corpus @@ -161,7 +188,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` | Corte de relevância; abaixo disso o sistema responde que o texto não aborda o assunto | +| `MIN_SIMILARITY` | `0.35` | Piso do lado vetorial; abaixo disso o trecho só entra se o BM25 o encontrar | +| `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`) | | `REINDEX_TOKEN` | vazio | Se preenchido, `/api/reindex` exige o header `X-Reindex-Token` | @@ -173,9 +201,23 @@ Todas as variáveis ficam no `.env`. As principais: ## Solução de problemas **O chat responde que o texto "não aborda diretamente o assunto".** -O índice pode estar vazio (`/api/status`) ou a pergunta pode estar longe do corpus. -Reduza `MIN_SIMILARITY` para 0.25, ou aumente `TOP_K` para 10. Se a pergunta citar -uma referência explícita, a busca é determinística e não depende do limiar. +Nenhum dos dois lados da busca encontrou nada acima do piso. Confira `/api/status` +(índice vazio?), reduza `MIN_SIMILARITY` para 0.25 ou aumente `TOP_K` para 10. Se a +pergunta citar uma referência explícita (`João 3:16`), a busca é determinística por +metadados e não depende de limiar nenhum. + +**As respostas citam versículos que parecem fora do tema.** +Verifique em `/api/status` o campo `model`. Se aparecer +`hash-determinístico (sem semântica)` em vez do nome do modelo configurado, o +embedding **não** está usando o modelo real — o campo `errors` traz o motivo. Isso +acontece quando `torch`/`transformers`/`sentence-transformers` saem de alinhamento; +as versões estão pinadas em `backend/requirements.txt` e no `Dockerfile` exatamente +por isso. Depois de corrigir, reindexe: os vetores gravados são do embedder antigo. + +**A busca por palavra não está funcionando.** +O índice BM25 vive em memória e é reconstruído no arranque e após cada indexação. +Confira no log a linha `Índice de palavras-chave construído com N trechos`. Sem ela, +a recuperação degrada para só-vetorial. **"nenhum versículo pôde ser lido" em `/api/status`.** O arquivo em `./corpus` não é um USFX válido, ou não contém ``. Confira com: @@ -210,8 +252,10 @@ indicado em `OLLAMA_MODEL` na primeira execução. Para outro modelo: │ ├── app/ │ │ ├── config.py # settings via pydantic-settings │ │ ├── models.py # schemas da API -│ │ ├── corpus.py # USFX → versículos + nomes canônicos dos livros +│ │ ├── corpus.py # USFX → versículos + janela de contexto + nomes canônicos │ │ ├── embedding.py # sentence-transformers +│ │ ├── keyword.py # tokenizador PT, BM25 e fusão RRF +│ │ ├── retrieval.py # recuperadores vetorial e híbrido │ │ ├── vector_store.py # ChromaDB (+ versão em memória para testes) │ │ ├── indexer.py # descoberta, manifesto incremental, progresso │ │ ├── llm.py # adaptadores Ollama/OpenAI/Groq/Anthropic/stub diff --git a/backend/app/chat.py b/backend/app/chat.py index 92c5bc1..2c74628 100644 --- a/backend/app/chat.py +++ b/backend/app/chat.py @@ -20,6 +20,7 @@ from typing import Iterable, Sequence from .corpus import canonical_book from .config import Settings from .embedding import Embedder +from .retrieval import Retriever, VectorRetriever from .llm import SYSTEM_PROMPT, LLMProvider from .vector_store import SearchResult, VectorStore @@ -94,7 +95,9 @@ class Source: def from_result(cls, result: SearchResult) -> "Source": return cls( reference=result.reference, - text=result.text, + # O usuário vê o versículo citado, não a janela de contexto que foi + # para o modelo: a referência exibida precisa corresponder ao texto. + text=result.cited_text, book=result.book, chapter=result.chapter, verse=result.verse, @@ -178,11 +181,20 @@ class ChatService: store: VectorStore, embedder: Embedder, provider: LLMProvider, + retriever: Retriever | None = None, ): self.settings = settings self.store = store self.embedder = embedder self.provider = provider + # Sem retriever injetado, mantém o comportamento vetorial simples — os + # testes existentes e qualquer uso embutido seguem iguais. + self.retriever: Retriever = retriever or VectorRetriever( + store=store, + embedder=embedder, + top_k=settings.top_k, + min_similarity=settings.min_similarity, + ) # ------------------------------------------------------------------ # # Recuperação @@ -192,7 +204,8 @@ class ChatService: """Recupera os trechos que sustentam a resposta. Perguntas com referência explícita (`João 3:16`) usam busca determinística - por metadados; as demais usam similaridade semântica. + por metadados; as demais passam pelo retriever configurado (híbrido em + produção: vetorial + BM25). """ reference = extract_reference(message) if reference is not None: @@ -201,12 +214,7 @@ class ChatService: if exact: return exact - vector = self.embedder.encode([message])[0] - return self.store.query( - vector, - n_results=self.settings.top_k, - min_similarity=self.settings.min_similarity, - ) + return self.retriever.search(message) # ------------------------------------------------------------------ # # Contexto diff --git a/backend/app/config.py b/backend/app/config.py index 30000a6..7f37e3a 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -48,6 +48,9 @@ class Settings(BaseSettings): # Tamanho do lote gravado no banco vetorial por vez: limita a memória e dá # granularidade ao progresso da indexação. index_batch_size: int = 512 + # Versículos vizinhos incluídos em cada lado do chunk indexado. Um versículo + # isolado é curto demais para o embedding captar o tema. + context_window: int = 2 # API cors_origins: str = "http://localhost:8080,http://127.0.0.1:8080" diff --git a/backend/app/corpus.py b/backend/app/corpus.py index 704c8d9..c3387fa 100644 --- a/backend/app/corpus.py +++ b/backend/app/corpus.py @@ -160,19 +160,32 @@ def canonical_book(name: str) -> str | None: @dataclass(frozen=True) class VerseChunk: - """Um versículo indexável com seus metadados de origem.""" + """Um versículo indexável com seus metadados de origem. + + `text` é o versículo citado — é o que aparece ao usuário. `context` é a janela + de versículos vizinhos usada na busca: um versículo isolado é curto demais + para o embedding captar o tema (medidos: "estou com medo" contra "não temas" + dava 0,19 de similaridade). A referência continua apontando para o versículo + central, então a citação exibida não perde precisão. + """ book: str chapter: int verse: int file: str text: str + context: str = "" @property def reference(self) -> str: """Referência no formato `Livro Capítulo:Versículo`.""" return f"{self.book} {self.chapter}:{self.verse}" + @property + def embedding_text(self) -> str: + """Texto usado para embeddar e para a busca por palavras-chave.""" + return self.context or self.text + @property def chunk_id(self) -> str: """ID determinístico do chunk no banco vetorial. @@ -211,9 +224,15 @@ def _parse_id(raw: str | None) -> int | None: return int(match.group(1)) if match else None -def parse_usfx(path: Path, file_name: str | None = None) -> Iterator[VerseChunk]: +def parse_usfx( + path: Path, file_name: str | None = None, context_window: int = 2 +) -> Iterator[VerseChunk]: """Percorre um arquivo USFX produzindo um chunk por versículo. + Cada chunk carrega o versículo (`text`) e uma janela de `context_window` + vizinhos para cada lado (`context`). A janela é montada **dentro do livro** — + atravessa capítulos, porque a narrativa continua, mas nunca mistura livros. + Levanta `CorpusParseError` se o arquivo não puder ser lido ou não contiver nenhum livro: um corpus vazio indexado em silêncio deixaria o assistente sem evidência e respondendo a partir do nada. @@ -234,6 +253,7 @@ def parse_usfx(path: Path, file_name: str | None = None) -> Iterator[VerseChunk] raise CorpusParseError(f"{file_name}: nenhum livro encontrado no corpus") skipped = 0 + window = max(0, context_window) for book in books: header = book.find("h") book_name = (header.text or "").strip() if header is not None else "" @@ -241,6 +261,7 @@ def parse_usfx(path: Path, file_name: str | None = None) -> Iterator[VerseChunk] book_id = book.get("id") or "" book_name = canonical_book(book_id) or book_id or "Desconhecido" + entries: list[tuple[int, int, str]] = [] chapter = 0 for element in book: if element.tag == "c": @@ -251,13 +272,22 @@ def parse_usfx(path: Path, file_name: str | None = None) -> Iterator[VerseChunk] if chapter < 1 or verse < 1 or not _is_indexable(text): skipped += 1 continue - yield VerseChunk( - book=book_name, - chapter=chapter, - verse=verse, - file=file_name, - text=text, - ) + entries.append((chapter, verse, text)) + + for index, (verse_chapter, verse_number, text) in enumerate(entries): + if window: + neighbours = entries[max(0, index - window) : index + window + 1] + context = " ".join(neighbour[2] for neighbour in neighbours) + else: + context = text + yield VerseChunk( + book=book_name, + chapter=verse_chapter, + verse=verse_number, + file=file_name, + text=text, + context=context, + ) if skipped: logger.info("%s: %s versículo(s) ignorado(s) sem texto", file_name, skipped) diff --git a/backend/app/indexer.py b/backend/app/indexer.py index 9ceb42d..cba6f5a 100644 --- a/backend/app/indexer.py +++ b/backend/app/indexer.py @@ -17,7 +17,7 @@ from datetime import datetime, timezone from pathlib import Path from .config import Settings -from .corpus import CorpusParseError, VerseChunk, parse_usfx +from .corpus import CorpusParseError, parse_usfx from .embedding import Embedder from .vector_store import VectorStore @@ -198,7 +198,13 @@ class Indexer: self.report_progress(source.name, 0, 0) try: - chunks = list(parse_usfx(source, file_name=source.name)) + chunks = list( + parse_usfx( + source, + file_name=source.name, + context_window=self.settings.context_window, + ) + ) except CorpusParseError as exc: self._manifest[source.name] = _ManifestEntry( sha256=file_hash, state=STATE_ERROR, indexed_at=_now() @@ -224,7 +230,7 @@ class Indexer: batch_size = max(1, self.settings.index_batch_size) for start in range(0, total, batch_size): batch = chunks[start : start + batch_size] - vectors = self.embedder.encode([chunk.text for chunk in batch]) + vectors = self.embedder.encode([chunk.embedding_text for chunk in batch]) self.store.upsert(batch, vectors) self.report_progress(source.name, min(start + batch_size, total), total) diff --git a/backend/app/keyword.py b/backend/app/keyword.py new file mode 100644 index 0000000..d7ef334 --- /dev/null +++ b/backend/app/keyword.py @@ -0,0 +1,147 @@ +"""Busca por palavras-chave (BM25) e fusão de rankings (RRF). + +A busca vetorial falha quando a pergunta compartilha o vocabulário do versículo +mas não a sua forma de frase — "o que Jesus disse sobre amar os inimigos?" não +recuperava Mateus 5:44 com nenhum dos três modelos medidos. O BM25 cobre esse +caso pelo lado lexical. + +As duas escalas são incomparáveis (cosseno em 0..1, BM25 sem limite superior), +então a fusão usa **Reciprocal Rank Fusion**: só a posição em cada ranking +importa, não a pontuação absoluta. +""" + +from __future__ import annotations + +import math +import re +import unicodedata +from collections import defaultdict +from typing import Hashable, Iterable, Sequence + +# Palavras que aparecem em quase todo versículo: não discriminam nada e só +# consomem memória do índice invertido. +STOPWORDS: frozenset[str] = frozenset( + """ + a ao aos aquela aquelas aquele aqueles aquilo as até com como da das de dela + delas dele deles depois do dos e ela elas ele eles em entre era eram essa + essas esse esses esta estas este estes eu foi foram há isso isto ja lhe lhes + mais mas me mesmo meu meus muito na nao nas nem no nos nossa nossas nosso + nossos num numa o os ou para pela pelas pelo pelos por qual quando que quem + se sem ser sua suas seu seus te tem tinha tu tua tuas um uma você vocês vos + """.split() +) + +_NON_WORD_RE = re.compile(r"[^a-z0-9]+") +_DIGITS_RE = re.compile(r"^\d+$") + + +def normalize_token(word: str) -> str: + """Minúsculas e sem acentos, para que "coração" e "coracao" coincidam.""" + decomposed = unicodedata.normalize("NFKD", word) + return "".join(ch for ch in decomposed if not unicodedata.combining(ch)).lower() + + +def tokenize(text: str) -> list[str]: + """Separa o texto em termos indexáveis.""" + tokens: list[str] = [] + for raw in _NON_WORD_RE.split(normalize_token(text or "")): + if not raw or _DIGITS_RE.match(raw): + continue + if raw in STOPWORDS: + continue + tokens.append(raw) + return tokens + + +class BM25Index: + """Índice BM25 (Okapi) sobre documentos de texto. + + Mantido em memória: 31 mil versículos cabem em poucos MB e a reconstrução a + partir do banco vetorial leva cerca de um segundo. + """ + + def __init__(self, k1: float = 1.5, b: float = 0.75): + self.k1 = k1 + self.b = b + self._doc_freqs: dict[str, int] = {} + self._postings: dict[str, list[tuple[int, int]]] = {} + self._doc_lengths: list[int] = [] + self._idf: dict[str, float] = {} + self._avgdl: float = 0.0 + + def __len__(self) -> int: + return len(self._doc_lengths) + + def build(self, documents: Sequence[str]) -> None: + """(Re)constrói o índice, descartando os documentos anteriores.""" + postings: dict[str, list[tuple[int, int]]] = defaultdict(list) + doc_lengths: list[int] = [] + doc_freqs: dict[str, int] = {} + + for index, document in enumerate(documents): + tokens = tokenize(document) + doc_lengths.append(len(tokens)) + counts: dict[str, int] = defaultdict(int) + for token in tokens: + counts[token] += 1 + for token, count in counts.items(): + postings[token].append((index, count)) + doc_freqs[token] = doc_freqs.get(token, 0) + 1 + + total = len(doc_lengths) + self._postings = dict(postings) + self._doc_lengths = doc_lengths + self._doc_freqs = doc_freqs + self._avgdl = (sum(doc_lengths) / total) if total else 0.0 + # IDF de Robertson-Spärck Jones, na forma sempre positiva usada pelo + # Lucene: evita peso negativo para termos presentes em quase todo doc. + self._idf = { + token: math.log(1.0 + (total - freq + 0.5) / (freq + 0.5)) + for token, freq in doc_freqs.items() + } + + def search(self, query: str, k: int = 10) -> list[tuple[int, float]]: + """Devolve `[(índice_do_documento, pontuação)]` em ordem decrescente.""" + if not self._doc_lengths or not self._avgdl: + return [] + + terms = dict.fromkeys(tokenize(query)) + if not terms: + return [] + + scores: dict[int, float] = defaultdict(float) + for term in terms: + idf = self._idf.get(term) + if not idf: + continue + for doc_index, term_freq in self._postings.get(term, ()): + length = self._doc_lengths[doc_index] + denominator = term_freq + self.k1 * ( + 1.0 - self.b + self.b * length / self._avgdl + ) + scores[doc_index] += idf * (term_freq * (self.k1 + 1.0)) / denominator + + ranked = sorted(scores.items(), key=lambda item: (-item[1], item[0])) + return ranked[: max(0, k)] + + +def reciprocal_rank_fusion( + rankings: Iterable[Sequence[Hashable]], limit: int, k: int = 60 +) -> list[tuple[Hashable, float]]: + """Combina rankings por posição: `score = Σ 1/(k + posição)`. + + A posição é 1-based. Itens ausentes de um ranking simplesmente não somam + ali — é isso que faz um resultado confirmado pelas duas buscas liderar. + Empates são desempatados pela ordem de aparição, para o resultado ser + determinístico entre execuções. + """ + scores: dict[Hashable, float] = defaultdict(float) + order: dict[Hashable, int] = {} + + for ranking in rankings: + for position, item in enumerate(ranking, start=1): + scores[item] += 1.0 / (k + position) + order.setdefault(item, len(order)) + + fused = sorted(scores.items(), key=lambda entry: (-entry[1], order[entry[0]])) + return fused[: max(0, limit)] diff --git a/backend/app/main.py b/backend/app/main.py index fc9a171..0d7449e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -35,6 +35,7 @@ from .models import ( SourcesResponse, StatusResponse, ) +from .retrieval import HybridRetriever from .vector_store import InMemoryVectorStore, VectorStore logger = logging.getLogger(__name__) @@ -51,6 +52,7 @@ class Services: indexer: Indexer chat: ChatService last_sources: list[Source] + retriever: HybridRetriever embedder_warning: str | None = None @@ -117,8 +119,19 @@ def create_app( if indexer is None: indexer = Indexer(settings=settings, embedder=embedder, store=store) + retriever = HybridRetriever( + store=store, + embedder=embedder, + top_k=settings.top_k, + min_similarity=settings.min_similarity, + ) + chat = ChatService( - settings=settings, store=store, embedder=embedder, provider=provider + settings=settings, + store=store, + embedder=embedder, + provider=provider, + retriever=retriever, ) services = Services( @@ -129,15 +142,20 @@ def create_app( indexer=indexer, chat=chat, last_sources=[], + retriever=retriever, embedder_warning=embedder_warning, ) reindex_lock = asyncio.Lock() @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncIterator[None]: - """Dispara a indexação inicial sem bloquear o startup.""" - if settings.auto_index: - asyncio.create_task(_run_initial_index(services)) + """Indexa se configurado e sempre reconstrói o índice lexical. + + O BM25 vive em memória enquanto o Chroma persiste em disco: sem a + reconstrução no arranque, um restart deixaria a busca híbrida degradada + em só-vetorial, em silêncio. + """ + asyncio.create_task(_run_initial_index(services)) yield close = getattr(provider, "aclose", None) if close is not None: @@ -291,19 +309,37 @@ def _sse(event: str, data: dict) -> str: async def _run_initial_index(services: Services) -> None: - """Indexa o corpus na inicialização, sem bloquear o startup.""" - await asyncio.to_thread(services.indexer.reindex) + """Indexa o corpus (se `auto_index`) e reconstrói o índice lexical.""" + if services.settings.auto_index: + await asyncio.to_thread(services.indexer.reindex) + await asyncio.to_thread(_rebuild_keyword_index, services) async def _run_reindex(services: Services, lock: asyncio.Lock) -> None: """Executa a reindexação em background, segurando o lock durante o trabalho.""" try: await asyncio.to_thread(services.indexer.reindex, True) + await asyncio.to_thread(_rebuild_keyword_index, services) finally: if lock.locked(): lock.release() +def _rebuild_keyword_index(services: Services) -> None: + """Reconstrói o BM25 a partir do banco vetorial, tolerando falha. + + Se falhar, a busca continua funcionando no lado vetorial — degradada, mas + disponível. Silenciar aqui seria esconder o problema, então fica no log. + """ + try: + services.retriever.rebuild() + except Exception: + logger.exception( + "Falha ao reconstruir o índice de palavras-chave; " + "a busca seguirá apenas vetorial" + ) + + def _build_default_app() -> FastAPI: """Cria a aplicação padrão, tolerando falha no carregamento do Chroma. diff --git a/backend/app/retrieval.py b/backend/app/retrieval.py new file mode 100644 index 0000000..82e5824 --- /dev/null +++ b/backend/app/retrieval.py @@ -0,0 +1,147 @@ +"""Estratégias de recuperação: vetorial e híbrida (vetorial + BM25). + +A recuperação híbrida existe porque as duas buscas erram de modos diferentes. A +vetorial acha "não temas" para "estou com medo" só se o embedding captar a +paráfrase — medido: não capta. A lexical acha Mateus 5:44 para "amar os +inimigos" pela palavra, mas não entende sentimento. Fundir os dois rankings +cobre os dois casos. + +A fusão é por **posição** (RRF), não por pontuação: cosseno vive em 0..1 e BM25 +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 .embedding import Embedder +from .keyword import BM25Index, reciprocal_rank_fusion +from .vector_store import SearchResult + +logger = logging.getLogger(__name__) + +# Identidade de um trecho para fusão. Não pode ser o `SearchResult` inteiro: o +# mesmo versículo chega das duas buscas com `distance` diferente, e como o +# dataclass compara todos os campos as duas ocorrências pareceriam itens +# distintos — o RRF nunca premiaria o que ambas confirmam. +CandidateKey = tuple[str, str, int, int] + + +def _key(result: SearchResult) -> CandidateKey: + return (result.file, result.book, result.chapter, result.verse) + + +class Retriever(Protocol): + """Interface de recuperação de trechos.""" + + def search(self, message: str) -> list[SearchResult]: + """Trechos que sustentam a resposta, em ordem de relevância.""" + ... + + +class VectorRetriever: + """Recuperação só por similaridade vetorial.""" + + def __init__( + self, + store, + embedder: Embedder, + top_k: int = 6, + min_similarity: float = 0.35, + ): + self.store = store + self.embedder = embedder + self.top_k = top_k + self.min_similarity = min_similarity + + def search(self, message: str) -> list[SearchResult]: + if not message or not message.strip(): + return [] + vector = self.embedder.encode([message])[0] + return self.store.query( + vector, n_results=self.top_k, min_similarity=self.min_similarity + ) + + +class HybridRetriever(VectorRetriever): + """Vetorial + BM25 fundidos por RRF.""" + + def __init__( + self, + store, + embedder: Embedder, + top_k: int = 6, + min_similarity: float = 0.35, + keyword_index: BM25Index | None = None, + candidate_multiplier: int = 4, + rrf_k: int = 60, + ): + super().__init__( + store=store, + embedder=embedder, + top_k=top_k, + min_similarity=min_similarity, + ) + self.keyword_index = keyword_index or BM25Index() + self.candidate_multiplier = max(1, candidate_multiplier) + self.rrf_k = rrf_k + self._entries: dict[CandidateKey, SearchResult] = {} + self._order: list[CandidateKey] = [] + + def rebuild(self) -> None: + """Reconstrói o índice lexical a partir do que está no banco vetorial. + + Chamado após cada indexação e no arranque: o Chroma persiste em disco, + mas o BM25 vive em memória e nasce vazio a cada processo novo. + """ + entries = self.store.snapshot() + self._order = [_key(entry) for entry in entries] + self._entries = {_key(entry): entry for entry in entries} + self.keyword_index.build([entry.text for entry in entries]) + logger.info("Índice de palavras-chave construído com %s trechos", len(entries)) + + def search(self, message: str) -> list[SearchResult]: + if not message or not message.strip(): + return [] + + candidates = self.top_k * self.candidate_multiplier + vector_hits = self.store.query( + self.embedder.encode([message])[0], + n_results=candidates, + min_similarity=self.min_similarity, + ) + keyword_hits = self._keyword_search(message, candidates) + + if not keyword_hits: + return vector_hits[: self.top_k] + if not vector_hits: + return keyword_hits[: self.top_k] + + fused = reciprocal_rank_fusion( + [[_key(hit) for hit in vector_hits], [_key(hit) for hit in keyword_hits]], + limit=self.top_k, + k=self.rrf_k, + ) + + # 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 [] + hits: list[SearchResult] = [] + for doc_index, _score in self.keyword_index.search(message, k=limit): + if 0 <= doc_index < len(self._order): + entry = self._entries.get(self._order[doc_index]) + if entry is not None: + hits.append(entry) + return hits diff --git a/backend/app/vector_store.py b/backend/app/vector_store.py index 25f41f7..2fb7e8e 100644 --- a/backend/app/vector_store.py +++ b/backend/app/vector_store.py @@ -16,19 +16,29 @@ logger = logging.getLogger(__name__) @dataclass(frozen=True) class SearchResult: - """Trecho recuperado da busca vetorial.""" + """Trecho recuperado da busca. + + `text` é a janela de contexto que a busca encontrou (e que vai para o LLM); + `verse_text` é só o versículo citado, que é o que o usuário vê. + """ book: str chapter: int verse: int file: str text: str + verse_text: str = "" distance: float = 0.0 @property def reference(self) -> str: return f"{self.book} {self.chapter}:{self.verse}" + @property + def cited_text(self) -> str: + """Texto a exibir como citação: o versículo, ou a janela se não houver.""" + return self.verse_text or self.text + @property def score(self) -> float: """Similaridade em escala 0..1 (1 = idêntico).""" @@ -84,13 +94,17 @@ class VectorStore: self._collection.upsert( ids=[chunk.chunk_id for chunk in chunk_list], embeddings=[list(vector) for vector in vectors], - documents=[chunk.text for chunk in chunk_list], + # O documento gravado é a janela de contexto: é ela que foi embeddada + # e é ela que serve de evidência ao LLM. O versículo citado vai no + # metadado, para a UI mostrar a referência exata. + documents=[chunk.embedding_text for chunk in chunk_list], metadatas=[ { "book": chunk.book, "chapter": chunk.chapter, "verse": chunk.verse, "file": chunk.file, + "verse_text": chunk.text, "reference": f"{chunk.book} {chunk.chapter}:{chunk.verse}", } for chunk in chunk_list @@ -134,6 +148,7 @@ class VectorStore: verse=int(metadata.get("verse", 0)), file=str(metadata.get("file", "")), text=document or "", + verse_text=str(metadata.get("verse_text", "") or ""), distance=float(distance), ) ) @@ -162,11 +177,36 @@ class VectorStore: verse=int(metadata.get("verse", verse)), file=str(metadata.get("file", "")), text=document or "", + verse_text=str(metadata.get("verse_text", "") or ""), distance=0.0, ) for document, metadata in zip(documents, metadatas) ] + def snapshot(self) -> list[SearchResult]: + """Inventário de todos os trechos indexados, sem pontuação. + + Serve para (re)construir o índice de palavras-chave depois de uma + reinicialização, quando o Chroma persistiu mas o BM25 em memória não. + `distance=1.0` deixa `score` em 0: snapshot não é ranking e não pode + fingir similaridade. + """ + raw = self._collection.get(include=["documents", "metadatas"]) + documents = raw.get("documents") or [] + metadatas = raw.get("metadatas") or [] + return [ + SearchResult( + book=str(metadata.get("book", "")), + chapter=int(metadata.get("chapter", 0)), + verse=int(metadata.get("verse", 0)), + file=str(metadata.get("file", "")), + text=document or "", + verse_text=str(metadata.get("verse_text", "") or ""), + distance=1.0, + ) + for document, metadata in zip(documents, metadatas) + ] + def count(self) -> int: """Total de chunks indexados.""" return int(self._collection.count()) @@ -212,7 +252,8 @@ class InMemoryVectorStore: chapter=chunk.chapter, verse=chunk.verse, file=chunk.file, - text=chunk.text, + text=chunk.embedding_text, + verse_text=chunk.text, distance=1.0 - similarity, ) ) @@ -228,12 +269,28 @@ class InMemoryVectorStore: chapter=chunk.chapter, verse=chunk.verse, file=chunk.file, - text=chunk.text, + text=chunk.embedding_text, + verse_text=chunk.text, distance=0.0, ) for chunk in self._chunks.values() if chunk.book == book and chunk.chapter == chapter and chunk.verse == verse ] + def snapshot(self) -> list[SearchResult]: + """Inventário de todos os trechos indexados, sem pontuação.""" + return [ + SearchResult( + book=chunk.book, + chapter=chunk.chapter, + verse=chunk.verse, + file=chunk.file, + text=chunk.embedding_text, + verse_text=chunk.text, + distance=1.0, + ) + for chunk in self._chunks.values() + ] + def count(self) -> int: return len(self._chunks) diff --git a/backend/tests/test_chat.py b/backend/tests/test_chat.py index 159d6da..5208e09 100644 --- a/backend/tests/test_chat.py +++ b/backend/tests/test_chat.py @@ -295,3 +295,64 @@ def test_history_is_limited(settings, store): sent = provider.calls[0] assert len(sent) <= settings.max_history_turns + 1 + + +def test_explicit_reference_bypasses_similarity_search(settings, store): + """Quem digita `João 3:16` quer aquele versículo, não um parecido. + + A busca determinística por metadados tem precedência e não depende de limiar + de similaridade nenhum. + """ + embedder = HashEmbedder(dimensions=64) + target = make_chunk(book="João", chapter=3, verse=16, text="Porque Deus amou o mundo.") + distractor = make_chunk(book="Salmos", chapter=23, verse=1, text="O Senhor é o meu pastor.") + store.upsert( + [target, distractor], + embedder.encode([chunk.embedding_text for chunk in (target, distractor)]), + ) + + class NeverCalled: + def search(self, message): + raise AssertionError("a busca por similaridade não deveria rodar") + + service = ChatService( + settings=settings, + store=store, + embedder=embedder, + provider=RecordingProvider(), + retriever=NeverCalled(), + ) + + results = service.retrieve("o que diz João 3:16?") + + assert [result.reference for result in results] == ["João 3:16"] + + +def test_source_shows_the_cited_verse_not_the_context_window(settings): + """A referência exibida tem de corresponder ao texto exibido. + + A busca usa uma janela de versículos vizinhos, mas o usuário vê a citação: + mostrar a janela inteira rotulada como um único versículo seria mentira. + """ + from app.chat import Source + + store = InMemoryVectorStore(embedder=HashEmbedder(dimensions=64)) + embedder = HashEmbedder(dimensions=64) + chunk = VerseChunk( + book="Salmos", + chapter=23, + verse=1, + file="almeida.usfx.xml", + text="O Senhor é o meu pastor.", + context="Versículo anterior. O Senhor é o meu pastor. Versículo seguinte.", + ) + store.upsert([chunk], embedder.encode([chunk.embedding_text])) + + settings.min_similarity = -1.0 + service = build_service(settings, store, RecordingProvider()) + result = service.retrieve("pastor")[0] + source = Source.from_result(result) + + assert source.reference == "Salmos 23:1" + assert source.text == "O Senhor é o meu pastor." + assert "Versículo anterior" in result.text, "o modelo recebe a janela completa" diff --git a/backend/tests/test_corpus.py b/backend/tests/test_corpus.py index 0910ba7..4bd7131 100644 --- a/backend/tests/test_corpus.py +++ b/backend/tests/test_corpus.py @@ -333,3 +333,107 @@ def test_real_corpus_declares_every_book_as_canonical(): books = {chunk.book for chunk in parse_usfx(REAL_CORPUS)} assert books <= set(BOOKS) + + +# -------------------------------------------------------------------------- +# Janela de contexto +# -------------------------------------------------------------------------- + +CONTEXT_BOOK = book_xml = """ + + Salmos + + O Senhor é o meu pastor; nada me faltará. + Deitar-me faz em verdes pastos. + Guia-me mansamente a águas tranquilas. + Refrigera a minha alma. + + Do Senhor é a terra e a sua plenitude. + +""" + + +def test_text_stays_the_single_verse_and_context_holds_the_window(tmp_path): + """A citação continua apontando para um versículo; o contexto é só para busca.""" + path = write_usfx(tmp_path, CONTEXT_BOOK) + + chunks = list(parse_usfx(path, context_window=1)) + + first = chunks[0] + assert first.text == "O Senhor é o meu pastor; nada me faltará." + assert first.reference == "Salmos 23:1" + assert first.context.startswith("O Senhor é o meu pastor") + assert "Deitar-me faz em verdes pastos." in first.context + + +def test_context_is_clipped_at_the_start_of_the_book(tmp_path): + path = write_usfx(tmp_path, CONTEXT_BOOK) + + chunks = list(parse_usfx(path, context_window=2)) + + assert chunks[0].context.startswith("O Senhor é o meu pastor") + + +def test_context_does_not_cross_books(tmp_path): + """Contexto de outro livro misturaria evidência de obras diferentes.""" + path = write_usfx( + tmp_path, + """ + + Salmos + + Bem-aventurado o varão. + Antes tem o seu prazer na lei. + + + Provérbios + + Provérbios de Salomão. + Para se conhecer a sabedoria. + + """, + ) + + chunks = list(parse_usfx(path, context_window=2)) + salmos = [chunk for chunk in chunks if chunk.book == "Salmos"] + + assert salmos, "esperava chunks de Salmos" + assert all("Provérbios de Salomão" not in chunk.context for chunk in salmos) + + +def test_context_window_zero_falls_back_to_the_verse(tmp_path): + path = write_usfx(tmp_path, CONTEXT_BOOK) + + chunks = list(parse_usfx(path, context_window=0)) + + assert chunks[0].context == chunks[0].text + + +def test_embedding_text_prefers_the_context_window(tmp_path): + path = write_usfx(tmp_path, CONTEXT_BOOK) + + chunk = parse_usfx(path, context_window=1).__next__() + + assert chunk.embedding_text == chunk.context + assert chunk.embedding_text != chunk.text + + +def test_context_window_crosses_chapters_inside_the_same_book(tmp_path): + """A narrativa continua entre capítulos; cortar ali perderia sentido.""" + path = write_usfx(tmp_path, CONTEXT_BOOK) + + chunks = {chunk.reference: chunk for chunk in parse_usfx(path, context_window=1)} + + assert "Do Senhor é a terra" in chunks["Salmos 23:4"].context + assert "Refrigera a minha alma" in chunks["Salmos 24:1"].context + + +@pytest.mark.skipif(not REAL_CORPUS.exists(), reason="corpus real não versionado") +def test_real_corpus_chunks_keep_verse_and_context_consistent(): + chunks = list(parse_usfx(REAL_CORPUS, context_window=2)) + + assert len(chunks) == 31098 + sample = next(c for c in chunks if c.reference == "Salmos 23:1") + assert sample.text == "O Senhor é o meu pastor; nada me faltará." + assert "Deitar-me faz em pastos verdejantes" in sample.context + assert all(chunk.text in chunk.context for chunk in chunks[:500]) diff --git a/backend/tests/test_indexer.py b/backend/tests/test_indexer.py index befb9c7..761ccd2 100644 --- a/backend/tests/test_indexer.py +++ b/backend/tests/test_indexer.py @@ -177,9 +177,9 @@ def test_reindex_is_incremental_when_hash_is_unchanged(settings, monkeypatch): real_parse = indexer_module.parse_usfx - def tracked_parse(path, file_name=None): + def tracked_parse(path, file_name=None, **kwargs): calls.append(Path(path).name) - return real_parse(path, file_name) + return real_parse(path, file_name, **kwargs) monkeypatch.setattr(indexer_module, "parse_usfx", tracked_parse) indexer.reindex() @@ -197,9 +197,9 @@ def test_reindex_reprocesses_when_content_changes(settings, monkeypatch): real_parse = indexer_module.parse_usfx - def tracked_parse(p, file_name=None): + def tracked_parse(p, file_name=None, **kwargs): calls.append(Path(p).name) - return real_parse(p, file_name) + return real_parse(p, file_name, **kwargs) monkeypatch.setattr(indexer_module, "parse_usfx", tracked_parse) @@ -220,9 +220,9 @@ def test_force_reprocesses_everything(settings, monkeypatch): real_parse = indexer_module.parse_usfx - def tracked_parse(path, file_name=None): + def tracked_parse(path, file_name=None, **kwargs): calls.append(Path(path).name) - return real_parse(path, file_name) + return real_parse(path, file_name, **kwargs) monkeypatch.setattr(indexer_module, "parse_usfx", tracked_parse) indexer.reindex(force=True) diff --git a/backend/tests/test_keyword.py b/backend/tests/test_keyword.py new file mode 100644 index 0000000..e36534c --- /dev/null +++ b/backend/tests/test_keyword.py @@ -0,0 +1,174 @@ +"""Testes da busca por palavras-chave (BM25) e da fusão de rankings (RRF). + +A busca vetorial sozinha erra quando a pergunta compartilha palavras com o +versículo mas não a estrutura da frase — "o que Jesus disse sobre amar os +inimigos?" não achava Mateus 5:44 com nenhum dos três modelos medidos. O BM25 +cobre esse caso, e a fusão RRF combina os dois rankings sem exigir que cosseno e +BM25 (escalas incomparáveis) sejam normalizados um contra o outro. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from app.keyword import BM25Index, reciprocal_rank_fusion, tokenize # noqa: E402 + + +# -------------------------------------------------------------------------- +# Tokenizador +# -------------------------------------------------------------------------- + + +def test_tokenize_lowercases_and_strips_accents(): + assert tokenize("Coração") == ["coracao"] + + +def test_tokenize_splits_on_punctuation(): + assert tokenize("Deus é amor; crê.") == ["deus", "amor", "cre"] + + +def test_tokenize_drops_portuguese_stopwords(): + """Stopwords em todo versículo não discriminam nada e só incham o índice.""" + tokens = tokenize("O amor de Deus é grande para com o mundo") + + assert "o" not in tokens + assert "de" not in tokens + assert "e" not in tokens + assert "amor" in tokens and "mundo" in tokens + + +def test_tokenize_drops_pure_numbers(): + assert tokenize("Salmo 23 de Davi") == ["salmo", "davi"] + + +def test_tokenize_handles_empty_and_punctuation_only_input(): + assert tokenize("") == [] + assert tokenize("... — !!!") == [] + + +# -------------------------------------------------------------------------- +# BM25 +# -------------------------------------------------------------------------- + +DOCS = [ + "Eu, porém, vos digo: Amai aos vossos inimigos, e orai pelos que vos perseguem", + "O Senhor é o meu pastor; nada me faltará", + "Deitar-me faz em pastos verdejantes; guia-me mansamente a águas tranquilas", + "Porque Deus amou o mundo de tal maneira que deu o seu Filho unigênito", + "E tirará toda a gordura do novilho da oferta pelo pecado", +] + + +@pytest.fixture +def index() -> BM25Index: + built = BM25Index() + built.build(DOCS) + return built + + +def test_bm25_finds_the_lexical_match(index): + """O caso que a busca vetorial perdeu: a palavra exata está no versículo.""" + hits = index.search("amar os inimigos", k=3) + + assert hits + assert hits[0][0] == 0 + + +def test_bm25_ranks_by_term_specificity(index): + """Termo raro pesa mais: 'pastor' aparece em 1 doc, 'Deus' em 2.""" + hits = dict(index.search("pastor", k=5)) + + assert hits.get(1, 0) > 0 + assert 4 not in hits + + +def test_bm25_returns_empty_when_no_term_matches(index): + assert index.search("astronomia quântica", k=5) == [] + + +def test_bm25_ignores_stopwords_only_query(index): + assert index.search("o de que e", k=5) == [] + + +def test_bm25_empty_index_returns_nothing(): + empty = BM25Index() + empty.build([]) + + assert empty.search("qualquer coisa", k=5) == [] + assert len(empty) == 0 + + +def test_bm25_respects_k(index): + hits = index.search("Deus amor mundo pastor", k=2) + + assert len(hits) <= 2 + + +def test_bm25_scores_are_descending(index): + hits = index.search("Deus amor mundo", k=5) + scores = [score for _, score in hits] + + assert scores == sorted(scores, reverse=True) + + +def test_bm25_rebuild_replaces_the_previous_documents(): + """Reindexar não pode deixar documentos antigos pontuando.""" + index = BM25Index() + index.build(["texto antigo sobre gordura do novilho"]) + index.build(["texto novo sobre o bom pastor"]) + + assert index.search("gordura novilho", k=3) == [] + assert index.search("bom pastor", k=3) + + +# -------------------------------------------------------------------------- +# Fusão RRF +# -------------------------------------------------------------------------- + + +def test_rrf_prefers_items_present_in_both_rankings(): + fused = reciprocal_rank_fusion([["a", "b", "c"], ["b", "c", "d"]], limit=4) + + assert fused[0][0] == "b", "item nos dois rankings deve liderar" + + +def test_rrf_orders_by_rank_within_a_single_list(): + fused = reciprocal_rank_fusion([["a", "b", "c"]], limit=3) + + assert [item for item, _ in fused] == ["a", "b", "c"] + + +def test_rrf_first_position_beats_second_across_lists(): + """Posição é o que conta: rank 1 numa lista vale o mesmo que rank 1 noutra.""" + fused = reciprocal_rank_fusion([["a"], ["b", "c"]], limit=3, k=60) + scores = dict(fused) + + assert scores["a"] == pytest.approx(1 / 61) + assert scores["b"] == pytest.approx(1 / 61), "ambos são rank 1 em suas listas" + assert scores["a"] > scores["c"] + assert scores["c"] == pytest.approx(1 / 62) + + +def test_rrf_respects_limit(): + fused = reciprocal_rank_fusion([["a", "b", "c", "d"]], limit=2) + + assert len(fused) == 2 + + +def test_rrf_handles_empty_rankings(): + assert reciprocal_rank_fusion([], limit=5) == [] + assert reciprocal_rank_fusion([[], []], limit=5) == [] + + +def test_rrf_is_deterministic_on_ties(): + """Empate precisa de desempate estável, senão a ordem varia entre execuções.""" + first = reciprocal_rank_fusion([["a", "b"], ["b", "a"]], limit=2) + second = reciprocal_rank_fusion([["a", "b"], ["b", "a"]], limit=2) + + assert first == second + assert [item for item, _ in first] == ["a", "b"] diff --git a/backend/tests/test_retrieval.py b/backend/tests/test_retrieval.py new file mode 100644 index 0000000..799062b --- /dev/null +++ b/backend/tests/test_retrieval.py @@ -0,0 +1,215 @@ +"""Testes da recuperação híbrida (vetorial + BM25 com fusão RRF). + +O ponto central: o mesmo versículo chega das duas buscas com `distance` +diferente, então a identidade para fusão não pode ser o objeto — tem de ser a +coordenada (arquivo, livro, capítulo, versículo). Sem isso o RRF trata as duas +ocorrências como itens distintos e nunca premia o que as duas buscas confirmam. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +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.vector_store import InMemoryVectorStore, SearchResult # noqa: E402 + + +def chunk(book="Mateus", chapter=5, verse=44, text="Amai aos vossos inimigos.", **kwargs): + return VerseChunk( + book=book, + chapter=chapter, + verse=verse, + file="almeida.usfx.xml", + text=text, + context=text, + **kwargs, + ) + + +@pytest.fixture +def embedder(): + return HashEmbedder(dimensions=32) + + +@pytest.fixture +def store(embedder): + populated = InMemoryVectorStore(embedder=embedder) + chunks = [ + chunk(verse=44, text="Amai aos vossos inimigos, e orai pelos que vos perseguem"), + chunk(chapter=6, verse=14, text="Se perdoardes aos homens as suas ofensas"), + chunk(book="Salmos", chapter=23, verse=1, text="O Senhor é o meu pastor"), + ] + populated.upsert(chunks, embedder.encode([c.embedding_text for c in chunks])) + return populated + + +# -------------------------------------------------------------------------- +# Snapshot do banco +# -------------------------------------------------------------------------- + + +def test_snapshot_returns_every_indexed_chunk(store): + entries = store.snapshot() + + assert len(entries) == 3 + assert {entry.reference for entry in entries} == { + "Mateus 5:44", + "Mateus 6:14", + "Salmos 23:1", + } + + +def test_snapshot_carries_both_the_context_and_the_cited_verse(store): + entry = next(e for e in store.snapshot() if e.reference == "Salmos 23:1") + + assert entry.verse_text == "O Senhor é o meu pastor" + assert entry.text == entry.verse_text + + +def test_snapshot_entries_are_unscored(store): + """Snapshot é inventário, não ranking: não pode fingir similaridade.""" + assert all(entry.score == 0.0 for entry in store.snapshot()) + + +# -------------------------------------------------------------------------- +# Recuperação vetorial (comportamento anterior, preservado) +# -------------------------------------------------------------------------- + + +def test_vector_retriever_respects_min_similarity(store, embedder): + retriever = VectorRetriever( + store=store, embedder=embedder, top_k=3, min_similarity=0.99 + ) + + assert retriever.search("pastor") == [] + + +def test_vector_retriever_returns_hits_above_the_threshold(store, embedder): + retriever = VectorRetriever( + store=store, embedder=embedder, top_k=3, min_similarity=-1.0 + ) + + assert len(retriever.search("pastor")) == 3 + + +# -------------------------------------------------------------------------- +# Recuperação híbrida +# -------------------------------------------------------------------------- + + +def test_hybrid_rebuild_indexes_every_chunk_from_the_store(store, embedder): + retriever = HybridRetriever( + store=store, embedder=embedder, top_k=3, min_similarity=0.99 + ) + retriever.rebuild() + + assert len(retriever.keyword_index) == 3 + + +def test_hybrid_finds_a_lexical_match_the_vector_side_missed(store, embedder): + """min_similarity alto derruba o lado vetorial; o BM25 ainda acha o versículo.""" + retriever = HybridRetriever( + store=store, embedder=embedder, top_k=3, min_similarity=0.99 + ) + retriever.rebuild() + + results = retriever.search("Amai aos vossos inimigos") + + assert [r.reference for r in results] == ["Mateus 5:44"] + + +def test_hybrid_prefers_a_result_confirmed_by_both_sides(store, embedder): + retriever = HybridRetriever( + store=store, embedder=embedder, top_k=3, min_similarity=-1.0 + ) + retriever.rebuild() + + results = retriever.search("O Senhor é o meu pastor") + + assert results[0].reference == "Salmos 23:1" + + +def test_hybrid_merges_the_same_verse_coming_from_both_sides(store, embedder): + """O mesmo versículo não pode aparecer duas vezes na resposta.""" + retriever = HybridRetriever( + store=store, embedder=embedder, top_k=3, min_similarity=-1.0 + ) + retriever.rebuild() + + results = retriever.search("pastor inimigos") + references = [result.reference for result in results] + + assert len(references) == len(set(references)) + + +def test_hybrid_returns_the_cited_verse_text(store, embedder): + retriever = HybridRetriever( + store=store, embedder=embedder, top_k=3, min_similarity=0.99 + ) + retriever.rebuild() + + result = retriever.search("Amai aos vossos inimigos")[0] + + assert result.cited_text.startswith("Amai aos vossos inimigos") + + +def test_hybrid_returns_nothing_when_neither_side_finds_anything(store, embedder): + retriever = HybridRetriever( + store=store, embedder=embedder, top_k=3, min_similarity=0.99 + ) + retriever.rebuild() + + assert retriever.search("astronomia quântica") == [] + + +def test_hybrid_on_empty_index_returns_nothing(embedder): + empty = InMemoryVectorStore(embedder=embedder) + retriever = HybridRetriever( + store=empty, embedder=embedder, top_k=3, min_similarity=0.3 + ) + retriever.rebuild() + + assert retriever.search("qualquer coisa") == [] + + +def test_hybrid_respects_top_k(store, embedder): + retriever = HybridRetriever( + store=store, embedder=embedder, top_k=1, min_similarity=-1.0 + ) + retriever.rebuild() + + assert len(retriever.search("pastor inimigos ofensas")) == 1 + + +def test_keyword_only_hit_is_not_reported_as_a_vector_match(store, embedder): + """Score é similaridade vetorial; achado só por palavra não pode fingir 1.0.""" + retriever = HybridRetriever( + store=store, embedder=embedder, top_k=3, min_similarity=0.99 + ) + retriever.rebuild() + + result = retriever.search("Amai aos vossos inimigos")[0] + + assert result.score == 0.0 + + +def test_rebuild_after_new_chunks_picks_them_up(store, embedder): + retriever = HybridRetriever( + store=store, embedder=embedder, top_k=5, min_similarity=-1.0 + ) + retriever.rebuild() + + extra = chunk(book="João", chapter=3, verse=16, text="Porque Deus amou o mundo") + store.upsert([extra], embedder.encode([extra.embedding_text])) + retriever.rebuild() + + assert len(retriever.keyword_index) == 4 + assert retriever.search("Deus amou o mundo")[0].reference == "João 3:16" diff --git a/docker-compose.yml b/docker-compose.yml index 680891a..a26b18d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,6 +25,7 @@ services: TOP_K: ${TOP_K:-6} MIN_SIMILARITY: ${MIN_SIMILARITY:-0.35} 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} REINDEX_TOKEN: ${REINDEX_TOKEN:-} AUTO_INDEX: ${AUTO_INDEX:-true}