feat: Add semantic search with vector embeddings

Add vector-based semantic search to complement keyword search.
  Users can toggle between "Keyword" and "Semantic" modes in the
  search modal (Cmd+K, then Tab to switch).

  Semantic search:
  - Uses OpenAI text-embedding-ada-002 (1536 dimensions)
  - Finds content by meaning, not exact words
  - Shows similarity scores as percentages
  - ~300ms latency, ~$0.0001/query
  - Graceful fallback if OPENAI_API_KEY not set

  New files:
  - convex/embeddings.ts - Embedding generation actions
  - convex/embeddingsQueries.ts - Queries/mutations for embeddings
  - convex/semanticSearch.ts - Vector search action
  - convex/semanticSearchQueries.ts - Result hydration queries
  - content/pages/docs-search.md - Keyword search docs
  - content/pages/docs-semantic-search.md - Semantic search docs

  Changes:
  - convex/schema.ts: Add embedding field and by_embedding vectorIndex
  - SearchModal.tsx: Add mode toggle (TextAa/Brain icons)
  - sync-posts.ts: Generate embeddings after content sync
  - global.css: Search mode toggle styles

  Documentation updated:
  - changelog.md, TASK.md, files.md, about.md, home.md

  Configuration:
  npx convex env set OPENAI_API_KEY sk-your-key

  Generated with [Claude Code](https://claude.com/claude-code)

  Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

  Status: Ready to commit. All semantic search files are staged. The TypeScript warnings are pre-existing (unused variables) and don't affect the build.
This commit is contained in:
Wayne Sutton
2026-01-05 18:30:48 -08:00
parent 83411ec1b2
commit 5a8df46681
58 changed files with 7024 additions and 2527 deletions

View File

@@ -1,32 +1,94 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery } from "convex/react";
import { useQuery, useAction } from "convex/react";
import { api } from "../../convex/_generated/api";
import { MagnifyingGlass, X, FileText, Article, ArrowRight } from "@phosphor-icons/react";
import {
MagnifyingGlass,
X,
FileText,
Article,
ArrowRight,
TextAa,
Brain,
} from "@phosphor-icons/react";
interface SearchModalProps {
isOpen: boolean;
onClose: () => void;
}
type SearchMode = "keyword" | "semantic";
interface SearchResult {
_id: string;
type: "post" | "page";
slug: string;
title: string;
description?: string;
snippet: string;
score?: number;
anchor?: string;
}
export default function SearchModal({ isOpen, onClose }: SearchModalProps) {
const [searchQuery, setSearchQuery] = useState("");
const [selectedIndex, setSelectedIndex] = useState(0);
const [searchMode, setSearchMode] = useState<SearchMode>("keyword");
const [semanticResults, setSemanticResults] = useState<SearchResult[] | null>(null);
const [isSemanticSearching, setIsSemanticSearching] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const navigate = useNavigate();
// Fetch search results from Convex
const results = useQuery(
// Keyword search (reactive query)
const keywordResults = useQuery(
api.search.search,
searchQuery.trim() ? { query: searchQuery } : "skip"
searchMode === "keyword" && searchQuery.trim() ? { query: searchQuery } : "skip"
);
// Semantic search action
const semanticSearchAction = useAction(api.semanticSearch.semanticSearch);
// Trigger semantic search with debounce
useEffect(() => {
if (searchMode !== "semantic" || !searchQuery.trim()) {
setSemanticResults(null);
setIsSemanticSearching(false);
return;
}
setIsSemanticSearching(true);
const timeoutId = setTimeout(async () => {
try {
const results = await semanticSearchAction({ query: searchQuery });
setSemanticResults(results as SearchResult[]);
} catch (error) {
console.error("Semantic search error:", error);
setSemanticResults([]);
} finally {
setIsSemanticSearching(false);
}
}, 300); // 300ms debounce for API calls
return () => clearTimeout(timeoutId);
}, [searchQuery, searchMode, semanticSearchAction]);
// Get current results based on mode
const results: SearchResult[] | undefined =
searchMode === "keyword"
? (keywordResults as SearchResult[] | undefined)
: (semanticResults ?? undefined);
const isLoading =
searchMode === "keyword"
? keywordResults === undefined && searchQuery.trim() !== ""
: isSemanticSearching;
// Focus input when modal opens
useEffect(() => {
if (isOpen && inputRef.current) {
inputRef.current.focus();
setSearchQuery("");
setSelectedIndex(0);
setSemanticResults(null);
}
}, [isOpen]);
@@ -38,6 +100,21 @@ export default function SearchModal({ isOpen, onClose }: SearchModalProps) {
// Handle keyboard navigation
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
// Tab toggles between search modes
if (e.key === "Tab") {
e.preventDefault();
setSearchMode((prev) => (prev === "keyword" ? "semantic" : "keyword"));
return;
}
// Escape closes modal
if (e.key === "Escape") {
e.preventDefault();
onClose();
return;
}
// Arrow/Enter only work when there are results
if (!results || results.length === 0) return;
switch (e.key) {
@@ -53,25 +130,28 @@ export default function SearchModal({ isOpen, onClose }: SearchModalProps) {
e.preventDefault();
if (results[selectedIndex]) {
const result = results[selectedIndex];
// Pass search query as URL param for highlighting on destination page
const url = `/${result.slug}?q=${encodeURIComponent(searchQuery)}`;
// Only pass query param for keyword search (highlighting)
// Semantic search doesn't match exact words
const url =
searchMode === "keyword"
? `/${result.slug}?q=${encodeURIComponent(searchQuery)}`
: `/${result.slug}`;
navigate(url);
onClose();
}
break;
case "Escape":
e.preventDefault();
onClose();
break;
}
},
[results, selectedIndex, navigate, onClose]
[results, selectedIndex, navigate, onClose, searchMode, searchQuery]
);
// Handle clicking on a result
const handleResultClick = (slug: string) => {
// Pass search query as URL param for highlighting on destination page
const url = `/${slug}?q=${encodeURIComponent(searchQuery)}`;
// Only pass query param for keyword search (highlighting)
const url =
searchMode === "keyword"
? `/${slug}?q=${encodeURIComponent(searchQuery)}`
: `/${slug}`;
navigate(url);
onClose();
};
@@ -88,6 +168,26 @@ export default function SearchModal({ isOpen, onClose }: SearchModalProps) {
return (
<div className="search-modal-backdrop" onClick={handleBackdropClick}>
<div className="search-modal">
{/* Search mode toggle */}
<div className="search-mode-toggle">
<button
className={`search-mode-btn ${searchMode === "keyword" ? "active" : ""}`}
onClick={() => setSearchMode("keyword")}
title="Keyword search - matches exact words"
>
<TextAa size={16} weight="bold" />
<span>Keyword</span>
</button>
<button
className={`search-mode-btn ${searchMode === "semantic" ? "active" : ""}`}
onClick={() => setSearchMode("semantic")}
title="Semantic search - finds similar meaning"
>
<Brain size={16} weight="bold" />
<span>Semantic</span>
</button>
</div>
{/* Search input */}
<div className="search-modal-input-wrapper">
<MagnifyingGlass size={20} className="search-modal-icon" weight="bold" />
@@ -97,7 +197,11 @@ export default function SearchModal({ isOpen, onClose }: SearchModalProps) {
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Search posts and pages..."
placeholder={
searchMode === "keyword"
? "Search posts and pages..."
: "Describe what you're looking for..."
}
className="search-modal-input"
autoComplete="off"
autoCorrect="off"
@@ -113,10 +217,18 @@ export default function SearchModal({ isOpen, onClose }: SearchModalProps) {
<div className="search-modal-results">
{searchQuery.trim() === "" ? (
<div className="search-modal-hint">
<p>Type to search posts and pages</p>
<p>
{searchMode === "keyword"
? "Type to search posts and pages"
: "Describe what you're looking for"}
</p>
<div className="search-modal-shortcuts">
<span className="search-shortcut">
<kbd></kbd><kbd></kbd> Navigate
<kbd>Tab</kbd> Switch mode
</span>
<span className="search-shortcut">
<kbd></kbd>
<kbd></kbd> Navigate
</span>
<span className="search-shortcut">
<kbd></kbd> Select
@@ -126,13 +238,20 @@ export default function SearchModal({ isOpen, onClose }: SearchModalProps) {
</span>
</div>
</div>
) : results === undefined ? (
<div className="search-modal-loading">Searching...</div>
) : results.length === 0 ? (
) : isLoading ? (
<div className="search-modal-loading">
{searchMode === "semantic" ? "Finding similar content..." : "Searching..."}
</div>
) : results && results.length === 0 ? (
<div className="search-modal-empty">
No results found for "{searchQuery}"
{searchMode === "semantic" && (
<p className="search-modal-empty-hint">
Try keyword search for exact matches
</p>
)}
</div>
) : (
) : results ? (
<ul className="search-results-list">
{results.map((result, index) => (
<li key={result._id}>
@@ -152,25 +271,36 @@ export default function SearchModal({ isOpen, onClose }: SearchModalProps) {
<div className="search-result-title">{result.title}</div>
<div className="search-result-snippet">{result.snippet}</div>
</div>
<div className="search-result-type">
{result.type === "post" ? "Post" : "Page"}
<div className="search-result-meta">
<span className="search-result-type">
{result.type === "post" ? "Post" : "Page"}
</span>
{searchMode === "semantic" && result.score !== undefined && (
<span className="search-result-score">
{Math.round(result.score * 100)}%
</span>
)}
</div>
<ArrowRight size={16} className="search-result-arrow" weight="bold" />
</button>
</li>
))}
</ul>
)}
) : null}
</div>
{/* Footer with keyboard hints */}
{results && results.length > 0 && (
<div className="search-modal-footer">
<span className="search-footer-hint">
<kbd></kbd> to select
<kbd>Tab</kbd> switch mode
</span>
<span className="search-footer-hint">
<kbd></kbd><kbd></kbd> to navigate
<kbd></kbd> select
</span>
<span className="search-footer-hint">
<kbd></kbd>
<kbd></kbd> navigate
</span>
</div>
)}
@@ -178,4 +308,3 @@ export default function SearchModal({ isOpen, onClose }: SearchModalProps) {
</div>
);
}