Skip to content

Search — Technical Reference

Last verified: Session 210 (29 April 2026) — refresh covering S196 §1.16, S197 §1.20, S198 §1.6, S205 WP-E (find_exact_duplicates / count_auth_users RPCs), S208 §5.5 Phase 4 (MCP filter widening), S208 §5.5 Phase 5 (cadence-compliance scorer modifier on quality_score), and the S195 unified SearchBar (hero / compact / inline). Pending updates: none — stable area.

Search in Canonical is a hybrid pipeline that combines vector cosine similarity (pgvector 0.8.0, text-embedding-3-large, 1024-dim) with keyword matching against title / suggested_title / content / ai_keywords / summary / author. There is no standalone /search page — the route 308-redirects to /browse?q=… so search is inherently content-type-aware and integrated with filters, sort, and saved presets.

Three TS/JSX surfaces, three API routes, the hybrid_search / search_for_form_response / search_content_chunks RPCs, the consolidated find MCP tool (whose branches replaced the former search trio + find_similar_items), and one Bun CLI all funnel through the same canonical scoring formula in hybrid_search.

MethodRouteAuthPurposeFile
POST/api/searchAll authExecute hybrid semantic/keyword search (full pipeline)app/api/search/route.ts
GET/api/search/suggestionsAll authFetch popular keywords for the dropdown’s “Popular topics” rowapp/api/search/suggestions/route.ts
GET/api/search/previewAll authLightweight .ilike() preview for live search-as-you-typeapp/api/search/preview/route.ts

Full hybrid search via the hybrid_search RPC. Used by useSearch() (the Content Library Drawer in form sessions) and by the /browse?q= Search Mode branch in useBrowseData().

Request body (JSON):

ParamTypeRequiredDefaultDescription
querystringYesSearch query (trimmed, 1-2000 chars)
thresholdnumberNo0.35Minimum similarity threshold (0-1)
limitnumberNo20Result count, clamped server-side to 1-100
layerstringNoPost-filter results by content layer (max 50ch)

Schema: SearchBodySchema in lib/validation/schemas.ts:97. Bodies parsed via parseBody().

Rate limiting: 30 requests per minute per authenticated user (key search:<userId>).

Pipeline:

  1. generateEmbedding(query) via shared singleton OpenAI client (lib/ai/embed.ts) — text-embedding-3-large truncated at MAX_EMBEDDING_CHARS = 24_000.
  2. supabase.rpc('hybrid_search', { query_embedding: JSON.stringify(embedding), query_text, similarity_threshold, limit_count }) — stringified vector is mandatory (raw arrays serialise wrong).
  3. Optional in-memory layer post-filter on the returned rows.
  4. Returns { results, count }.

Error codes:

  • 400 VALIDATION_FAILED — Zod failure (empty query, etc.).
  • 401 UNAUTHENTICATED / 403 FORBIDDEN / 500 ROLE_LOOKUP_FAILED — auth failures, routed via authFailureResponse(auth).
  • 429 RATE_LIMITED — window exceeded.
  • 503 EMBEDDING_FAILED — OpenAI embedding service unreachable. UI surfaces “Search is temporarily unavailable. Please try again shortly.” rather than showing partial results.
  • 500 INTERNAL_SERVER_ERROR — RPC failure; safeErrorMessage() redacts.

Returns up to 12 popular keywords for the SearchBar dropdown’s “Popular topics” section.

Pipeline: supabase.rpc('get_popular_keywords', { p_limit: 12 }).

Response: 200 OK{ keywords: string[] }. On RPC failure, logs and returns { keywords: [] } (non-critical, fail-soft).

Lightweight lexical preview powering the SearchBar’s inline-variant dropdown. Distinct from POST /api/search — no embedding generation, no hybrid ranking, just .ilike() on title and content.

Query params:

ParamTypeRequiredDefaultDescription
qstringYesTrimmed; non-empty
limitnumberNo8Server-side clamp at 20 (over-spec values silently trimmed)

Schema: inline PreviewSearchSchema in app/api/search/preview/route.ts, parsed via parseSearchParams() from @/lib/validation.

