[TERMINAL · SKILLS]
> mounting /skills...
> indexing 295 manifests...
> linking agents: claude · codex · gemini · cursor
> ready.
[░░░░░░░░░░░░░░░░░░░░░░░░░░░░] 0%
Terminal.skills
Use Cases/Build AI Search Autocomplete with Embeddings

Build AI Search Autocomplete with Embeddings

Build a search-as-you-type experience that combines traditional prefix matching with semantic embeddings to surface relevant results even when users don't know the exact keywords.

#redis#caching#database#pub-sub#queues
Works with:claude-codeopenai-codexgemini-clicursor

Skills stack · 6 skills

Avg quality 92/100·All SAFE
>

typescript

v

Not yet scored
View skill
>

openai

v

Not yet scored
View skill
>

redis

v1.0.0

Build applications with Redis — caching, session storage, pub/sub, streams, rate limiting, leaderboards, and queues. Use when tasks involve in-memory data storage, real-time messaging, distributed locking, or performance optimization with caching layers.

93/100 quality
1.81× impact
SAFE
View skill
>

postgresql

v1.0.0

Assists with designing schemas, writing performant queries, managing indexes, and operating PostgreSQL databases. Use when working with JSONB, full-text search, window functions, CTEs, row-level security, replication, or performance tuning. Trigger words: postgresql, postgres, sql, database, jsonb, rls, window functions, cte.

87/100 quality
1.53× impact
SAFE
View skill
>

hono

v1.0.0

You are an expert in Hono, the ultrafast web framework for the edge. You help developers build APIs and web applications that run on Cloudflare Workers, Deno, Bun, Node.js, AWS Lambda, and Vercel Edge — with a tiny footprint (~14KB), middleware ecosystem, JSX support, RPC client, and Web Standards API compatibility that makes code truly portable across runtimes.

93/100 quality
3.00× impact
SAFE
View skill
>

nextjs

v1.0.0

Assists with building production-grade React applications using Next.js. Use when working with the App Router, Server Components, Server Actions, Middleware, or deploying to Vercel or self-hosted environments. Trigger words: nextjs, next.js, app router, server components, server actions, react framework, ssr, isr.

93/100 quality
1.16× impact
SAFE
View skill
$

The Problem

Mika leads product at a 35-person e-commerce platform selling 120,000 SKUs of industrial supplies. Site search drives 65% of purchases, but the current keyword-based autocomplete fails 30% of the time. A customer searching "waterproof adhesive for outdoor metal" gets zero results because the product is listed as "weather-resistant bonding agent — exterior ferrous surfaces." The search team estimates these failed searches cost $340K/month in lost conversions. They need autocomplete that understands intent, not just keywords.

Step 1: Build the Embedding Index

Every product gets an embedding that captures its semantic meaning. These are pre-computed and stored in PostgreSQL with pgvector for efficient similarity search.

typescript
// src/services/embedding-index.ts — Product embedding generation and storage
import OpenAI from "openai";
import { pool } from "../db";

const openai = new OpenAI();

// Schema: pgvector extension required
// CREATE EXTENSION IF NOT EXISTS vector;
// ALTER TABLE products ADD COLUMN embedding vector(1536);
// CREATE INDEX idx_products_embedding ON products USING ivfflat (embedding vector_cosine_ops) WITH (lists = 200);

const BATCH_SIZE = 100; // OpenAI embeddings API handles 100 inputs per call

export async function indexAllProducts(): Promise<{ indexed: number; errors: number }> {
  let indexed = 0;
  let errors = 0;
  let offset = 0;

  while (true) {
    const { rows: products } = await pool.query(
      `SELECT id, name, description, category, brand, specifications
       FROM products WHERE embedding IS NULL
       ORDER BY id LIMIT $1 OFFSET $2`,
      [BATCH_SIZE, offset]
    );

    if (products.length === 0) break;

    // Build rich text for each product combining all searchable fields
    const inputs = products.map((p) =>
      buildEmbeddingText(p.name, p.description, p.category, p.brand, p.specifications)
    );

    try {
      const response = await openai.embeddings.create({
        model: "text-embedding-3-small", // 1536 dimensions, $0.02/1M tokens
        input: inputs,
      });

      // Batch update embeddings
      const client = await pool.connect();
      try {
        await client.query("BEGIN");
        for (let i = 0; i < products.length; i++) {
          const embedding = response.data[i].embedding;
          await client.query(
            "UPDATE products SET embedding = $1 WHERE id = $2",
            [`[${embedding.join(",")}]`, products[i].id]
          );
        }
        await client.query("COMMIT");
        indexed += products.length;
      } catch (err) {
        await client.query("ROLLBACK");
        errors += products.length;
      } finally {
        client.release();
      }
    } catch (err) {
      errors += products.length;
    }

    offset += BATCH_SIZE;
  }

  return { indexed, errors };
}

