Compare commits

...

4 commits

Author SHA1 Message Date
oskar cb8a19de83 test(kb-query): luki T1–T3 z test_fallback.py + status kalibracji ollama-piha (salvage S3+S4)
T1: one-shot switch przy timeoutcie mid-embed (ścieżka asyncio.wait_for,
dotąd nieprzetestowana — scenariusz 'SOLARIA wisi'). T2: breaker zostaje
'down' po mid-embed failure — kolejne requesty w oknie TTL idą prosto na
fallback bez probe'a. T3: noga fallbacku nie dziedziczy twardego timeoutu
primary. T4 pominięty (semantyka granicy TTL identyczna, wg raportu).
Pytest kb-query: 42/42 PASS.

S4: pomiar kalibracji 2026-07-27 (peak ~983 MiB, GO) dopisany do override'u
i sekcji Calibration w README — master mówił dotąd 'Confirm/trim after live
calibration'; konfiguracja kontenera identyczna z mierzoną, pomiar się
przenosi. Raport dedup: status zaktualizowany na 'salvage wykonany'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 16:40:24 +02:00
oskar 4b92924332 docs(sessions): wciągnięcie loga 2026-07-27 kb-f4-fallback z porzucanego brancha (salvage S2)
Implementacja kodu z tej sesji porzucona na rzecz e7625cd (dopisek redakcyjny
na górze pliku), ale log jest jedynym zapisem faktów operacyjnych: osierocony
natywny ollama.service na PIHA wyłączony 2026-07-27 (backlog odinstalowania
≈2026-08-10), kalibracja live ollama-piha z werdyktem GO (peak ~983 MiB,
~4.2–5.3 s/embed) i baseline bramki §9 (HTTP-equivalence 0 rozbieżności,
sol-down Δ~3e-4 — do powtórki na masterze, raport dedup follow-up (b)).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 16:40:24 +02:00
oskar 71eb264448 feat(eval): retrieval_eval --transport http — bramka §9 przez żywe /search kb-query (salvage S1)
Cherry-pick 1:1 z porzucanego brancha task/kb-f4-fallback (3d4ee38) wg decyzji
z raportu dedup (docs/kb/modules/05-fallback-dedup-raport.md): plan §2 D6/§9
wymaga trybu HTTP-equivalence, a master go nie miał — e7625cd nie tknął tego
pliku, patch aplikuje się czysto i woła wyłącznie GET /search (pola
envelope_id/dist/source zgodne z odpowiedzią mastera). Live-PASS 2026-07-27
na kodzie brancha; smoke na masterze: CLI + pełny przebieg http przeciwko
stubowi /search (raport i werdykt bramki generują się poprawnie).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 16:40:24 +02:00
oskar 8ab262d38f docs(kb): raport dedup fallbacku embed — e7625cd vs 3d4ee38, klasyfikacja delty i plan salvage
Recon read-only przed salvage (moduł 5 faza 4 §2/§5): embed_router.py na
masterze pokrywa 100% zachowań fallback.py z porzucanego brancha i dodaje
weryfikację modelu per backend, mapowanie 503/500 i tryb bez fallbacku.
Do uratowania: --transport http w retrieval_eval.py (plan §2 D6/§9, czysty
cherry-pick), session log 2026-07-27 (kalibracja GO, osierocony ollama.service),
luki testowe T1/T2, wynik kalibracji do override/README ollama-piha.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 16:40:24 +02:00
7 changed files with 528 additions and 9 deletions

View file

