Skip to content

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.

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 PointRouteComponentAccessible 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 CLIterminalbun run scripts/kb-search.ts "<query>"Operators

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.

  1. 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).
  2. Begin typing (≥3 chars).
    • After 300 ms of inactivity (PREVIEW_DEBOUNCE_MS), the inline variant fires GET /api/search/preview?q=<query>&limit=8. While the request is in flight, the dropdown shows a “Searching…” row with a spinner inside an aria-live="polite" region.
    • “Popular topics” hides while the preview section is showing or loading (spec §4.1).
  3. 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.
  4. 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).
  5. 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 full POST /api/search pipeline against the page’s active filters.
  • 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=… via router.push.
  • from_bid URL 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 while from_bid is active.
  • <3 characters: the preview is gated by PREVIEW_MIN_QUERY_LENGTH and 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 signal aborts the in-flight request automatically — no race conditions surface.

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.

  1. 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) flips useBrowseData() into Search Mode.
  2. Pipeline runs.
    • Browse Mode is suspended (useInfiniteQuery enabled: false).
    • Search Mode useQuery calls POST /api/search with { query, threshold: 0.35, limit: 20 }.
    • Server: validate via SearchBodySchema → rate-limit check (30/min) → generateEmbedding()hybrid_search RPC → optional layer post-filter → return { results, count }.
  3. 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_keywords matches (10-15% each), summary / author / 30-day-recency boosts, and a 3% × win-rate multiplier for items with ≥2 citations across won forms.
  4. 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.
  • Q&A specific search: /library (Q&A Library page) constrains useBrowseData Search Mode to content_type = 'q_a_pair' post-filter.
  • Content Library Drawer (form session): opens via Cmd+L, uses the same useSearch() hook against POST /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 when onInsert callback is provided).
  • MCP find (document granularity): LLM clients hit the same RPC via the MCP server with optional domain and workspace_id filters (AND logic when both provided). Returns dual content (Markdown + structured JSON). This is the find branch that replaced the former search_knowledge_base tool.
  • MCP find (type: "q_a_pair"): Q&A-only filter on top of hybrid_search for form response drafting (the branch that replaced search_qa_library).
  • 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('') calls mutation.reset() and results return to []. The submit handler short-circuits trim-empty strings.
  • Low similarity scores: threshold: 0.35 is the app default. Results may appear thin even when relevant items exist. The diagnostic CLI uses 0.25 and an operator can lower it via --threshold for triage.
  • Superseded items: excluded by default in app routes (include_superseded: false baked into hybrid_search). The CLI flips this to default-true for diagnostic context.
  • Drafts excluded: governance_review_status = 'draft' is filtered out at the RPC level. Use include_drafts=true on 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).

  1. Open /browse.
    • The 5-condition gate shouldShowColdStartPrompts() evaluates true.
    • 3 cards render in a grid-cols-1 sm:grid-cols-2 layout above the results area.
  2. See persona-tailored cards.
    • usePrimaryFocus() reads user_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).
  3. 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 via useTopDomains()); click a chip applies { domain: [chipName] }; click “More domains…” opens the FilterPanel at the Domain section without mutating URL.
  • Editor / admin with primary_focus unset: sees fallback set.
  • Year-dependent fallback: “Recent case studies” applies date_from: ${currentYear}-01-01 computed once via useMemo so the card stays stable across midnight within a session.

Actor: Any role (presets are localStorage-only — no server sync). Goal: Save the current Browse filter combination to apply again later.

  1. Apply filters.
    • Open /browse, set Domain / Subtopic / Freshness / etc. via the FilterPanel.
    • <PresetBar> shows a “Save” button when any filter is active (canSave: true).
  2. Save.
    • Click “Save” → <SavePresetDialog> opens.
    • Enter a name (1-40 chars). Submit → useFilterPresets().savePreset(name) creates a FilterPreset { id: 'u_<uuid8>', name, params, isSystem: false, createdAt } in kb-filter-presets localStorage.
    • The preset chip appears in <PresetBar> after the 5 system presets.
  3. Apply later.
    • Click the preset chip → applyPreset(presetId)router.push('/browse?<preset.params>&from_bid=<sticky>'). The from_bid URL param survives the navigation.
  4. Detect active preset.
    • <PresetBar> highlights the chip whose normaliseParams(p.params) === normaliseParams(currentURL.searchParams). Clicking an active chip clears all filters.
  5. Manage user presets.
    • “Manage” button (visible when at least one user preset exists) opens <ManagePresetsDialog> for rename / delete with undo via restorePreset(). System presets are read-only.
  • 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)
  • localStorage unavailable (private browsing, full quota): saves silently no-op; system presets continue to work.
  • Active query (?q=...) at save time: stripped by normaliseParams() so presets capture filters only, not the search term.
  • sort / order / cursor are 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.

  1. Ask Claude a precision question.
    • Example: “Find the risk assessment section in our health and safety policy.”
  2. Claude calls find with granularity: "chunk".
    • Tool params (chunk-granularity branch): query, optional limit (default 10, max 30), optional content_item_id to scope to one document, optional review-cadence filters (overdue_review: boolean / review_due_within_days: 1-365). This is the find branch that replaced the former standalone search_content_chunks tool.
    • 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 with heading_path breadcrumb selection.
  3. 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.
  • Document-control workflow: an admin asks “Show me overdue health and safety chunks.” Claude sets overdue_review: true and the RPC restricts to chunks from items with governance_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 whose next_review_date falls within CURRENT_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.

  1. 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.
  2. Search.
    • Type a query → useSearch().search(query) calls POST /api/search with default threshold 0.35 and limit 20.
    • Results stream into the drawer as <ContentLibraryResult> cards.
  3. 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.
  • Content-type scope: the drawer can be filtered to Q&A pairs, articles, case studies, etc. via the contentType state.
  • MCP search_for_form_response RPC: when Claude is the form-drafter (via the KH plugin), it calls the form-tuned RPC (cosine 80% + title/keyword 10% — renamed from search_for_bid_response) rather than the generic hybrid_search. Returns brief / detail / reference columns alongside the standard fields for richer drafting context.
  • onInsert not 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).

ActionViewerEditorAdmin
Search the KB (any surface)YesYesYes
Live preview dropdownYesYesYes
Save / rename / delete user filter presetsYes (local)Yes (local)Yes (local)
See the system filter presetsYesYesYes
Cold-start persona prompts (primary_focus set)Fallback onlyYes (persona-branched)Yes (persona-branched)
Form drafting Content Library Drawer (Cmd+L)No (no form sessions)YesYes
MCP find tool (all branches)Yes (RLS-scoped)YesYes
MCP search_for_form_response RPCYesYesYes
Diagnostic kb-search.ts CLIOperators only (env var access)
  • 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 the DashboardFirstRunCard) 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.tsx still calls GET /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.
  • 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 / summary boosts): docs/reference/classification-prompt.md
  • Supersession behaviour: docs/specs/supersession-model-spec.md
  • Document control / cadence: docs/specs/p0-document-control-lifecycle-spec.md