Rate limiting: 60 requests per minute per authenticated user (window key search-preview:<userId> — higher than the full search because the query is cheaper).

Wildcard escape: %, _, and \ in q are backslash-escaped before interpolation into the %<q>% ilike pattern (backslash first, then the two wildcards). Without this, "50%" matches every row. Helper escapeIlike() is exported for unit testing.

Pipeline:

  1. Validate via Zod (q non-empty, limit positive int if present).
  2. Wildcard-escape q.
  3. supabase.from('content_items').select('id, title, content_type, primary_domain, layer').or('title.ilike.%X%,content.ilike.%X%').limit(N) wrapped in sb() (fail-fast).
  4. In-memory sort: title matches before content-only matches.
  5. Map to response shape — layer is selected from the DB but stripped before responding (reserved for a Phase 3 use; see spec §4.1).

Response: 200 OK{ results: PreviewResult[], count: number } where PreviewResult = { id, title, content_type, primary_domain }.

Error codes:

  • 400 VALIDATION_FAILED — empty/missing q, non-positive-int limit. Shape: { error: 'Validation failed', details: [{ field, message }] }.
  • 429 RATE_LIMITED — 60 req/min window exceeded.
  • 500 INTERNAL_SERVER_ERRORsb() wrapper caught a Postgres error.
ComponentFilePurposeNotes
SearchBarcomponents/browse/search-bar.tsxUnified search input — three variants (hero / compact / inline) share one implLive preview only on inline. Cmd+K hint kbd on compact.
SearchPromptCardscomponents/browse/search-prompt-cards.tsxCold-start persona prompts (3 cards × 4 personas + fallback)Discriminated-union card data: kind: 'filter' | 'search' | 'chipComposite'
PromptCardChipCompositecomponents/browse/prompt-card-chip-composite.tsxTop-3 domain chips + “More domains…” entry-point cardTop-domain selection 24h-cached via useTopDomains()
PresetBarcomponents/browse/preset-bar.tsxHorizontally-scrollable filter preset toolbar with active-preset chip + Save/Managearia-pressed toggle on active preset
SavePresetDialogcomponents/browse/save-preset-dialog.tsxModal to save current URL filter params as a named user presetPersists via useFilterPresets().savePreset(name)
ManagePresetsDialogcomponents/browse/manage-presets-dialog.tsxModal to rename / delete user presets (system presets read-only)Undo via restorePreset()
BrowseContentapp/browse/browse-content.tsxHosts SearchBar (inline), FilterPanel, PresetBar, SearchPromptCards, results gridCold-start visibility gate shouldShowColdStartPrompts()
ContentLibraryResultcomponents/content/content-library-result.tsxSingle search result card with copy / insert-with-citation actions (drawer surface)onInsert is the gate — drawer hides Insert when prop is null
ContentLibraryDrawercomponents/content/content-library-drawer.tsxCmd+L drawer: search + insert into Tiptap editor in form sessionConsumes useSearch()
VariantWhere renderedLive previewSubmit behaviour
heroDashboard /, 404 page, prominent landing surfacesNorouter.push('/browse?q=...')
compactHeader (always-visible), shows Cmd+K hintNorouter.push('/browse?q=...')
inline/browse page in-page searchYesCalls onSearch(q) (parent triggers semantic search)

All three share Recent searches, Popular topics fallback, ArrowUp/Down keyboard navigation, click-outside-to-close, and aria-activedescendant for screen readers. The inline variant additionally renders a preview-results section between Recent searches and Popular topics; the Popular topics section is hidden while the preview section has data or is loading. A “See all results” footer submits the full semantic search.