@ -0,0 +1,142 @@
# Raport dedup: fallback embed SOLARIA→PIHA — e7625cd (master) vs 3d4ee38 (task/kb-f4-fallback)
**Data**: 2026-07-30 · **Worktree**: `task/kb-fallback-dedup` · **Status**: salvage **wykonany**
2026-07-30 — operator zatwierdził pełną listę S1S4 (sekcja „Rekomendacja zbiorcza");
zrealizowane na tym branchu: S1 (cherry-pick `--transport http` + README), S2 (session log
2026-07-27 z dopiskiem redakcyjnym), S3 (testy T1/T2/T3 w `test_embed_router.py`; T4
pominięty zgodnie z raportem), S4 (wynik kalibracji w override + README ollama-piha).
Weryfikacja: pytest kb-query 42/42 PASS; `retrieval_eval.py --transport http` przepuszczony
end-to-end przeciwko stubowi `/search` (raport + werdykt bramki generują się poprawnie;
kryterium 4 na stubie nie przechodzi wyłącznie dlatego, że `mail_queries` w `queries.yaml`
mają `expected_envelope: null` — placeholdery, artefakt danych, nie kodu).
Dwie równoległe sesje zaimplementowały ten sam krok planu (moduł 5 faza 4, §2 decyzja 2 / §5):
- **e7625cd** (2026-07-29, **ZMERGOWANY do master, wdrożony na PIHA**): `app/embed_router.py`
+ `test_embed_router.py` + `services/ollama-piha` + README z testami A/B/C.
- **3d4ee38** (2026-07-27, `origin/task/kb-f4-fallback`, **NIEZMERGOWANY**): `app/fallback.py`
+ `test_fallback.py` + własny `services/ollama-piha` + `retrieval_eval.py --transport http`
+ session log z kalibracją live.
Master = źródło prawdy. 3d4ee38 **nie będzie mergowany** — ten raport klasyfikuje jego deltę
i rekomenduje, co uratować.
---
## 1. Porównanie maszyn stanów (`embed_router.py` vs `fallback.py`)
Werdykt: **`embed_router.py` na masterze pokrywa 100% zachowań `fallback.py` i dodaje
kilka istotnych rzeczy ponad to. Nic nie zgubił.**
| Zachowanie (wymóg planu §2 D2 / zadania) | `fallback.py` (branch) | `embed_router.py` (master) |
|---|---|---|
| Health-check cache 30 s (probe `GET /api/tags` po wygaśnięciu) | ✅ `SolCircuitBreaker`, TTL 30 s | ✅ TTL 30 s (env `EMBED_HEALTH_TTL_S`) |
| Timeout probe'a | 0.5 s (stała) | 1.5 s (env; udokumentowane: absorbuje jitter Tailscale, spec zadania mówił „12 s") |
| Twardy timeout 3 s na nodze SOLARIA | ✅ `ClientTimeout(total=3)` przez nowy param `embed_chunk(timeout_s=...)` | ✅ `asyncio.wait_for(3 s)` wokół niezmienionego `embed_chunk`**bez ruszania pakietu** kb-retrieval; obejmuje też parsowanie odpowiedzi |
| One-shot switch (fail mid-embed → **ten sam request** z PIHA, breaker „down" na okno TTL) | ✅ | ✅ + łapie dodatkowo `ValueError` (brak `embedding` w odpowiedzi) i loguje `circuit open for 30s` |
| Noga PIHA bez twardego timeoutu (cold-load, ostatnia deska) | ✅ | ✅ (celowe, udokumentowane w docstringu) |
| Powrót na SOLARIĘ ≤ 30 s po wygaśnięciu TTL | ✅ | ✅ |
| Obsługa 503 (oba backendy padnięte → 503, nie gołe 500) | ⚠️ częściowa: tylko `aiohttp.ClientError` → 503 w `main.py`; **`TimeoutError` z nogi PIHA wyleciałby jako gołe 500** | ✅ dedykowany `EmbedBackendError` → 503; osobno pokryty przypadek „fallback nieskonfigurowany" |
| Inwariant embed-model (query model == `document_chunk.model`) | startowy DB-check + jedna stała `embed_model` na obie nogi (dowód strukturalny w teście) | to samo **plus** leniwa weryfikacja per-backend przy pierwszym użyciu: `/api/tags` backendu musi listować `EMBED_MODEL`, inaczej `ModelMismatchError` → głośne 500 (nigdy cichy embed w złej przestrzeni wektorowej) |
| `/healthz` dzieli cache z `/search` | ✅ `sol_status` | ✅ `sol_status` + dodatkowo `fallback_status` (up/down/unconfigured) |
| `embed_backend` w odpowiedzi `/search` | ❌ (tylko `sol_status`) | ✅ (potrzebne do debugowania jakości per backend) |
| Praca bez fallbacku (env nieustawiony) | placeholder `http://localhost:11434` (fail-closed, ale mylący `sol_status`) | jawny tryb `EMBED_FALLBACK_URL` unset → zachowanie sprzed Kroku 2 (503) |
| Parametryzacja (TTL/timeouty) | stałe modułowe | env vars z defaultami |
| Logging decyzji routingu | ❌ | ✅ (`kb-query.embed`, backend=…, elapsed_ms) |
Różnice czysto kosmetyczne: semantyka wygaśnięcia dokładnie na granicy TTL identyczna
(obie strony: `t == TTL` → wygasłe); branch trzymał breaker jako dataclass z injektowalnym
zegarem, master trzyma to samo wewnątrz `EmbedRouter` (też injektowalny zegar).
---
## 2. Pełna delta 3d4ee38 (git diff origin/master...origin/task/kb-f4-fallback) + klasyfikacja
| Plik | Rozmiar delty | Klasyfikacja | Rekomendacja | Uzasadnienie |
|---|---|---|---|---|
| `services/kb-query/app/fallback.py` | +113 | duplikat funkcji | **porzuć** | `embed_router.py` = nadzbiór (tabela §1) |
| `services/kb-query/app/main.py` | ±32 | duplikat | **porzuć** | master ma własną, pełniejszą integrację (mapowanie 503/500, env-schemat `EMBED_PRIMARY/FALLBACK_URL`) |
| `services/kb-query/app/search.py` | ±36 | duplikat | **porzuć** | identyczny pomysł (embed raz → `*_retrieve` z gotowym wektorem); master dodaje `embed_backend` |
| `services/kb-query/tests/test_fallback.py` | +221 | duplikat + **3 unikalne scenariusze** | **adaptuj** | luki testowe do przeniesienia do `test_embed_router.py` — patrz §3 |
| `services/kb-query/tests/test_search.py` | ±38 | duplikat | **porzuć** | master zaadaptował te same testy przez `_FakeRouter` (czystsze — nie dubluje maszyny stanów w testach searcha) |
| `services/kb-query/{README,env.example,service.yaml,docker-compose.yml}` | ~±58 | duplikat | **porzuć** | inny (porzucony) schemat env `OLLAMA_PIHA_URL`; master bogatszy (testy A/B/C, rename `OLLAMA_URL`→`EMBED_PRIMARY_URL`) |
| `packages/kb-retrieval/src/kb_retrieval/embed.py` (`timeout_s` w `embed_chunk`) | +16 | duplikat funkcji | **porzuć** | master osiąga twardy timeout przez `asyncio.wait_for` bez zmiany współdzielonego pakietu — mniejsza powierzchnia zmian, ten sam efekt |
| `jobs/documents-ingest/eval/retrieval_eval.py` (`--transport {direct,http}` + `--base-url`) | +109 | **unikalna wartość** | **cherry-pick** | plan §2 decyzja 6 / §9 (bramka HTTP-equivalence) — **na masterze w ogóle nie istnieje**; e7625cd nie tknął tego pliku, patch aplikuje się czysto; kod woła tylko `GET /search` i czyta `envelope_id`/`dist`/`source` — w pełni zgodny z odpowiedzią mastera |
| `jobs/documents-ingest/README.md` | +6 | **unikalna wartość** | **cherry-pick** | dokumentacja powyższego, idzie w parze |
| `docs/sessions/2026-07-27-kb-f4-fallback.md` | +189 | **unikalna wartość** | **adaptuj** | jedyny zapis: (1) znalezisko osieroconego natywnego `ollama.service` na PIHA + jego wyłączenie 2026-07-27 i backlog odinstalowania, (2) kalibracja live ollama-piha (GO: peak ~983 MiB, ~4.25.3 s/embed), (3) metodologia i wyniki bramki §9 (HTTP-equivalence 0 rozbieżności; sol-down Δ~3e-4), (4) rsync-deploy → dirty working tree na PIHA. Wciągnąć z dopiskiem redakcyjnym, że zmergowana implementacja to **inny kod** (e7625cd) i wyniki bramki wymagają powtórki |
| `services/ollama-piha/*` (5 plików) | +155 | duplikat | **porzuć** | wersja mastera lepsza: named volume `ollama_piha_models` (uzasadnienie uid-pattern PIHA), healthcheck sprawdza obecność `bge-m3`, bind tylko 127.0.0.1+LAN |
| `hosts/piha/runtime/ollama-piha/docker-compose.override.yml` | +13 | duplikat + **1 unikalny fakt** | **adaptuj (mikro)** | ten sam `mem_limit: 2560m`; ale komentarz brancha zawiera potwierdzony pomiar (peak ~983 MiB), a master wciąż mówi „Confirm/trim after live calibration" — dopisać wynik kalibracji do komentarza override'u i/lub sekcji „Calibration" w `services/ollama-piha/README.md` |
| `hosts/piha/services.yaml` | ±26 | duplikat | **porzuć** | master ma własny wpis `ollama-piha` + soft-dependency kb-query; drobna różnica (`offline_required: true` na branchu vs `false` na masterze) — master źródłem prawdy |
---
## 3. Pokrycie testowe — `test_embed_router.py` (13 testów) vs `test_fallback.py` (16 testów)
Scenariusze wspólne (pokryte po obu stronach): embed na SOLARIA gdy up; cache werdyktu
w TTL (bez re-probe); down z probe'a → PIHA bez próby embedu na SOLARIA; down cache'owany
(kolejne requesty omijają SOLARIĘ bez probe'a); **one-shot same-request switch** (wariant
connection-error); **powrót na SOLARIĘ ≤30 s** (test C — po wygaśnięciu TTL); oba
backendy padnięte → wyjątek; obie nogi z identycznym `embed_model` (na masterze mocniej:
weryfikacja `/api/tags` per backend).
Master ma ponadto testy, których branch nie miał: `ModelMismatchError` (fallback bez
bge-m3 → głośny błąd, nie cichy embed; primary bez bge-m3 → failover), brak fallbacku
skonfigurowanego → `EmbedBackendError`, `fallback_status` (up/unconfigured), sufiks
`:latest` spełnia gołą nazwę modelu.
**Luki mastera względem `test_fallback.py`** (kandydaci na salvage — pozycja „adaptuj" z §2):
| # | Scenariusz z `test_fallback.py` | Stan na masterze | Waga |
|---|---|---|---|
| T1 | One-shot switch przy **timeoutcie** mid-embed (nie tylko connection-error) — na masterze to inna ścieżka kodu (`asyncio.wait_for` → `TimeoutError`) i jest **nieprzetestowana** | brak | **wysoka** — to główny scenariusz produkcyjny („SOLARIA wisi", nie „SOLARIA odrzuca") |
| T2 | Po one-shot switchu breaker **zostaje** „down": kolejny request w tym samym oknie TTL idzie prosto na PIHA bez probe'a i bez próby SOLARIA | brak (master testuje cache „down" tylko z probe'a, nie z mid-embed failure) | średnia |
| T3 | Noga PIHA bez twardego timeoutu (strukturalna asercja) | brak (własność tylko udokumentowana) | niska — opcjonalnie |
| T4 | Wygaśnięcie cache dokładnie na granicy TTL (`t == 30 s`) | brak (master testuje 31 s) | niska — semantyka i tak identyczna, można pominąć |
---
## 4. Rekomendacja zbiorcza (lista do zatwierdzenia)
1. **S1 — cherry-pick**: `retrieval_eval.py --transport http --base-url` + akapit w
`jobs/documents-ingest/README.md` (plan §2 D6/§9; aplikuje się czysto, zero zależności
od porzuconego kodu brancha).
2. **S2 — adaptuj**: `docs/sessions/2026-07-27-kb-f4-fallback.md``docs/sessions/`
z dopiskiem redakcyjnym na górze (implementacja z tej sesji porzucona na rzecz
e7625cd; fakty operacyjne — ollama.service, kalibracja, metodologia bramki — pozostają
w mocy).
3. **S3 — adaptuj**: luki testowe T1 + T2 (T3 opcjonalnie) do `test_embed_router.py`.
4. **S4 — adaptuj (mikro)**: wynik kalibracji 2026-07-27 (peak ~983 MiB, ~4.25.3 s,
werdykt GO) do komentarza `hosts/piha/runtime/ollama-piha/docker-compose.override.yml`
i sekcji Calibration w `services/ollama-piha/README.md` — pomiar dotyczył kontenera
ollama-piha (ta sama konfiguracja: obraz, `OLLAMA_KEEP_ALIVE=0`, `mem_limit 2560m`),
więc **przenosi się** na wersję mastera; różni się tylko storage (bind vs named
volume), co nie wpływa na RAM/latencję.
5. **Porzuć** całą resztę delty (kolumna „porzuć" w §2).
---
## 5. Follow-up (po salvage)
- **(a) Sprzątanie brancha** (wykona Oskar): skasować `origin/task/kb-f4-fallback`
(3d4ee38) i worktree `~/homelab-codex-ws-kb-f4-fallback` po zakończeniu salvage.
- **(b) Powtórka testu sol-down na żywym masterze**: kalibracja i bramka z 2026-07-27
dotyczyły **innego kodu** (`fallback.py`, env `OLLAMA_PIHA_URL`) — na wdrożonym
e7625cd trzeba przejść testy A/B/C z `services/kb-query/README.md` oraz bramkę
`retrieval_eval.py --transport http` (po S1): HTTP-equivalence przy SOLARIA-up
(identyczne `dist`) i sol-down (Δ≤epsilon, kolejność top-k identyczna; baseline
z 27.07: Δ~3e-4). Symulacja wg README: `EMBED_PRIMARY_URL=http://192.0.2.1:11434`
(TEST-NET), nie dotykając SOLARII.
- **(c) Weryfikacja stanu PIHA po podwójnym deployu** (znalezisko z session loga):
sesja 27.07 wdrożyła kod brancha przez **rsync do `~/homelab-codex-ws` na PIHA**
(dirty working tree), a ollama-piha wystartował tam z **bind mountem**
`/opt/homelab/data/ollama-piha`; master definiuje **named volume**
`ollama_piha_models`. Sprawdzić: (1) working tree na PIHA czysty po merge'u e7625cd
i kontenery faktycznie zbudowane z kodu mastera, (2) który storage żywy kontener
naprawdę montuje; jeśli named volume — czy `bge-m3` jest w nim spullowany, a stary
katalog bind (`/opt/homelab/data/ollama-piha`, ~1.2 GB modelu) do skasowania,
(3) `.env` kb-query używa `EMBED_FALLBACK_URL` (schemat mastera), nie martwego
`OLLAMA_PIHA_URL` — szybki test: `/healthz` musi zwracać `fallback_status: "up"`,
nie `"unconfigured"`.
- **(d) Backlog z 27.07** (przetrwa tylko dzięki S2): natywny, osierocony
`ollama.service` na PIHA wyłączony (`systemctl disable --now`) 2026-07-27 —
odinstalować binarkę/unit po ~2 tygodniach ciszy (≈ 2026-08-10).

View file

@ -0,0 +1,203 @@
# Sesja 2026-07-27 — KB faza 4: fallback embed SOLARIA→PIHA (krok 3, ostatni element rdzenia)
> **Dopisek redakcyjny (2026-07-30, dedup — `docs/kb/modules/05-fallback-dedup-raport.md`):**
> implementacja kodu z tej sesji (`app/fallback.py`, branch `task/kb-f4-fallback`, 3d4ee38)
> została **porzucona** — do mastera weszła równoległa, szersza implementacja tego samego
> kroku planu (e7625cd, `app/embed_router.py`, 2026-07-29) i to ona biega na PIHA. Ten log
> wciągnięto do repo, bo dokumentuje fakty operacyjne niezależne od porzuconego kodu:
> znalezisko i wyłączenie osieroconego natywnego `ollama.service` na PIHA (§3, z backlogiem
> odinstalowania ≈2026-08-10), kalibrację live ollama-piha z werdyktem GO (§4 — konfiguracja
> kontenera identyczna na masterze, pomiar przenosi się) oraz metodologię i baseline bramki
> §9 (§5, Δ~3e-4). Wyniki bramki i testu sol-down dotyczyły kodu z brancha — na wdrożonym
> masterze wymagają powtórki (raport dedup, follow-up (b)). Sekcje o deployu (§6) i
> "Do zrobienia przez operatora" pkt 12 opisują stan sprzed merge'a e7625cd — historyczne.
> Z delty brancha uratowano ponadto: `retrieval_eval.py --transport http` (plan §2 D6/§9)
> i luki testowe T1/T2 przeniesione do `test_embed_router.py`.
**Zakres**: `docs/kb/modules/05-faza4-plan.md` §2 decyzja 2 / §5 — aktywny fallback
embedu, ostatni brakujący element rdzenia fazy 4 (frontend i ingress LIVE od
2026-07-22/23, `docs/sessions/2026-07-23-kb-f4-ingress.md`). Zero zmian w schemacie
DB, zero zmian w `kb_retrieval`'s retrieval logice — wyłącznie warstwa embed + health.
Praca wykonana w task worktree (`task/kb-f4-fallback`, `.claude/skills/worktree-aware`).
Zgodnie z ustaleniem na starcie sesji (patrz "Ustalenia proceduralne" niżej): kod
napisany i przetestowany lokalnie w worktree, produkcyjne kroki (kalibracja, deploy,
live-test) wykonane po jawnej zgodzie operatora, z osobnym potwierdzeniem przed
każdym kolejnym krokiem dotykającym PIHA/SOLARIĘ.
## Ustalenia proceduralne
Zadanie wprost wymagało kroków produkcyjnych (kalibracja RAM/latencji na żywym PIHA,
symulacja sol-down dotykająca SOLARII, deploy, push) — sprzeczne z ogólną dyscypliną
`worktree-aware` ("nigdy nie uruchamiaj deployów/healthchecków przeciw produkcji z
worktree"). Zamiast rozstrzygać to samodzielnie, zapytano operatora:
1. Recon read-only (bez zmian stanu) — zgoda bez pytania.
2. Właściwe kroki produkcyjne (kalibracja, deploy, live-test, push) — operator
potwierdził jawnie ("Yes, proceed with all of it") po zobaczeniu pełnego zakresu.
## 1. Kod (warstwa embed + health, zero zmian retrievalu/DB)
- **`packages/kb_retrieval/embed.py`**: `embed_chunk` dostał opcjonalny `timeout_s`
(domyślnie `None`, zero zmiany zachowania istniejących wołań) — potrzebny do
twardego 3 s timeoutu na nodze SOLARIA bez zmiany zachowania nogi PIHA.
- **`services/kb-query/app/fallback.py`** (nowy): `SolCircuitBreaker` (cache 30 s,
zegar wstrzykiwalny do testów) + `resolve_sol_status` (probe `/api/tags`, 500 ms) +
`embed_with_fallback` (SOLARIA z twardym 3 s timeoutem → jednorazowe przełączenie na
PIHA **w tym samym requeście** przy timeout/błędzie → PIHA bez dodatkowego
timeoutu). Dokładnie maszyna stanów z planu §2 decyzja 2.
- **`app/search.py`**: `run_search` liczy embedding raz przez `embed_with_fallback`,
potem woła `flat_retrieve`/`cascade_retrieve`/`hybrid_retrieve` (niskopoziomowe
funkcje `kb_retrieval`, biorą gotowy wektor) zamiast `flat_query`/`cascade_query`/
`hybrid_query` (które embedują same) — dzięki temu decyzja fallbacku żyje wyłącznie
w warstwie HTTP kb-query, zero zmiany w `kb_retrieval`. `sol_status` w odpowiedzi to
teraz realny wynik, nie zahardkodowane `"up"`.
- **`app/main.py`**: `/healthz` i `/search` dzielą jeden `SolCircuitBreaker`
(`app.state.sol_breaker`) — oba endpointy zawsze zgadzają się co do aktualnego
stanu. Nowy env `OLLAMA_PIHA_URL` (domyślnie `http://localhost:11434` — celowo
"inertny" placeholder, fail-closed, dopóki operator nie ustawi realnego adresu).
- **Inwariant modelu**: **nie dodano** drugiego, per-request sprawdzenia w DB —
`EMBED_MODEL` to jedna stała wątkowana przez obie nogi `embed_with_fallback`,
więc startowy check (`app/startup.py`, niezmieniony) pokrywa obie ścieżki z
konstrukcji. Dodanie drugiego DB-checka chroniłoby przed scenariuszem, który nie
może wystąpić (CLAUDE.md: nie dodawaj walidacji dla scenariuszy, które nie mogą się
zdarzyć) — zamiast tego nowy test (`test_both_legs_use_identical_embed_model`)
strukturalnie dowodzi, że obie nogi w tym samym requeście dostają identyczny
`embed_model`.
- **`jobs/documents-ingest/eval/retrieval_eval.py`**: dodano `--transport
{direct,http}` + `--base-url` (plan §2 decyzja 6 / §9) — dotąd nieistniejące (tylko
ręczny smoke-test, `docs/sessions/2026-07-23-kb-f4-ingress.md` follow-up). Tryb
`http` woła trzy `GET /search` (flat/cascade/hybrid) na żywym kb-query zamiast
embedować+odpytywać lokalnie; `envelope.source` do kryterium 4 bierze się z pola
`source` w odpowiedzi JSON, nie z osobnego zapytania do DB. Nie da się swipe'ować
N przez HTTP (kb-query serwuje jeden N per request) — tryb http raportuje tylko
przy `--gate-n`.
## 2. Nowy serwis `services/ollama-piha`
Klon wzorca `services/ollama` (`owner_node: piha` zamiast `solaria`, bez rezerwacji
GPU — PIHA to arm64 bez akceleracji), `OLLAMA_KEEP_ALIVE=0` (model ładowany tylko na
czas requestu). `mem_limit: 2560m` (tentatywny wg planu, potwierdzony pomiarem —
patrz §3). Wpisany do `hosts/piha/services.yaml` (`depends_on.local` kb-query →
`[kb-postgres, ollama-piha]`, fallback nie jest twardą zależnością na starcie).
## 3. Znalezisko: osierocony natywny `ollama.service` na PIHA
Podczas pierwszej próby deployu `ollama-piha` (bind `127.0.0.1:11434`) — konflikt
portu. Okazało się, że PIHA ma **natywny (nie-Docker) systemd `ollama.service`**
(v0.6.1, `enabled`, działający od 2026-06-22, PATH env wskazujący na użytkownika
`/home/pi/...`), o którym nic nie wiadomo w repo — plan §1.2 wprost zakładał "PIHA:
brak Ollamy", co okazało się nieaktualne/błędne. To realna sprzeczność planu z
rzeczywistością → STOP, pytanie do operatora zamiast cichej decyzji.
Weryfikacja przed jakąkolwiek akcją: `journalctl -u ollama --since "7 days ago"`
**tylko własne, właśnie wykonane** zapytania probe (`/api/version`, `/api/tags`),
`total blobs: 0` od startu (nigdy nic nie pobrano). Operator potwierdził: martwy
balast, `sudo systemctl disable --now ollama.service` (**disable, nie uninstall** —
odwracalne). Port 11434 zwolniony, `ollama-piha` wystartował normalnie.
**Backlog**: PIHA host-level shadow — natywny `ollama.service` wyłączony
2026-07-27; odinstalować binarkę/unit po ~2 tygodniach jeśli nic się nie posypie.
## 4. Kalibracja (plan §5, gate) — **werdykt: GO**
Zmierzone na żywym PIHA pod normalnym obciążeniem (kb-postgres, paperless, Immich,
HA, Forgejo działające, nie okno nocnej ciszy), 3 kolejne wywołania `/api/embeddings`
po `ollama pull bge-m3`:
| Wywołanie | Latencja |
|---|---|
| 1 (pierwsze, zimny start) | 5.25 s |
| 2 | 4.41 s |
| 3 | 4.16 s |
Brak przyspieszenia między wywołaniami — zgodnie z projektem (`OLLAMA_KEEP_ALIVE=0`
zwalnia model po każdym requeście, `ollama ps` pokazuje zero rezydentnych modeli
między wywołaniami).
RAM: baseline idle ~66 MiB, szczyt podczas burst ~983 MiB (`docker stats`, próbkowane
co 0.3 s w trakcie 3 wywołań) — komfortowo w granicach ceilingu `2560m`. `free -h`
systemowe: `available` nie spadło poniżej ~1.3 GiB w trakcie, osiadło na ~4.2 GiB po
(dla porównania: przed startem eksperymentu `available` = 3.7 GiB).
**Werdykt**: oba kryteria planu spełnione (latencja pojedyncze sekundy, nie
dziesiątki; RAM ze sporym zapasem) → **włączony jako domyślny fallback**, bez flagi
`KB_QUERY_LOCAL_FALLBACK_ENABLED`.
## 5. Bramka jakościowa (plan §9)
Wszystko uruchomione z `~/kb/venv` na PIHA (istniejący venv z poprzednich sesji,
`aiohttp`/`asyncpg`/`yaml` już obecne) przeciw żywej bazie + żywemu kb-query.
**HTTP-equivalence** (`--transport http` vs `--transport direct`, SOLARIA up, ten sam
`--gate-n 10`): oba PASS, **0 rozbieżności** w `dist` na wszystkich zapytaniach
(`flat_top1_dist`, `hybrid_top1_dist`, `cascade[10].top1_dist`) — identyczne bit w
bit, jak wymagał plan (nie ±epsilon, bo to ten sam kod, HTTP to tylko opakowanie).
**Live sol-down fallback test**: symulacja przez `OLLAMA_URL=http://solaria:1`
(zły port, zgodnie z rekomendacją planu — zero dotknięcia SOLARII/innych
konsumentów Ollamy) w `.env` kb-query, restart kontenera. `/healthz`
`sol_status: "down"`. `/search` → 200, wyniki z PIHA, ~4.3 s (zgodnie z kalibracją).
Pełna bramka `retrieval_eval.py --transport http` z SOLARIA-down: **PASS**
identyczny wzorzec hit@3 co na SOLARII, `dist` w granicach epsilon:
| Zapytanie | dist (SOLARIA) | dist (PIHA fallback) | Δ |
|---|---|---|---|
| 1 | 0.341780 | 0.341509 | 0.000271 |
| 2 | 0.324808 | 0.324858 | 0.00005 |
| 3 | 0.428898 | 0.429184 | 0.000286 |
| 4 | 0.448199 | 0.447903 | 0.000296 |
| 5 | 0.386901 | 0.386816 | 0.000085 |
| N (negative control) | 0.598301 | 0.598017 | 0.000283 |
| N2 (negative control borderline) | 0.529772 | 0.529530 | 0.000242 |
Maksymalna rozbieżność: **~3e-4** — rząd wielkości mniejszy niż oczekiwany przez plan
(1e-31e-2), kolejność top-k identyczna, wynik bramki (`gate.passed`) identyczny.
Kb-query przywrócony do normalnej konfiguracji po teście (`.env` z prawdziwym
`OLLAMA_URL`, restart), `/healthz` z powrotem `sol_status: "up"`.
## 6. Deploy
Kod nie był jeszcze zmergowany do `master` (dyscyplina worktree: agent nigdy nie
mergeuje/pushuje `master`) — deploy przez standardowy `deploy-node.sh`
niedostępny bez mastera. Zamiast tego: `rsync` zmienionych plików
(`packages/kb-retrieval`, `services/kb-query`, `services/ollama-piha`,
`hosts/piha/runtime/ollama-piha`, `hosts/piha/services.yaml`,
`jobs/documents-ingest/eval/retrieval_eval.py` + README) do żywego checkoutu
`~/homelab-codex-ws` na PIHA (bez zmiany brancha — working tree pozostaje na
`master` z niescommitowanym diffem 1:1 identycznym z tą gałęzią), potem
standardowy `docker compose ... up -d --build` z tego miejsca. Efekt: realny,
działający deploy, ale **repo na PIHA ma dziś dirty working tree** — wymaga domknięcia
(patrz "Do zrobienia przez operatora" niżej).
Zweryfikowane: `kb-query` (healthy), `ollama-piha` (healthy, `bge-m3` w wolumenie),
`curl https://kb.kapala.org/healthz``200 {"sol_status":"up"}`,
`curl https://kb.kapala.org/search?q=test``200`.
## Stan na koniec sesji
| Element | Status |
|---|---|
| `packages/kb-retrieval``embed_chunk(timeout_s=...)` | ✅ kod + testy |
| `services/kb-query/app/fallback.py` — maszyna stanów | ✅ kod + testy (38/38 kb-query, 25/25 kb-retrieval) |
| `services/ollama-piha` — nowy serwis GitOps | ✅ zdefiniowany, ✅ LIVE na PIHA |
| Natywny `ollama.service` na PIHA (osierocony) | ✅ wyłączony (nie odinstalowany) |
| Kalibracja RAM/latencja | ✅ zmierzone — werdykt GO |
| `retrieval_eval.py --transport http` | ✅ zaimplementowane, ✅ PASS na żywo |
| Live sol-down fallback test | ✅ PASS, Δ~3e-4 |
| Deploy kb-query + ollama-piha na PIHA | ✅ LIVE, working tree PIHA dirty (patrz niżej) |
| Merge do `master` | ⛔ nie wykonany (dyscyplina worktree — operator) |
## Do zrobienia przez operatora
1. **Merge** `task/kb-f4-fallback``master` (`scripts/dev/agent.sh merge` albo
ręcznie) — branch popchnięty do `origin/task/kb-f4-fallback` (patrz commit poniżej).
2. Na PIHA: `cd ~/homelab-codex-ws && git status` będzie dirty (diff identyczny z tym
commitem, bo już wdrożony ad-hoc przez `rsync` w tej sesji) — po mergu do mastera,
`git checkout -- .` (working tree już ma dokładnie tę treść) albo zwyczajnie
`git pull` po mergu powinien wylądować "already up to date"/no-op, bo pliki na
dysku już są zgodne z tym co przyjdzie z mastera. **Zweryfikować** `git diff` jest
puste po pull, nie zakładać.
3. Backlog: natywny `ollama.service` na PIHA wyłączony `systemctl disable --now`
2026-07-27 (§3 wyżej) — jeśli nic się nie posypie przez ~2 tygodnie, odinstalować
binarkę/unit całkiem.
4. OIDC dla kb-query nadal odłożone (decyzja z 2026-07-23) — nie w zakresie tej sesji.

View file

@ -10,8 +10,11 @@ services:
ollama-piha:
# Plan §2 D2 starting value — the cgroup OOM killer restarts this container
# on breach instead of the host OOM killer picking a victim (which could be
# Home Assistant). Confirm or trim after live calibration (plan §5 step 4:
# a few embeds + docker stats at a normal-load hour).
# Home Assistant). Confirmed by live calibration 2026-07-27 (plan §5 step 4,
# docs/sessions/2026-07-27-kb-f4-fallback.md §4): peak ~983 MiB during an
# embed burst at a normal-load hour, ~66 MiB idle — comfortably inside this
# ceiling, verdict GO. Same container config as measured (image,
# OLLAMA_KEEP_ALIVE=0, this limit), so the number carries over.
mem_limit: 2560m
# Deliberately no mem_reservation: the working set is a transient spike and
# the idle daemon is ~100 MB — soft-reserving gigabytes would permanently

View file

@ -517,6 +517,12 @@ python eval/retrieval_eval.py --dsn postgresql://kb:<pw>@piha:5433/kb \
--ollama-url http://solaria:11434 --n-sweep 5,10,20
```
`--transport http --base-url http://<kb-query-host>:8230` (module 5 phase 4 plan §2 decision 6 /
§9) calls a live `kb-query`'s `/search` instead of embedding+querying locally — no `--dsn`
needed, `--n-sweep` is ignored (kb-query serves one server-side default N per request). Gate
criterion: `dist` must be **identical** to the same run with `--transport direct` against the
same live SOLARIA (same DB, same retrieval code — HTTP is only a wrapper).
**Result (2026-07-17, live run)**: PASS at N=10, k=5 — see plan §6.3 for the full table,
the N-sweep calibration (N=5 is the measured safety floor; the plan's N=10 default carries a
2× margin), and the cost/improvement analysis. `cascade_query` (N=10, k=5,

View file

@ -23,9 +23,23 @@ mocked test). Runs every query in `queries.yaml`'s `queries:` list through flat
writes nothing. Query embeddings go through Ollama on localhost/SOLARIA (bge-m3), same as
`chunk_embed.py`/`summarize.py`.
`--transport {direct,http}` (module 5 phase 4 plan §2 decision 6 / §9, added alongside the
fallback task): `direct` (default) is everything above, unchanged. `http` instead calls
`GET {base_url}/search?q=...&mode=flat|cascade|hybrid` on a live `kb-query` and reshapes its
JSON `results` into the same `{"chunks": [...]}` shape the direct-mode functions return, so
`summarize_query_result`/`evaluate_gate` run identically either way. The gate criterion (plan
§9): `dist` for `http` must be **identical** to `direct` against the same live SOLARIA -- same
DB, same retrieval code, HTTP is only a wrapper, so any difference is a serialization/handler
bug, never expected numerical drift. `http` mode cannot sweep `N` (kb-query serves one
server-side default per request, plan §4) -- it reports only at `--gate-n`, and needs no `--dsn`
(kb-query already owns the DB connection; `envelope.source` for criterion 4 comes straight from
each result's `source` field instead of a separate DB lookup).
Usage:
python retrieval_eval.py --dsn postgresql://kb:<pw>@piha:5433/kb \\
--ollama-url http://solaria:11434 --n-sweep 5,10,20
python retrieval_eval.py --transport http --base-url http://192.168.31.5:8230 \\
--gate-n 10
"""
from __future__ import annotations
@ -156,6 +170,44 @@ async def run_query_all_tracks(
return {"query": query, "flat": flat, "cascades": cascades, "hybrid": hybrid}
async def call_search_http(
session: aiohttp.ClientSession, base_url: str, query_text: str, mode: str
) -> list[dict]:
"""One `GET {base_url}/search?q=...&mode=...` call -> its `results` list. Each result
already carries `envelope_id`/`dist`/`source` -- exactly the fields `top1_dist`/`hit_at_3`/
`mail_hit_at_3` need, no DB lookup required on this side."""
async with session.get(
f"{base_url}/search", params={"q": query_text, "mode": mode}
) as resp:
resp.raise_for_status()
data = await resp.json()
return data["results"]
async def run_query_all_tracks_http(
session: aiohttp.ClientSession, base_url: str, query: dict, gate_n: int
) -> dict:
"""HTTP-transport equivalent of `run_query_all_tracks` -- three `/search` calls (one per
mode) instead of embedding+querying locally. `stage1_summaries` isn't part of the HTTP
response shape (plan §4) so it's reported empty; nothing in `evaluate_gate` reads it."""
flat_chunks = await call_search_http(session, base_url, query["text"], "flat")
cascade_chunks = await call_search_http(session, base_url, query["text"], "cascade")
hybrid_chunks = await call_search_http(session, base_url, query["text"], "hybrid")
return {
"query": query,
"flat": {"chunks": flat_chunks},
"cascades": {gate_n: {"chunks": cascade_chunks, "stage1_summaries": []}},
"hybrid": {"chunks": hybrid_chunks},
}
def envelope_sources_from_results(*chunk_lists: list[dict]) -> dict[str, str]:
"""http transport has no DB to `fetch_envelope_sources` from -- each `/search` result
already carries its envelope's `source`, so build the same envelope_id -> source mapping
straight from the response bodies already fetched for this query."""
return {c["envelope_id"]: c["source"] for chunks in chunk_lists for c in chunks}
def summarize_query_result(result: dict, envelope_sources: Optional[dict[str, str]] = None) -> dict:
query = result["query"]
expected = query["expected_envelope"]
@ -342,7 +394,34 @@ def print_report(
"hybrid = 1 embed (shared) + cascade's queries + 1 extra SQL query (mail branch).")
async def main_async(args: argparse.Namespace) -> dict:
async def main_async_http(args: argparse.Namespace) -> tuple[list[dict], list[dict], list[int]]:
"""`--transport http` path -- no DB connection, three `/search` calls per query. Returns
only at `--gate-n` (see module docstring: kb-query serves one server-side N per request)."""
queries = load_queries(Path(args.queries))
mail_queries = load_mail_queries(Path(args.queries))
n_values = [args.gate_n]
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=60)) as session:
results = []
envelope_sources: dict[str, str] = {}
for query in queries:
result = await run_query_all_tracks_http(session, args.base_url, query, args.gate_n)
envelope_sources.update(envelope_sources_from_results(
result["flat"]["chunks"], result["cascades"][args.gate_n]["chunks"], result["hybrid"]["chunks"],
))
results.append(summarize_query_result(result))
mail_results_raw = []
for query in mail_queries:
result = await run_query_all_tracks_http(session, args.base_url, query, args.gate_n)
envelope_sources.update(envelope_sources_from_results(result["hybrid"]["chunks"]))
mail_results_raw.append(result)
mail_results = [summarize_query_result(r, envelope_sources) for r in mail_results_raw]
return results, mail_results, n_values
async def main_async_direct(args: argparse.Namespace) -> tuple[list[dict], list[dict], list[int]]:
queries = load_queries(Path(args.queries))
mail_queries = load_mail_queries(Path(args.queries))
n_values = [int(n) for n in args.n_sweep.split(",")]
@ -380,6 +459,21 @@ async def main_async(args: argparse.Namespace) -> dict:
finally:
await conn.close()
return results, mail_results, n_values
async def main_async(args: argparse.Namespace) -> dict:
if args.transport == "http":
if args.n_sweep != "5,10,20": # the argparse default -- operator didn't ask for a sweep
print(
"note: --transport http ignores --n-sweep (kb-query serves a single "
f"server-side default N per request); reporting only --gate-n={args.gate_n}",
file=sys.stderr,
)
results, mail_results, n_values = await main_async_http(args)
else:
results, mail_results, n_values = await main_async_direct(args)
gate_result = evaluate_gate(results, gate_n=args.gate_n, mail_rows=mail_results)
print_report(results, n_values, gate_result, mail_rows=mail_results)
@ -395,19 +489,26 @@ async def main_async(args: argparse.Namespace) -> dict:
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--dsn", default=os.environ.get("KB_DSN"), help="asyncpg DSN for kb-postgres (or KB_DSN env var)")
parser.add_argument("--transport", choices=["direct", "http"], default="direct",
help="direct = query DB+Ollama locally (default); http = call a live kb-query's /search")
parser.add_argument("--base-url", default=None, help="kb-query base URL, required for --transport http (e.g. http://192.168.31.5:8230)")
parser.add_argument("--dsn", default=os.environ.get("KB_DSN"), help="asyncpg DSN for kb-postgres (or KB_DSN env var); required for --transport direct")
parser.add_argument("--ollama-url", default=os.environ.get("OLLAMA_URL", "http://localhost:11434"))
parser.add_argument("--embed-model", default=DEFAULT_EMBED_MODEL)
parser.add_argument("--summary-model", default=DEFAULT_SUMMARY_MODEL,
help="document_summary.model to pre-filter on (plan §2 D3 resolution)")
parser.add_argument("--k", type=int, default=DEFAULT_K)
parser.add_argument("--gate-n", type=int, default=DEFAULT_N, help="N used for the PASS/FAIL verdict")
parser.add_argument("--n-sweep", default="5,10,20", help="comma-separated N values to report (diagnostic)")
parser.add_argument("--n-sweep", default="5,10,20", help="comma-separated N values to report (diagnostic; ignored by --transport http)")
parser.add_argument("--queries", default=str(DEFAULT_QUERIES_PATH))
parser.add_argument("--json-out", default=None, help="optional path to dump full results as JSON")
args = parser.parse_args()
if not args.dsn:
if args.transport == "http":
if not args.base_url:
print("error: --transport http requires --base-url", file=sys.stderr)
sys.exit(1)
elif not args.dsn:
print("error: pass --dsn or set KB_DSN", file=sys.stderr)
sys.exit(1)

View file

@ -3,6 +3,7 @@ real Ollama on either side. Fake backends are keyed by URL so one fake session c
both legs; the clock is injected so the 30 s TTL is tested without sleeping."""
from __future__ import annotations
import asyncio
import pathlib
import sys
@ -18,12 +19,15 @@ FALLBACK = "http://192.168.31.5:11434"
class _FakeResponse:
def __init__(self, payload=None, exc=None, status=200):
def __init__(self, payload=None, exc=None, status=200, delay=0.0):
self._payload = payload
self._exc = exc
self.status = status # check_ollama_health reads resp.status directly
self._delay = delay
async def __aenter__(self):
if self._delay:
await asyncio.sleep(self._delay)
if self._exc is not None:
raise self._exc
return self
@ -43,11 +47,13 @@ class _FakeBackend:
`embed_exc` makes /api/embeddings fail while /api/tags still answers (the plan's step-3b
scenario: probe says up, the real embed then dies mid-query)."""
def __init__(self, models=("bge-m3:latest",), down=False, embed_exc=None, vector_value=0.01):
def __init__(self, models=("bge-m3:latest",), down=False, embed_exc=None, vector_value=0.01,
embed_delay=0.0):
self.models = list(models)
self.down = down
self.embed_exc = embed_exc
self.vector_value = vector_value
self.embed_delay = embed_delay # seconds the /api/embeddings answer hangs before serving
self.tags_calls = 0
self.embed_calls = 0
@ -76,7 +82,8 @@ class _FakeSession:
return _FakeResponse(exc=aiohttp.ClientConnectionError("connection refused"))
if backend.embed_exc is not None:
return _FakeResponse(exc=backend.embed_exc)
return _FakeResponse({"embedding": [backend.vector_value] * 1024})
return _FakeResponse({"embedding": [backend.vector_value] * 1024},
delay=backend.embed_delay)
class _FakeClock:
@ -148,6 +155,48 @@ class TestFailover:
assert embedding[0] == 0.02
assert solaria.embed_calls == 1
async def test_mid_embed_timeout_serves_same_query_from_fallback(self):
# The step-3b guarantee for the OTHER failure shape: SOLARIA hangs instead of
# refusing. This exercises the asyncio.wait_for path (a hang raises TimeoutError,
# not aiohttp.ClientError) -- the hard primary timeout must cut the hang off and
# the SAME request must still come back from the fallback.
solaria = _FakeBackend(embed_delay=0.2)
piha = _FakeBackend(vector_value=0.02)
session = _FakeSession({PRIMARY: solaria, FALLBACK: piha})
router = _router(primary_embed_timeout_s=0.05)
embedding, backend = await router.embed(session, "q")
assert backend == "piha"
assert embedding[0] == 0.02
assert solaria.embed_calls == 1
async def test_mid_embed_failure_opens_circuit_for_subsequent_requests(self):
# The one-shot flip must persist: after a mid-embed failure, requests inside the
# same TTL window go straight to the fallback -- no re-probe, no primary retry.
solaria = _FakeBackend(embed_exc=aiohttp.ClientConnectionError("died mid-embed"))
piha = _FakeBackend()
clock = _FakeClock()
router = _router(clock=clock)
session = _FakeSession({PRIMARY: solaria, FALLBACK: piha})
await router.embed(session, "q1") # probe says up -> embed dies -> flip to down
tags_after_first = solaria.tags_calls
clock.now += 10 # still inside the TTL window the flip opened
_, backend = await router.embed(session, "q2")
assert backend == "piha"
assert solaria.embed_calls == 1 # primary never retried inside the window
assert solaria.tags_calls == tags_after_first # and never re-probed either
async def test_fallback_embed_is_not_bounded_by_the_primary_timeout(self):
# Last-resort semantics: a Pi-5 CPU embed plus a cold model load is legitimately
# slow -- the fallback leg must NOT inherit the primary's hard timeout, or "slow
# but alive" would turn back into "dead".
solaria = _FakeBackend(down=True)
piha = _FakeBackend(embed_delay=0.2, vector_value=0.02)
session = _FakeSession({PRIMARY: solaria, FALLBACK: piha})
router = _router(primary_embed_timeout_s=0.05)
embedding, backend = await router.embed(session, "q")
assert backend == "piha"
assert embedding[0] == 0.02
async def test_down_verdict_is_cached_and_skips_primary_until_ttl_expires(self):
solaria = _FakeBackend(down=True)
piha = _FakeBackend()

View file

@ -65,6 +65,21 @@ night-quiet) hour: run a few embeds as above while watching
step 5: keep as default fallback / tune `mem_limit` / fall back to explicit
503 degradation.
**Calibration status: measured 2026-07-27 — verdict GO** (live PIHA under
normal load, 3 consecutive `/api/embeddings` calls after `ollama pull bge-m3`;
full protocol in `docs/sessions/2026-07-27-kb-f4-fallback.md` §4):
- Latency: 5.25 s (cold start) / 4.41 s / 4.16 s — single seconds as expected,
no warm-up between calls by design (`OLLAMA_KEEP_ALIVE=0` releases the model
after every request, `ollama ps` shows nothing resident in between).
- RAM: idle ~66 MiB, burst peak ~983 MiB (`docker stats` sampled at 0.3 s) —
well inside the 2560m ceiling; host `available` never dropped below ~1.3 GiB.
- Kept as **default fallback** (no feature flag). The measurement was taken
against the same container configuration this repo deploys (image,
`OLLAMA_KEEP_ALIVE=0`, `mem_limit: 2560m`), so it carries over; only the
model storage differed (bind mount then, named volume now), which does not
affect RAM/latency.
## Relation to kb-query
kb-query's router (`services/kb-query/app/embed_router.py`) health-checks