Skip to content
← Search · intermediate · 9 min · 05 / 06

Search in Practice

Relevance tuning, synonyms, personalization, analytics, and the operational patterns that keep search working well over time.

relevance tuningsynonymsanalyticsA/B testingsearch qualityclick-through rate

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`);
    }
  }
}