HookFilePurposeReturns
useSearchhooks/use-search.tsTanStack useMutation wrapper around POST /api/search. AbortController ref cancels in-flight requests when a new search starts. Handles EMBEDDING_FAILED by surfacing user-friendly error string.{ results, count, isLoading, error, search }
useDebouncedPreviewhooks/browse/use-debounced-preview.tsTanStack useQuery wrapper around GET /api/search/preview. 300 ms debounce, 3-char minimum, AbortController via signal, 30 s staleTime, stable EMPTY_RESULTS reference, external enabled? gate.{ results: PreviewResult[], isLoading: boolean }
useFilterPresetshooks/browse/use-filter-presets.tslocalStorage-backed saved filter presets — 5 system presets + user presets. Detects active preset via normalised URL params, preserves from_bid URL param across preset switches.{ presets, activePreset, applyPreset, savePreset, renamePreset, deletePreset, restorePreset, canSave }
useBrowseDatahooks/browse/use-browse-data.tsSwitches between Browse Mode (useInfiniteQuery against content_items) and Search Mode (useQuery against POST /api/search) based on searchQuery presence.{ items, totalCount, isLoading, isSearchMode, searchQuery, setSearchQuery, … }
useTopDomainshooks/browse/use-top-domains.tsTop-3 domains by content_items.primary_domain count, 24 h cached. Powers chipComposite card.{ topDomains: string[] }
useContentLibraryDrawerhooks/use-content-library-drawer.tsDrawer open/close + content type filter for the form-session Cmd+L drawer.{ isOpen, open, close, contentType, setContentType }

Search is an imperative actionuseMutation (not useQuery) is the idiomatic choice. The hook signature is preserved exactly across consumers:

const { results, count, isLoading, error, search } = useSearch();
await search(query, threshold = 0.35, limit = 20);

The hook handles the EMBEDDING_FAILED (HTTP 503) error code specially: instead of bubbling the raw RPC failure, it throws a user-friendly message: “Search is temporarily unavailable. The embedding service could not be reached. Please try again shortly.”

  • Constants (single source of truth in lib/search-history.ts):
    • PREVIEW_DEBOUNCE_MS = 300
    • PREVIEW_MIN_QUERY_LENGTH = 3
    • PREVIEW_MAX_RESULTS = 8
  • Canonical query key: queryKeys.search.preview(q) in lib/query/query-keys.ts.
  • AbortController: TanStack Query passes its signal to the queryFn, which forwards it to fetch. When the user keeps typing and the debounced query key changes, in-flight requests are aborted automatically.
  • Stable empty: module-level EMPTY_RESULTS: PreviewResult[] = [] returned when disabled — inline [] would create a fresh ref on every render and break downstream useMemo deps.
  • Gating: externallyEnabled = options?.enabled ?? true ties the hook lifecycle to focus + variant in SearchBar (only inline variant + dropdown open).
