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:
1 parent
2043d5ddcf
commit
957c73b09e
16 files changed
+1085
-45
No files matched your search
+8
-1
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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])
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user