KB semantic-search frontend (custom app, phase 1) — design

Give the knowledgebase a real frontend built around semantic search — type a question, get the best manual sections ranked by meaning via the existing Qdrant vectors — served by the existing Flask app. mkdocs-material’s client-side keyword search (the “Initializing search” hang) can’t use the semantic backend at all; this replaces it as the primary way in, while keeping the mkdocs-rendered manual pages, wiring viewer, consolidated articles, and i18n intact.

Related: 2026-07-24-knowledgebase, 2026-07-24-kb-vectorize-complete, 2026-07-29-kb-wiring-extraction-v1-complete, 2026-07-28-kb-hungarian-i18n-design, telep-mainframe.

Problem

The KB has a strong backend — Qdrant semantic index (manuals collection, bge-large-en, ~370×N points), clusters, consolidated articles, extracted wiring netlists — but the frontend is mkdocs-material, a static docs renderer whose only search is a client-side lunr keyword index. That index is now 6,683 sections / 5.3 MB and is built in the browser on every visit → “Initializing search” grinds. Worse, it’s keyword search that ignores the semantic vectors entirely. The frontend is the weak layer over a strong backend.

Goal

A search-first entry point: a clean, fast, keyboard-friendly page where a natural-language query returns manual sections ranked by semantic similarity (existing Qdrant), with snippets, deep links, vehicle/manual filters, and result-type badges (manual / wiring / consolidated article). No page-build search, no lunr, no browser index. Fast (~100–300 ms/query), no claude dependency.

Non-goals (phase 1)

  • No answer synthesis / RAG (user chose ranked results, not generated answers) — retrieval only. (Synthesis is a possible phase 2.)
  • No re-rendering of the 1,300 manual markdown pages — keep the mkdocs static output; augment, don’t replace.
  • No JS SPA / build toolchain — extend the existing Flask app, server-rendered + light progressive JS (power-flaky box; no new always-on service).
  • No re-embedding / no change to Qdrant, clustering, wiring extraction, or i18n.
  • No hybrid keyword+semantic fusion in v1 (semantic only; hybrid is a later option).

Key decisions

DecisionChoice
HeroSemantic search / ask-a-question (ranked sections)
BackendReuse existing kb-vectors/search.py::search(client, query, …) + Qdrant manuals
Where it runsExtend the existing knowledgebase Flask app (waitress @127.0.0.1:8092) — no new service
Query embeddingbge-large loaded ONCE at app startup (process singleton, ~1.3 GB; box has 42 GB free)
UIServer-rendered search page + progressive JS (debounced fetch to /api/search)
ResultsRanked sections: heading, vehicle/manual, snippet (query-highlighted), score, type badge, deep link; deduped by page
FiltersVehicle (top folder) + manual, from payload facets
Manual pagesKeep mkdocs static output; results deep-link into them (wiring pages → the extracted section anchor)
i18nKeep the lang cookie + i18n.py string table for UI chrome
Trust/speedRetrieval only (no claude); Qdrant/embedder failures degrade gracefully

Architecture

Extend /home/levander/knowledgebase/app.py (Flask + waitress). The semantic backend is already in /home/levander/kb-vectors/ — import it (add kb-vectors to sys.path) or expose a thin shared module.

knowledgebase/ (existing Flask app)
  app.py         + startup: build Qdrant client + load embedder ONCE (singleton)
                 + GET /api/search?q=&folder=&manual=&limit=  → JSON hits
                 + GET / (or /search) → SEARCH_HTML (search-first home)
                 + keep all existing routes (/semantics, /drafts, generate, /lang, site catch-all)
  searchui.py    (new, optional) pure helpers: dedupe_by_page(hits), make_snippet(text, query),
                 highlight(snippet, query), classify(manual_id, page_url) -> "manual|wiring|article",
                 facet_label(folder) -> vehicle name
kb-vectors/ (existing, unchanged)
  search.py      search(client, query, limit, folder, manual) -> [payload+score]   # already exists
  embed.py       embed_query(text)  # loads bge-large; make the model a cached singleton

Startup (app.py main() / module init)

  • Create one QdrantClient(url="http://127.0.0.1:6333").
  • Warm the embedder once (call embed.embed_query("warmup") at startup so the first real query is fast; the model stays resident). Cap torch threads modestly (OMP_NUM_THREADS/torch.set_num_threads(4)) to avoid a PSU brown-out under concurrent encodes.
  • Hold both as module globals for the request handlers.