ModuleFilePurpose
generateEmbeddinglib/ai/embed.tsSingleton OpenAI client wrapper. text-embedding-3-large, 1024-dim, char-truncated at MAX_EMBEDDING_CHARS.
escapeIlikeapp/api/search/preview/route.ts (export)Escapes \, %, _ for safe .ilike() interpolation.
addRecentSearchlib/search-history.tslocalStorage write to kb-recent-searches (max 10 items, dedup, FIFO).
getRecentSearcheslib/search-history.tslocalStorage read; returns [] when storage is unavailable.
normaliseParamshooks/browse/use-filter-presets.tsStrips sort, order, cursor, q and sorts keys for stable preset comparison.
shouldShowColdStartPromptslib/browse-cold-start.ts5-condition visibility gate for <SearchPromptCards> (no query, no filters, not unread-only, not loading, totalCount > 0).
highlightTermscomponents/shared/highlight.tsxSafely injects <mark> elements into result fields based on keyword matches.
TablePurposeKey ColumnsRLS
content_itemsCore content storage (search source of truth)id, title, content, tags, embedding vector(1024), superseded_by, governance_review_status, next_review_date, publication_statusSELECT: All authenticated
content_chunksHeading-scoped sub-document chunks for section-level searchid, content_item_id, heading_path text[], content, embedding vector(1024)SELECT: All authenticated
FunctionSignaturePurpose
hybrid_search(query_embedding vector, query_text text DEFAULT '', similarity_threshold numeric DEFAULT 0.3, limit_count integer DEFAULT 10, include_superseded boolean DEFAULT false)Canonical hybrid scorer. Combines cosine similarity (70%), title/keyword boosts (≤30%), summary/author/recency boosts, and a win-rate boost from the polymorphic citations table.
search_for_form_response(query_embedding vector, query_text text DEFAULT '', limit_count integer DEFAULT 10, include_superseded boolean DEFAULT false)Tighter scorer for form drafting (cosine 80% + title/keyword 10% each). Renamed from search_for_bid_response.
search_content_chunks(query_embedding vector, similarity_threshold numeric DEFAULT 0.3, limit_count integer DEFAULT 20, filter_content_item_id uuid DEFAULT NULL, filter_overdue_review boolean DEFAULT NULL, filter_review_due_within_days integer DEFAULT NULL)Section-level (chunk) semantic search with heading_path breadcrumb. S208 §5.5 Phase 4 added the two trailing review-cadence filters. Drop-then-CREATE migration 20260428212936_extend_search_content_chunks_review_filters.sql.
get_popular_keywords(p_limit integer DEFAULT 12)Top-N keywords across content_items.ai_keywords for the SearchBar dropdown.
find_exact_duplicates(p_content_hash text, p_exclude_id uuid DEFAULT NULL)Returns {id, title} rows where content_text_hash matches. Used by ingest dedup gates and MCP duplicate detection (S205 WP-E).
count_auth_users()bigintService-role-only RPC behind the user_profiles parity probe (S205 WP-B0a). Not user-facing search.
similarity = LEAST(1.0, base_score × win_boost_multiplier)
base_score =
(1 - cosine_distance) × 0.70
+ suggested_title_match ? 0.15 : title_match ? 0.15 : 0
+ ai_keyword_exact_match ? 0.10 : ai_keyword_partial_match ? 0.05 : 0
+ summary_match ? 0.03 : 0
+ author_name_match ? 0.02 : 0
+ recency_boost (0-0.05, linear over the 30-day captured_date window)
win_boost_multiplier =
if total_citations >= 2 then 1.0 + (0.03 × win_rate)
else 1.0

win_rate is sourced from a citations → form_responses → form_questions → workspaces join (via citations.citing_form_response_id), filtered by domain_metadata->>'outcome' = 'won'. The boost is conservative (3% × win rate, min 2 citations) so cold rows never dominate.

FilterEffect
embedding IS NOT NULLExcludes never-embedded rows
archived_at IS NULLExcludes archived rows
governance_review_status IS NULL OR != 'draft'Excludes drafts (browse opt-in via include_drafts happens client-side)
include_superseded OR superseded_by IS NULLExcludes superseded rows by default (S186 WP-B.3). CLI kb-search.ts flips default to --include-superseded for diagnostic context.

Under ID-71 the former search trio (search_knowledge_base / search_qa_library / search_content_chunks) plus find_similar_items collapsed into a single outcome-shaped find tool, parameterised by type / scope / granularity / similar_to. Each row below is now a branch of find rather than a standalone tool; the underlying RPCs are unchanged.

ToolFilePurposeRead-only
find (document, default)lib/mcp/tools/search.ts(Former search_knowledge_base.) Calls hybrid_search with optional domain (post-filter) and workspace_id (post-filter via content_item_workspaces junction; AND logic when both provided). Over-fetches by 1 to compute has_more. Domain list rendered in tool description from taxonomy_domains at registration time.Yes
find (type: "q_a_pair")lib/mcp/tools/search.ts(Former search_qa_library.) Q&A-only filter on top of hybrid_search. Over-fetches (offset+limit)*3+1 to compensate for type filtering plus offset pagination.Yes
find (similar_to: <id>)lib/mcp/tools/search.ts(Former find_similar_items.) Vector cosine similarity from a known item’s embedding. Items above 0.95 are flagged likely_duplicate. Threshold range 0.5-1.0 (default 0.8); limit 1-25 (default 10).Yes
find (granularity: "chunk")lib/mcp/tools/search.ts(Former search_content_chunks tool; backed by the search_content_chunks RPC, name unchanged.) Section-level chunk search with heading_path breadcrumb. S208 §5.5 Phase 4 widened with overdue_review: boolean + review_due_within_days: integer (1-365). Both pass through to RPC params (Option A — RPC-level filter via existing JOIN, zero round-trip cost).Yes
Form-draft retrieval (procurement surface)lib/mcp/tools/procurement.tsForm-draft-tuned scorer (cosine 80% + title/keyword 10%). Calls the search_for_form_response RPC (renamed from search_for_bid_response). Filters by domain post-hoc.Yes

