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
+1077
-37
No files matched your search
+8
-1
@@ -40,6 +40,9 @@ EMBEDDING_MODEL=paraphrase-multilingual-MiniLM-L12-v2
|
|||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Busca
|
# 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
|
TOP_K=6
|
||||||
MIN_SIMILARITY=0.35
|
MIN_SIMILARITY=0.35
|
||||||
@@ -47,10 +50,14 @@ MIN_SIMILARITY=0.35
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Ingestão do corpus
|
# Ingestão do corpus
|
||||||
# Diretório com os arquivos USFX (*.xml), montado somente-leitura em /data/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
|
# CORPUS_DIR=/data/corpus
|
||||||
INDEX_BATCH_SIZE=512
|
INDEX_BATCH_SIZE=512
|
||||||
|
CONTEXT_WINDOW=2
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# API
|
# API
|
||||||
|
|||||||
@@ -12,9 +12,16 @@ Containerizado com Docker Compose: frontend React, backend FastAPI, ChromaDB e
|
|||||||
## Como funciona
|
## Como funciona
|
||||||
|
|
||||||
```
|
```
|
||||||
./corpus/*.usfx.xml ──▶ parser USFX ──▶ embeddings ──▶ ChromaDB
|
./corpus/*.usfx.xml ──▶ parser USFX ──▶ versículo + janela de contexto
|
||||||
│
|
│
|
||||||
navegador ◀── SSE ── FastAPI ◀── busca + validação de citações
|
┌───────────────┴───────────────┐
|
||||||
|
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
|
**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
|
digitalização — a referência exibida é exatamente a coordenada de onde o texto foi
|
||||||
lido.
|
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
|
## Corpus
|
||||||
@@ -161,7 +188,8 @@ Todas as variáveis ficam no `.env`. As principais:
|
|||||||
| `LLM_PROVIDER` | `ollama` | `ollama`, `openai`, `groq`, `anthropic` ou `stub` |
|
| `LLM_PROVIDER` | `ollama` | `ollama`, `openai`, `groq`, `anthropic` ou `stub` |
|
||||||
| `EMBEDDING_MODEL` | `paraphrase-multilingual-MiniLM-L12-v2` | `all-MiniLM-L6-v2` é menor e mais rápido |
|
| `EMBEDDING_MODEL` | `paraphrase-multilingual-MiniLM-L12-v2` | `all-MiniLM-L6-v2` é menor e mais rápido |
|
||||||
| `TOP_K` | `6` | Quantos trechos são recuperados por pergunta |
|
| `TOP_K` | `6` | Quantos trechos são recuperados por pergunta |
|
||||||
| `MIN_SIMILARITY` | `0.35` | 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 |
|
| `INDEX_BATCH_SIZE` | `512` | Versículos gravados no banco por lote |
|
||||||
| `CORPUS_DIR` | `/data/corpus` | Diretório dos arquivos USFX (montado de `./corpus`) |
|
| `CORPUS_DIR` | `/data/corpus` | Diretório dos arquivos USFX (montado de `./corpus`) |
|
||||||
| `REINDEX_TOKEN` | vazio | Se preenchido, `/api/reindex` exige o header `X-Reindex-Token` |
|
| `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
|
## Solução de problemas
|
||||||
|
|
||||||
**O chat responde que o texto "não aborda diretamente o assunto".**
|
**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.
|
Nenhum dos dois lados da busca encontrou nada acima do piso. Confira `/api/status`
|
||||||
Reduza `MIN_SIMILARITY` para 0.25, ou aumente `TOP_K` para 10. Se a pergunta citar
|
(índice vazio?), reduza `MIN_SIMILARITY` para 0.25 ou aumente `TOP_K` para 10. Se a
|
||||||
uma referência explícita, a busca é determinística e não depende do limiar.
|
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`.**
|
**"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:
|
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/
|
│ ├── app/
|
||||||
│ │ ├── config.py # settings via pydantic-settings
|
│ │ ├── config.py # settings via pydantic-settings
|
||||||
│ │ ├── models.py # schemas da API
|
│ │ ├── 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
|
│ │ ├── 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)
|
│ │ ├── vector_store.py # ChromaDB (+ versão em memória para testes)
|
||||||
│ │ ├── indexer.py # descoberta, manifesto incremental, progresso
|
│ │ ├── indexer.py # descoberta, manifesto incremental, progresso
|
||||||
│ │ ├── llm.py # adaptadores Ollama/OpenAI/Groq/Anthropic/stub
|
│ │ ├── 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 .corpus import canonical_book
|
||||||
from .config import Settings
|
from .config import Settings
|
||||||
from .embedding import Embedder
|
from .embedding import Embedder
|
||||||
|
from .retrieval import Retriever, VectorRetriever
|
||||||
from .llm import SYSTEM_PROMPT, LLMProvider
|
from .llm import SYSTEM_PROMPT, LLMProvider
|
||||||
from .vector_store import SearchResult, VectorStore
|
from .vector_store import SearchResult, VectorStore
|
||||||
|
|
||||||
@@ -94,7 +95,9 @@ class Source:
|
|||||||
def from_result(cls, result: SearchResult) -> "Source":
|
def from_result(cls, result: SearchResult) -> "Source":
|
||||||
return cls(
|
return cls(
|
||||||
reference=result.reference,
|
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,
|
book=result.book,
|
||||||
chapter=result.chapter,
|
chapter=result.chapter,
|
||||||
verse=result.verse,
|
verse=result.verse,
|
||||||
@@ -178,11 +181,20 @@ class ChatService:
|
|||||||
store: VectorStore,
|
store: VectorStore,
|
||||||
embedder: Embedder,
|
embedder: Embedder,
|
||||||
provider: LLMProvider,
|
provider: LLMProvider,
|
||||||
|
retriever: Retriever | None = None,
|
||||||
):
|
):
|
||||||
self.settings = settings
|
self.settings = settings
|
||||||
self.store = store
|
self.store = store
|
||||||
self.embedder = embedder
|
self.embedder = embedder
|
||||||
self.provider = provider
|
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
|
# Recuperação
|
||||||
@@ -192,7 +204,8 @@ class ChatService:
|
|||||||
"""Recupera os trechos que sustentam a resposta.
|
"""Recupera os trechos que sustentam a resposta.
|
||||||
|
|
||||||
Perguntas com referência explícita (`João 3:16`) usam busca determinística
|
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)
|
reference = extract_reference(message)
|
||||||
if reference is not None:
|
if reference is not None:
|
||||||
@@ -201,12 +214,7 @@ class ChatService:
|
|||||||
if exact:
|
if exact:
|
||||||
return exact
|
return exact
|
||||||
|
|
||||||
vector = self.embedder.encode([message])[0]
|
return self.retriever.search(message)
|
||||||
return self.store.query(
|
|
||||||
vector,
|
|
||||||
n_results=self.settings.top_k,
|
|
||||||
min_similarity=self.settings.min_similarity,
|
|
||||||
)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Contexto
|
# Contexto
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ class Settings(BaseSettings):
|
|||||||
# Tamanho do lote gravado no banco vetorial por vez: limita a memória e dá
|
# Tamanho do lote gravado no banco vetorial por vez: limita a memória e dá
|
||||||
# granularidade ao progresso da indexação.
|
# granularidade ao progresso da indexação.
|
||||||
index_batch_size: int = 512
|
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
|
# API
|
||||||
cors_origins: str = "http://localhost:8080,http://127.0.0.1:8080"
|
cors_origins: str = "http://localhost:8080,http://127.0.0.1:8080"
|
||||||
|
|||||||
+34
-4
@@ -160,19 +160,32 @@ def canonical_book(name: str) -> str | None:
|
|||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class VerseChunk:
|
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
|
book: str
|
||||||
chapter: int
|
chapter: int
|
||||||
verse: int
|
verse: int
|
||||||
file: str
|
file: str
|
||||||
text: str
|
text: str
|
||||||
|
context: str = ""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def reference(self) -> str:
|
def reference(self) -> str:
|
||||||
"""Referência no formato `Livro Capítulo:Versículo`."""
|
"""Referência no formato `Livro Capítulo:Versículo`."""
|
||||||
return f"{self.book} {self.chapter}:{self.verse}"
|
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
|
@property
|
||||||
def chunk_id(self) -> str:
|
def chunk_id(self) -> str:
|
||||||
"""ID determinístico do chunk no banco vetorial.
|
"""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
|
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.
|
"""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
|
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
|
nenhum livro: um corpus vazio indexado em silêncio deixaria o assistente sem
|
||||||
evidência e respondendo a partir do nada.
|
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")
|
raise CorpusParseError(f"{file_name}: nenhum livro encontrado no corpus")
|
||||||
|
|
||||||
skipped = 0
|
skipped = 0
|
||||||
|
window = max(0, context_window)
|
||||||
for book in books:
|
for book in books:
|
||||||
header = book.find("h")
|
header = book.find("h")
|
||||||
book_name = (header.text or "").strip() if header is not None else ""
|
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_id = book.get("id") or ""
|
||||||
book_name = canonical_book(book_id) or book_id or "Desconhecido"
|
book_name = canonical_book(book_id) or book_id or "Desconhecido"
|
||||||
|
|
||||||
|
entries: list[tuple[int, int, str]] = []
|
||||||
chapter = 0
|
chapter = 0
|
||||||
for element in book:
|
for element in book:
|
||||||
if element.tag == "c":
|
if element.tag == "c":
|
||||||
@@ -251,12 +272,21 @@ def parse_usfx(path: Path, file_name: str | None = None) -> Iterator[VerseChunk]
|
|||||||
if chapter < 1 or verse < 1 or not _is_indexable(text):
|
if chapter < 1 or verse < 1 or not _is_indexable(text):
|
||||||
skipped += 1
|
skipped += 1
|
||||||
continue
|
continue
|
||||||
|
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(
|
yield VerseChunk(
|
||||||
book=book_name,
|
book=book_name,
|
||||||
chapter=chapter,
|
chapter=verse_chapter,
|
||||||
verse=verse,
|
verse=verse_number,
|
||||||
file=file_name,
|
file=file_name,
|
||||||
text=text,
|
text=text,
|
||||||
|
context=context,
|
||||||
)
|
)
|
||||||
|
|
||||||
if skipped:
|
if skipped:
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from datetime import datetime, timezone
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .config import Settings
|
from .config import Settings
|
||||||
from .corpus import CorpusParseError, VerseChunk, parse_usfx
|
from .corpus import CorpusParseError, parse_usfx
|
||||||
from .embedding import Embedder
|
from .embedding import Embedder
|
||||||
from .vector_store import VectorStore
|
from .vector_store import VectorStore
|
||||||
|
|
||||||
@@ -198,7 +198,13 @@ class Indexer:
|
|||||||
self.report_progress(source.name, 0, 0)
|
self.report_progress(source.name, 0, 0)
|
||||||
|
|
||||||
try:
|
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:
|
except CorpusParseError as exc:
|
||||||
self._manifest[source.name] = _ManifestEntry(
|
self._manifest[source.name] = _ManifestEntry(
|
||||||
sha256=file_hash, state=STATE_ERROR, indexed_at=_now()
|
sha256=file_hash, state=STATE_ERROR, indexed_at=_now()
|
||||||
@@ -224,7 +230,7 @@ class Indexer:
|
|||||||
batch_size = max(1, self.settings.index_batch_size)
|
batch_size = max(1, self.settings.index_batch_size)
|
||||||
for start in range(0, total, batch_size):
|
for start in range(0, total, batch_size):
|
||||||
batch = chunks[start : start + 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.store.upsert(batch, vectors)
|
||||||
self.report_progress(source.name, min(start + batch_size, total), total)
|
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)]
|
||||||
+40
-4
@@ -35,6 +35,7 @@ from .models import (
|
|||||||
SourcesResponse,
|
SourcesResponse,
|
||||||
StatusResponse,
|
StatusResponse,
|
||||||
)
|
)
|
||||||
|
from .retrieval import HybridRetriever
|
||||||
from .vector_store import InMemoryVectorStore, VectorStore
|
from .vector_store import InMemoryVectorStore, VectorStore
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -51,6 +52,7 @@ class Services:
|
|||||||
indexer: Indexer
|
indexer: Indexer
|
||||||
chat: ChatService
|
chat: ChatService
|
||||||
last_sources: list[Source]
|
last_sources: list[Source]
|
||||||
|
retriever: HybridRetriever
|
||||||
embedder_warning: str | None = None
|
embedder_warning: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@@ -117,8 +119,19 @@ def create_app(
|
|||||||
if indexer is None:
|
if indexer is None:
|
||||||
indexer = Indexer(settings=settings, embedder=embedder, store=store)
|
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(
|
chat = ChatService(
|
||||||
settings=settings, store=store, embedder=embedder, provider=provider
|
settings=settings,
|
||||||
|
store=store,
|
||||||
|
embedder=embedder,
|
||||||
|
provider=provider,
|
||||||
|
retriever=retriever,
|
||||||
)
|
)
|
||||||
|
|
||||||
services = Services(
|
services = Services(
|
||||||
@@ -129,14 +142,19 @@ def create_app(
|
|||||||
indexer=indexer,
|
indexer=indexer,
|
||||||
chat=chat,
|
chat=chat,
|
||||||
last_sources=[],
|
last_sources=[],
|
||||||
|
retriever=retriever,
|
||||||
embedder_warning=embedder_warning,
|
embedder_warning=embedder_warning,
|
||||||
)
|
)
|
||||||
reindex_lock = asyncio.Lock()
|
reindex_lock = asyncio.Lock()
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||||
"""Dispara a indexação inicial sem bloquear o startup."""
|
"""Indexa se configurado e sempre reconstrói o índice lexical.
|
||||||
if settings.auto_index:
|
|
||||||
|
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))
|
asyncio.create_task(_run_initial_index(services))
|
||||||
yield
|
yield
|
||||||
close = getattr(provider, "aclose", None)
|
close = getattr(provider, "aclose", None)
|
||||||
@@ -291,19 +309,37 @@ def _sse(event: str, data: dict) -> str:
|
|||||||
|
|
||||||
|
|
||||||
async def _run_initial_index(services: Services) -> None:
|
async def _run_initial_index(services: Services) -> None:
|
||||||
"""Indexa o corpus na inicialização, sem bloquear o startup."""
|
"""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(services.indexer.reindex)
|
||||||
|
await asyncio.to_thread(_rebuild_keyword_index, services)
|
||||||
|
|
||||||
|
|
||||||
async def _run_reindex(services: Services, lock: asyncio.Lock) -> None:
|
async def _run_reindex(services: Services, lock: asyncio.Lock) -> None:
|
||||||
"""Executa a reindexação em background, segurando o lock durante o trabalho."""
|
"""Executa a reindexação em background, segurando o lock durante o trabalho."""
|
||||||
try:
|
try:
|
||||||
await asyncio.to_thread(services.indexer.reindex, True)
|
await asyncio.to_thread(services.indexer.reindex, True)
|
||||||
|
await asyncio.to_thread(_rebuild_keyword_index, services)
|
||||||
finally:
|
finally:
|
||||||
if lock.locked():
|
if lock.locked():
|
||||||
lock.release()
|
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:
|
def _build_default_app() -> FastAPI:
|
||||||
"""Cria a aplicação padrão, tolerando falha no carregamento do Chroma.
|
"""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)
|
@dataclass(frozen=True)
|
||||||
class SearchResult:
|
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
|
book: str
|
||||||
chapter: int
|
chapter: int
|
||||||
verse: int
|
verse: int
|
||||||
file: str
|
file: str
|
||||||
text: str
|
text: str
|
||||||
|
verse_text: str = ""
|
||||||
distance: float = 0.0
|
distance: float = 0.0
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def reference(self) -> str:
|
def reference(self) -> str:
|
||||||
return f"{self.book} {self.chapter}:{self.verse}"
|
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
|
@property
|
||||||
def score(self) -> float:
|
def score(self) -> float:
|
||||||
"""Similaridade em escala 0..1 (1 = idêntico)."""
|
"""Similaridade em escala 0..1 (1 = idêntico)."""
|
||||||
@@ -84,13 +94,17 @@ class VectorStore:
|
|||||||
self._collection.upsert(
|
self._collection.upsert(
|
||||||
ids=[chunk.chunk_id for chunk in chunk_list],
|
ids=[chunk.chunk_id for chunk in chunk_list],
|
||||||
embeddings=[list(vector) for vector in vectors],
|
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=[
|
metadatas=[
|
||||||
{
|
{
|
||||||
"book": chunk.book,
|
"book": chunk.book,
|
||||||
"chapter": chunk.chapter,
|
"chapter": chunk.chapter,
|
||||||
"verse": chunk.verse,
|
"verse": chunk.verse,
|
||||||
"file": chunk.file,
|
"file": chunk.file,
|
||||||
|
"verse_text": chunk.text,
|
||||||
"reference": f"{chunk.book} {chunk.chapter}:{chunk.verse}",
|
"reference": f"{chunk.book} {chunk.chapter}:{chunk.verse}",
|
||||||
}
|
}
|
||||||
for chunk in chunk_list
|
for chunk in chunk_list
|
||||||
@@ -134,6 +148,7 @@ class VectorStore:
|
|||||||
verse=int(metadata.get("verse", 0)),
|
verse=int(metadata.get("verse", 0)),
|
||||||
file=str(metadata.get("file", "")),
|
file=str(metadata.get("file", "")),
|
||||||
text=document or "",
|
text=document or "",
|
||||||
|
verse_text=str(metadata.get("verse_text", "") or ""),
|
||||||
distance=float(distance),
|
distance=float(distance),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -162,11 +177,36 @@ class VectorStore:
|
|||||||
verse=int(metadata.get("verse", verse)),
|
verse=int(metadata.get("verse", verse)),
|
||||||
file=str(metadata.get("file", "")),
|
file=str(metadata.get("file", "")),
|
||||||
text=document or "",
|
text=document or "",
|
||||||
|
verse_text=str(metadata.get("verse_text", "") or ""),
|
||||||
distance=0.0,
|
distance=0.0,
|
||||||
)
|
)
|
||||||
for document, metadata in zip(documents, metadatas)
|
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:
|
def count(self) -> int:
|
||||||
"""Total de chunks indexados."""
|
"""Total de chunks indexados."""
|
||||||
return int(self._collection.count())
|
return int(self._collection.count())
|
||||||
@@ -212,7 +252,8 @@ class InMemoryVectorStore:
|
|||||||
chapter=chunk.chapter,
|
chapter=chunk.chapter,
|
||||||
verse=chunk.verse,
|
verse=chunk.verse,
|
||||||
file=chunk.file,
|
file=chunk.file,
|
||||||
text=chunk.text,
|
text=chunk.embedding_text,
|
||||||
|
verse_text=chunk.text,
|
||||||
distance=1.0 - similarity,
|
distance=1.0 - similarity,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -228,12 +269,28 @@ class InMemoryVectorStore:
|
|||||||
chapter=chunk.chapter,
|
chapter=chunk.chapter,
|
||||||
verse=chunk.verse,
|
verse=chunk.verse,
|
||||||
file=chunk.file,
|
file=chunk.file,
|
||||||
text=chunk.text,
|
text=chunk.embedding_text,
|
||||||
|
verse_text=chunk.text,
|
||||||
distance=0.0,
|
distance=0.0,
|
||||||
)
|
)
|
||||||
for chunk in self._chunks.values()
|
for chunk in self._chunks.values()
|
||||||
if chunk.book == book and chunk.chapter == chapter and chunk.verse == verse
|
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:
|
def count(self) -> int:
|
||||||
return len(self._chunks)
|
return len(self._chunks)
|
||||||
@@ -295,3 +295,64 @@ def test_history_is_limited(settings, store):
|
|||||||
|
|
||||||
sent = provider.calls[0]
|
sent = provider.calls[0]
|
||||||
assert len(sent) <= settings.max_history_turns + 1
|
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)}
|
books = {chunk.book for chunk in parse_usfx(REAL_CORPUS)}
|
||||||
|
|
||||||
assert books <= set(BOOKS)
|
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
|
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)
|
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)
|
monkeypatch.setattr(indexer_module, "parse_usfx", tracked_parse)
|
||||||
indexer.reindex()
|
indexer.reindex()
|
||||||
@@ -197,9 +197,9 @@ def test_reindex_reprocesses_when_content_changes(settings, monkeypatch):
|
|||||||
|
|
||||||
real_parse = indexer_module.parse_usfx
|
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)
|
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)
|
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
|
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)
|
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)
|
monkeypatch.setattr(indexer_module, "parse_usfx", tracked_parse)
|
||||||
indexer.reindex(force=True)
|
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}
|
TOP_K: ${TOP_K:-6}
|
||||||
MIN_SIMILARITY: ${MIN_SIMILARITY:-0.35}
|
MIN_SIMILARITY: ${MIN_SIMILARITY:-0.35}
|
||||||
INDEX_BATCH_SIZE: ${INDEX_BATCH_SIZE:-512}
|
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}
|
CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:8080,http://127.0.0.1:8080}
|
||||||
REINDEX_TOKEN: ${REINDEX_TOKEN:-}
|
REINDEX_TOKEN: ${REINDEX_TOKEN:-}
|
||||||
AUTO_INDEX: ${AUTO_INDEX:-true}
|
AUTO_INDEX: ${AUTO_INDEX:-true}
|
||||||
|
|||||||
Reference in new issue
Block a user