DEV Community

myougaTheAxo
myougaTheAxo

Posted on

Design Search Ranking with Claude Code: BM25, Personalization, Click-Through Learning

Introduction

'Top search result is actually unpopular' — combine BM25 text relevance with user behavior (clicks/purchases) for personalized search ranking. Let Claude Code design this.

CLAUDE.md Rules

## Search Ranking Rules
- Text relevance (60%): Elasticsearch BM25
- Popularity (25%): 30-day clicks/purchases
- Freshness (10%): decay score from listing date
- Personalization (5%): user browse history
- Popular queries: Redis cache 10 minutes
- Learning loop: 1-hour click aggregation
Enter fullscreen mode Exit fullscreen mode

Generated Implementation

const esQuery = {
  query: {
    function_score: {
      query: {
        multi_match: {
          query,
          fields: ['name^3', 'description^1', 'category^2'],
          fuzziness: 'AUTO',
        },
      },
      functions: [
        // Popularity (log scale to normalize large differences)
        { field_value_factor: { field: 'popularityScore', modifier: 'log1p', factor: 2.5 }, weight: 0.25 },
        // Freshness decay
        { gauss: { createdAt: { origin: 'now', scale: '30d', decay: 0.5 } }, weight: 0.10 },
        // Personalization: prefer user's top categories
        ...userPrefs.topCategories.map(cat => ({ filter: { term: { category: cat.name } }, weight: 0.05 * cat.weight })),
      ],
      boost_mode: 'multiply',
    },
  },
};

// Track clicks and update popularity
export async function trackSearchClick(params: { productId: string; userId?: string }) {
  const hourKey = Math.floor(Date.now() / 3_600_000);
  await redis.zincrby(`clicks:products:${hourKey}`, 1, params.productId);
  if (params.userId) {
    await redis.zadd(`user:history:${params.userId}`, Date.now(), params.productId);
  }
}

// Hourly popularity score update (exponential moving average)
await elasticsearch.bulk({ body: [
  { update: { _index: 'products', _id: productId } },
  { script: { source: 'ctx._source.popularityScore = ctx._source.popularityScore * 0.9 + params.clicks * 0.1', params: { clicks: clickCount } } },
] });
Enter fullscreen mode Exit fullscreen mode

Summary

  1. function_score: combine BM25 × popularity × freshness × personalization
  2. Redis Sorted Set: collect click events in 1-hour buckets
  3. Exponential moving average: 0.9 fade for old data, 0.1 weight for new
  4. Popular query cache: Redis 10min cache for top 100 queries

Review with **Code Review Pack (¥980)* at prompt-works.jp*

myouga (@myougatheaxo) — Axolotl VTuber.

Top comments (0)