All search tools return dual content (Markdown for humans + structuredContent JSON for machines) and truncate the Markdown body at 10,000 chars via truncateResponse().

Quality score modifier (S208 §5.5 Phase 5 — cadence-compliance)

Section titled “Quality score modifier (S208 §5.5 Phase 5 — cadence-compliance)”

Quality score (content_items.quality_score) influences ranking indirectly via the quality_score sort option in Browse and via Quality badge surfacing on search-result cards. lib/quality/quality-score.ts exports a pure helper:

cadenceCompliancePenalty(nextReviewDate: string | null, now?: Date): number
// Returns 0 / 5-10 / 15 / 25 / 40 per spec §9.3 schedule:
// nextReviewDate null → 0 (preservation rule)
// >30 days before due → 0
// 1-30 days before due → 0-10 (graduated linear)
// 1-14 days overdue → 15
// 15-30 days overdue → 25
// >30 days overdue → 40

freshnessRaw() applies the penalty only when next_review_date is non-null — items without a cadence produce identical scores to pre-Phase-5 (preservation rule §9.4). Wired in content-card.tsx, app/api/cron/quality-score/route.ts, and metadata-sidebar.tsx. Search ranking is unaffected directly; the modifier flows into quality_score column reads, which the Browse sort=quality_score option uses.

Search Mode (?q=…) and Browse Mode (?domain=…&content_type=…&…) share the filter URL contract via BrowseFilters in types/content.ts:177. In Search Mode, filters are applied client-side as post-filters on the search results array (applyPostFilters() in useBrowseData); in Browse Mode they become Supabase query predicates.

DimensionURL keyTypeNotes
Primary domaindomainstring[] (multi)Pipe-delimited; resolved via taxonomy_domains.name
SubtopicsubtopicstringSingle, scoped to one domain
Content typecontent_typestring[] (multi)Pipe-delimited; resolved against the content-type taxonomy
Platformplatformstring[] (multi)Pipe-delimited
Authorauthorstring[] (multi)Pipe-delimited
Date rangedate_from/date_toYYYY-MM-DDUK date format; inclusive
Keyword tagskeywordsstring[] (multi)Resolved to content_item_keywords ID join
Starredstarredboolean
Priorityprioritystring[] (multi)
WorkspaceworkspaceuuidResolved via content_item_workspaces junction
User tagsuser_tagsstring[] (multi)
Freshnessfreshnessstring[] (multi)fresh, aging, stale, expired
Content layerlayerstringVocabulary from CLIENT_CONFIG.content_layers
EntityentitystringCanonical entity name
Entity typeentity_typestringOne of 12 types (organisation, certification, …)
Quality issuesquality_issuesbooleanItems with open quality flags
Include draftsinclude_draftsbooleanDrafts excluded by default
Include Q&Ainclude_qabooleanQ&A pairs excluded by default — they live in /library
Ownerownerme | unowned | uuidme resolves to current user
Review statusreview_statusstringverified / unverified / flagged
Sourcesourcestringmetadata->>source JSONB filter (e.g. intelligence_pipeline)
Sortsortenumcaptured_date (default) / classification_confidence / primary_domain / freshness / quality_score / relevance
Orderorderasc | desc
from_bid (sticky)from_biduuidWorkspace ID for contextual quick-assign; survives all in-/browse mutations and preset switches; only dropped on navigation away

Inline preview (/api/search/preview) returns only id, title, content_type, primary_domain — it does not know about filters. Submitting “See all results” or pressing Enter delegates to the full pipeline.

