homelab-codex-ws/kb/services/kb-query.md
oskar b1b6692382 feat(kb-query): domyslny mode /search = hybrid (DoD (d) fazy mailowej)
Eval na pelnym korpusie 2026-08-06 (187 025 zembedowanych chunkow mailowych
w HNSW) dal PASS: kryterium 1 (regresja paperless) bez degradacji zadnego
istniejacego hitu we flat ani w hybrid, mailowe hit@3 5/5. Koszt hybrydy to
jedno dodatkowe zapytanie SQL na wyszukiwanie. Surowe wyniki:
eval-http-2026-08-06.json / eval-direct-2026-08-06.json w ~/kb/mail/ingest-logs
na PIHA (niecommitowane, artefakt runu).

- app/main.py: Query("cascade") -> Query("hybrid"); pattern bez zmian, wiec
  jawne ?mode=cascade i ?mode=flat dzialaja dokladnie jak dotad.
- app/static/app.js: przy odznaczonym "tryb flat (debug)" UI nie wysyla juz
  parametru mode w ogole -- dziedziczy default API. Default zdefiniowany
  w jednym miejscu (serwer), nie zduplikowany w JS.
- testy: nowa klasa TestSearchEndpointModeDefault (TestClient bez lifespan,
  fake pool/router) sprawdza kontrakt HTTP -- brak mode => tor hybrid
  (weryfikowany po obecnosci koperty gmail osiagalnej wylacznie galezia
  hybrid, nie po samej etykiecie), jawne mode=flat / mode=cascade => stare
  tory, nieznany mode => 422. Frontend: buildSearchUrl pomija mode gdy brak.
- docs: kb/services/kb-query.md (tabela trybow + endpoint + przyklad
  odpowiedzi + opis przelacznika w UI), env.example/service.yaml (komentarze
  SUMMARY_MODEL; default mode nie jest konfigurowalny przez env),
  kb/phases/kb-m5-faza-mailowa.md (DoD (d) SPELNIONE 2026-08-06 + wzmianki
  w Kroku 3, Wyniku bramki, decyzjach Etapu B i tabeli planu).

Weryfikacja: pytest services/kb-query -> 46 passed; node --test
tests/frontend/app.test.js -> 6/6; docker build OK + smoke run (uvicorn
startuje, bez KB_DSN swiadomie konczy sie RuntimeError z env.example).
Deploy NIE wykonany -- operator wdraza z mastera na PIHA po mergu.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 13:30:06 +02:00

