feat: busca híbrida (vetorial + BM25 com fusão RRF) e janela de contexto

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.
This commit is contained in:
omp committed 2026-11-02 11:57:19 -03:00
1 parent 2043d5ddcf
commit 957c73b09e
16 files changed
+1085 -45

No files matched your search

+8 -1
View File
@@ -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
+52 -8
View File
@@ -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 `<book>`. 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
+16 -8
View File
@@ -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
+3
View File
@@ -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"
+39 -9
View File
@@ -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)
+9 -3
View File
@@ -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)
+147
View File
@@ -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)]
+42 -6
View File
@@ -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.
+147
View File
@@ -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
+61 -4
View File
@@ -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)
+61
View File
@@ -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"
+104
View File
@@ -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 = """
<book id="PSA">
<h>Salmos</h>
<c id="23"/>
<v id="1"/>O Senhor é o meu pastor; nada me faltará. <ve/>
<v id="2"/>Deitar-me faz em verdes pastos. <ve/>
<v id="3"/>Guia-me mansamente a águas tranquilas. <ve/>
<v id="4"/>Refrigera a minha alma. <ve/>
<c id="24"/>
<v id="1"/>Do Senhor é a terra e a sua plenitude. <ve/>
</book>
"""
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,
"""
<book id="PSA">
<h>Salmos</h>
<c id="1"/>
<v id="1"/>Bem-aventurado o varão. <ve/>
<v id="2"/>Antes tem o seu prazer na lei. <ve/>
</book>
<book id="PRO">
<h>Provérbios</h>
<c id="1"/>
<v id="1"/>Provérbios de Salomão. <ve/>
<v id="2"/>Para se conhecer a sabedoria. <ve/>
</book>
""",
)
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])
+6 -6
View File
@@ -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)
+174
View File
@@ -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"]
+215
View File
@@ -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"
+1
View File
@@ -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}