Persisted under kb-filter-presets. Five system presets are always present:

IDNameParams
system-staleStale contentfreshness=stale,expired
system-unreviewedUnreviewed itemsreview_status=unverified
system-flaggedFlagged itemsquality_issues=true
system-my-contentMy contentowner=me
system-siSector intelligencesource=intelligence_pipeline

User presets sort by createdAt (asc) and prepend system presets. Active preset detection compares normaliseParams(preset.params) === normaliseParams(currentURL.searchParams) so non-filter params (sort, order, cursor, q) never affect equality.

The from_bid URL param is preserved across applyPreset(presetId)useFilterPresets.applyPreset() reads from_bid from the current URL and re-appends it after composing the preset params (SD-5 / risk R-4).

Cold-start persona prompts (SearchPromptCards)

Section titled “Cold-start persona prompts (SearchPromptCards)”

Visibility gated by shouldShowColdStartPrompts(items, filters, isSearchMode, isLoading, totalCount) in lib/browse-cold-start.ts — five conditions must hold:

  1. No active search query
  2. No active filters
  3. Not in unread-only mode
  4. Not in a loading state
  5. totalCount > 0 (i.e. the KB has items, but none rendered because the reset filter view was empty for some other reason)

Card data is a discriminated union in components/browse/search-prompt-cards.tsx:

kindTriggerDataEffect
'search'Click / Enter / SpaceexampleQuery: stringaddRecentSearch(query) + onSelectQuery(query) (parent triggers semantic search)
'filter'Click / Enter / SpacefilterPreset: AllowedFilterPreset (Pick of {domain, content_type, include_qa, source, date_from, freshness, layer})onApplyFilter(preset) writes URL params via setFilters
'chipComposite'Click chip / “More…”panelTarget: 'domain' + moreLabelChips apply { domain: [chipName] }; “More…” opens the FilterPanel at the Domain section

Personas are sourced from usePrimaryFocus() reading user_metadata.primary_focus (bid_writing / account_management / marketing), with viewers always seeing the fallback set regardless of primary_focus. The whitelisted preset keys (AllowedPresetKey) are enforced at compile time via a Pick<BrowseFilters, …>-derived AllowedFilterPreset plus a satisfies ReadonlyArray<PromptCard> check on each card array — adding a stray key fails the type check.

Year-dependent fallback cards (e.g. “Recent case studies” with date_from: ${currentYear}-01-01) compute the year via useMemo(() => new Date().getFullYear(), []) so cards stay stable across midnight within a session.

bun run scripts/kb-search.ts "<query>" [--limit N] [--domain "NAME"] [--threshold N] [--full] [--json] [--include-superseded] [--exclude-superseded] [--env=prod]

Diagnostic search CLI for operators. Wraps the full hybrid pipeline (OpenAI embedding → hybrid_search RPC → optional domain post-filter → optional summary_data join with --full).

Diagnostic-CLI default flips:

  • --include-superseded is default true (operators debugging want every row, including superseded). Pass --exclude-superseded to match the app default.
  • --env=prod asserts the env-resolved SUPABASE_URL includes the prod project ref (rovrymhhffssilaftdwd); it does not swap env values. Override invocation: SUPABASE_URL=<prod-url> SUPABASE_PUBLISHABLE_KEY=<key> bun run scripts/kb-search.ts "query" --env=prod.
