mirror of
https://github.com/waynesutton/markdown-site.git
synced 2026-01-12 04:09:14 +00:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user