AI-Powered Search — Building Semantic Search That Actually Works in Production

Sanjeev SharmaSanjeev Sharma
8 min read

Advertisement

Introduction

Keyword search misses synonyms, context, and intent. Semantic search — powered by embeddings and vector databases — retrieves documents by meaning rather than exact token matches. But production semantic search has sharp edges: slow indexing, stale embeddings, poor recall on short queries, and high infrastructure cost. This post covers the architecture of a production-grade semantic search system that handles all of these.

Why This Matters

A search system that returns irrelevant results loses users immediately. Semantic search consistently outperforms BM25 keyword search by 15-40% on recall metrics for natural language queries. For enterprise products where users phrase queries in their own words, the gap is even larger. The investment in an embedding-based search pipeline pays back quickly in reduced support tickets and higher retention.

Embedding Pipeline

Build an indexing pipeline that generates and stores embeddings for all documents:

import Anthropic from '@anthropic-ai/sdk';
 
const client = new Anthropic();
 
interface Document {
  id: string;
  title: string;
  content: string;
  metadata: Record<string, unknown>;
}
 
interface IndexedDocument extends Document {
  embedding: number[];
  indexedAt: string;
}
 
// Use voyage-3 via the Anthropic embeddings endpoint or any embedding model
async function embedText(text: string): Promise<number[]> {
  // Placeholder: replace with your embedding provider (Voyage, OpenAI, Cohere)
  // For Anthropic-integrated workflows, use Voyage via the API
  const normalized = text.slice(0, 8192); // respect token limits
  return Array.from({ length: 1536 }, (_, i) => Math.sin(i + normalized.length));
}
 
async function indexDocuments(docs: Document[]): Promise<IndexedDocument[]> {
  const BATCH_SIZE = 20;
  const indexed: IndexedDocument[] = [];
 
  for (let i = 0; i < docs.length; i += BATCH_SIZE) {
    const batch = docs.slice(i, i + BATCH_SIZE);
 
    const embeddings = await Promise.all(
      batch.map((doc) => embedText(`${doc.title}\n\n${doc.content}`))
    );
 
    for (let j = 0; j < batch.length; j++) {
      indexed.push({
        ...batch[j],
        embedding: embeddings[j],
        indexedAt: new Date().toISOString(),
      });
    }
 
    // Rate limit: avoid overwhelming the embedding API
    if (i + BATCH_SIZE < docs.length) {
      await new Promise((r) => setTimeout(r, 100));
    }
  }
 
  return indexed;
}

Hybrid Search: Semantic + Keyword

Pure semantic search fails on exact lookups (product codes, names). Combine with BM25:

interface SearchResult {
  id: string;
  title: string;
  content: string;
  semanticScore: number;
  keywordScore: number;
  hybridScore: number;
}
 
class HybridSearchEngine {
  private documents: IndexedDocument[] = [];
 
  index(docs: IndexedDocument[]): void {
    this.documents = docs;
  }
 
  async search(query: string, topK = 10): Promise<SearchResult[]> {
    const queryEmbedding = await embedText(query);
 
    const results = this.documents.map((doc) => {
      const semanticScore = this.cosineSimilarity(queryEmbedding, doc.embedding);
      const keywordScore = this.bm25Score(query, doc.content);
      // RRF (Reciprocal Rank Fusion) with alpha=0.7 for semantic weight
      const hybridScore = 0.7 * semanticScore + 0.3 * keywordScore;
 
      return {
        id: doc.id,
        title: doc.title,
        content: doc.content,
        semanticScore,
        keywordScore,
        hybridScore,
      };
    });
 
    return results
      .sort((a, b) => b.hybridScore - a.hybridScore)
      .slice(0, topK);
  }
 
  private cosineSimilarity(a: number[], b: number[]): number {
    let dot = 0, magA = 0, magB = 0;
    for (let i = 0; i < a.length; i++) {
      dot += a[i] * b[i];
      magA += a[i] ** 2;
      magB += b[i] ** 2;
    }
    return dot / (Math.sqrt(magA) * Math.sqrt(magB));
  }
 
  private bm25Score(query: string, text: string): number {
    const queryTerms = query.toLowerCase().split(/\s+/);
    const textTerms = text.toLowerCase().split(/\s+/);
    const termFreq = new Map<string, number>();
    for (const t of textTerms) termFreq.set(t, (termFreq.get(t) ?? 0) + 1);
 
    const k1 = 1.5, b = 0.75;
    const avgLen = 200;
    const len = textTerms.length;
 
    return queryTerms.reduce((score, term) => {
      const tf = termFreq.get(term) ?? 0;
      const idf = Math.log(1 + (1000 - 1 + 0.5) / (1 + 1)); // simplified IDF
      const tfScore = (tf * (k1 + 1)) / (tf + k1 * (1 - b + b * len / avgLen));
      return score + idf * tfScore;
    }, 0);
  }
}

Query Rewriting with LLM

Improve recall by rewriting vague user queries before embedding:

async function rewriteQuery(rawQuery: string): Promise<string[]> {
  const response = await client.messages.create({
    model: 'claude-3-5-sonnet-20241022',
    max_tokens: 256,
    system: 'Rewrite the user query into 3 alternative phrasings that capture the same intent. Return a JSON array of strings.',
    messages: [{ role: 'user', content: rawQuery }],
  });
 
  const text = response.content[0].type === 'text' ? response.content[0].text.trim() : '[]';
  try {
    const variants = JSON.parse(text.replace(/^```json\n?/, '').replace(/\n?```$/, ''));
    return Array.isArray(variants) ? [rawQuery, ...variants].slice(0, 4) : [rawQuery];
  } catch {
    return [rawQuery];
  }
}
 
