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>
This commit is contained in:
oskar 2026-08-06 13:30:02 +02:00
parent a14909d921
commit b1b6692382
8 changed files with 151 additions and 17 deletions

View file

@ -463,7 +463,8 @@ i idempotencji (drugi przebieg = zero insertów). Definition of Done z CLAUDE.md
`hybrid_query` analogicznie do `cascade_query` (jeden embed zapytania).
- `kb-query`: `mode` pattern `^(cascade|flat|hybrid)$`; **domyślny `mode`
przełączany na `hybrid` dopiero po PASS bramki (§8)** — do tego czasu
hybrid dostępny jawnie. Wyniki gmail w UI już obsłużone (faza 4: subject/from
hybrid dostępny jawnie. *(Wykonane 2026-08-06: default = `hybrid`, patrz
DoD (d) w §12.)* Wyniki gmail w UI już obsłużone (faza 4: subject/from
z headers + „Kopiuj Message-ID").
- Testy jednostkowe na mockach (merge, pusta gałąź summary, pusta gałąź mail).
@ -578,7 +579,8 @@ płonił bramki co uruchomienie).
**`kb-query` domyślny `mode`**: przełączenie na `hybrid` jako follow-up (poza
zakresem tego zamknięcia bramki — `kb-query`'s `mode` param zmiana to osobna,
mała zmiana w serwisie, nie w `packages/kb-retrieval`).
mała zmiana w serwisie, nie w `packages/kb-retrieval`). **Wykonane 2026-08-06**
po regresji na pełnym korpusie (Etap B) — szczegóły przy DoD (d) w §12.
## 9. Krok 6 — Etap B: pełne archiwum
@ -620,7 +622,7 @@ zatrzymaniu) wykazał dwie rzeczy do rozstrzygnięcia. Decyzje:
wystarczają; ewentualna kalibracja heurystyki na dekadzie 20102015 po fakcie,
na już zapisanych flagach.
4. Przełączenie domyślnego `mode` kb-query na `hybrid` (DoD (d)) — **poza zakresem
Etapu B**, osobny task po PASS regresji.
Etapu B**, osobny task po PASS regresji. *(Wykonany 2026-08-06 — DoD (d) w §12.)*
### Hardening toru embed przed Etapem B (2026-08-05)
@ -728,7 +730,7 @@ Zakotwiczone w kb-00 jako etapy 34 (`jobs/fastmail-poller`,
| 0 | Chunker → `packages/kb-mail` | — | 0,5 sesji | **WYKONANE** | `348ce10`; `packages/kb-mail/src/kb_mail/chunking.py` + `tests/test_chunking.py` |
| 1 | `embed_batch` w kb-retrieval | — | 0,5 sesji | **WYKONANE** | `51998fd`; `kb_retrieval/embed.py:61` (`embed_batch`) + `tests/test_embed.py` |
| 2 | Job `mail-body-ingest` | 0, 1 | 2 sesje | **WYKONANE** | `ad0ef40` (job), `a95524c` (README), `fc5c698` (fix html_to_text); `jobs/mail-body-ingest/` + `tests/test_ingest.py` |
| 3 | Tryb hybrid (kb-retrieval + kb-query) | — (równolegle z 2) | 1 sesja | **WYKONANE** | `a640cf1`; `kb_retrieval/retrieval.py:112` (`hybrid_retrieve`), `:195` (`hybrid_query`), `kb-query/app/main.py:119` (`mode` pattern). Uwaga: domyślny `mode` to nadal `cascade` — przełączenie to follow-up z §8, nie część Kroku 3 |
| 3 | Tryb hybrid (kb-retrieval + kb-query) | — (równolegle z 2) | 1 sesja | **WYKONANE** | `a640cf1`; `kb_retrieval/retrieval.py:112` (`hybrid_retrieve`), `:195` (`hybrid_query`), `kb-query/app/main.py:119` (`mode` pattern). Domyślny `mode` przełączony na `hybrid` 2026-08-06 (follow-up z §8, DoD (d) niżej) |
| 4 | rsync + Etap A (12 mies.) + kalibracja | 2 | 1 sesja | **WYKONANE** | §7 „Wynik Etapu A" (run na żywo 2026-07-23); potwierdzone na żywej bazie 2026-08-04: `document_chunk` gmail = 33 871 (6 398 z embeddingiem + 27 473 `newsletter`) — zgodne co do sztuki z tabelą §7 |
| 5 | Bramka jakościowa (eval mailowy + regresja) | 3, 4 + zapytania od operatora | 1 sesja | **WYKONANE** (PASS) | §8 „Wynik bramki"; `56f64e9` (eval + queries.yaml dla hybrid), `bce635c` (`mail_hit@3`, próg N2, werdykt PASS), `71eb264` (`--transport http`) |
| 6 | Etap B (pełne archiwum) + regresja + obserwacja PIHA | 5 = PASS | 1 sesja | **WYKONANE** (2026-08-06) | §9 „Wynik Etapu B"; żywa baza: 389 012 chunków, 0 nie-excluded bez embeddingu. Weryfikacja plastrami 0-4 (wszystkie EXIT 0) + fix NUL `4ec0b78`; `docs/sessions/2026-08-06-kb-etapb-backfill.md` |
@ -741,6 +743,20 @@ hybrid, (c) bramka §8 PASS wraz z regresją po Etapie B, (d) `kb-query`
domyślnie odpowiada trybem hybrid na `kb.kapala.org`, (e) koperty gmail mają
`entities[type=threading]`.
**(d) SPEŁNIONE w repo 2026-08-06** — domyślny `mode` w `/search` przełączony
`cascade``hybrid` (`services/kb-query/app/main.py`, walidator `Query`;
frontend przestał wysyłać `mode` przy odznaczonym „tryb flat", więc UI
dziedziczy domyślny tryb API). Podstawa: eval na **pełnym** korpusie
(187 025 zembedowanych chunków mailowych w HNSW, §9) —
kryterium 1 (regresja paperless) **PASS**: żaden istniejący hit nie
zdegradował ani 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` i `eval-direct-2026-08-06.json`
w `~/kb/mail/ingest-logs` na PIHA (celowo niecommitowane — artefakt runu).
Jawne `?mode=cascade` / `?mode=flat` działają bez zmian.
**Deploy na PIHA robi operator z mastera po mergu** — do tego czasu
`kb.kapala.org` nadal odpowiada kaskadą.
## 13. Szacunki zbiorcze
- **Dane**: +~496k wierszy `document_chunk` (~271k z embeddingiem, ~225k

View file

@ -3,7 +3,7 @@ okf: "0.1"
type: service
visibility: private
status: active
updated: 2026-07-29
updated: 2026-08-06
links:
- ../runbooks/kb-query-deploy.md
---
@ -23,7 +23,23 @@ that's phase 5.
| `/` | 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=cascade\|flat` | GET | `query_text -> embed -> cascade_query/flat_query -> results`, `mode` defaults to `cascade` |
| `/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
@ -31,7 +47,7 @@ change any field the plan §4 shape already defined):
```json
{
"query": "...", "mode": "cascade", "sol_status": "up", "embed_backend": "solaria",
"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",
@ -55,8 +71,11 @@ 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)").
- 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

View file

@ -15,9 +15,13 @@ silent cross-space distance computation.
container (plan §2 decision 4): a Jinja2 shell + a static vanilla-JS file, no node build step.
`/` and `/static/*` need no DB/Ollama, so they stay reachable even while `/search` is 503ing.
`mode=hybrid` (faza mailowa, plan Krok 3, kb/phases/kb-m5-faza-mailowa.md §6) is
available explicitly starting here, but the default stays `cascade` until the quality gate
(plan §8) PASSes on the full mail corpus -- flipping the default is a separate, later change.
`mode=hybrid` (faza mailowa, plan Krok 3, kb/phases/kb-m5-faza-mailowa.md §6) is the
DEFAULT since 2026-08-06 (DoD (d) of that phase): the quality gate (§8) re-ran on the full
mail corpus (187k embedded mail chunks in HNSW) and PASSed -- no paperless hit degraded,
mail hit@3 5/5 -- so mail bodies are now visible without asking for them. Cost: one extra
SQL query per search (the summaryless-source chunk scan). `mode=cascade` (documents only,
the old default) and `mode=flat` (debug, no summary pre-filter) stay available explicitly
and behave exactly as before.
"""
from __future__ import annotations
@ -116,7 +120,7 @@ async def healthz() -> dict:
@app.get("/search")
async def search(
q: str = Query(..., min_length=1),
mode: str = Query("cascade", pattern="^(cascade|flat|hybrid)$"),
mode: str = Query("hybrid", pattern="^(cascade|flat|hybrid)$"),
) -> dict:
try:
async with app.state.pool.acquire() as conn:

View file

@ -12,8 +12,11 @@
const GREEN_MAX = 0.45;
const YELLOW_MAX = 0.55;
// mode omitted (falsy) -> the API's own default answers (hybrid since 2026-08-06,
// kb/phases/kb-m5-faza-mailowa.md DoD (d)). One place defines the default: the server.
function buildSearchUrl(query, mode) {
const params = new URLSearchParams({ q: query, mode: mode });
const params = new URLSearchParams({ q: query });
if (mode) params.set('mode', mode);
return '/search?' + params.toString();
}
@ -200,7 +203,7 @@
event.preventDefault();
const query = queryInput.value.trim();
if (!query) return;
const mode = modeToggle.checked ? 'flat' : 'cascade';
const mode = modeToggle.checked ? 'flat' : null;
errorBox.hidden = true;
results.innerHTML = '<p class="loading">Szukam…</p>';

View file

@ -36,6 +36,8 @@ EMBED_FALLBACK_URL=http://192.168.31.5:11434
# Optional: defaults to bge-m3.
# EMBED_MODEL=bge-m3
# document_summary.model kb-query's cascade path pre-filters on (the
# document_summary.model kb-query's cascade/hybrid stage 1 pre-filters on (the
# compilation track, plan §2 D3). Optional: defaults to claude-haiku-4-5.
# The /search `mode` default itself is NOT configurable via env — it is the
# API contract (hybrid since 2026-08-06, kb/services/kb-query.md).
# SUMMARY_MODEL=claude-haiku-4-5

View file

@ -38,4 +38,4 @@ service:
- EMBED_HEALTH_TIMEOUT_S # optional — /api/tags probe timeout, defaults to 1.5
- EMBED_PRIMARY_TIMEOUT_S # optional — hard timeout for a primary embed call, defaults to 3
- EMBED_MODEL # optional — defaults to bge-m3; startup invariant vs document_chunk/document_summary + per-backend /api/tags check
- SUMMARY_MODEL # optional — defaults to claude-haiku-4-5; cascade stage-1 model
- SUMMARY_MODEL # optional — defaults to claude-haiku-4-5; stage-1 summary model for the cascade/hybrid paths (hybrid = default mode since 2026-08-06)

View file

@ -27,6 +27,13 @@ test('buildSearchUrl carries mode=flat through untouched', () => {
assert.equal(url, '/search?q=faktura&mode=flat');
});
// Toggle unchecked (the normal case) sends no mode at all -- the UI inherits the API
// default (hybrid since 2026-08-06), instead of pinning a second copy of it in JS.
test('buildSearchUrl omits mode entirely when none is given', () => {
assert.equal(buildSearchUrl('faktura', null), '/search?q=faktura');
assert.equal(buildSearchUrl('faktura'), '/search?q=faktura');
});
test('distClass applies the phase-3 thresholds (green <0.45, yellow 0.45-0.55, red >0.55)', () => {
assert.equal(distClass(0.1), 'dist-green');
assert.equal(distClass(0.449), 'dist-green');

View file

@ -8,6 +8,9 @@ import sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
from fastapi.testclient import TestClient # noqa: E402
from app.main import app # noqa: E402
from app.search import run_search # noqa: E402
@ -246,3 +249,83 @@ class TestRunSearchNoGoodResults:
conn, session, _FakeRouter(), "nothing matches", "cascade", "claude-haiku-4-5"
)
assert result["results"] == []
class _FakePool:
"""asyncpg pool stand-in: `async with pool.acquire() as conn` yields the fake conn."""
def __init__(self, conn):
self._conn = conn
def acquire(self):
conn = self._conn
class _Acquire:
async def __aenter__(self):
return conn
async def __aexit__(self, *exc_info):
return False
return _Acquire()
def _client_with_fake_state(conn) -> TestClient:
# Same trick as tests/test_frontend.py: TestClient is NOT entered as a context manager,
# so `lifespan` (live DSN + Ollama) never runs and we inject app.state ourselves.
app.state.pool = _FakePool(conn)
app.state.http = _FakeSession()
app.state.embed_router = _FakeRouter()
return TestClient(app)
def _mixed_corpus_conn() -> _FakeConn:
"""One paperless envelope reachable via the summary pre-filter + one gmail envelope
reachable ONLY through hybrid's summaryless-source branch -- so the mode a request
actually took is visible in the results, not just in the echoed `mode` field."""
return _FakeConn(
summaries=[("paperless:1", 0.3)],
chunks_by_envelope={"paperless:1": [(0, "doc text", 0.3)]},
envelopes={
"paperless:1": {"source": "paperless", "entities": []},
"<msgid@example.com>": {
"source": "gmail",
"entities": [{"type": "headers", "from": None, "subject": "s", "date_raw": "d"}],
},
},
mail_chunks_by_source={"gmail": [("<msgid@example.com>", 0, "mail text", 0.2)]},
)
class TestSearchEndpointModeDefault:
"""HTTP-level contract of `mode` (kb/phases/kb-m5-faza-mailowa.md DoD (d), bramka §8
PASS on the full corpus 2026-08-06): no `mode` parameter => hybrid."""
def test_default_without_mode_param_takes_the_hybrid_path(self):
client = _client_with_fake_state(_mixed_corpus_conn())
response = client.get("/search", params={"q": "q"})
assert response.status_code == 200
body = response.json()
assert body["mode"] == "hybrid"
# The mail envelope is unreachable from the cascade/flat paths in this fixture,
# so its presence proves the hybrid branch really ran (not just a relabelled cascade).
assert [r["envelope_id"] for r in body["results"]] == [
"<msgid@example.com>",
"paperless:1",
]
def test_explicit_mode_flat_still_takes_the_flat_path(self):
client = _client_with_fake_state(_mixed_corpus_conn())
body = client.get("/search", params={"q": "q", "mode": "flat"}).json()
assert body["mode"] == "flat"
assert [r["envelope_id"] for r in body["results"]] == ["paperless:1"]
def test_explicit_mode_cascade_still_takes_the_cascade_path(self):
client = _client_with_fake_state(_mixed_corpus_conn())
body = client.get("/search", params={"q": "q", "mode": "cascade"}).json()
assert body["mode"] == "cascade"
assert [r["envelope_id"] for r in body["results"]] == ["paperless:1"]
def test_unknown_mode_is_rejected(self):
client = _client_with_fake_state(_mixed_corpus_conn())
assert client.get("/search", params={"q": "q", "mode": "turbo"}).status_code == 422