This guide explains the e-commerce hybrid search code step-by-step. By the end, you'll understand how to combine vector search (semantic understanding) with keyword search (exact matching) for product searches.
- What is Hybrid Search?
- Setup and Imports
- Configuration
- Core Functions
- Example 1: SKU and Brand Challenges
- Example 2: Score Normalization
- Example 3: Multi-Field Search
- Example 4: Dynamic Weight Adjustment
- Example 5: Fallback Strategies
- Example 6: Filter-Aware Search
- Example 7: Performance Optimization
Hybrid search combines two different search methods:
-
Vector Search (Semantic)
- Understands the meaning of words
- Example: "laptop for editing" matches "MacBook Pro for video work"
- Good for: Natural language, concepts, paraphrased queries
-
Keyword Search (BM25)
- Matches exact words
- Example: "MBP-M3MAX-32" only matches that exact SKU
- Good for: Product codes, brand names, technical specs
Why combine them? Each has strengths and weaknesses. Hybrid search gives you the best of both worlds!
import { fileURLToPath } from "url";
import path from "path";
import { VectorDB } from "embedded-vector-db";
import { getLlama } from "node-llama-cpp";
import { Document } from "../../../src/index.js";
import { OutputHelper } from "../../../helpers/output-helper.js";
import chalk from "chalk";What each import does:
fileURLToPathandpath: Help find files on your computerVectorDB: Database that stores product embeddings (vector representations)getLlama: Loads the AI model that creates embeddingsDocument: Wrapper class for product dataOutputHelper: Utility for nice console outputchalk: Makes console text colorful (not essential, just pretty!)
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const EMBEDDING_MODEL_PATH = path.join(__dirname, "..", "..", "..", "models", "bge-small-en-v1.5.Q8_0.gguf");
const DIM = 384;
const MAX_ELEMENTS = 10000;
const NS = "product_search";What these constants mean:
__dirname: Current file's directoryEMBEDDING_MODEL_PATH: Where the AI model file livesDIM: Embedding dimension (384 numbers represent each product)MAX_ELEMENTS: Maximum products the database can holdNS: Namespace (like a folder name in the database)
async function initializeEmbeddingModel() {
try {
const llama = await getLlama({ logLevel: "error" });
const model = await llama.loadModel({ modelPath: EMBEDDING_MODEL_PATH });
return await model.createEmbeddingContext();
} catch (error) {
throw new Error(`Failed to initialize embedding model: ${error.message}`);
}
}What it does:
- Loads the AI model from disk
- Creates an "embedding context" (a tool to convert text → numbers)
- Returns the embedding context so we can use it later
Why we need it: To convert product descriptions and search queries into vectors (arrays of numbers) that the computer can compare.
function createProductCatalog() {
return [
new Document("Apple MacBook Pro 16-inch with M3 Max chip...", {
id: "PROD-001",
title: "MacBook Pro 16-inch M3 Max",
brand: "Apple",
category: "laptops",
price: 3499,
sku: "MBP-M3MAX-32-1TB",
attributes: "M3 Max, 32GB RAM, 1TB SSD..."
}),
// ... more products
];
}What it does: Creates an array of product documents. Each product has:
- Content (first parameter): Full description
- Metadata (second parameter): Structured data like SKU, price, brand
Why structured this way:
- Content is for semantic search (understanding meaning)
- Metadata fields are for exact matching and filtering
async function addProductsToStore(vectorStore, embeddingContext, products) {
try {
// Enable full-text indexing on multiple fields
await vectorStore.setFullTextIndexedFields(NS,
['content', 'title', 'brand', 'sku', 'attributes']
);
for (const product of products) {
// Convert product description to vector
const embedding = await embeddingContext.getEmbeddingFor(product.pageContent);
// Prepare metadata
const metadata = {
content: product.pageContent,
...product.metadata,
};
// Store in database
await vectorStore.insert(
NS,
product.metadata.id,
Array.from(embedding.vector),
metadata
);
}
} catch (error) {
throw new Error(`Failed to add products to store: ${error.message}`);
}
}Step-by-step:
-
setFullTextIndexedFields: Tells the database which fields to index for keyword search- Like creating an index in a book for quick lookup
-
Loop through products: Process each product one by one
-
getEmbeddingFor: Convert product description to a vector (array of 384 numbers)- Example: "MacBook Pro 16-inch..." → [0.23, -0.45, 0.12, ...]
-
insert: Save the product with:- Namespace (folder)
- Product ID
- Vector (for semantic search)
- Metadata (for keyword search and filtering)
This example shows why keyword search is critical for product codes.
const query1 = "MBP-M3MAX-32-1TB";
const queryEmbedding1 = await embeddingContext.getEmbeddingFor(query1);
const vectorResults1 = await vectorStore.search(NS, Array.from(queryEmbedding1.vector), 3);
const keywordResults1 = await vectorStore.fullTextSearch(NS, query1, 3);What happens:
-
Vector Search:
- Converts "MBP-M3MAX-32-1TB" to numbers
- Problem: The model doesn't understand product codes!
- Result: Might return random products
-
Keyword Search (BM25):
- Looks for exact text match
- Finds products with "MBP-M3MAX-32-1TB" in SKU field
- Result: Perfect match!
Key Insight:
SKU codes like "MBP-M3MAX-32-1TB" are "out-of-vocabulary" (OOV) for AI models.
The model treats them like random characters, but keyword search finds them perfectly.
const query2 = "Sony headphones";What happens:
- Vector Search: Finds Sony products + similar products (like Bose)
- Keyword Search: Prioritizes exact "Sony" matches
Best approach: Use BOTH (hybrid search) to get:
- Exact brand match (keyword)
- Semantic category understanding (vector)
const vecScores = vectorResults.map(r => r.similarity);
const keyScores = keywordResults.map(r => r.similarity);
// Vector scores might be: [0.85, 0.83, 0.80]
// Keyword scores might be: [12.5, 8.3, 5.1]Problem: Different scales! How do you combine them fairly?
If you just add them: 0.85 + 12.5 = 13.35 (keyword dominates!)
const normalizeMinMax = (scores) => {
const min = Math.min(...scores);
const max = Math.max(...scores);
const range = max - min || 1;
return scores.map(s => (s - min) / range);
};What it does:
- Scales all scores to [0, 1] range
- Formula:
(score - min) / (max - min) - Example: [12.5, 8.3, 5.1] → [1.0, 0.43, 0]
Pros: Simple, intuitive Cons: Sensitive to outliers (one very high/low score affects everything)
const normalizeZScore = (scores) => {
const mean = scores.reduce((a, b) => a + b, 0) / scores.length;
const variance = scores.reduce((a, b) => a + Math.pow(b - mean, 2), 0) / scores.length;
const stdDev = Math.sqrt(variance) || 1;
return scores.map(s => (s - mean) / stdDev);
};What it does:
- Centers scores around 0
- Formula:
(score - mean) / standard_deviation - Example: [12.5, 8.3, 5.1] → [1.2, 0.1, -1.3]
Pros: Preserves distribution, handles outliers better Cons: Can produce negative scores (but that's okay for comparison)
const normalizeRank = (scores) => {
const n = scores.length;
return scores.map((_, idx) => (n - idx) / n);
};What it does:
- Ignores actual scores, uses only ranking
- Formula:
(total - rank) / total - Example (for 5 results): [1.0, 0.8, 0.6, 0.4, 0.2]
Pros: Most robust, used by Reciprocal Rank Fusion (RRF) Cons: Loses information about score gaps
await vectorStore.setFullTextIndexedFields(NS,
['content', 'title', 'brand', 'sku', 'attributes']
);Each field serves a purpose:
| Field | Purpose | Example Query |
|---|---|---|
content |
Semantic understanding | "laptop for video editing" |
title |
Product name matching | "MacBook Pro" |
brand |
Exact brand matching | "Apple" |
sku |
Product code matching | "MBP-M3MAX-32-1TB" |
attributes |
Technical specs | "32GB RAM" |
const query = "Apple wireless keyboard";Search process:
- Searches ALL indexed fields simultaneously
- "Apple" matches strongly in
brandfield - "wireless" matches in
titleandattributes - "keyboard" matches in
titleandcontent - Combines all signals for final score
Result: More comprehensive, accurate search!
function analyzeQuery(query) {
const upperCount = (query.match(/[A-Z]/g) || []).length;
const digitCount = (query.match(/\d/g) || []).length;
const hyphenCount = (query.match(/-/g) || []).length;
const wordCount = query.split(/\s+/).length;
const hasQuestionWord = /^(what|how|which|where|why|when|who)/i.test(query);
// Detect query type and return optimal weights
let queryType, weights;
if (hyphenCount >= 2 || (upperCount > 3 && digitCount > 0)) {
queryType = "SKU/Model Number";
weights = { vector: 0.2, text: 0.8 };
} else if (digitCount >= 3) {
queryType = "Technical Specs";
weights = { vector: 0.3, text: 0.7 };
} else if (hasQuestionWord || wordCount >= 6) {
queryType = "Natural Language Question";
weights = { vector: 0.8, text: 0.2 };
} else if (wordCount <= 2) {
queryType = "Short Keyword";
weights = { vector: 0.4, text: 0.6 };
} else {
queryType = "Mixed Query";
weights = { vector: 0.5, text: 0.5 };
}
return { queryType, weights };
}Pattern Detection:
-
SKU/Model Number (
hyphenCount >= 2)- Example: "ASUS-G14-R9-4070"
- Many hyphens + capitals + digits = product code
- Use keyword-heavy: 0.2 vector / 0.8 text
-
Technical Specs (
digitCount >= 3)- Example: "32GB RAM 4K display"
- Multiple numbers = technical requirements
- Use keyword-heavy: 0.3 vector / 0.7 text
-
Natural Language Question (starts with question word)
- Example: "What's the best laptop for video editing?"
- Needs semantic understanding
- Use vector-heavy: 0.8 vector / 0.2 text
-
Short Keyword (
wordCount <= 2)- Example: "Sony headphones"
- Brief, likely includes brand/category
- Use keyword-leaning: 0.4 vector / 0.6 text
-
Mixed Query (default)
- Example: "portable charger fast charging"
- Could benefit from both approaches
- Use balanced: 0.5 vector / 0.5 text
const query = "ASUS-G14-R9-4070";
const analysis = analyzeQuery(query);
// Returns: { queryType: "SKU/Model Number", weights: { vector: 0.2, text: 0.8 } }
const results = await vectorStore.hybridSearch(
NS,
Array.from(queryEmbedding.vector),
query,
{
vectorWeight: analysis.weights.vector, // 0.2
textWeight: analysis.weights.text, // 0.8
k: 2
}
);Why this matters:
- Automatic optimization based on query type
- Better user experience without manual configuration
- Adapts to different search patterns
const query = "Microsoft Surface laptop touchscreen";Problem: No Microsoft products in our catalog!
const keywordOnly = await vectorStore.fullTextSearch(NS, query, 3);
// Result: 0 results (no exact "Microsoft" match)Why it fails: No products contain the word "Microsoft"
const hybrid = await vectorStore.hybridSearch(
NS,
Array.from(queryEmbedding.vector),
query,
{ k: 3 } // Default: 0.5/0.5
);
// Result: Returns laptops with touchscreens (similar products)Why it works better: Vector search understands "laptop" and "touchscreen" semantically
const vectorFallback = await vectorStore.hybridSearch(
NS,
Array.from(queryEmbedding.vector),
query,
{
vectorWeight: 0.9,
textWeight: 0.1,
k: 3
}
);
// Result: Returns most semantically similar laptopsWhy use this: When keyword matching completely fails, rely on semantic similarity
1. Try balanced hybrid (0.5/0.5)
↓ (if < 3 results)
2. Try vector-heavy (0.8/0.2)
↓ (if still < 3 results)
3. Use pure vector (1.0/0.0)
↓
4. Show "Similar products" message to user
In code:
let results = await hybridSearch(query, { vectorWeight: 0.5, textWeight: 0.5, k: 10 });
if (results.length < 3) {
// Fallback to vector-heavy
results = await hybridSearch(query, { vectorWeight: 0.8, textWeight: 0.2, k: 10 });
}
if (results.length < 3) {
// Final fallback: pure vector
results = await hybridSearch(query, { vectorWeight: 1.0, textWeight: 0.0, k: 10 });
// Show message: "No exact matches. Here are similar products:"
}const query = "high-end laptop professional work";
const maxPrice = 2500;Two-step process:
const results = await vectorStore.hybridSearch(
NS,
Array.from(queryEmbedding.vector),
query,
{ k: 5 }
);This finds relevant products based on the query.
// Filter by price
const priceFiltered = results.filter(doc => doc.metadata.price <= maxPrice);
// Filter by category AND price
const categoryFiltered = results.filter(doc =>
doc.metadata.category === 'laptops' &&
doc.metadata.price <= maxPrice
);// Price range
const inBudget = results.filter(r =>
r.metadata.price >= minPrice &&
r.metadata.price <= maxPrice
);
// In stock
const available = results.filter(r =>
r.metadata.inStock === true
);
// Category
const category = results.filter(r =>
r.metadata.category === 'laptops'
);
// Brand
const brand = results.filter(r =>
r.metadata.brand === 'Apple'
);
// Rating
const highRated = results.filter(r =>
r.metadata.rating >= 4.0
);Post-filtering (shown above):
// Search first, filter after
const results = await hybridSearch(query, { k: 100 });
const filtered = results.filter(r => r.metadata.price <= maxPrice);Pros: Simple, flexible Cons: Inefficient for large databases
Pre-filtering (production approach):
// Filter BEFORE searching (if database supports it)
const results = await hybridSearch(query, {
k: 100,
metadataFilter: {
price: { $lte: maxPrice },
category: 'laptops'
}
});Pros: Much faster, searches smaller dataset Cons: Requires database support
const queryCache = new Map();
async function cachedHybridSearch(query, options = {}) {
// Create cache key from query + options
const cacheKey = `${query}:${JSON.stringify(options)}`;
// Check cache first
if (queryCache.has(cacheKey)) {
return { results: queryCache.get(cacheKey), cached: true };
}
// Cache miss: perform search
const queryEmbedding = await embeddingContext.getEmbeddingFor(query);
const results = await vectorStore.hybridSearch(
NS,
Array.from(queryEmbedding.vector),
query,
options
);
// Store in cache
queryCache.set(cacheKey, results);
return { results, cached: false };
}How it works:
-
First search for "wireless headphones":
- Computes embedding (slow)
- Searches database (slow)
- Caches result
- Time: ~100ms
-
Second search for same query:
- Finds in cache (instant)
- Returns cached results
- Time: ~1ms
- 100x speedup!
Important considerations:
// Set cache expiration (Time-To-Live)
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
queryCache.set(cacheKey, {
results: results,
timestamp: Date.now()
});
// Check if cache entry is still valid
if (queryCache.has(cacheKey)) {
const cached = queryCache.get(cacheKey);
if (Date.now() - cached.timestamp < CACHE_TTL) {
return cached.results;
} else {
queryCache.delete(cacheKey); // Expired
}
}Problem: Computing embeddings for many products is slow.
Solution: Search in two stages:
// Stage 1: Fast keyword search to get candidates
const candidates = await vectorStore.fullTextSearch(NS, query, 100);
// Returns 100 candidates in ~10ms
// Stage 2: Rerank top candidates with vector search
const candidateIds = candidates.map(c => c.metadata.id);
const reranked = await vectorStore.search(NS, queryEmbedding, 10, {
filter: { id: { $in: candidateIds } }
});
// Only computes similarity for 100 products, not all 10,000!Why it's faster:
- Stage 1: Keyword search is very fast
- Stage 2: Only compute expensive vectors for top candidates
- 10-100x faster for large databases
// Create separate indices for categories
const laptopStore = new VectorDB({ dim: 384, namespace: "laptops" });
const audioStore = new VectorDB({ dim: 384, namespace: "audio" });
const accessoryStore = new VectorDB({ dim: 384, namespace: "accessories" });
// Search only relevant partition
async function partitionedSearch(query, category) {
let store;
switch(category) {
case 'laptops': store = laptopStore; break;
case 'audio': store = audioStore; break;
case 'accessories': store = accessoryStore; break;
}
return await store.hybridSearch(/* ... */);
}Benefits:
- Smaller search space = faster
- Can optimize each partition differently
- Easier to scale horizontally
Bad (slow):
// Compute embedding every time
for (const product of products) {
const embedding = await getEmbedding(product.description);
await store.insert(id, embedding, metadata);
}Good (fast):
// Compute embeddings once, store them
const productsWithEmbeddings = products.map(async (p) => ({
...p,
embedding: await getEmbedding(p.description)
}));
// Save embeddings to database or file
await saveEmbeddings(productsWithEmbeddings);
// Later: Load pre-computed embeddings
const cachedProducts = await loadEmbeddings();
for (const product of cachedProducts) {
await store.insert(id, product.embedding, metadata);
}Why: Computing embeddings is the slowest part. Do it once, reuse many times!
Here's how all pieces fit together:
User Query: "Apple wireless keyboard"
↓
[Query Analysis]
Detects: Short Keyword
Weights: 0.4 vector / 0.6 text
↓
┌──────────────────┐
│ Hybrid Search │
└──────────────────┘
↓
┌──────┴──────┐
↓ ↓
[Vector Search] [Keyword Search]
↓ ↓
Semantic match Exact match
"keyboard" "Apple"
↓ ↓
Score: 0.85 Score: 12.5
↓ ↓
[Normalize Scores]
↓ ↓
Norm: 0.92 Norm: 0.88
↓ ↓
└──────┬──────┘
↓
[Combine with Weights]
Combined = (0.92 × 0.4) + (0.88 × 0.6) = 0.896
↓
[Apply Business Filters]
- Price <= $200
- In stock = true
- Category = accessories
↓
[Return Top Results]
1. Apple Magic Keyboard Touch ID (0.896)
2. Apple Magic Keyboard (0.874)
3. ...
| Query Type | Vector Weight | Text Weight |
|---|---|---|
| SKU/Product Code | 0.2 | 0.8 |
| Technical Specs | 0.3 | 0.7 |
| Natural Language | 0.8 | 0.2 |
| Short Keywords | 0.4 | 0.6 |
| Mixed/Default | 0.5 | 0.5 |
await vectorStore.setFullTextIndexedFields(NS,
['content', 'title', 'brand', 'sku', 'attributes']
);// Try balanced → vector-heavy → pure vector
if (results.length < threshold) {
// Shift toward vector search
}const cache = new Map();
// Cache results for 5-10 minutes// Filter BEFORE search, not after
const results = await search(query, {
metadataFilter: { price: { $lte: maxPrice } }
});// Log slow queries
if (searchTime > 100) {
console.log(`Slow query: ${query} (${searchTime}ms)`);
}Problem:
// Keyword scores: [15.2, 12.8, 9.3]
// Vector scores: [0.85, 0.82, 0.79]
combined = vectorScore + keywordScore; // Keywords dominate!Solution:
const normVector = normalizeMinMax(vectorScores);
const normKeyword = normalizeMinMax(keywordScores);
combined = (normVector * 0.5) + (normKeyword * 0.5);Problem:
const results = await search(query);
return results[0]; // Error if no results!Solution:
let results = await search(query, { vectorWeight: 0.5, textWeight: 0.5 });
if (results.length === 0) {
// Fallback to vector-only
results = await search(query, { vectorWeight: 1.0, textWeight: 0.0 });
}
if (results.length === 0) {
return { message: "No products found", suggestions: getPopularProducts() };
}
return results[0];Problem:
// Cache never expires!
queryCache.set(query, results);Solution:
// Set TTL and max cache size
const MAX_CACHE_SIZE = 1000;
const CACHE_TTL = 5 * 60 * 1000;
if (queryCache.size >= MAX_CACHE_SIZE) {
const firstKey = queryCache.keys().next().value;
queryCache.delete(firstKey); // Remove oldest
}
queryCache.set(query, {
results,
timestamp: Date.now()
});Problem:
// Always use same weights for all queries
const results = await hybridSearch(query, { vectorWeight: 0.5, textWeight: 0.5 });Solution:
// Adapt weights to query type
const analysis = analyzeQuery(query);
const results = await hybridSearch(query, {
vectorWeight: analysis.weights.vector,
textWeight: analysis.weights.text
});You now understand:
✅ What hybrid search is and why it's powerful
✅ How to set up a vector database with multi-field indexing
✅ Score normalization techniques for fair combination
✅ Dynamic weight adjustment based on query patterns
✅ Fallback strategies for zero-result scenarios
✅ Performance optimization with caching and two-stage retrieval
- Experiment: Try different weight combinations
- Monitor: Track which queries are slow
- Optimize: Implement caching for popular queries
- Iterate: Adjust weights based on user feedback
- Scale: Add index partitioning for large catalogs
- Vector Search: Learn about HNSW and FAISS
- BM25 Algorithm: Understand the math behind keyword search
- Query Classification: Use ML to auto-detect query types
- Reranking: Add a third stage for fine-tuned ranking
Happy searching! 🚀