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
| Decision | Choice |
|---|---|
| Hero | Semantic search / ask-a-question (ranked sections) |
| Backend | Reuse existing kb-vectors/search.py::search(client, query, …) + Qdrant manuals |
| Where it runs | Extend the existing knowledgebase Flask app (waitress @127.0.0.1:8092) — no new service |
| Query embedding | bge-large loaded ONCE at app startup (process singleton, ~1.3 GB; box has 42 GB free) |
| UI | Server-rendered search page + progressive JS (debounced fetch to /api/search) |
| Results | Ranked sections: heading, vehicle/manual, snippet (query-highlighted), score, type badge, deep link; deduped by page |
| Filters | Vehicle (top folder) + manual, from payload facets |
| Manual pages | Keep mkdocs static output; results deep-link into them (wiring pages → the extracted section anchor) |
| i18n | Keep the lang cookie + i18n.py string table for UI chrome |
| Trust/speed | Retrieval 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_urlkeeping best score; build a snippet from the matching chunktextaround the best query-term span; classify type frommanual_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-aianchor), 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/searchfor instant results + keyboard nav (↑/↓/Enter). Keep the existinglangcookie + i18n chrome strings. - Make this the landing (
/) — but preserve the mkdocs site: the catch-all still servessite/...; 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/searchreturns 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=alwaysservice. - 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
- searchui unit:
dedupe_by_pagekeeps best score per page;make_snippet/highlightwrap query terms and stay within length;classifymaps consolidated/wiring/manual correctly;facet_labelmaps folders → vehicle names. - /api/search unit: with
search.searchmonkeypatched, returns deduped JSON in the documented shape; emptyq→ error; filters passed through. - Embedder singleton: the model loads once (not per request) — assert the load happens at startup and
/api/searchdoesn’t reload it (timing/first-vs-second query). - 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.
- Latency: warm query end-to-end < ~500 ms (embed + Qdrant + render).
- Graceful degradation: with Qdrant stopped,
/api/search→ 503, home still renders, mkdocs pages still serve. - No regression: existing routes (/semantics, /drafts, generate, /lang, site catch-all) still work; mkdocs pages still served; app restarts cleanly under waitress.
Risks
| Risk | Mitigation |
|---|---|
| Embedder memory/startup cost in the app | 1.3 GB of 42 GB free; warm once at startup; Restart=always |
| Concurrent encodes spike CPU → PSU brown-out | thread-cap encodes; low-QPS single-user; queue not needed |
| Semantic results miss exact-keyword matches | acceptable for v1; hybrid keyword fusion is a documented phase-2 lever |
Overriding / breaks site serving | override ONLY root + /search; catch-all still serves site/...; test existing routes |
| bge-large is English-only | manuals 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.