---
adr: 002
title: "Caching e serving do reranker BGE-Reranker-v2-M3"
status: Proposed
date: 2026-05-17
owner: sa-principal
related_phase: Fase 4
---

# ADR-002 — Caching e serving do reranker

## Status

Proposed.

## Context

O `embed-service` (`/Users/guto/ufo/infra/embed-service/app.py`, FastAPI 148 LOC) carrega BGE-M3 + BGE-Reranker-v2-M3 em CPU. Lazy load via `Lock`. Primeira chamada após restart custa ~30s para carregar ~2.5GB em RAM.

O cliente em `/Users/guto/ufo/web/lib/retrieval/embed.ts:16` usa `AbortSignal.timeout(60_000)` — workaround, não solução. O brief lista isso como gap médio: "Reranker 60s".

Cenários observados:

- **Primeira query do dia:** 30-60s (cold start). UX inaceitável.
- **Query repetida quente:** 200-500ms por par query/doc. Para top-100 candidatos → 20-50s. Inaceitável também.
- **Query única quente, candidatos novos:** ~3-8s para top-20 candidates.

A causa raiz não é o tamanho do modelo. É (a) cold start sem warm-up, (b) reranker é chamado síncrono dentro do hybrid_search request, (c) nenhum cache compartilha resultados entre usuários.

## Opções consideradas

### Opção A — Warm-pool + cache de rerank scores

- `healthcheck` do `embed` dispara `/embed` no startup (warm-up).
- `restart: unless-stopped` no docker mantém quente.
- Tabela Postgres `rerank_cache(hash TEXT PK, scores REAL[], created_at TIMESTAMPTZ)` com hash = SHA-256 de `(query_normalized, candidate_ids_sorted)`.
- TTL 24h (lru_count <100k).
- **Custo:** 3d.
- **Prós:** simples, sem novo serviço; ataca cold start e queries repetidas.
- **Contras:** queries únicas com candidatos novos continuam 3-8s.

### Opção B — Warm-pool + cache + rerank assíncrono com SSE progress

- Mesmo cache da Opção A.
- Hybrid search retorna RRF top-20 imediatamente (já ordenado mas sem rerank). UI mostra resultados.
- Rerank roda em background, emite SSE event `rerank_progress` com top-5 reordenado. UI atualiza inline.
- **Custo:** 5d.
- **Prós:** UX percebida em <1s sempre; rerank vira luxo enriquecedor, não bloqueador.
- **Contras:** complexidade de coordenação client/server; rerank ainda gasta CPU.

### Opção C — Migrar reranker para GPU (Modal / RunPod / local GPU box)

- BGE-Reranker-v2-M3 em GPU custa <50ms por par.
- **Custo:** 4d + ongoing $40-150/mês.
- **Prós:** latência ideal.
- **Contras:** **VIOLA restrição "self-hosted Docker Compose em VPS"** se for SaaS; precisa de VPS GPU dedicado (e.g. Hetzner GEX44 ~€100/mês). Não cabe no orçamento atual.

### Opção D — Trocar o reranker por modelo menor

- BGE-Reranker-v2-M3 → MiniLM-L-6 (32x menor, 10x mais rápido, recall pior).
- **Custo:** 2d.
- **Prós:** baixa latência sem trocar de host.
- **Contras:** **recall@5 estimado -10 a -20%**. Não validado. Risco de regressão de qualidade.

### Opção E — Não rerankear; usar só RRF

- Skip rerank. Devolver direto top-k do `hybrid_search_chunks` RPC.
- **Custo:** 0.5d.
- **Prós:** latência consistente <1.3s.
- **Contras:** perde ~5-15% de precisão@5 dependendo da query (BGE-Reranker melhora especialmente queries semânticas longas).

## Decisão

**Adotar Opção B — Warm-pool + cache + rerank assíncrono com SSE progress.**

Fundamentos:

1. **Percepção de latência** é o que conta para o usuário, não latência total. Mostrar top-20 RRF em <1s e enriquecer com rerank em background dá UX excelente sem violar restrições.
2. **Cache cobre Persona A/B (jornalistas, pesquisadores)** que repetem queries semelhantes em sessão.
3. **Warm-pool é mandatório de qualquer forma** — o cold start em produção é defeito.
4. **GPU (Opção C) viola política self-hosted Docker Compose** e não cabe no orçamento. Reabrir só se latência rerank ficar reportada como bloqueante mesmo com cache + async.

## Implementação

### Warm-pool

```yaml
# infra/disclosure-stack/docker-compose.yml — embed:
healthcheck:
  test: ["CMD", "curl", "-f", "-X", "POST",
         "http://localhost:8000/embed",
         "-H", "content-type: application/json",
         "-d", '{"texts":["warmup"]}']
  interval: 60s
  timeout: 30s
  retries: 3
  start_period: 90s  # tolera carregamento inicial
restart: unless-stopped
```

E no `app.py`, um endpoint `/warmup` dedicado que invoca embed + rerank com payload trivial para ambos os modelos.

### Cache

```sql
-- migration 0004_rerank_cache.sql
CREATE TABLE public.rerank_cache (
  hash       TEXT PRIMARY KEY,
  query_normalized TEXT NOT NULL,
  candidate_ids INT[] NOT NULL,
  scores     REAL[] NOT NULL,
  hits       INT NOT NULL DEFAULT 0,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  last_hit   TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_rerank_cache_last_hit ON public.rerank_cache(last_hit);

-- TTL job: DELETE FROM rerank_cache WHERE last_hit < NOW() - INTERVAL '24 hours';
-- Roda via cron container leve, 1x/dia.
```

`web/lib/retrieval/hybrid.ts`:

1. Calcula `hash = sha256(normalize(query) + sort(candidate_ids))`.
2. `SELECT scores FROM rerank_cache WHERE hash = $1`. Se hit, devolve direto.
3. Se miss, chama `/rerank`. Insere com `ON CONFLICT (hash) DO UPDATE SET hits = hits + 1, last_hit = NOW()`.

### Rerank assíncrono

`web/app/api/search/hybrid/route.ts` vira SSE:

1. Recebe query, retorna `top-20 RRF` imediatamente como `event: results.initial`.
2. Em background, chama rerank.
3. Emite `event: results.reranked` com nova ordem.
4. UI: estado `results.optimistic` mostra os 20; ao receber `results.reranked`, transição animada.

Para chat (`hybrid_search` tool), mantém comportamento síncrono — o LLM precisa do top-5 estável. Mas o cache acelera.

## Consequences

### Positivas

- Cold start eliminado pelo warm-pool.
- p50 hybrid_search percebido <800ms.
- p95 rerank warm <3s (com cache hit, instantâneo).
- Cache compartilhado entre usuários — escala viral em queries trending.

### Negativas

- Complexidade de coordenação client/server no SSE inicial→reranked.
- Cache pode entupir se `unaccent + normalization` falhar — monitorar tamanho e hits ratio.
- ~3d a mais que a Opção A simples.

### Neutras

- Tudo se mantém em CPU local; nada vai para SaaS.
- BGE-Reranker-v2-M3 stays — sem regressão de recall.

## Validação

- Latência: medir p50/p95 de `hybrid_search` end-to-end e de `/rerank` isolado, antes e depois.
- Cache hit ratio após 7d de tráfego → meta ≥40%.
- Smoke: matar o container `embed` e verificar que `healthcheck` reativa warm em <90s.

## Referências

- BGE-Reranker-v2-M3 perf: <https://huggingface.co/BAAI/bge-reranker-v2-m3>
- Gap #5 médio do brief
- `web/lib/retrieval/embed.ts:16` timeout 60s atual