function buildEmbeddingText(
  name: string,
  description: string,
  category: string,
  brand: string,
  specs: Record<string, string> | null
): string {
  // Combine fields with semantic context so the embedding captures all aspects
  const parts = [
    name,
    description,
    `Category: ${category}`,
    `Brand: ${brand}`,
  ];

  if (specs) {
    // Include key specifications as natural language
    const specText = Object.entries(specs)
      .map(([k, v]) => `${k}: ${v}`)
      .join(", ");
    parts.push(`Specifications: ${specText}`);
  }

  return parts.join(". ");
}

// Incremental indexing: called when products are created or updated
export async function indexProduct(productId: string): Promise<void> {
  const { rows } = await pool.query(
    "SELECT name, description, category, brand, specifications FROM products WHERE id = $1",
    [productId]
  );

  if (rows.length === 0) return;
  const p = rows[0];

  const text = buildEmbeddingText(p.name, p.description, p.category, p.brand, p.specifications);

  const response = await openai.embeddings.create({
    model: "text-embedding-3-small",
    input: text,
  });

  await pool.query("UPDATE products SET embedding = $1 WHERE id = $2", [
    `[${response.data[0].embedding.join(",")}]`,
    productId,
  ]);
}

Step 2: Build the Hybrid Search Engine

The search combines three strategies: prefix matching for exact keyword hits, trigram similarity for typo tolerance, and semantic vector search for intent matching. Results are ranked by a weighted fusion of all three scores.

typescript
// src/services/search-engine.ts — Hybrid search combining keyword + semantic approaches
import OpenAI from "openai";
import { pool } from "../db";
import { Redis } from "ioredis";

const openai = new OpenAI();
const redis = new Redis(process.env.REDIS_URL!);

interface SearchResult {
  id: string;
  name: string;
  category: string;
  brand: string;
  price: number;
  imageUrl: string;
  score: number;        // combined relevance score
  matchType: "keyword" | "semantic" | "hybrid";
}

export async function hybridSearch(
  query: string,
  limit: number = 10
): Promise<SearchResult[]> {
  // Check cache first — autocomplete queries repeat heavily
  const cacheKey = `search:${query.toLowerCase().trim()}`;
  const cached = await redis.get(cacheKey);
  if (cached) return JSON.parse(cached);

  // Run keyword and semantic searches in parallel
  const [keywordResults, semanticResults] = await Promise.all([
    keywordSearch(query, limit * 2),
    semanticSearch(query, limit * 2),
  ]);

  // Reciprocal Rank Fusion — combines rankings from multiple search strategies
  const scores = new Map<string, { score: number; data: any; types: Set<string> }>();

  const RRF_K = 60; // smoothing constant (standard value from literature)

  keywordResults.forEach((result, rank) => {
    const existing = scores.get(result.id) || { score: 0, data: result, types: new Set() };
    existing.score += 1 / (RRF_K + rank + 1);
    existing.types.add("keyword");
    scores.set(result.id, existing);
  });

  semanticResults.forEach((result, rank) => {
    const existing = scores.get(result.id) || { score: 0, data: result, types: new Set() };
    existing.score += 1 / (RRF_K + rank + 1);
    existing.types.add("semantic");
    scores.set(result.id, existing);
  });

  const merged = [...scores.values()]
    .sort((a, b) => b.score - a.score)
    .slice(0, limit)
    .map(({ score, data, types }) => ({
      ...data,
      score: Math.round(score * 1000) / 1000,
      matchType: types.size > 1 ? "hybrid" : types.has("keyword") ? "keyword" : "semantic",
    }));

  // Cache for 5 minutes — invalidated on product updates
  await redis.setex(cacheKey, 300, JSON.stringify(merged));

  return merged;
}

async function keywordSearch(query: string, limit: number): Promise<any[]> {
  // Combine prefix match (for autocomplete speed) with trigram similarity (for typos)
  const { rows } = await pool.query(
    `SELECT id, name, category, brand, price, image_url,
            ts_rank(search_vector, plainto_tsquery('english', $1)) as text_rank,
            similarity(name, $1) as trigram_score
     FROM products
     WHERE search_vector @@ plainto_tsquery('english', $1)
        OR similarity(name, $1) > 0.15
        OR name ILIKE $2
     ORDER BY 
       (ts_rank(search_vector, plainto_tsquery('english', $1)) * 0.6 + similarity(name, $1) * 0.4) DESC
     LIMIT $3`,
    [query, `%${query}%`, limit]
  );

  return rows;
}