Test FileTestsCovers
__tests__/api/search.test.ts~12POST /api/search — auth, rate limit, embedding-failed branch, layer post-filter
__tests__/api/search-preview.test.ts~25GET /api/search/preview — wildcard escape, validation, sort, limit clamp
__tests__/components/search-bar.test.tsx~35SearchBar variant rendering, dropdown sections, keyboard nav, preview lifecycle
__tests__/hooks/use-search.test.ts~15useSearch mutation lifecycle, EMBEDDING_FAILED error mapping
__tests__/components/browse/search-prompt-cards.test.tsx~40Persona branching, card kind dispatch, AC-10 reverse round-trip, expectTypeOf gate
__tests__/mcp/search-chunks-tool.test.ts~30The find tool’s chunk-granularity branch (backed by the search_content_chunks RPC) — including S208 review-cadence filter pass-through
__tests__/eval/search-eval.test.ts~12Baseline regression eval against __tests__/fixtures/eval-baselines/search.baseline.json
__tests__/validation/parse-search-params.test.ts~10parseSearchParams() Zod helper
e2e/tests/browse-search.spec.tsn/aPlaywright E2E — preview dropdown, keyboard nav, See-all-results submit
SettingLocationDefaultPurpose
OPENAI_API_KEY.env.localrequiredEmbedding generation
SUPABASE_URL.env.localrequiredSupabase project URL (staging branch by default)
MAX_EMBEDDING_CHARSlib/ai/embed.ts24,000Char-truncate before sending to OpenAI
PREVIEW_DEBOUNCE_MSlib/search-history.ts300Inline-variant debounce window
PREVIEW_MIN_QUERY_LENGTHlib/search-history.ts3Minimum chars before preview fires
PREVIEW_MAX_RESULTSlib/search-history.ts8Default preview limit (server clamps to 20)
Search rate limitapp/api/search/route.ts30/minPer-user POST /api/search
Preview rate limitapp/api/search/preview/route.ts60/minPer-user GET /api/search/preview
  • /components/guide/guide-research-feed.tsx still calls GET /api/search?q=… (the route only exposes POST), so the additional-research fallback in guide pages is non-functional. Tracked for cleanup; harmless because the surrounding if (existingItems.length >= 5 || !domainFilter) return short-circuit skips most calls and the catch block swallows the failure silently.
  • Preview returns no rank / no snippet — by design (it’s pure .ilike()) but means the dropdown cannot show similarity badges or matched-context. Users must press “See all results” or Enter to get the ranked hybrid set.
  • Filter post-filtering on Search Mode results can produce empty result pages even when more items would match if filters had been pushed into the query — Search Mode caps at SEARCH_RESULT_LIMIT (20) and filters then. Acceptable for the current corpus size.
  • hybrid_search has no native offset — MCP tools over-fetch by searchOffset + searchLimit + 1 and slice to paginate. Pages beyond the default limit_count = 10 need explicit limit arguments per call.
  • Supersession is hidden by defaultkb-search.ts CLI flips this for diagnostics, but app routes and MCP tools must opt-in by passing include_superseded: true if a workflow needs to see superseded rows (rare).
  • MCP find (document branch) post-filtering of workspace and domain — applied after the RPC limit, so a domain or workspace filter combined with a small limit may return fewer than limit items even when more exist. Trade-off accepted for tool simplicity; over-fetch in TS would require a separate RPC.
DecisionRationaleAlternative Considered
Hybrid scorer, not pure semanticCosine alone misses recent-but-poorly-embedded items and verbatim title/keyword matchesPure cosine; pure FTS
Two RPCs (hybrid_search vs search_for_form_response)Form drafting needs tighter cosine weight (80%) and structural fields (brief/detail/reference)Single RPC with weight params (rejected for clarity)
Live preview as separate route, not POST /api/search.ilike() is 50× cheaper than embedding + RPC; can run at 60/min vs 30/minDebounced full search (rejected for cost + cancellation complexity)
Drop-then-CREATE for search_content_chunks wideningPostgres CREATE OR REPLACE FUNCTION cannot ADD parameters; without DROP, calls become ambiguousVersioned function name (rejected — would fork callers)
Discriminated union on <SearchPromptCards> dataCompile-time exhaustiveness via _exhaustive: never plus satisfies ReadonlyArray<PromptCard>String tag + runtime switch (rejected — silent regressions)
useMutation for full search, useQuery for previewFull search is imperative (user-triggered); preview is reactive to typingBoth useMutation (rejected — preview cancellation is messier)
MCP filter widening at RPC level (Option A) for search_content_chunksExisting JOIN to content_items makes the cadence predicates zero round-trip costTwo-stage TS filter (rejected — extra round trip per chunk batch)