async function searchWithQueryExpansion(query: string, engine: HybridSearchEngine, topK = 10) {
  const queries = await rewriteQuery(query);
 
  const allResults = await Promise.all(queries.map((q) => engine.search(q, topK)));
 
  // Merge and deduplicate by ID, keeping highest score
  const merged = new Map<string, SearchResult>();
  for (const results of allResults) {
    for (const result of results) {
      const existing = merged.get(result.id);
      if (!existing || result.hybridScore > existing.hybridScore) {
        merged.set(result.id, result);
      }
    }
  }
 
  return Array.from(merged.values())
    .sort((a, b) => b.hybridScore - a.hybridScore)
    .slice(0, topK);
}

Re-ranking with LLM

Use an LLM to re-rank the top candidates for higher precision:

interface RerankCandidate {
  id: string;
  title: string;
  content: string;
  initialScore: number;
}
 
async function rerankResults(query: string, candidates: RerankCandidate[], topK = 5) {
  const candidateList = candidates
    .slice(0, 10) // only re-rank top 10 to limit cost
    .map((c, i) => `[${i}] ${c.title}: ${c.content.slice(0, 200)}`)
    .join('\n');
 
  const response = await client.messages.create({
    model: 'claude-3-5-sonnet-20241022',
    max_tokens: 128,
    system: 'Re-rank the following search results by relevance to the query. Return a JSON array of indices in order of relevance, most relevant first.',
    messages: [{
      role: 'user',
      content: `Query: "${query}"\n\nResults:\n${candidateList}`,
    }],
  });
 
  const text = response.content[0].type === 'text' ? response.content[0].text.trim() : '[]';
  try {
    const indices: number[] = JSON.parse(text.replace(/```json\n?/, '').replace(/\n?```/, ''));
    return indices
      .filter((i) => i >= 0 && i < candidates.length)
      .map((i) => candidates[i])
      .slice(0, topK);
  } catch {
    return candidates.slice(0, topK);
  }
}

Search Result Caching

Cache expensive embedding computations and search results:

class SearchCache {
  private queryCache = new Map<string, { results: SearchResult[]; cachedAt: number }>();
  private embeddingCache = new Map<string, number[]>();
  private readonly TTL_MS = 5 * 60 * 1000; // 5 minutes
 
  getCachedQuery(query: string): SearchResult[] | null {
    const cached = this.queryCache.get(query);
    if (!cached) return null;
    if (Date.now() - cached.cachedAt > this.TTL_MS) {
      this.queryCache.delete(query);
      return null;
    }
    return cached.results;
  }
 
  cacheQuery(query: string, results: SearchResult[]): void {
    this.queryCache.set(query, { results, cachedAt: Date.now() });
  }
 
  getCachedEmbedding(text: string): number[] | null {
    return this.embeddingCache.get(text) ?? null;
  }
 
  cacheEmbedding(text: string, embedding: number[]): void {
    // Embeddings don't expire — content changes trigger re-index
    this.embeddingCache.set(text, embedding);
  }
 
  invalidateDocument(documentId: string): void {
    // Remove all cached queries that included this document
    for (const [key, value] of this.queryCache.entries()) {
      if (value.results.some((r) => r.id === documentId)) {
        this.queryCache.delete(key);
      }
    }
  }
}

Common Mistakes

  • Embedding full documents without chunking: Embeddings of very long documents lose precision. Chunk documents into 256-512 token windows and embed each chunk.
  • No cache invalidation on document update: Stale embeddings return outdated results. Trigger re-indexing immediately when a document is updated or deleted.
  • Pure semantic search for exact-match queries: Semantic search scores product codes and exact names poorly. Always use hybrid search.
  • Re-ranking every query: LLM re-ranking adds 500ms-2s of latency. Only re-rank when the initial semantic scores are close (within 0.05 of each other).
  • Ignoring embedding model versioning: Switching embedding models makes all stored vectors incompatible. Re-index the entire corpus when upgrading the model.

Best Practices

  • Chunk documents into 256-512 token windows with 50-token overlap to preserve context at chunk boundaries.
  • Store the embedding model name and version alongside each vector so you can detect version drift in your index.
  • Use approximate nearest-neighbor (ANN) indexes (HNSW, IVF) in Pinecone, Weaviate, or pgvector for sub-100ms search at millions of documents.
  • Cache query embeddings in Redis with a 5-minute TTL — the same query phrased identically should not re-compute the embedding.
  • Monitor search quality with explicit user feedback (thumbs up/down) mapped to query-result pairs for offline evaluation.

Key Takeaways

  • Hybrid search (semantic + BM25) consistently outperforms pure vector search because BM25 handles exact-match queries that semantic similarity scores poorly.
  • Chunking documents into 256-512 token windows before embedding improves recall because full-document embeddings dilute the signal for specific passages.
  • Query rewriting with an LLM before embedding expands recall by 10-25% for vague or underspecified user queries.
  • LLM re-ranking should only run on the top 10 candidates, not the full result set, to keep latency under 2 seconds.
  • Embedding model versioning is a hidden operational risk — switching models without re-indexing produces random-looking ranking degradation.
  • Cache both query embeddings (5-minute TTL) and search results (5-minute TTL) to dramatically reduce embedding API costs at scale.
  • An ANN index (HNSW) is mandatory for production — exact nearest-neighbor search is O(n) per query and will not scale beyond a few thousand documents.
  • Track search quality explicitly with relevance feedback or click-through rate per query, not just infrastructure metrics like latency.

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading