AI-Powered Search Implementation — Beyond Keywords
Build semantic search with vector embeddings, hybrid queries, typo tolerance, and relevance ranking for modern applications.
// table of contents (9 sections)
Traditional keyword search has served us well, but it has limitations. A user searches for “laptop battery life” but your product database only has “battery runtime” — no match. Semantic search bridges this gap by understanding meaning, not just matching words.
This post covers building an AI-powered search system: vector embeddings for semantic understanding, hybrid search combining keywords and meaning, query understanding for intent detection, and relevance ranking for better results.
The Problem with Keyword-Only Search
Keyword search works by matching tokens. If the user types exactly what is in your database, it works great. But real-world queries are messy:
- Synonyms: “cheap” vs “budget” vs “affordable”
- Typos: “iphne” vs “iphone”
- Intent: “gift for dad” vs “mens watch”
- Context: “bank” (river) vs “bank” (finance)
Vector embeddings capture semantic meaning, allowing your search to understand that “laptop battery life” and “laptop battery runtime” are essentially the same query.
Vector Embeddings for Search
Embeddings convert text into fixed-length vectors where similar meanings are close together in vector space.
interface EmbeddingModel {
dimension: number;
encode(text: string): Promise<number[]>;
}
class OpenAIEmbeddings implements EmbeddingModel {
dimension = 1536; // text-embedding-3-small
private apiKey: string;
constructor(apiKey: string) {
this.apiKey = apiKey;
}
async encode(text: string): Promise<number[]> {
const response = await fetch('https://api.openai.com/v1/embeddings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`,
},
body: JSON.stringify({
model: 'text-embedding-3-small',
input: text,
}),
});
const data = await response.json();
return data.data[0].embedding;
}
}
// Utility for batch encoding
async function batchEncode(
texts: string[],
model: EmbeddingModel,
batchSize = 100
): Promise<number[][]> {
const results: number[][] = [];
for (let i = 0; i < texts.length; i += batchSize) {
const batch = texts.slice(i, i + batchSize);
const embeddings = await Promise.all(
batch.map(text => model.encode(text))
);
results.push(...embeddings);
}
return results;
}
For better performance, consider using a local embedding model like sentence-transformers or running OpenAI-compatible models via Ollama for lower latency and no API costs.
Building the Search Index
A good search index combines traditional full-text search with vector similarity.
interface SearchDocument {
id: string;
title: string;
description: string;
category?: string;
tags: string[];
price?: number;
popularity: number;
// Vector field for semantic search
embedding?: number[];
}
class SearchIndex {
private documents: Map<string, SearchDocument> = new Map();
private embeddingModel: EmbeddingModel;
constructor(embeddingModel: EmbeddingModel) {
this.embeddingModel = embeddingModel;
}
async addDocument(doc: SearchDocument): Promise<void> {
// Generate embedding for combined text
const searchText = `${doc.title} ${doc.description} ${doc.tags.join(' ')}`;
doc.embedding = await this.embeddingModel.encode(searchText);
this.documents.set(doc.id, doc);
}
async addDocuments(docs: SearchDocument[]): Promise<void> {
const searchTexts = docs.map(
doc => `${doc.title} ${doc.description} ${doc.tags.join(' ')}`
);
const embeddings = await batchEncode(searchTexts, this.embeddingModel);
docs.forEach((doc, i) => {
doc.embedding = embeddings[i];
this.documents.set(doc.id, doc);
});
}
getDocument(id: string): SearchDocument | undefined {
return this.documents.get(id);
}
getAllDocuments(): SearchDocument[] {
return Array.from(this.documents.values());
}
}
Hybrid Search: Keywords + Semantics
The best search combines keyword matching with semantic similarity:
interface SearchOptions {
keywordWeight?: number; // Default 0.5
semanticWeight?: number; // Default 0.5
category?: string;
minPrice?: number;
maxPrice?: number;
limit?: number;
}
interface SearchResult {
document: SearchDocument;
score: number;
keywordScore: number;
semanticScore: number;
}
class HybridSearchEngine {
private index: SearchIndex;
constructor(index: SearchIndex) {
this.index = index;
}
async search(
query: string,
options: SearchOptions = {}
): Promise<SearchResult[]> {
const {
keywordWeight = 0.4,
semanticWeight = 0.6,
category,
minPrice,
maxPrice,
limit = 10,
} = options;
// Generate query embedding
const queryEmbedding = await this.index['embeddingModel'].encode(query);
// Calculate scores for all documents
const results: SearchResult[] = [];
for (const doc of this.index.getAllDocuments()) {
// Apply filters
if (category && doc.category !== category) continue;
if (minPrice && doc.price && doc.price < minPrice) continue;
if (maxPrice && doc.price && doc.price > maxPrice) continue;
// Keyword score (token overlap)
const keywordScore = this.calculateKeywordScore(query, doc);
// Semantic score (cosine similarity)
const semanticScore = this.cosineSimilarity(
queryEmbedding,
doc.embedding || []
);
// Combined score
const score = keywordScore * keywordWeight + semanticScore * semanticWeight;
results.push({
document: doc,
score,
keywordScore,
semanticScore,
});
}
// Sort by score and return top results
return results
.sort((a, b) => b.score - a.score)
.slice(0, limit);
}
private calculateKeywordScore(query: string, doc: SearchDocument): number {
const queryTokens = new Set(
query.toLowerCase().split(/\s+/).filter(t => t.length > 2)
);
const docText = `${doc.title} ${doc.description} ${doc.tags.join(' ')}`;
const docTokens = new Set(
docText.toLowerCase().split(/\s+/).filter(t => t.length > 2)
);
if (queryTokens.size === 0) return 0;
let matches = 0;
for (const token of queryTokens) {
if (docTokens.has(token)) {
matches++;
} else {
// Check for partial matches (fuzzy)
for (const docToken of docTokens) {
if (this.fuzzyMatch(token, docToken)) {
matches += 0.5;
break;
}
}
}
}
return matches / queryTokens.size;
}
private fuzzyMatch(a: string, b: string): boolean {
// Simple Levenshtein-based fuzzy match
const maxDistance = Math.max(a.length, b.length) * 0.3;
return this.levenshteinDistance(a, b) <= maxDistance;
}
private levenshteinDistance(a: string, b: string): number {
const matrix: number[][] = [];
for (let i = 0; i <= b.length; i++) {
matrix[i] = [i];
}
for (let j = 0; j <= a.length; j++) {
matrix[0][j] = j;
}
for (let i = 1; i <= b.length; i++) {
for (let j = 1; j <= a.length; j++) {
if (b.charAt(i - 1) === a.charAt(j - 1)) {
matrix[i][j] = matrix[i - 1][j - 1];
} else {
matrix[i][j] = Math.min(
matrix[i - 1][j - 1] + 1, // substitution
matrix[i][j - 1] + 1, // insertion
matrix[i - 1][j] + 1 // deletion
);
}
}
}
return matrix[b.length][a.length];
}
private cosineSimilarity(a: number[], b: number[]): number {
if (a.length !== b.length) return 0;
let dotProduct = 0;
let normA = 0;
let normB = 0;
for (let i = 0; i < a.length; i++) {
dotProduct += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
}
}
The 40/60 split between keywords and semantics works well for most applications. Adjust based on your content and user behavior.
Query Understanding
Better search means understanding what the user actually wants:
interface QueryIntent {
type: 'search' | 'filter' | 'compare' | 'recommendation';
category?: string;
priceRange?: { min?: number; max?: number };
keywords: string[];
entities: Map<string, string>;
}
class QueryAnalyzer {
async analyze(query: string): Promise<QueryIntent> {
const result: QueryIntent = {
type: 'search',
keywords: [],
entities: new Map(),
};
const lowerQuery = query.toLowerCase();
// Detect category mentions
const categories = ['laptop', 'phone', 'tablet', 'watch', 'headphones'];
for (const cat of categories) {
if (lowerQuery.includes(cat)) {
result.category = cat;
result.keywords.push(cat);
}
}
// Detect price ranges
const priceUnder = lowerQuery.match(/under\s+\$?(\d+)/);
const priceOver = lowerQuery.match(/over\s+\$?(\d+)/);
const priceBetween = lowerQuery.match(/between\s+\$?(\d+)\s+and\s+\$?(\d+)/);
if (priceUnder) {
result.priceRange = { max: parseInt(priceUnder[1]) };
} else if (priceOver) {
result.priceRange = { min: parseInt(priceOver[1]) };
} else if (priceBetween) {
result.priceRange = {
min: parseInt(priceBetween[1]),
max: parseInt(priceBetween[2]),
};
}
// Detect intent type
if (lowerQuery.includes('best') || lowerQuery.includes('top') || lowerQuery.includes('recommend')) {
result.type = 'recommendation';
} else if (lowerQuery.includes('vs') || lowerQuery.includes('versus') || lowerQuery.includes('compare')) {
result.type = 'compare';
}
// Extract remaining keywords
const words = lowerQuery
.replace(/under|over|between|and|best|top|recommend|vs|versus|compare/g, '')
.replace(/\$\d+/g, '')
.split(/\s+/)
.filter(w => w.length > 2);
result.keywords.push(...words);
return result;
}
}
class IntelligentSearchEngine extends HybridSearchEngine {
private analyzer: QueryAnalyzer;
constructor(index: SearchIndex) {
super(index);
this.analyzer = new QueryAnalyzer();
}
async intelligentSearch(query: string): Promise<{
results: SearchResult[];
intent: QueryIntent;
}> {
const intent = await this.analyzer.analyze(query);
const results = await this.search(query, {
category: intent.category,
minPrice: intent.priceRange?.min,
maxPrice: intent.priceRange?.max,
});
return { results, intent };
}
}
Query understanding transforms “laptops under $1000” into a structured search with category and price filters, rather than just keyword matching.
Relevance Ranking and Learning
Good search learns from user behavior:
interface FeedbackEvent {
query: string;
documentId: string;
action: 'click' | 'add_to_cart' | 'purchase' | 'bounce';
position: number;
timestamp: Date;
}
class LearningSearchEngine extends IntelligentSearchEngine {
private feedback: FeedbackEvent[] = [];
private documentBoosts: Map<string, number> = new Map();
recordFeedback(event: FeedbackEvent): void {
this.feedback.push(event);
// Update document popularity scores
const boost = this.actionToBoost(event.action);
const currentBoost = this.documentBoosts.get(event.documentId) || 0;
// Decay old boosts and add new
this.documentBoosts.set(
event.documentId,
currentBoost * 0.95 + boost * 0.05
);
}
private actionToBoost(action: FeedbackEvent['action']): number {
switch (action) {
case 'purchase':
return 1.0;
case 'add_to_cart':
return 0.5;
case 'click':
return 0.1;
case 'bounce':
return -0.1;
}
}
async search(
query: string,
options: SearchOptions = {}
): Promise<SearchResult[]> {
const results = await super.search(query, options);
// Apply learned boosts
return results
.map(result => ({
...result,
score: result.score * (1 + (this.documentBoosts.get(result.document.id) || 0)),
}))
.sort((a, b) => b.score - a.score);
}
// Generate analytics for search quality
getAnalytics(): {
averageClickPosition: number;
zeroResultsRate: number;
topQueries: Array<{ query: string; count: number }>;
} {
const clicks = this.feedback.filter(f => f.action === 'click');
return {
averageClickPosition:
clicks.reduce((sum, c) => sum + c.position, 0) / clicks.length || 0,
zeroResultsRate: 0, // Calculate from search logs
topQueries: [], // Aggregate query frequencies
};
}
}
User feedback loops continuously improve search quality. Documents that users actually click and buy get boosted over time.
Putting It All Together
// Initialize the search engine
const embeddingModel = new OpenAIEmbeddings(process.env.OPENAI_API_KEY!);
const index = new SearchIndex(embeddingModel);
const searchEngine = new LearningSearchEngine(index);
// Index your products
await searchEngine.addDocuments([
{
id: '1',
title: 'MacBook Pro 14"',
description: 'Apple M3 Pro chip, 18-hour battery life, 16GB RAM, 512GB SSD',
category: 'laptop',
tags: ['apple', 'm3', 'pro', 'premium'],
price: 1999,
popularity: 0.8,
},
{
id: '2',
title: 'Dell XPS 13',
description: 'Intel Core i7, 12-hour battery, 16GB RAM, 512GB SSD',
category: 'laptop',
tags: ['dell', 'intel', 'ultrabook'],
price: 1299,
popularity: 0.6,
},
// ... more products
]);
// Search
const { results, intent } = await searchEngine.intelligentSearch(
'laptop with good battery life under $1500'
);
console.log('Intent:', intent);
console.log('Results:', results);
// Record feedback when users interact
searchEngine.recordFeedback({
query: 'laptop with good battery life',
documentId: '2',
action: 'click',
position: 0,
timestamp: new Date(),
});
Production Considerations
For production deployments, consider these optimizations:
1. Use a proper vector database. Pinecone, Weaviate, or Qdrant scale much better than in-memory maps.
2. Cache query embeddings. Identical queries should not re-encode.
class CachedEmbeddingModel implements EmbeddingModel {
private base: EmbeddingModel;
private cache = new Map<string, number[]>();
constructor(base: EmbeddingModel) {
this.base = base;
}
async encode(text: string): Promise<number[]> {
if (this.cache.has(text)) {
return this.cache.get(text)!;
}
const embedding = await this.base.encode(text);
this.cache.set(text, embedding);
return embedding;
}
}
3. Implement autocomplete. Generate embeddings for common queries and serve results as users type.
4. Monitor search quality. Track click-through rate, average result position, and zero-result queries.
Conclusion
AI-powered search combines the reliability of keyword matching with the intelligence of semantic understanding. By using vector embeddings, hybrid scoring, query understanding, and learning from user behavior, you can build search that actually understands what your users are looking for.
Start simple: add semantic search alongside your existing keyword search. Measure the improvement. Iterate based on real user behavior. The patterns in this post provide a foundation for search that keeps getting better.
You might also like
Building RAG Applications — A Practical Guide
Learn to build production-ready Retrieval-Augmented Generation applications with vector embeddings, semantic search, and context-aware AI responses.
Vector Database Integration Guide — From PostgreSQL to Pinecone
Learn to integrate vector databases for semantic search: pgvector for PostgreSQL, Pinecone for managed solutions, and implementation patterns.
Building Autonomous AI Workflows with LangGraph: A Practical Guide
Learn how to build production-ready autonomous AI workflows using LangGraph with cycles, state management, human-in-the-loop patterns, and persistent memory.
More Posts
API Gateway Patterns: The Front Door to Your Microservices
Web Components 2026: Building Framework-Agnostic UI Libraries
Building Autonomous AI Workflows with LangGraph: A Practical Guide
Building Type-Safe APIs with tRPC in 2026: Full-Stack TypeScript Without Schemas
Database Connection Pooling: Patterns for High-Performance Applications
Prompt Caching: Reduce LLM Costs by 90% with Smart Context Management
Enjoyed This Post?
Want to discuss the topic, have questions, or looking to collaborate on something similar? Drop a comment below or reach out directly.