async function semanticSearch(query: string, limit: number): Promise<any[]> {
  // Generate embedding for the search query
  const response = await openai.embeddings.create({
    model: "text-embedding-3-small",
    input: query,
  });
  const queryEmbedding = response.data[0].embedding;

  // Cosine similarity search using pgvector
  const { rows } = await pool.query(
    `SELECT id, name, category, brand, price, image_url,
            1 - (embedding <=> $1::vector) as similarity
     FROM products
     WHERE embedding IS NOT NULL
     ORDER BY embedding <=> $1::vector
     LIMIT $2`,
    [`[${queryEmbedding.join(",")}]`, limit]
  );

  return rows;
}

Step 3: Build the Autocomplete API with Debounce-Friendly Response Times

The API needs to return results in under 100ms for a smooth autocomplete experience. Popular queries are pre-cached, and the embedding call is the bottleneck managed through aggressive caching.

typescript
// src/routes/search.ts — Autocomplete API optimized for sub-100ms responses
import { Hono } from "hono";
import { Redis } from "ioredis";
import { hybridSearch } from "../services/search-engine";
import { pool } from "../db";

const redis = new Redis(process.env.REDIS_URL!);
const app = new Hono();

// Fast autocomplete endpoint — called on every keystroke (after 2 chars)
app.get("/search/autocomplete", async (c) => {
  const query = c.req.query("q")?.trim();
  if (!query || query.length < 2) {
    return c.json({ suggestions: [] });
  }

  const start = Date.now();

  // For very short queries (2-3 chars), use fast prefix matching only
  if (query.length <= 3) {
    const suggestions = await prefixSuggestions(query);
    return c.json({
      suggestions,
      latencyMs: Date.now() - start,
      strategy: "prefix",
    });
  }

  // For longer queries, use hybrid search
  const results = await hybridSearch(query, 8);

  // Also fetch category suggestions for broad queries
  const categories = await categorySuggestions(query);

  // Log query for analytics (async, don't block response)
  logSearchQuery(query, results.length).catch(() => {});

  return c.json({
    suggestions: results.map((r) => ({
      id: r.id,
      name: r.name,
      category: r.category,
      brand: r.brand,
      price: r.price,
      imageUrl: r.imageUrl,
      matchType: r.matchType,
    })),
    categories,
    latencyMs: Date.now() - start,
    strategy: results[0]?.matchType || "none",
  });
});

// Trending and popular searches — pre-computed, fast
app.get("/search/trending", async (c) => {
  const cached = await redis.get("search:trending");
  if (cached) return c.json(JSON.parse(cached));

  const { rows } = await pool.query(
    `SELECT query, COUNT(*) as count
     FROM search_logs
     WHERE searched_at > NOW() - INTERVAL '24 hours'
       AND results_count > 0
     GROUP BY query
     ORDER BY count DESC
     LIMIT 10`
  );

  const trending = rows.map((r) => r.query);
  await redis.setex("search:trending", 600, JSON.stringify({ trending }));

  return c.json({ trending });
});

async function prefixSuggestions(query: string): Promise<any[]> {
  const { rows } = await pool.query(
    `SELECT DISTINCT id, name, category, brand, price, image_url
     FROM products
     WHERE name ILIKE $1
     ORDER BY popularity_score DESC
     LIMIT 8`,
    [`${query}%`]
  );
  return rows;
}

async function categorySuggestions(query: string): Promise<string[]> {
  const { rows } = await pool.query(
    `SELECT DISTINCT category, COUNT(*) as count
     FROM products
     WHERE search_vector @@ plainto_tsquery('english', $1)
     GROUP BY category
     ORDER BY count DESC
     LIMIT 4`,
    [query]
  );
  return rows.map((r) => r.category);
}

async function logSearchQuery(query: string, resultsCount: number) {
  await pool.query(
    "INSERT INTO search_logs (query, results_count, searched_at) VALUES ($1, $2, NOW())",
    [query, resultsCount]
  );
}

export default app;

Step 4: Build the React Autocomplete Component

The frontend debounces keystrokes, shows results in a dropdown with category grouping, and highlights why each result matched — giving users confidence that the search understands their intent.

typescript
// src/components/SearchAutocomplete.tsx — Debounced autocomplete with semantic highlights
import { useState, useRef, useCallback, useEffect } from "react";

interface Suggestion {
  id: string;
  name: string;
  category: string;
  brand: string;
  price: number;
  imageUrl: string;
  matchType: "keyword" | "semantic" | "hybrid";
}

