homelab-codex-ws/services/kb-query
oskar 3d4ee3818d feat(kb-query): active embed fallback SOLARIA→PIHA (module 5 phase 4, plan §2/§5)
Last missing core piece of KB phase 4: kb-query no longer hard-fails /search
when Ollama@SOLARIA is unreachable. app/fallback.py implements the plan's
circuit-breaker exactly (30s cached health probe, 3s hard embed timeout on
SOLARIA, one-shot same-request switch to a new local ollama-piha@PIHA
container on timeout/error). sol_status in /healthz and /search now reflects
the real breaker state instead of a hardcoded "up".

New services/ollama-piha (bge-m3, OLLAMA_KEEP_ALIVE=0, arm64/no-GPU) is the
local fallback leg. Live calibration on PIHA (2026-07-27, normal load):
embed latency 4.2-5.2s, RAM peak ~983MiB against a 2.5GiB ceiling -- both
inside the plan's go-bar, so the fallback is enabled by default rather than
gated behind a flag. Calibration also surfaced and disabled (not removed) a
previously-undocumented orphaned native ollama.service on PIHA that had been
conflicting with the container's port.

The embed-model invariant (query embedding == document_chunk.model) still
enforces once at startup, since both fallback legs share one EMBED_MODEL
constant by construction; a redundant per-request DB check was deliberately
skipped and the invariant is instead proven structurally by test.

retrieval_eval.py gains --transport http (plan §2 decision 6/§9), previously
unimplemented. Verified live: HTTP transport is bit-identical to direct
transport against the same live SOLARIA (0 mismatches), and a live sol-down
simulation (kb-query's own OLLAMA_URL pointed at a dead address, no other
Ollama consumer touched) shows the PIHA fallback answering with the same
hit@3 gate outcome and dist within ~3e-4 of the SOLARIA baseline.

Zero changes to DB schema or kb_retrieval's retrieval logic -- only the
embed + health layer, per task constraints.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 22:23:53 +02:00
..
app feat(kb-query): active embed fallback SOLARIA→PIHA (module 5 phase 4, plan §2/§5) 2026-07-27 22:23:53 +02:00
tests feat(kb-query): active embed fallback SOLARIA→PIHA (module 5 phase 4, plan §2/§5) 2026-07-27 22:23:53 +02:00
docker-compose.yml feat(kb-query): active embed fallback SOLARIA→PIHA (module 5 phase 4, plan §2/§5) 2026-07-27 22:23:53 +02:00
Dockerfile feat(kb): add kb-query service skeleton (search API, no ingress yet) 2026-07-22 16:06:18 +02:00
Dockerfile.dockerignore feat(kb): add kb-query service skeleton (search API, no ingress yet) 2026-07-22 16:06:18 +02:00
env.example feat(kb-query): active embed fallback SOLARIA→PIHA (module 5 phase 4, plan §2/§5) 2026-07-27 22:23:53 +02:00
healthcheck.sh feat(kb): add kb-query service skeleton (search API, no ingress yet) 2026-07-22 16:06:18 +02:00
pytest.ini feat(kb): add kb-query service skeleton (search API, no ingress yet) 2026-07-22 16:06:18 +02:00
README.md feat(kb-query): active embed fallback SOLARIA→PIHA (module 5 phase 4, plan §2/§5) 2026-07-27 22:23:53 +02:00
requirements.txt feat(kb-query): add search frontend (module 5 phase 4, plan §7, Krok 4) 2026-07-22 16:49:36 +02:00
service.yaml feat(kb-query): active embed fallback SOLARIA→PIHA (module 5 phase 4, plan §2/§5) 2026-07-27 22:23:53 +02:00

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"}sol_status is a live probe of Ollama@SOLARIA, no auth required (monitoring must reach it)
/search?q=<text>&mode=cascade|flat GET query_text -> embed -> cascade_query/flat_query -> results, mode defaults to cascade

/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):

{
  "query": "...", "mode": "cascade", "sol_status": "up",
  "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 kaskada/flat (domyślnie kaskada — checkbox "tryb flat (debug)").
  • 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 fallback (plan §2 decision 2, §5)

app/fallback.py holds one process-global SolCircuitBreaker:

  1. Cached sol_status (30s TTL) is used as-is when fresh — no network call.
  2. On expiry, probe GET {OLLAMA_URL}/api/tags (500ms timeout); cache the result (up/down) for another 30s.
  3. up → embed on SOLARIA with a hard 3s timeout.
    • Success → done, sol_status: "up".
    • Timeout/error on the real embed call (not just the probe) → flip the breaker to down immediately and fall through to step 4 in the same request — the caller never sees an error for this, only the first unlucky request in a 30s window pays one extra timeout.
  4. down → embed locally against OLLAMA_PIHA_URL (ollama-piha@PIHA, bge-m3, same model constant as SOLARIA — see the invariant note below).

If both legs fail (SOLARIA down and PIHA unreachable/not deployed), /search returns 503; /healthz still answers (sol_status: "down"), same tolerance pattern as llm-gateway. /healthz shares the same cached breaker as /search, so both report the same view of the world.

OLLAMA_PIHA_URL defaults to http://localhost:11434, which is inert inside this container (nothing listens there) until you point it at the real ollama-piha@PIHA address — see services/ollama-piha/README.md for that container's deploy status and the RAM/latency calibration gate that decides whether it's safe to rely on as a default fallback.

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).

Covers both fallback legs. EMBED_MODEL is a single constant threaded through app/fallback.py's embed_with_fallback and used identically for the SOLARIA and PIHA embed calls — there is no per-request or per-leg model choice, so this one startup check already covers both paths. See app/fallback.py's module docstring for why a second, redundant per-request DB check was deliberately not added.

Configuration

.envgitignored, copy from env.example. Required: LAN_BIND_IP, KB_DSN. Optional: OLLAMA_URL, OLLAMA_PIHA_URL, EMBED_MODEL, SUMMARY_MODEL.

Deploy (PIHA)

  1. git pull on PIHA (~/homelab-codex-ws).
  2. cp services/kb-query/env.example services/kb-query/.env and fill in the real KB_DSN password.
  3. docker compose -f services/kb-query/docker-compose.yml \
      -f hosts/piha/runtime/kb-query/docker-compose.override.yml up -d --build
    
  4. Verify: services/kb-query/healthcheck.sh, then from PIHA: curl "http://192.168.31.5:8230/search?q=test" and open http://192.168.31.5:8230/ in a browser.

Tests

pip install -e packages/kb-retrieval/
cd services/kb-query && pip install -r requirements.txt pytest pytest-asyncio && pytest

Unit tests mock the DB connection and Ollama HTTP session (no live DB/Ollama required) — same style as packages/kb-retrieval/tests/. tests/test_frontend.py drives GET ///static/* through FastAPI's TestClient without entering it as a context manager, so the DB-requiring lifespan never runs.

Frontend JS has its own pure-function tests (query-URL encoding, threshold colouring, envelope grouping), run without a browser via Node's built-in test runner: node --test services/kb-query/tests/frontend/.

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.orghttp://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.org192.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.org100.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.