Search — User Journeys
Search — User Journeys
Section titled “Search — User Journeys”Last verified: Session 210 (29 April 2026) — refresh covering S196-S198, S205, S207, and S208 (live preview, persona-branched cold-start prompts, saved filter presets, MCP filter widening). Pending updates: none — stable area.
Overview
Section titled “Overview”Search is the canonical entry point into the knowledge base. Standalone
/search was deprecated; the route now 308-redirects to /browse?q=… so
search is inseparable from filtering, sort, and saved presets. Three surfaces
(Dashboard hero, header compact, in-Browse inline) all funnel into the same
hybrid pipeline.
Entry Points
Section titled “Entry Points”| Entry Point | Route | Component | Accessible By |
|---|---|---|---|
| Dashboard hero search | / | <SearchBar variant="hero"> | All roles |
| Header compact search (Cmd+K hint) | every authenticated route | <SearchBar variant="compact"> | All roles |
| Browse inline search | /browse | <SearchBar variant="inline"> | All roles |
| Persona prompt cards (cold start) | /browse (no query, no filters) | <SearchPromptCards> | All roles |
| Saved filter presets | /browse | <PresetBar> | All roles |
| Content Library Drawer | /procurement/[id]/session (Cmd+L) | <ContentLibraryDrawer> + useSearch() | Editor / admin |
| 404 page | /<unknown> | <SearchBar variant="hero"> | All roles |
| Legacy redirect | /search?q=… | 308 → /browse?q=… | All roles |
| Diagnostic CLI | terminal | bun run scripts/kb-search.ts "<query>" | Operators |
User Journeys
Section titled “User Journeys”Journey 1: Live preview while typing (Browse inline)
Section titled “Journey 1: Live preview while typing (Browse inline)”Actor: Any role (viewer, editor, admin).
Goal: Find a known item by typing a few characters and clicking through to
the detail page without committing to a full semantic search.
Preconditions: User is on /browse. Inline SearchBar has focus.
- Focus the inline SearchBar.
- Component:
<SearchBar variant="inline">. - What the user sees: dropdown opens with “Recent searches” (last 10, FIFO,
localStorage) and “Popular topics” (up to 12 keywords from
get_popular_keywords).
- Component:
- Begin typing (≥3 chars).
- After 300 ms of inactivity (
PREVIEW_DEBOUNCE_MS), the inline variant firesGET /api/search/preview?q=<query>&limit=8. While the request is in flight, the dropdown shows a “Searching…” row with a spinner inside anaria-live="polite"region. - “Popular topics” hides while the preview section is showing or loading (spec §4.1).
- After 300 ms of inactivity (
- Browse the live preview.
- Up to 8 matching items render with
<ContentTypeIcon>and<DomainBadge>, title-matches first, content-only matches after. - ArrowUp / ArrowDown moves a highlight; Enter activates the focused row; Escape closes the dropdown.
- Hover sets the active index for visual + screen-reader sync via
aria-activedescendant.
- Up to 8 matching items render with
- Click a preview row → item detail.
- Action: click or Enter on a preview row.
- Result: SPA navigation via
router.push('/item/{id}'). Recent searches are NOT updated (preview-row navigation is direct).
- OR click “See all results” → full hybrid search.
- Action: click the “See all results” footer or press Enter on the SearchBar with a non-empty query.
- Result: query is added to recent searches and the parent’s
onSearch(q)callback runs the fullPOST /api/searchpipeline against the page’s active filters.
Variations
Section titled “Variations”- Hero / compact variants: no live preview. Recent searches and Popular
topics still appear on focus, but the preview section is suppressed and the
Enter key submits directly to
/browse?q=…viarouter.push. from_bidURL param: if the user arrived via the form session “Browse for content” button (?from_bid=<workspaceId>— the param name is unchanged), the param sticks across filter changes, search submission, search clear, and preset application. Browse cards render an inline “Add to form” quick-assign button whilefrom_bidis active.
Edge cases
Section titled “Edge cases”<3characters: the preview is gated byPREVIEW_MIN_QUERY_LENGTHand no fetch is issued. Recent + Popular sections continue to render.- Wildcards in query:
%,_,\are backslash-escaped server-side so that “50%” matches the literal string, not every row. - Rate-limit (60 req/min) hit: the SearchBar dropdown shows the most-recent
cached results (TanStack Query 30 s
staleTime) and the request silently fails until the window resets. The failure does not surface a toast — the user sees stale results until they keep typing. - AbortController: when the user keeps typing, TanStack Query’s
signalaborts the in-flight request automatically — no race conditions surface.
Journey 2: Full hybrid semantic + keyword search
Section titled “Journey 2: Full hybrid semantic + keyword search”Actor: Any role.
Goal: Search across the entire KB (or a content-type-scoped slice) using
a natural-language query that may not contain literal keywords from the
target items.
Preconditions: Embeddings have been generated for the target content
(content_items.embedding IS NOT NULL). User is authenticated.
- Submit a query.
- Hero / compact variant: type, press Enter. SPA navigation to
/browse?q=<query>. - Inline variant on
/browse: type, press Enter or click “See all results”.setSearchQuery(query)flipsuseBrowseData()into Search Mode.
- Hero / compact variant: type, press Enter. SPA navigation to
- Pipeline runs.
- Browse Mode is suspended (
useInfiniteQueryenabled: false). - Search Mode
useQuerycallsPOST /api/searchwith{ query, threshold: 0.35, limit: 20 }. - Server: validate via
SearchBodySchema→ rate-limit check (30/min) →generateEmbedding()→hybrid_searchRPC → optional layer post-filter → return{ results, count }.
- Browse Mode is suspended (
- View ranked results.
- Hits are displayed in the active view mode (grid / list) using
ContentGrid/ContentList. - Each card shows similarity-derived ranking implicitly (results are
ordered DESC by
similarity). Ranking factors: cosine similarity (70% weight), title / suggested_title /ai_keywordsmatches (10-15% each), summary / author / 30-day-recency boosts, and a 3% × win-rate multiplier for items with ≥2 citations across won forms.
- Hits are displayed in the active view mode (grid / list) using
- Refine via filters.
- Open the FilterPanel (Filters button) to apply Domain / Subtopic / Content Type / Layer / Freshness / etc. — applied as post-filters on the search result array (Search Mode caps at 20 results before post-filter).
- Apply a saved preset via
<PresetBar>to swap an entire filter set.
Variations
Section titled “Variations”- Q&A specific search:
/library(Q&A Library page) constrainsuseBrowseDataSearch Mode tocontent_type = 'q_a_pair'post-filter. - Content Library Drawer (form session): opens via Cmd+L, uses the same
useSearch()hook againstPOST /api/search. Drawer adds an “Insert” button on each result that emits the result’s HTML +id+ title to the parent Tiptap editor (only shown whenonInsertcallback is provided). - MCP
find(document granularity): LLM clients hit the same RPC via the MCP server with optionaldomainandworkspace_idfilters (AND logic when both provided). Returns dual content (Markdown + structured JSON). This is thefindbranch that replaced the formersearch_knowledge_basetool. - MCP
find(type: "q_a_pair"): Q&A-only filter on top ofhybrid_searchfor form response drafting (the branch that replacedsearch_qa_library).
Edge cases
Section titled “Edge cases”- Embedding service down:
503 EMBEDDING_FAILED— the UI surfaces “Search is temporarily unavailable. Please try again shortly.” rather than partial / misleading results. - Empty query:
useSearch().search('')callsmutation.reset()and results return to[]. The submit handler short-circuits trim-empty strings. - Low similarity scores:
threshold: 0.35is the app default. Results may appear thin even when relevant items exist. The diagnostic CLI uses0.25and an operator can lower it via--thresholdfor triage. - Superseded items: excluded by default in app routes
(
include_superseded: falsebaked intohybrid_search). The CLI flips this to default-true for diagnostic context. - Drafts excluded:
governance_review_status = 'draft'is filtered out at the RPC level. Useinclude_drafts=trueon Browse for editor+ workflows.
Journey 3: Cold-start persona prompts (first visit to /browse)
Section titled “Journey 3: Cold-start persona prompts (first visit to /browse)”Actor: Any role; viewer always sees the fallback set.
Goal: Discover the KB through guided prompts when there are no recent
queries or filters to fall back on.
Preconditions: /browse opened with no q, no filters, not in unread-only
mode, not loading, but totalCount > 0 (KB has items).
- Open
/browse.- The 5-condition gate
shouldShowColdStartPrompts()evaluates true. - 3 cards render in a
grid-cols-1 sm:grid-cols-2layout above the results area.
- The 5-condition gate
- See persona-tailored cards.
usePrimaryFocus()readsuser_metadata.primary_focus:bid_writing→ “Past bid responses” (search) / “Q&A library” (filter) / “Case studies and evidence” (filter).account_management→ “Account context” (search) / “Win themes and proposals” (search) / “Sector intelligence” (filter).marketing→ “Case studies” (filter) / “Sector narratives” (search) / “Company evidence” (filter).
- Fallback / viewer: “Browse by domain” (chipComposite with top-3
domains) / “Find policies and standards” (filter) / “Recent case
studies” (filter — current year
date_from) / “Q&A library” (filter).
- Activate a card.
- Search card (
kind: 'search'): click / Enter / Space →addRecentSearch(exampleQuery)+onSelectQuery(exampleQuery)→ parent runs the full hybrid search. - Filter card (
kind: 'filter'): click / Enter / Space →onApplyFilter(preset)→setFilters(preset)writes URL params (same ingress as the FilterPanel Apply button). - ChipComposite card (
kind: 'chipComposite'): renders top-3 domain chips (24 h cached viauseTopDomains()); click a chip applies{ domain: [chipName] }; click “More domains…” opens the FilterPanel at the Domain section without mutating URL.
- Search card (
Variations
Section titled “Variations”- Editor / admin with
primary_focusunset: sees fallback set. - Year-dependent fallback: “Recent case studies” applies
date_from: ${currentYear}-01-01computed once viauseMemoso the card stays stable across midnight within a session.
Journey 4: Save & restore a filter preset
Section titled “Journey 4: Save & restore a filter preset”Actor: Any role (presets are localStorage-only — no server sync). Goal: Save the current Browse filter combination to apply again later.
- Apply filters.
- Open
/browse, set Domain / Subtopic / Freshness / etc. via the FilterPanel. <PresetBar>shows a “Save” button when any filter is active (canSave: true).
- Open
- Save.
- Click “Save” →
<SavePresetDialog>opens. - Enter a name (1-40 chars). Submit →
useFilterPresets().savePreset(name)creates aFilterPreset { id: 'u_<uuid8>', name, params, isSystem: false, createdAt }inkb-filter-presetslocalStorage. - The preset chip appears in
<PresetBar>after the 5 system presets.
- Click “Save” →
- Apply later.
- Click the preset chip →
applyPreset(presetId)→router.push('/browse?<preset.params>&from_bid=<sticky>'). Thefrom_bidURL param survives the navigation.
- Click the preset chip →
- Detect active preset.
<PresetBar>highlights the chip whosenormaliseParams(p.params) === normaliseParams(currentURL.searchParams). Clicking an active chip clears all filters.
- Manage user presets.
- “Manage” button (visible when at least one user preset exists) opens
<ManagePresetsDialog>for rename / delete with undo viarestorePreset(). System presets are read-only.
- “Manage” button (visible when at least one user preset exists) opens
Variations
Section titled “Variations”- System presets are always present:
- Stale content (
freshness=stale,expired) - Unreviewed items (
review_status=unverified) - Flagged items (
quality_issues=true) - My content (
owner=me) - Sector intelligence (
source=intelligence_pipeline)
- Stale content (
- localStorage unavailable (private browsing, full quota): saves silently no-op; system presets continue to work.
Edge cases
Section titled “Edge cases”- Active query (
?q=...) at save time: stripped bynormaliseParams()so presets capture filters only, not the search term. sort/order/cursorare also stripped — they’re not part of preset identity.
Journey 5: MCP-driven section search (Claude Desktop / Claude.ai)
Section titled “Journey 5: MCP-driven section search (Claude Desktop / Claude.ai)”Actor: Editor / admin / viewer using a Claude client that has the Canonical MCP server connected. Goal: Find a specific section within a long document (e.g. “the Risk Assessment section of a health and safety policy”) with chunk-level precision.
- Ask Claude a precision question.
- Example: “Find the risk assessment section in our health and safety policy.”
- Claude calls
findwithgranularity: "chunk".- Tool params (chunk-granularity branch):
query, optionallimit(default 10, max 30), optionalcontent_item_idto scope to one document, optional review-cadence filters (overdue_review: boolean/review_due_within_days: 1-365). This is thefindbranch that replaced the former standalonesearch_content_chunkstool. - The RPC
search_content_chunks(query_embedding, similarity_threshold: 0.3, limit_count, filter_content_item_id, filter_overdue_review, filter_review_due_within_days)(RPC name unchanged) runs the chunk-level cosine search withheading_pathbreadcrumb selection.
- Tool params (chunk-granularity branch):
- Receive ranked chunks.
- Each chunk includes
heading_path, parent document title, similarity, and a content snippet — Markdown for humans, structured JSON for downstream tool calls.
- Each chunk includes
Variations
Section titled “Variations”- Document-control workflow: an admin asks “Show me overdue health and
safety chunks.” Claude sets
overdue_review: trueand the RPC restricts to chunks from items withgovernance_review_status = 'review_overdue'(S208 §5.5 Phase 4 widening). - Pre-emptive review: “What policies are due for review in the next two
weeks?” →
review_due_within_days: 14, returning chunks from items whosenext_review_datefalls withinCURRENT_DATE + 14 days. - Whole-document search:
find(document granularity, the default) for item-level results (use this when the user wants whole documents, not specific sections).
Journey 6: Form drafting via Content Library Drawer
Section titled “Journey 6: Form drafting via Content Library Drawer”Actor: Editor / admin in a form session. Goal: Find a reusable answer or evidence and insert it directly into the active Tiptap response with a citation.
- Open the drawer.
- In
/procurement/[id]/session, press Cmd+L (or click the “Browse for content” button). <ContentLibraryDrawer>slides in.useContentLibraryDrawer()manages open state and content-type filter.
- In
- Search.
- Type a query →
useSearch().search(query)callsPOST /api/searchwith default threshold 0.35 and limit 20. - Results stream into the drawer as
<ContentLibraryResult>cards.
- Type a query →
- Insert with citation.
- Click “Insert” on a result → drawer fires
onInsert(html, sourceId, sourceTitle)→ parent Tiptap editor inserts the HTML body with a citation reference linking back to the source. - Click “Copy” to clipboard instead, when the goal is paraphrase.
- Click “Insert” on a result → drawer fires
Variations
Section titled “Variations”- Content-type scope: the drawer can be filtered to Q&A pairs, articles,
case studies, etc. via the
contentTypestate. - MCP
search_for_form_responseRPC: when Claude is the form-drafter (via the KH plugin), it calls the form-tuned RPC (cosine 80% + title/keyword 10% — renamed fromsearch_for_bid_response) rather than the generichybrid_search. Returnsbrief/detail/referencecolumns alongside the standard fields for richer drafting context.
Edge cases
Section titled “Edge cases”onInsertnot provided: the Insert button hides automatically; drawer becomes read-only. This is the “browse from outside a form session” pattern (rare — drawer is form-session-specific by convention).
Role Permissions Summary
Section titled “Role Permissions Summary”| Action | Viewer | Editor | Admin |
|---|---|---|---|
| Search the KB (any surface) | Yes | Yes | Yes |
| Live preview dropdown | Yes | Yes | Yes |
| Save / rename / delete user filter presets | Yes (local) | Yes (local) | Yes (local) |
| See the system filter presets | Yes | Yes | Yes |
Cold-start persona prompts (primary_focus set) | Fallback only | Yes (persona-branched) | Yes (persona-branched) |
| Form drafting Content Library Drawer (Cmd+L) | No (no form sessions) | Yes | Yes |
MCP find tool (all branches) | Yes (RLS-scoped) | Yes | Yes |
MCP search_for_form_response RPC | Yes | Yes | Yes |
Diagnostic kb-search.ts CLI | Operators only (env var access) | — | — |
Current Limitations
Section titled “Current Limitations”- Empty-state ambiguity — when 0 hits return, the UI does not distinguish between “no semantic match above threshold” vs “no keyword match” vs “filters too narrow”. Users may try lowering threshold via the diagnostic CLI to confirm.
- Filter post-filtering can produce empty pages — Search Mode caps at 20 results before post-filter. A narrow filter set can show “0 results” while matching items exist beyond the cap. The fix (push filters into the RPC) is parked at backlog.
- Recent searches are per-browser — localStorage isolation means recent searches don’t sync across devices.
- Saved presets are per-browser — no server-side persistence; clearing storage erases user presets but leaves system presets.
- Cold-start prompts depend on
primary_focus— users who never set the preference (via Profile Settings or theDashboardFirstRunCard) see the fallback set permanently. - Live preview is title+content only — no
ai_keywords,summary,author_name, or chunk-level matching. By design (the full RPC is too expensive at 60/min), but means certain known-item searches require the full pipeline. /components/guide/guide-research-feed.tsxstill callsGET /api/search?q=…(route only exposes POST). The fallback is silent (catch block swallows the failure) so guides render without research-feed augmentation when the pre-existing items are sparse.
Related Documentation
Section titled “Related Documentation”- Technical reference:
docs/product-functionality/search/technical.md - Workflows:
docs/product-functionality/search/workflows.md - Schema:
docs/reference/SCHEMA-QUICK-REFERENCE.md - Classification (drives
ai_keywords/summaryboosts):docs/reference/classification-prompt.md - Supersession behaviour:
docs/specs/supersession-model-spec.md - Document control / cadence:
docs/specs/p0-document-control-lifecycle-spec.md