export function SearchAutocomplete({ onSelect }: { onSelect: (id: string) => void }) {
  const [query, setQuery] = useState("");
  const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
  const [categories, setCategories] = useState<string[]>([]);
  const [isOpen, setIsOpen] = useState(false);
  const [activeIndex, setActiveIndex] = useState(-1);
  const debounceRef = useRef<NodeJS.Timeout>();

  const search = useCallback(async (q: string) => {
    if (q.length < 2) {
      setSuggestions([]);
      setIsOpen(false);
      return;
    }

    try {
      const res = await fetch(`/api/search/autocomplete?q=${encodeURIComponent(q)}`);
      const data = await res.json();
      setSuggestions(data.suggestions);
      setCategories(data.categories || []);
      setIsOpen(data.suggestions.length > 0);
    } catch {
      // fail silently — autocomplete is non-critical UI
    }
  }, []);

  const handleInput = (value: string) => {
    setQuery(value);
    setActiveIndex(-1);

    // Debounce: 150ms for fast typers, fires immediately after pause
    clearTimeout(debounceRef.current);
    debounceRef.current = setTimeout(() => search(value), 150);
  };

  const handleKeyDown = (e: React.KeyboardEvent) => {
    if (e.key === "ArrowDown") {
      e.preventDefault();
      setActiveIndex((i) => Math.min(i + 1, suggestions.length - 1));
    } else if (e.key === "ArrowUp") {
      e.preventDefault();
      setActiveIndex((i) => Math.max(i - 1, -1));
    } else if (e.key === "Enter" && activeIndex >= 0) {
      onSelect(suggestions[activeIndex].id);
      setIsOpen(false);
    } else if (e.key === "Escape") {
      setIsOpen(false);
    }
  };

  return (
    <div className="relative w-full max-w-2xl">
      <input
        type="text"
        value={query}
        onChange={(e) => handleInput(e.target.value)}
        onKeyDown={handleKeyDown}
        onFocus={() => suggestions.length > 0 && setIsOpen(true)}
        placeholder="Search 120,000+ products..."
        className="w-full px-4 py-3 border rounded-lg text-lg focus:ring-2 focus:ring-blue-500"
        aria-autocomplete="list"
        role="combobox"
        aria-expanded={isOpen}
      />

      {isOpen && (
        <div className="absolute z-50 w-full mt-1 bg-white border rounded-lg shadow-xl max-h-96 overflow-y-auto">
          {/* Category suggestions */}
          {categories.length > 0 && (
            <div className="px-4 py-2 border-b bg-gray-50">
              <span className="text-xs text-gray-500 uppercase">Categories</span>
              <div className="flex gap-2 mt-1">
                {categories.map((cat) => (
                  <button
                    key={cat}
                    className="px-2 py-1 text-sm bg-blue-100 text-blue-700 rounded hover:bg-blue-200"
                    onClick={() => handleInput(`${query} in ${cat}`)}
                  >
                    {cat}
                  </button>
                ))}
              </div>
            </div>
          )}

          {/* Product suggestions */}
          {suggestions.map((item, index) => (
            <button
              key={item.id}
              className={`w-full flex items-center gap-3 px-4 py-3 text-left hover:bg-gray-50 ${
                index === activeIndex ? "bg-blue-50" : ""
              }`}
              onClick={() => {
                onSelect(item.id);
                setIsOpen(false);
              }}
              role="option"
              aria-selected={index === activeIndex}
            >
              <img src={item.imageUrl} alt="" className="w-10 h-10 object-cover rounded" />
              <div className="flex-1">
                <div className="font-medium">{item.name}</div>
                <div className="text-sm text-gray-500">
                  {item.brand} · {item.category}
                </div>
              </div>
              <div className="text-right">
                <div className="font-semibold">${item.price.toFixed(2)}</div>
                {item.matchType === "semantic" && (
                  <span className="text-xs text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded">
                    ✨ Smart match
                  </span>
                )}
              </div>
            </button>
          ))}
        </div>
      )}
    </div>
  );
}

Results

After two weeks with semantic autocomplete in production:

  • Failed search rate dropped from 30% to 4% — semantic matching finds relevant products even when customers use completely different terminology than product listings
  • Search-to-purchase conversion increased by 23% — users find what they need faster; average time from first keystroke to add-to-cart dropped from 45s to 18s
  • Revenue impact: +$78K/month from recovered failed searches converting to purchases
  • Autocomplete latency: 85ms average (p99: 140ms) — prefix queries return in 20ms, semantic queries cached after first hit
  • Embedding index cost: $14/month — 120K products indexed with text-embedding-3-small; incremental updates on product changes cost ~$0.50/day
  • "Smart match" badge builds user trust — customers see the system is working harder to find relevant results, not just doing dumb keyword matching