Search in Practice
Relevance tuning, synonyms, personalization, analytics, and the operational patterns that keep search working well over time.
Real-World Analogy
A new reference librarian vs a seasoned one: both can answer “where is the book on databases?” but the seasoned one knows that when people ask for “databases,” they usually want relational databases, not data warehouses — and automatically guides them there. Search relevance tuning is teaching the search engine what your seasoned librarian already knows.
Measuring Search Quality
You can’t improve what you don’t measure. Track:
Click-through rate (CTR): what percentage of searches result in a click?
// Log search events
async function trackSearch(query: string, userId: string, results: SearchResult[]) {
await db.query(
`INSERT INTO search_events (query, user_id, result_ids, searched_at)
VALUES ($1, $2, $3, NOW())`,
[query, userId, results.map(r => r.id)]
);
}
// Log click events
async function trackClick(query: string, userId: string, clickedId: string, position: number) {
await db.query(
`INSERT INTO search_clicks (query, user_id, clicked_id, position, clicked_at)
VALUES ($1, $2, $3, $4, NOW())`,
[query, userId, clickedId, position]
);
}
// CTR query
const ctr = await db.query(`
SELECT
e.query,
COUNT(DISTINCT e.id) AS searches,
COUNT(DISTINCT c.id) AS clicks,
COUNT(DISTINCT c.id)::float / COUNT(DISTINCT e.id) AS ctr
FROM search_events e
LEFT JOIN search_clicks c ON c.query = e.query
AND c.user_id = e.user_id
AND c.clicked_at > e.searched_at
AND c.clicked_at < e.searched_at + INTERVAL '5 minutes'
WHERE e.searched_at > NOW() - INTERVAL '7 days'
GROUP BY e.query
HAVING COUNT(DISTINCT e.id) > 10
ORDER BY ctr ASC -- lowest CTR = worst performing queries
LIMIT 50
`); Low CTR queries are your worst performers — users search, see results, click nothing. These are the highest-value queries to fix.
Mean Reciprocal Rank (MRR): how high is the clicked result?
// MRR = average of (1 / position of first click)
const mrr = await db.query(`
SELECT AVG(1.0 / c.position) AS mrr
FROM search_clicks c
WHERE c.clicked_at > NOW() - INTERVAL '7 days'
AND c.position <= 10
`);
// MRR of 1.0 = always clicking first result
// MRR of 0.5 = average first click at position 2 Zero-results rate: what percentage of searches return no results?
const zeroResults = await db.query(`
SELECT
query,
COUNT(*) AS searches
FROM search_events
WHERE result_count = 0
AND searched_at > NOW() - INTERVAL '7 days'
GROUP BY query
ORDER BY searches DESC
LIMIT 50
`); Zero-results queries reveal gaps: missing products, missing synonyms, or very specific queries that need fuzzy matching.
Synonyms
Users say “couch,” you have “sofa.” Users say “laptop,” you have “notebook computer.”
// Meilisearch synonyms
await index.updateSettings({
synonyms: {
'couch': ['sofa', 'settee', 'loveseat'],
'laptop': ['notebook', 'portable computer'],
'tv': ['television', 'monitor', 'screen'],
'cellphone': ['mobile', 'smartphone', 'phone'],
},
});
// Elasticsearch synonyms (more powerful — supports one-way and multi-way)
// In analyzer settings:
filter: {
synonym_filter: {
type: 'synonym',
synonyms: [
'couch, sofa, settee => couch', // normalize to one term
'laptop, notebook, portable computer', // multi-way (bidirectional)
'tv => television, tv', // expand tv to both
],
},
}, Build synonyms from analytics — if users frequently search for X and click a result for Y, X and Y might be synonyms.
Query Rules (Curated Results)
Business-curated results that override relevance for specific queries:
// Meilisearch query rules
// Pin a specific product to position 1 for "macbook"
// (not directly supported in Meilisearch — use result boosting)
// Elasticsearch — pin documents at the top of results
const results = await es.search({
index: 'products',
query: {
pinned: {
ids: ['prod-123', 'prod-456'], // always first
organic: {
multi_match: {
query: 'macbook',
fields: ['name^3', 'description'],
},
},
},
},
}); Implement query rules in a database table:
CREATE TABLE search_rules (
id UUID PRIMARY KEY,
query TEXT NOT NULL, -- exact query to match
action TEXT NOT NULL, -- 'pin', 'boost', 'hide', 'redirect'
target_ids TEXT[], -- for pin/boost/hide
redirect TEXT, -- for redirect action
priority INT DEFAULT 0
);
-- When searching for "macbook" → pin prod-123 to top
INSERT INTO search_rules (query, action, target_ids)
VALUES ('macbook', 'pin', ARRAY['prod-123']); Personalization
Boost results based on user behavior:
async function personalizedSearch(userId: string, query: string) {
// Get user's preferred categories based on purchase history
const preferences = await db.query(
`SELECT category, COUNT(*) AS count
FROM orders
JOIN order_items USING (order_id)
JOIN products USING (product_id)
WHERE user_id = $1
AND created_at > NOW() - INTERVAL '90 days'
GROUP BY category
ORDER BY count DESC
LIMIT 5`,
[userId]
);
// Build category boost function
const categoryBoosts = preferences.rows.map((p, i) => ({
filter: { term: { category: p.category } },
weight: 3 - (i * 0.5), // 3x, 2.5x, 2x, 1.5x, 1x
}));
return es.search({
index: 'products',
query: {
function_score: {
query: {
multi_match: {
query,
fields: ['name^3', 'description'],
},
},
functions: [
...categoryBoosts,
// Also boost recently viewed
{
filter: { terms: { id: await getRecentlyViewed(userId) } },
weight: 1.5,
},
],
score_mode: 'multiply',
boost_mode: 'multiply',
},
},
});
} Personalization is powerful but adds latency (extra DB query per search). Cache user preferences for a few minutes.
A/B Testing Relevance
Don’t guess which ranking is better — measure it:
// Assign users to variants
function getSearchVariant(userId: string): 'control' | 'treatment' {
const hash = parseInt(
createHash('md5').update(userId).digest('hex').slice(0, 8), 16
);
return hash % 2 === 0 ? 'control' : 'treatment';
}
// Search with variant-specific settings
async function abSearch(userId: string, query: string) {
const variant = getSearchVariant(userId);
const searchParams = variant === 'control'
? { rankingRules: ['words', 'typo', 'proximity', 'attribute', 'exactness'] }
: { rankingRules: ['words', 'typo', 'attribute', 'proximity', 'exactness'] };
// Treatment: attribute before proximity — test if it improves CTR
const results = await index.search(query, searchParams);
// Log variant for analysis
await trackSearch(query, userId, results.hits, { variant });
return results;
}
// After 1 week: compare CTR between control and treatment
const comparison = await db.query(`
SELECT
variant,
COUNT(DISTINCT e.id) AS searches,
COUNT(DISTINCT c.id) AS clicks,
COUNT(DISTINCT c.id)::float / COUNT(DISTINCT e.id) AS ctr
FROM search_events e
LEFT JOIN search_clicks c ON c.query = e.query AND c.user_id = e.user_id
GROUP BY variant
`); Handling “No Results”
Never show a blank “no results” page:
async function searchWithFallback(query: string) {
// First: exact search
let results = await index.search(query, {
filter: ['in_stock = true'],
});
if (results.hits.length > 0) return { results, mode: 'exact' };
// Fallback 1: relax filters
results = await index.search(query, {});
if (results.hits.length > 0) return { results, mode: 'relaxed_filters' };
// Fallback 2: fuzzy / partial terms
const tokens = query.split(' ').filter(t => t.length > 3);
if (tokens.length > 1) {
results = await index.search(tokens.slice(0, 2).join(' '));
if (results.hits.length > 0) return { results, mode: 'partial_query' };
}
// Fallback 3: popular items in the queried category
const popularItems = await db.query(
'SELECT * FROM products WHERE in_stock = true ORDER BY popularity DESC LIMIT 20'
);
return {
results: { hits: popularItems.rows },
mode: 'popular_fallback',
suggestions: await getSuggestions(query),
};
} Operational Checklist
□ Index monitoring: track index size, doc count, search latency
□ Zero-results monitoring: alert if > 10% of queries return 0 results
□ Sync monitoring: alert if Meilisearch lags database by > 5 minutes
□ Weekly: review bottom 50 queries by CTR
□ Monthly: review zero-results query list → add synonyms or missing products
□ After deploys: verify search still returns expected results (smoke test)
□ Search analytics dashboard: CTR, MRR, zero-results rate, query volume // Smoke test after deploy
async function searchSmokeTest() {
const testCases = [
{ query: 'laptop', expectedMinResults: 10 },
{ query: 'macbook pro', expectedFirstId: 'prod-123' },
{ query: 'iphone', expectedCategory: 'phones' },
];
for (const tc of testCases) {
const results = await index.search(tc.query, { limit: 1 });
if (results.hits.length < (tc.expectedMinResults ?? 1)) {
throw new Error(`Search smoke test failed: "${tc.query}" returned too few results`);
}
}
}