feat: US-006 - Integrate semantic search into command palette
This commit is contained in:
+1
-1
@@ -107,7 +107,7 @@
|
||||
"Verify in browser: search 'data analysis' surfaces analytics-related roles/skills not just items with 'data' in title"
|
||||
],
|
||||
"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."
|
||||
},
|
||||
{
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
- `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
|
||||
- `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
|
||||
- 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
|
||||
---
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
groupBySection,
|
||||
} 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
|
||||
|
||||
@@ -53,13 +55,62 @@ export function CommandPalette({ isOpen, onClose, onAction }: CommandPaletteProp
|
||||
const paletteData = useMemo(() => buildPaletteData(), [])
|
||||
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(() => {
|
||||
if (!query.trim()) {
|
||||
return paletteData
|
||||
}
|
||||
if (semanticResults !== null) {
|
||||
return semanticResults
|
||||
}
|
||||
return searchIndex.search(query).map(result => result.item)
|
||||
}, [query, paletteData, searchIndex])
|
||||
}, [query, paletteData, searchIndex, semanticResults])
|
||||
|
||||
// Group visible items by section
|
||||
const groupedResults = useMemo(() => groupBySection(visibleItems), [visibleItems])
|
||||
@@ -80,6 +131,7 @@ export function CommandPalette({ isOpen, onClose, onAction }: CommandPaletteProp
|
||||
if (isOpen) {
|
||||
setQuery('')
|
||||
setSelectedIndex(-1)
|
||||
setSemanticResults(null)
|
||||
// Focus input on next frame
|
||||
requestAnimationFrame(() => {
|
||||
inputRef.current?.focus()
|
||||
|
||||
Reference in New Issue
Block a user