Search — Technical Reference
Search — Technical Reference
Section titled “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_usersRPCs), S208 §5.5 Phase 4 (MCP filter widening), S208 §5.5 Phase 5 (cadence-compliance scorer modifier onquality_score), and the S195 unifiedSearchBar(hero / compact / inline). Pending updates: none — stable area.
Overview
Section titled “Overview”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.
API Routes
Section titled “API Routes”| Method | Route | Auth | Purpose | File |
|---|---|---|---|---|
| POST | /api/search | All auth | Execute hybrid semantic/keyword search (full pipeline) | app/api/search/route.ts |
| GET | /api/search/suggestions | All auth | Fetch popular keywords for the dropdown’s “Popular topics” row | app/api/search/suggestions/route.ts |
| GET | /api/search/preview | All auth | Lightweight .ilike() preview for live search-as-you-type | app/api/search/preview/route.ts |
POST /api/search
Section titled “POST /api/search”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):
| Param | Type | Required | Default | Description |
|---|---|---|---|---|
query | string | Yes | Search query (trimmed, 1-2000 chars) | |
threshold | number | No | 0.35 | Minimum similarity threshold (0-1) |
limit | number | No | 20 | Result count, clamped server-side to 1-100 |
layer | string | No | Post-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:
generateEmbedding(query)via shared singleton OpenAI client (lib/ai/embed.ts) —text-embedding-3-largetruncated atMAX_EMBEDDING_CHARS = 24_000.supabase.rpc('hybrid_search', { query_embedding: JSON.stringify(embedding), query_text, similarity_threshold, limit_count })— stringified vector is mandatory (raw arrays serialise wrong).- Optional in-memory
layerpost-filter on the returned rows. - Returns
{ results, count }.
Error codes:
400 VALIDATION_FAILED— Zod failure (empty query, etc.).401 UNAUTHENTICATED/403 FORBIDDEN/500 ROLE_LOOKUP_FAILED— auth failures, routed viaauthFailureResponse(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.
GET /api/search/suggestions
Section titled “GET /api/search/suggestions”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).
GET /api/search/preview
Section titled “GET /api/search/preview”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:
| Param | Type | Required | Default | Description |
|---|---|---|---|---|
q | string | Yes | Trimmed; non-empty | |
limit | number | No | 8 | Server-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:
- Validate via Zod (
qnon-empty,limitpositive int if present). - Wildcard-escape
q. supabase.from('content_items').select('id, title, content_type, primary_domain, layer').or('title.ilike.%X%,content.ilike.%X%').limit(N)wrapped insb()(fail-fast).- In-memory sort: title matches before content-only matches.
- Map to response shape —
layeris 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/missingq, non-positive-intlimit. Shape:{ error: 'Validation failed', details: [{ field, message }] }.429 RATE_LIMITED— 60 req/min window exceeded.500 INTERNAL_SERVER_ERROR—sb()wrapper caught a Postgres error.
Components
Section titled “Components”| Component | File | Purpose | Notes |
|---|---|---|---|
SearchBar | components/browse/search-bar.tsx | Unified search input — three variants (hero / compact / inline) share one impl | Live preview only on inline. Cmd+K hint kbd on compact. |
SearchPromptCards | components/browse/search-prompt-cards.tsx | Cold-start persona prompts (3 cards × 4 personas + fallback) | Discriminated-union card data: kind: 'filter' | 'search' | 'chipComposite' |
PromptCardChipComposite | components/browse/prompt-card-chip-composite.tsx | Top-3 domain chips + “More domains…” entry-point card | Top-domain selection 24h-cached via useTopDomains() |
PresetBar | components/browse/preset-bar.tsx | Horizontally-scrollable filter preset toolbar with active-preset chip + Save/Manage | aria-pressed toggle on active preset |
SavePresetDialog | components/browse/save-preset-dialog.tsx | Modal to save current URL filter params as a named user preset | Persists via useFilterPresets().savePreset(name) |
ManagePresetsDialog | components/browse/manage-presets-dialog.tsx | Modal to rename / delete user presets (system presets read-only) | Undo via restorePreset() |
BrowseContent | app/browse/browse-content.tsx | Hosts SearchBar (inline), FilterPanel, PresetBar, SearchPromptCards, results grid | Cold-start visibility gate shouldShowColdStartPrompts() |
ContentLibraryResult | components/content/content-library-result.tsx | Single search result card with copy / insert-with-citation actions (drawer surface) | onInsert is the gate — drawer hides Insert when prop is null |
ContentLibraryDrawer | components/content/content-library-drawer.tsx | Cmd+L drawer: search + insert into Tiptap editor in form session | Consumes useSearch() |
SearchBar variants
Section titled “SearchBar variants”| Variant | Where rendered | Live preview | Submit behaviour |
|---|---|---|---|
hero | Dashboard /, 404 page, prominent landing surfaces | No | router.push('/browse?q=...') |
compact | Header (always-visible), shows Cmd+K hint | No | router.push('/browse?q=...') |
inline | /browse page in-page search | Yes | Calls 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.
| Hook | File | Purpose | Returns |
|---|---|---|---|
useSearch | hooks/use-search.ts | TanStack 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 } |
useDebouncedPreview | hooks/browse/use-debounced-preview.ts | TanStack 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 } |
useFilterPresets | hooks/browse/use-filter-presets.ts | localStorage-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 } |
useBrowseData | hooks/browse/use-browse-data.ts | Switches 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, … } |
useTopDomains | hooks/browse/use-top-domains.ts | Top-3 domains by content_items.primary_domain count, 24 h cached. Powers chipComposite card. | { topDomains: string[] } |
useContentLibraryDrawer | hooks/use-content-library-drawer.ts | Drawer open/close + content type filter for the form-session Cmd+L drawer. | { isOpen, open, close, contentType, setContentType } |
useSearch details
Section titled “useSearch details”Search is an imperative action — useMutation (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.”
useDebouncedPreview details
Section titled “useDebouncedPreview details”- Constants (single source of truth in
lib/search-history.ts):PREVIEW_DEBOUNCE_MS = 300PREVIEW_MIN_QUERY_LENGTH = 3PREVIEW_MAX_RESULTS = 8
- Canonical query key:
queryKeys.search.preview(q)inlib/query/query-keys.ts. - AbortController: TanStack Query passes its
signalto thequeryFn, which forwards it tofetch. 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 downstreamuseMemodeps. - Gating:
externallyEnabled = options?.enabled ?? trueties the hook lifecycle to focus + variant inSearchBar(onlyinlinevariant + dropdown open).
Library Modules
Section titled “Library Modules”| Module | File | Purpose |
|---|---|---|
generateEmbedding | lib/ai/embed.ts | Singleton OpenAI client wrapper. text-embedding-3-large, 1024-dim, char-truncated at MAX_EMBEDDING_CHARS. |
escapeIlike | app/api/search/preview/route.ts (export) | Escapes \, %, _ for safe .ilike() interpolation. |
addRecentSearch | lib/search-history.ts | localStorage write to kb-recent-searches (max 10 items, dedup, FIFO). |
getRecentSearches | lib/search-history.ts | localStorage read; returns [] when storage is unavailable. |
normaliseParams | hooks/browse/use-filter-presets.ts | Strips sort, order, cursor, q and sorts keys for stable preset comparison. |
shouldShowColdStartPrompts | lib/browse-cold-start.ts | 5-condition visibility gate for <SearchPromptCards> (no query, no filters, not unread-only, not loading, totalCount > 0). |
highlightTerms | components/shared/highlight.tsx | Safely injects <mark> elements into result fields based on keyword matches. |
Database Tables & RPCs
Section titled “Database Tables & RPCs”Tables
Section titled “Tables”| Table | Purpose | Key Columns | RLS |
|---|---|---|---|
content_items | Core content storage (search source of truth) | id, title, content, tags, embedding vector(1024), superseded_by, governance_review_status, next_review_date, publication_status | SELECT: All authenticated |
content_chunks | Heading-scoped sub-document chunks for section-level search | id, content_item_id, heading_path text[], content, embedding vector(1024) | SELECT: All authenticated |
RPC Functions
Section titled “RPC Functions”| Function | Signature | Purpose |
|---|---|---|
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 | () → bigint | Service-role-only RPC behind the user_profiles parity probe (S205 WP-B0a). Not user-facing search. |
hybrid_search scoring formula
Section titled “hybrid_search scoring formula”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.0win_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.
Default filters baked into hybrid_search
Section titled “Default filters baked into hybrid_search”| Filter | Effect |
|---|---|
embedding IS NOT NULL | Excludes never-embedded rows |
archived_at IS NULL | Excludes 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 NULL | Excludes superseded rows by default (S186 WP-B.3). CLI kb-search.ts flips default to --include-superseded for diagnostic context. |
MCP Tools
Section titled “MCP Tools”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.
| Tool | File | Purpose | Read-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.ts | Form-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 → 40freshnessRaw() 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.
Filter taxonomy (Browse)
Section titled “Filter taxonomy (Browse)”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.
| Dimension | URL key | Type | Notes |
|---|---|---|---|
| Primary domain | domain | string[] (multi) | Pipe-delimited; resolved via taxonomy_domains.name |
| Subtopic | subtopic | string | Single, scoped to one domain |
| Content type | content_type | string[] (multi) | Pipe-delimited; resolved against the content-type taxonomy |
| Platform | platform | string[] (multi) | Pipe-delimited |
| Author | author | string[] (multi) | Pipe-delimited |
| Date range | date_from/date_to | YYYY-MM-DD | UK date format; inclusive |
| Keyword tags | keywords | string[] (multi) | Resolved to content_item_keywords ID join |
| Starred | starred | boolean | |
| Priority | priority | string[] (multi) | |
| Workspace | workspace | uuid | Resolved via content_item_workspaces junction |
| User tags | user_tags | string[] (multi) | |
| Freshness | freshness | string[] (multi) | fresh, aging, stale, expired |
| Content layer | layer | string | Vocabulary from CLIENT_CONFIG.content_layers |
| Entity | entity | string | Canonical entity name |
| Entity type | entity_type | string | One of 12 types (organisation, certification, …) |
| Quality issues | quality_issues | boolean | Items with open quality flags |
| Include drafts | include_drafts | boolean | Drafts excluded by default |
| Include Q&A | include_qa | boolean | Q&A pairs excluded by default — they live in /library |
| Owner | owner | me | unowned | uuid | me resolves to current user |
| Review status | review_status | string | verified / unverified / flagged |
| Source | source | string | metadata->>source JSONB filter (e.g. intelligence_pipeline) |
| Sort | sort | enum | captured_date (default) / classification_confidence / primary_domain / freshness / quality_score / relevance |
| Order | order | asc | desc | |
from_bid (sticky) | from_bid | uuid | Workspace 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.
Saved filter presets (localStorage)
Section titled “Saved filter presets (localStorage)”Persisted under kb-filter-presets. Five system presets are always
present:
| ID | Name | Params |
|---|---|---|
system-stale | Stale content | freshness=stale,expired |
system-unreviewed | Unreviewed items | review_status=unverified |
system-flagged | Flagged items | quality_issues=true |
system-my-content | My content | owner=me |
system-si | Sector intelligence | source=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:
- No active search query
- No active filters
- Not in unread-only mode
- Not in a loading state
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:
kind | Trigger | Data | Effect |
|---|---|---|---|
'search' | Click / Enter / Space | exampleQuery: string | addRecentSearch(query) + onSelectQuery(query) (parent triggers semantic search) |
'filter' | Click / Enter / Space | filterPreset: 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' + moreLabel | Chips 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-supersededis default true (operators debugging want every row, including superseded). Pass--exclude-supersededto match the app default.--env=prodasserts the env-resolvedSUPABASE_URLincludes 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.
Testing
Section titled “Testing”| Test File | Tests | Covers |
|---|---|---|
__tests__/api/search.test.ts | ~12 | POST /api/search — auth, rate limit, embedding-failed branch, layer post-filter |
__tests__/api/search-preview.test.ts | ~25 | GET /api/search/preview — wildcard escape, validation, sort, limit clamp |
__tests__/components/search-bar.test.tsx | ~35 | SearchBar variant rendering, dropdown sections, keyboard nav, preview lifecycle |
__tests__/hooks/use-search.test.ts | ~15 | useSearch mutation lifecycle, EMBEDDING_FAILED error mapping |
__tests__/components/browse/search-prompt-cards.test.tsx | ~40 | Persona branching, card kind dispatch, AC-10 reverse round-trip, expectTypeOf gate |
__tests__/mcp/search-chunks-tool.test.ts | ~30 | The 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 | ~12 | Baseline regression eval against __tests__/fixtures/eval-baselines/search.baseline.json |
__tests__/validation/parse-search-params.test.ts | ~10 | parseSearchParams() Zod helper |
e2e/tests/browse-search.spec.ts | n/a | Playwright E2E — preview dropdown, keyboard nav, See-all-results submit |
Configuration
Section titled “Configuration”| Setting | Location | Default | Purpose |
|---|---|---|---|
OPENAI_API_KEY | .env.local | required | Embedding generation |
SUPABASE_URL | .env.local | required | Supabase project URL (staging branch by default) |
MAX_EMBEDDING_CHARS | lib/ai/embed.ts | 24,000 | Char-truncate before sending to OpenAI |
PREVIEW_DEBOUNCE_MS | lib/search-history.ts | 300 | Inline-variant debounce window |
PREVIEW_MIN_QUERY_LENGTH | lib/search-history.ts | 3 | Minimum chars before preview fires |
PREVIEW_MAX_RESULTS | lib/search-history.ts | 8 | Default preview limit (server clamps to 20) |
| Search rate limit | app/api/search/route.ts | 30/min | Per-user POST /api/search |
| Preview rate limit | app/api/search/preview/route.ts | 60/min | Per-user GET /api/search/preview |
Current Limitations
Section titled “Current Limitations”/components/guide/guide-research-feed.tsxstill callsGET /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 surroundingif (existingItems.length >= 5 || !domainFilter) returnshort-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_searchhas no native offset — MCP tools over-fetch bysearchOffset + searchLimit + 1andsliceto paginate. Pages beyond the defaultlimit_count = 10need explicitlimitarguments per call.- Supersession is hidden by default —
kb-search.tsCLI flips this for diagnostics, but app routes and MCP tools must opt-in by passinginclude_superseded: trueif 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 smalllimitmay return fewer thanlimititems even when more exist. Trade-off accepted for tool simplicity; over-fetch in TS would require a separate RPC.
Architecture Decisions
Section titled “Architecture Decisions”| Decision | Rationale | Alternative Considered |
|---|---|---|
| Hybrid scorer, not pure semantic | Cosine alone misses recent-but-poorly-embedded items and verbatim title/keyword matches | Pure 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/min | Debounced full search (rejected for cost + cancellation complexity) |
Drop-then-CREATE for search_content_chunks widening | Postgres CREATE OR REPLACE FUNCTION cannot ADD parameters; without DROP, calls become ambiguous | Versioned function name (rejected — would fork callers) |
Discriminated union on <SearchPromptCards> data | Compile-time exhaustiveness via _exhaustive: never plus satisfies ReadonlyArray<PromptCard> | String tag + runtime switch (rejected — silent regressions) |
useMutation for full search, useQuery for preview | Full search is imperative (user-triggered); preview is reactive to typing | Both useMutation (rejected — preview cancellation is messier) |
MCP filter widening at RPC level (Option A) for search_content_chunks | Existing JOIN to content_items makes the cadence predicates zero round-trip cost | Two-stage TS filter (rejected — extra round trip per chunk batch) |