GET /api/search

  • Params: q (required), folder, manual (optional filters), limit (default ~20, cap 50).
  • hits = search.search(CLIENT, q, limit=limit, folder=folder, manual=manual).
  • Post-process (searchui.py): dedupe by page_url keeping best score; build a snippet from the matching chunk text around the best query-term span; classify type from manual_id/page_url (consolidated/ → article; wiring manuals or pages with a .wiring.json → wiring; else manual); attach vehicle label.
  • Return JSON: {took_ms, count, hits:[{heading, manual_id, vehicle, page_url, snippet, score, type}]}.
  • Errors: empty q → 400/empty; Qdrant/embedder error → 503 + message (UI shows a friendly error).

Search-first UI (SEARCH_HTML, inline like the existing pages)

  • Big search box, autofocused; result count + timing; results list with: type badge, heading (link to page_url; wiring pages link to the #kinyert-huzalozasi-adatok-ai anchor), vehicle/manual, highlighted snippet, score bar.
  • Vehicle + manual filter dropdowns (populated from a small facets endpoint or a static list derived from Qdrant folders).
  • Progressive enhancement: server-rendered form (GET /search?q=) works with JS off; with JS, debounced fetch to /api/search for instant results + keyboard nav (↑/↓/Enter). Keep the existing lang cookie + i18n chrome strings.
  • Make this the landing (/) — but preserve the mkdocs site: the catch-all still serves site/...; only / (and /search) render the new UI. (Confirm the current / behavior and override just the root.)

Data flow

user query
  → /api/search: embed_query(q) [singleton bge-large]  →  Qdrant top-K (optional folder/manual filter)
  → dedupe by page_url, snippet+highlight, classify type, vehicle label
  → JSON → search UI renders ranked results → deep link into mkdocs page / wiring anchor / article

Error handling / ops

  • Embedder + Qdrant client are process singletons; if either fails at startup, /api/search returns 503 and the home still renders (with a notice) — the mkdocs site + other routes keep working.
  • No claude, no external calls → no quota/latency dependency; fully local.
  • Memory: bge-large ~1.3 GB resident in the app; box has ~42 GB free — fine. Startup +~few seconds (model load) — acceptable for a Restart=always service.
  • Thread-capped encodes; search is low-QPS (single-user) so contention is minimal; still cap to protect the PSU.
  • Non-destructive: only adds routes + a helper module + edits app.py; Qdrant/index/docs/site untouched. Backup app.py first.

Testing / verification

  1. searchui unit: dedupe_by_page keeps best score per page; make_snippet/highlight wrap query terms and stay within length; classify maps consolidated/wiring/manual correctly; facet_label maps folders → vehicle names.
  2. /api/search unit: with search.search monkeypatched, returns deduped JSON in the documented shape; empty q → error; filters passed through.
  3. Embedder singleton: the model loads once (not per request) — assert the load happens at startup and /api/search doesn’t reload it (timing/first-vs-second query).
  4. Integration (real): query “why won’t the fuel pump prime” (or “fuel cut controller”) returns relevant sections (the right manual pages) ranked above noise; a wiring query returns a wiring page linking to its extracted section; a filtered query (folder=suzuki-vitara) restricts correctly.
  5. Latency: warm query end-to-end < ~500 ms (embed + Qdrant + render).
  6. Graceful degradation: with Qdrant stopped, /api/search → 503, home still renders, mkdocs pages still serve.
  7. No regression: existing routes (/semantics, /drafts, generate, /lang, site catch-all) still work; mkdocs pages still served; app restarts cleanly under waitress.

Risks

RiskMitigation
Embedder memory/startup cost in the app1.3 GB of 42 GB free; warm once at startup; Restart=always
Concurrent encodes spike CPU → PSU brown-outthread-cap encodes; low-QPS single-user; queue not needed
Semantic results miss exact-keyword matchesacceptable for v1; hybrid keyword fusion is a documented phase-2 lever
Overriding / breaks site servingoverride ONLY root + /search; catch-all still serves site/...; test existing routes
bge-large is English-onlymanuals are English; HU UI chrome unaffected; HU content search is out of scope

Phase 2 candidates (not now)

  • Answer synthesis (RAG) over the top hits via claude, cited — the “assistant” tier.
  • Hybrid semantic + keyword (BM25) fusion for exact-term recall.
  • Search-as-you-type ranking of wiring components/nets specifically.
  • Retire the mkdocs lunr search entirely once this is the default.