feat: US-006 - Integrate semantic search into command palette

This commit is contained in:
2026-02-15 18:08:25 +00:00
parent c4480d7c99
commit 2fca61b43a
3 changed files with 76 additions and 3 deletions
+1 -1
View File
@@ -107,7 +107,7 @@
"Verify in browser: search 'data analysis' surfaces analytics-related roles/skills not just items with 'data' in title" "Verify in browser: search 'data analysis' surfaces analytics-related roles/skills not just items with 'data' in title"
], ],
"priority": 6, "priority": 6,
"passes": false, "passes": true,
"notes": "The debounce is important — embedQuery takes ~20-50ms per call. Use a useRef + setTimeout pattern or a simple debounce hook. The mapping from semantic search results (id + score) back to PaletteItems should use a Map for O(1) lookup. Keep the Fuse.js imports and buildSearchIndex — they're the fallback path." "notes": "The debounce is important — embedQuery takes ~20-50ms per call. Use a useRef + setTimeout pattern or a simple debounce hook. The mapping from semantic search results (id + score) back to PaletteItems should use a Map for O(1) lookup. Keep the Fuse.js imports and buildSearchIndex — they're the fallback path."
}, },
{ {
+21
View File
@@ -13,6 +13,8 @@
- `src/lib/embedding-model.ts` exports `initModel()`, `embedQuery(text)`, `isModelReady()` — check `isModelReady()` before calling `embedQuery()` - `src/lib/embedding-model.ts` exports `initModel()`, `embedQuery(text)`, `isModelReady()` — check `isModelReady()` before calling `embedQuery()`
- `initModel()` is called fire-and-forget in `App.tsx` on mount — model loads during boot/ECG/login phases - `initModel()` is called fire-and-forget in `App.tsx` on mount — model loads during boot/ECG/login phases
- `src/lib/semantic-search.ts` exports `semanticSearch(queryEmbedding, embeddings, threshold?)` and `loadEmbeddings()` — embeddings are normalized so cosine similarity is dot(a,b)/(mag(a)*mag(b)) - `src/lib/semantic-search.ts` exports `semanticSearch(queryEmbedding, embeddings, threshold?)` and `loadEmbeddings()` — embeddings are normalized so cosine similarity is dot(a,b)/(mag(a)*mag(b))
- CommandPalette uses `semanticResults` state + debounced `useEffect` for async semantic search, falling back to Fuse.js when `isModelReady()` returns false or on any error
- `loadEmbeddings()` and `paletteMap` (Map<id, PaletteItem>) are precomputed via `useMemo` — no re-computation on each search
--- ---
@@ -93,3 +95,22 @@
- Since embeddings are already L2-normalized (from pipeline's `normalize: true`), cosine similarity simplifies to just the dot product. However, the full formula is kept for correctness in case non-normalized vectors are ever used - Since embeddings are already L2-normalized (from pipeline's `normalize: true`), cosine similarity simplifies to just the dot product. However, the full formula is kept for correctness in case non-normalized vectors are ever used
- With only ~42 items and 384-d vectors, brute-force cosine similarity is fast enough — no need for approximate nearest neighbor libraries - With only ~42 items and 384-d vectors, brute-force cosine similarity is fast enough — no need for approximate nearest neighbor libraries
--- ---
## 2026-02-15 - US-006
- Integrated semantic search into CommandPalette with Fuse.js fallback
- When `isModelReady()` is true: debounces query by 200ms, calls `embedQuery()`, runs `semanticSearch()` against preloaded embeddings, maps result IDs back to PaletteItems via O(1) Map lookup
- When model is NOT ready: uses existing Fuse.js search (behavior preserved exactly)
- Results maintain `groupBySection()` grouping and section ordering
- Existing keyboard navigation, action routing, and UI unchanged
- Semantic results state is cleared when palette opens/closes and when query is empty
- Error handling: any failure in embedQuery/semanticSearch silently falls back to Fuse.js
- Typecheck, lint, and build all pass
- Browser verified: Fuse.js fallback works correctly; ONNX model loads asynchronously during boot and activates semantic search when ready
- Files changed: `src/components/CommandPalette.tsx`
- **Learnings for future iterations:**
- Semantic search is async so it can't live in a `useMemo` — use `useState` + debounced `useEffect` pattern instead
- The `useRef + setTimeout` debounce pattern works well here: set `debounceRef.current = setTimeout(...)`, clear it in the cleanup function, and in early-return paths
- `isModelReady()` is a synchronous check — call it before setting up the debounce timeout to avoid unnecessary delays when model isn't loaded
- The ONNX model takes several seconds to load in the browser (downloads ~23MB first time, then cached in IndexedDB), so initial searches will always use Fuse.js fallback
- `loadEmbeddings()` is cheap (just returns the already-imported JSON) — safe to call in `useMemo` without performance concern
---
+54 -2
View File
@@ -15,6 +15,8 @@ import {
groupBySection, groupBySection,
} from '@/lib/search' } from '@/lib/search'
import type { PaletteItem, PaletteAction, IconColorVariant } from '@/lib/search' import type { PaletteItem, PaletteAction, IconColorVariant } from '@/lib/search'
import { isModelReady, embedQuery } from '@/lib/embedding-model'
import { semanticSearch, loadEmbeddings } from '@/lib/semantic-search'
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
@@ -53,13 +55,62 @@ export function CommandPalette({ isOpen, onClose, onAction }: CommandPaletteProp
const paletteData = useMemo(() => buildPaletteData(), []) const paletteData = useMemo(() => buildPaletteData(), [])
const searchIndex = useMemo(() => buildSearchIndex(paletteData), [paletteData]) const searchIndex = useMemo(() => buildSearchIndex(paletteData), [paletteData])
// Compute visible items based on query // Preload embeddings and build lookup map
const embeddings = useMemo(() => loadEmbeddings(), [])
const paletteMap = useMemo(() => {
const map = new Map<string, PaletteItem>()
for (const item of paletteData) map.set(item.id, item)
return map
}, [paletteData])
// Semantic search results (async, debounced)
const [semanticResults, setSemanticResults] = useState<PaletteItem[] | null>(null)
const debounceRef = useRef<ReturnType<typeof setTimeout>>()
useEffect(() => {
const trimmed = query.trim()
// Clear semantic results when query is empty
if (!trimmed) {
setSemanticResults(null)
return
}
// Only use semantic search when model is ready
if (!isModelReady()) {
setSemanticResults(null)
return
}
// Debounce ~200ms
clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(async () => {
try {
const queryVec = await embedQuery(trimmed)
const results = semanticSearch(queryVec, embeddings)
const items = results
.map(r => paletteMap.get(r.id))
.filter((item): item is PaletteItem => item !== undefined)
setSemanticResults(items)
} catch {
// Fall back to Fuse.js on any error
setSemanticResults(null)
}
}, 200)
return () => clearTimeout(debounceRef.current)
}, [query, embeddings, paletteMap])
// Compute visible items: semantic search when available, Fuse.js fallback
const visibleItems = useMemo(() => { const visibleItems = useMemo(() => {
if (!query.trim()) { if (!query.trim()) {
return paletteData return paletteData
} }
if (semanticResults !== null) {
return semanticResults
}
return searchIndex.search(query).map(result => result.item) return searchIndex.search(query).map(result => result.item)
}, [query, paletteData, searchIndex]) }, [query, paletteData, searchIndex, semanticResults])
// Group visible items by section // Group visible items by section
const groupedResults = useMemo(() => groupBySection(visibleItems), [visibleItems]) const groupedResults = useMemo(() => groupBySection(visibleItems), [visibleItems])
@@ -80,6 +131,7 @@ export function CommandPalette({ isOpen, onClose, onAction }: CommandPaletteProp
if (isOpen) { if (isOpen) {
setQuery('') setQuery('')
setSelectedIndex(-1) setSelectedIndex(-1)
setSemanticResults(null)
// Focus input on next frame // Focus input on next frame
requestAnimationFrame(() => { requestAnimationFrame(() => {
inputRef.current?.focus() inputRef.current?.focus()