190 lines
10 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
okf: "0.1"
type: service
visibility: private
status: active
updated: 2026-08-06
links:
- ../runbooks/kb-query-deploy.md
---
# kb-query
FastAPI search API in front of the module-5 KB retrieval engine
(`packages/kb-retrieval/`). Runs on **PIHA**, bound to PIHA's LAN IP only
(`exposure: private`, same class as paperless/nextcloud — no public ingress
yet). This is a **search API, not chat**: no answer synthesis over results,
that's phase 5.
## Endpoints
| Endpoint | Method | Purpose |
|---|---|---|
| `/` | GET | Search UI (Jinja2 shell + `/static/app.js`, no login yet — plan §8 OIDC is a later step) |
| `/static/*` | GET | UI assets (`app.js`, `style.css`) |
| `/healthz` | GET | `{"status": "ok", "sol_status": "up"\|"down", "fallback_status": "up"\|"down"\|"unconfigured"}``sol_status` comes from the embed router's ~30 s-cached SOLARIA probe (same world view `/search` routes by), `fallback_status` is a live cheap probe of ollama-piha; no auth required (monitoring must reach it) |
| `/search?q=<text>&mode=hybrid\|cascade\|flat` | GET | `query_text -> embed -> hybrid/cascade/flat retrieval -> results`, `mode` **defaults to `hybrid`** (od 2026-08-06 — patrz „Tryby wyszukiwania" niżej) |
### Tryby wyszukiwania (`mode`)
| `mode` | Co robi | Kiedy |
|---|---|---|
| `hybrid` (**domyślny** od 2026-08-06) | kaskada (prefiltr po `document_summary`) **+** równoległe skanowanie chunków kopert ze źródeł bez streszczeń (`gmail`), merge po `dist` | normalne zapytania — jedyny tryb, w którym widać treść maili |
| `cascade` | tylko tor dokumentowy (prefiltr po streszczeniach) — poprzedni domyślny | porównania/debug toru dokumentowego |
| `flat` | pojedynczy skan `document_chunk` bez prefiltru | debug jakości prefiltru |
Domyślny `mode` przełączono na `hybrid` **2026-08-06** po ponownym przejściu
bramki jakościowej (`kb/phases/kb-m5-faza-mailowa.md` §8, DoD (d)) na pełnym
korpusie mailowym (187 025 zembedowanych chunków w HNSW): kryterium regresji
paperless PASS (żaden istniejący hit nie zdegradował), mailowe hit@3 5/5.
Koszt: jedno dodatkowe zapytanie SQL na wyszukiwanie (skan chunków źródeł
summaryless). Jawne `?mode=cascade` / `?mode=flat` działają bez zmian; nieznana
wartość `mode` to 422 (pattern `^(cascade|flat|hybrid)$`).
`/search` response shape (module 5 phase 4 plan §4, `summary`/`summary_tags`
added in Krok 4 for the UI's per-envelope result header — additive, does not
change any field the plan §4 shape already defined):
```json
{
"query": "...", "mode": "hybrid", "sol_status": "up", "embed_backend": "solaria",
"results": [
{"envelope_id": "paperless:119", "source": "paperless", "dist": 0.34,
"chunk_index": 2, "text": "...", "link": "https://paper.kapala.org/documents/119/details",
"summary": "...", "summary_tags": ["..."]},
{"envelope_id": "<Message-ID>", "source": "gmail", "dist": 0.44,
"chunk_index": 0, "text": "...", "subject": "...", "from": "...", "date": "...",
"link": null, "mail_ui_url": null, "summary": null, "summary_tags": []}
]
}
```
`dist` is never filtered server-side — the 0.45/0.55 colour thresholds (below)
are a frontend concern, not an API contract. `summary`/`summary_tags` come
from `document_summary` for `SUMMARY_MODEL`; `null`/`[]` when the envelope
has no summary yet.
## Frontend (Krok 4, plan §7)
One page, served from this same FastAPI process — no separate frontend
container, no node build step (plan §2 decision 4): `app/templates/index.html`
(Jinja2 shell) + `app/static/app.js` (vanilla JS, `fetch()` to `/search`) +
`app/static/style.css`. Wszystko po polsku.
- Pole zapytania + submit (Enter lub przycisk), przełącznik trybu (checkbox
„tryb flat (debug)"). Odznaczony — czyli normalny przypadek — **nie wysyła
parametru `mode` w ogóle**, więc UI dziedziczy domyślny tryb API (`hybrid`
od 2026-08-06); zaznaczony wysyła jawne `mode=flat`. Domyślny tryb jest
zdefiniowany w jednym miejscu (serwer), nie zduplikowany w JS.
- Wyniki grupowane po `envelope_id` (dokument): nagłówek trafienia to
streszczenie dokumentu (`summary`, tor haiku) gdy dostępne, w przeciwnym
razie `envelope_id`; chunki są rozwijanymi fragmentami (`<details>`) pod
nagłówkiem, posortowane po `dist`.
- Kolorowanie progów (fazy 3, zweryfikowane bramką): `dist < 0.45` zielony,
`0.450.55` żółty (nadal renderowany, z wizualnym ostrzeżeniem), `> 0.55`
nigdy nie renderowany jako pojedynczy wynik. Jeśli po tym filtrze żadna
grupa nie zostaje nic do pokazania (wszystkie trafienia > 0.55, albo brak
trafień w ogóle), całość zastępuje komunikat "Brak odpowiedzi w KB dla
tego zapytania" z najlepszym (najniższym) zaobserwowanym `dist` w nawiasie.
- Źródło: Paperless → link "Otwórz w Paperless" (`link`); Gmail → metadane
(`subject`/`from`/`date`) + przycisk "Kopiuj Message-ID" (`envelope_id`
**jest** Message-ID, plan §2 decyzja 3) — nie ma dokąd linkować, więc
kopiowalny identyfikator zamiast martwego linku.
- Stopka pokazuje `sol_status` dyskretnie (odświeżane z `/healthz` przy
starcie strony i po każdym wyszukiwaniu).
## Embed path — active SOLARIA→PIHA fallback (Krok 2, plan §2 D2/§5)
Query embeddings go through `app/embed_router.py` (`EmbedRouter`, one instance
per process):
1. SOLARIA's health verdict (`GET /api/tags`, 1.5 s timeout) is **cached for
30 s** — no per-request probing.
2. Verdict `up` → embed on `EMBED_PRIMARY_URL` (SOLARIA GPU, ~207 ms) under a
hard 3 s timeout.
3. A failure **during a real embed** flips the verdict to `down` for one TTL
window and the **same request** is served from `EMBED_FALLBACK_URL`
(`ollama-piha`, CPU, `OLLAMA_KEEP_ALIVE=0` — slower, single seconds, but
alive) — the user never sees a SOLARIA error while a fallback exists.
4. Verdict `down` → straight to the fallback until the TTL expires; when
SOLARIA wakes up, traffic returns to the GPU within ≤30 s.
Every `/search` response and log line says which backend embedded the query
(`embed_backend: "solaria"|"piha"`, log `backend=…` in `kb-query.embed`) —
needed to debug result quality per backend. `sol_status` in the response is
the router's world view; the UI footer renders `down` as
„SOLARIA: offline (fallback embed)".
**Model invariant, both halves:** at startup kb-query pins `EMBED_MODEL`
(bge-m3) to `document_chunk.model`/`document_summary.embedding_model` (below);
additionally each backend is verified once, at its first use, that its
`/api/tags` actually lists `EMBED_MODEL`. A backend serving the wrong model is
a **loud ERROR + 500** — never a silent distance computation across two
different embedding spaces. Verification is lazy because SOLARIA may be asleep
at boot and must not block startup.
`/search` returns **503** only when BOTH backends are unreachable (or
`EMBED_FALLBACK_URL` is unset — then the pre-fallback behaviour applies);
`/healthz` still answers, same tolerance pattern as `llm-gateway`.
## Startup invariant (hard-fail)
At startup, kb-query queries `document_chunk.model` and
`document_summary.embedding_model` for the set of models behind *active*
embeddings, and refuses to start (crash-loop, visible via container restarts)
if the configured `EMBED_MODEL` (default `bge-m3`) isn't in both sets. This
guards against querying with an embedding space that doesn't match what's
actually indexed — see `app/startup.py` for why the check reads
`document_summary.embedding_model` and not `.model` (the latter is the LLM
that *wrote* the summary, e.g. `claude-haiku-4-5`, not the embedder).
## Configuration
`.env`**gitignored**, copy from `env.example`. Required: `LAN_BIND_IP`,
`KB_DSN`. On PIHA also set `EMBED_FALLBACK_URL=http://192.168.31.5:11434`
(ollama-piha). Optional (defaults in parentheses): `EMBED_PRIMARY_URL`
(`http://solaria:11434`), `EMBED_PRIMARY_NAME`/`EMBED_FALLBACK_NAME`
(`solaria`/`piha`), `EMBED_HEALTH_TTL_S` (30), `EMBED_HEALTH_TIMEOUT_S` (1.5),
`EMBED_PRIMARY_TIMEOUT_S` (3), `EMBED_MODEL` (`bge-m3`), `SUMMARY_MODEL`
(`claude-haiku-4-5`). `OLLAMA_URL` was **renamed** to `EMBED_PRIMARY_URL` in
Krok 2 — if an old `.env` sets `OLLAMA_URL`, it is ignored.
## Ingress (`kb.kapala.org`, plan §8)
Wired up 2026-07-23 (`docs/sessions/2026-07-23-kb-f4-ingress.md`), **no code
change in this service** — pure infra step:
- npm@PIHA proxy host #35: `kb.kapala.org``http://192.168.31.5:8230`,
cert #49 (`*.kapala.org` wildcard, DNS-01 via Cloudflare, expires
2026-09-28) — same pattern as `paper.`/`vikunja.`/`ha.kapala.org`.
- Pi-hole Local DNS (`/etc/pihole/custom.list` on PIHA, runtime, not in Git):
`kb.kapala.org``192.168.31.5`. **This is the first `kapala.org` entry in
that file** — every other `kapala.org` vhost (paper/ha/immich/vikunja/forgejo)
has no LAN override and resolves via the public Cloudflare record
(Tailscale IP) even from LAN, a hairpin the plan assumed was already avoided
for those too. Not fixed here (out of this task's scope — no other vhosts
touched); worth a follow-up if it matters for those services.
- Cloudflare A record `kb.kapala.org``100.108.208.3` (Tailscale PIHA, DNS
only): added manually by the operator (no CF API token available in the
environment that did the rest of this step), verified against Cloudflare's
own authoritative NS and 8.8.8.8/1.1.1.1 — resolves everywhere now, both
LAN (via Pi-hole override) and Tailscale/public (via this record).
**No auth.** OIDC (plan §8: `authlib`, `/login`, `/auth/callback`) is explicitly
**not implemented** — confirmed no forward-auth/reverse-proxy-level auth pattern
exists anywhere in this repo (NPM community edition doesn't support it either);
the three precedents (paperless/nextcloud/vikunja) all do OIDC inside the app.
Building that is real service code (`authlib` dependency, session middleware,
Forgejo OAuth2 app registration) — deliberately deferred to a separate session,
decision confirmed with the operator 2026-07-23. Until then `kb.kapala.org` is
reachable by anyone on the LAN/tailnet with no login, same as before this vhost
existed.
## Out of scope for this step
- OIDC login (see above) — separate session, needs `authlib` + Forgejo OAuth2 app.
- Calibration verdict for the fallback (plan §5 steps 45: live PIHA
measurements → keep/tune/degrade decision) — the mechanism is built and
default-on when `EMBED_FALLBACK_URL` is set; the measurements are the
operator's post-deploy step (see `kb/services/ollama-piha.md`).