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"
|
"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."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
|
||||||
|
---
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user