./ahmedhashim

Managing Search Latency

The product search has come a long way since BM25 did all the ranking on its own. Business signals reorder the text matches now, and a hybrid query runs a second retrieval path through the vector index before fusing the lists. Each addition earned its place on the page, with the trade-off being a slightly more expensive query.

An expensive query is easy to live with on a small index, but as the catalog grows the arithmetic turns against you. A broad query that matched forty thousand products last year matches four hundred thousand today, and the p95 creeps upward without any single deploy to blame.

Most of the creep traces back to a single decision: ranking math that runs per query when it could run once per document. The fix is to precompute that math and store the result on each document, so the query reads a number instead of computing one.

Where the time goes

The layered ranking query runs four function_score functions: an in-stock bonus, a popularity curve on amount_sold, a freshness decay on date_added, and a price decay when the shopper gives an anchor. Each function runs for every matching document on every shard, before the top ten exists. Four functions against a broad query like record cabinet that matches 400,000 products is 1.6 million evaluations spent producing one page of results.

The cost scales with matches, not with page size. Narrow queries with tight filters stay fast at any scale. The expensive ones are the vague two-word searches that half the catalog matches, which is unfortunate, because those are also the most common.

There’s a second cost that doesn’t show up in a profiler. When scores come from index statistics alone, Lucene can skip whole blocks of documents that can’t reach the top of the page (block-max WAND). Wrapping the query in function_score turns that off. The engine can’t prove that a weak text match won’t be rescued by a popularity boost, so it has to score everything. If you’ve reached for script_score, both problems get worse, because now every match runs a script too.

Denormalization

Look at those four functions again. Only the price decay depends on the query. The other three produce the same number for record cabinet as for walnut console, for every shopper, on every page. Computing them per query is recomputing a constant.

So compute the constant once and store it. This is the same denormalization trade you’d make in a database: spend write-time work and a little storage so reads don’t repeat it.

PUT /products_semantic/_mapping
{
  "properties": {
    "ranking_score":       { "type": "rank_feature" },
    "ranking_computed_at": { "type": "date" }
  }
}

rank_feature is built for this: a positive number stored so the engine can use it inside relevance scoring. A regular float scored through field_value_factor would also work, but it puts you back in function territory where every match gets scored. A rank_feature clause keeps the skip optimizations alive, because the engine can rule out non-competitive documents from the indexed values.

The update path looks like this:

  flowchart LR
    E["<strong>Feature change</strong><br/><span class='mermaid-detail'>price, stock, materials</span>"]
    T["<strong>Timer</strong><br/><span class='mermaid-detail'>every 6 hours</span>"]
    U["<strong>_update</strong><br/><span class='mermaid-detail'>one document</span>"]
    UBQ["<strong>_update_by_query</strong><br/><span class='mermaid-detail'>stale documents</span>"]
    F["<strong>ranking_score</strong><br/><span class='mermaid-detail'>rank_feature field</span>"]
    S["<strong>Search</strong><br/><span class='mermaid-detail'>reads one number</span>"]

    E --> U --> F
    T --> UBQ --> F
    F --> S

Two triggers

Put the formula in a stored script, the same machinery as Painless Pipelines:

PUT /_scripts/products-ranking-score-v1
{
  "script": {
    "lang": "painless",
    "source": """
      double score = 0.0;

      if (ctx._source.stock_available != null &&
          ctx._source.stock_available > 0) {
        score += 2.0;
      }

      if (ctx._source.amount_sold != null) {
        score += Math.log1p(ctx._source.amount_sold * 0.15);
      }

      if (ctx._source.date_added != null) {
        long added = ZonedDateTime.parse(ctx._source.date_added)
          .toInstant().toEpochMilli();
        double ageDays = (params.now_ms - added) / 86400000.0;
        double distance = Math.max(0.0, ageDays - 7.0);
        score += 1.25 * Math.pow(0.5, Math.pow(distance / 30.0, 2));
      }

      ctx._source.ranking_score = Math.max(score, 0.001);
      ctx._source.ranking_computed_at = params.now;
    """
  }
}

The math mirrors the function_score version from the layered post: the same 2.0 stock bonus and log1p popularity curve, and the freshness line is the gauss decay written out by hand (origin: now, offset: 7d, scale: 30d, decay: 0.5). The floor on the last line matters because rank_feature rejects zero and negative values, and a stale product with no sales can otherwise underflow to exactly that.

The first trigger is a schedule. Freshness moves with the calendar whether or not the document changes, so a periodic job recomputes stale scores. This is the _update_by_query pattern from the pipelines post, pointed at a different script:

POST /products_semantic/_update_by_query?conflicts=proceed&wait_for_completion=false&slices=auto
{
  "query": {
    "bool": {
      "should": [
        { "bool": { "must_not": { "exists": { "field": "ranking_score" } } } },
        { "range": { "ranking_computed_at": { "lt": "now-6h" } } }
      ],
      "minimum_should_match": 1
    }
  },
  "script": {
    "id": "products-ranking-score-v1",
    "params": {
      "now": "2025-04-19T08:00:00Z",
      "now_ms": 1745049600000
    }
  }
}

The runner fills both params with the current time: the ISO string for the audit field and epoch milliseconds for the math. With a 30-day scale on the decay, six hours of staleness moves the freshness term by a rounding error. The cadence only has to outrun your fastest-moving time signal, not your indexing rate.

The second trigger is an event. When the application changes a feature that feeds the score, a price correction or a stock adjustment, it refreshes that one document with the same stored script:

POST /products_semantic/_update/vinyl_record_cabinet_v3
{
  "script": {
    "id": "products-ranking-score-v1",
    "params": {
      "now": "2025-04-19T08:00:00Z",
      "now_ms": 1745049600000
    }
  }
}

One formula lives in one place. The timer covers drift from the calendar and the event path covers changes to the data, so neither can disagree about what the score should be.

The part that pays off later is that the formula can now grow without touching latency. Fold in a margin curve next quarter, or replace the whole thing with an offline model’s output. Query time still reads one number per document. The complexity budget moved from your p95 to an update pipeline that runs off the critical path.

Query the score

At query time, the functions array collapses into a single rank_feature clause. In the lexical branch of the hybrid query from the last post:

POST /products_semantic/_search?search_pipeline=products-rrf
{
  "size": 10,
  "_source": {
    "excludes": ["search_embedding"]
  },
  "query": {
    "hybrid": {
      "queries": [
        {
          "bool": {
            "must": [
              {
                "multi_match": {
                  "query": "vinyl storage console",
                  "fields": ["title^4", "description"],
                  "type": "best_fields"
                }
              }
            ],
            "should": [
              {
                "rank_feature": {
                  "field": "ranking_score",
                  "boost": 2.0,
                  "saturation": {
                    "pivot": 3
                  }
                }
              }
            ],
            "filter": [
              {
                "range": {
                  "stock_available": {
                    "gt": 0
                  }
                }
              }
            ]
          }
        },
        {
          "knn": {
            "search_embedding": {
              "vector": [0.012, -0.034, 0.008],
              "k": 100,
              "filter": {
                "range": {
                  "stock_available": {
                    "gt": 0
                  }
                }
              }
            }
          }
        }
      ]
    }
  }
}

The same clause drops into the plain bool query from the layered post if hybrid retrieval isn’t in the picture. Either way the query got simpler: match and filter, plus one bounded boost.

Bounded is the useful word. The default saturation function maps the stored score into a contribution between zero and boost, with diminishing returns as scores climb, and a document at the pivot earns exactly half the boost. That’s the job log1p and max_boost were doing in the live function_score, now built into the field’s scoring shape. If you leave the pivot out, the engine derives one from the index’s score distribution. I prefer to set it, for the same reason as rank_constant in the RRF pipeline: the ranking contract shouldn’t shift because the data distribution did. Pick something near the median stored score and tune from judged queries.

One storage note: rank_feature keeps about nine significant bits of precision, a relative error around 0.4%. That’s nothing for ranking, but don’t read the field back and treat it as data.

Rescore what’s left

The price anchor can’t be precomputed because it comes from the shopper. It doesn’t need to run against every match either. A rescore block applies expensive scoring to only the top candidates from the cheap pass:

POST /products_semantic/_search
{
  "size": 10,
  "query": {
    "bool": {
      "must": [
        {
          "multi_match": {
            "query": "walnut record cabinet",
            "fields": ["title^4", "description"],
            "type": "best_fields"
          }
        }
      ],
      "should": [
        {
          "rank_feature": {
            "field": "ranking_score",
            "boost": 2.0,
            "saturation": { "pivot": 3 }
          }
        }
      ],
      "filter": [
        { "range": { "stock_available": { "gt": 0 } } }
      ]
    }
  },
  "rescore": {
    "window_size": 200,
    "query": {
      "rescore_query": {
        "function_score": {
          "functions": [
            {
              "gauss": {
                "price": {
                  "origin": 1200,
                  "scale": 300,
                  "decay": 0.5
                }
              },
              "weight": 0.75
            }
          ]
        }
      }
    }
  }
}

The gauss decay now runs 200 times per shard instead of once per match. The window is the knob: wide enough that the right documents can be reordered onto the page, narrow enough to stay cheap. For a ten-result page, a window of 200 is a comfortable start. And if the document that deserved rank one sits outside the top 200 of the base ranking, the problem is recall, and no rescore window was going to fix it.

Smaller wins

A few smaller habits compound with the big one.

Filters belong in filter. Clauses there skip scoring and their bitsets are cached across queries, so the stock_available range costs almost nothing by the second time a query uses it. The previous posts already lean on this, and it’s worth protecting in code review: a yes-or-no condition that sneaks into must quietly becomes scoring work.

Don’t count what you won’t show. track_total_hits stops counting at 10,000 by default. Leave it there, or lower it, unless something genuinely needs exact totals, because an exact count has to visit every match. That’s the function-score scaling problem all over again.

Page with search_after instead of deep from offsets. A from of 990 makes every shard build and merge a thousand-entry queue for one page of ten. Almost nobody is on page 100 on purpose, but crawlers are.

Keep _source lean. The hybrid queries already exclude search_embedding from responses, and any other fat stored field the result card doesn’t render can come out too.

Shard geometry matters too, and it deserves a post of its own, but the short version is that a handful of tens-of-gigabyte shards beats a pile of small ones. Every extra shard adds query fan-out and another set of top-k queues to merge.

Evaluation

Two checks before trusting any of this: is it faster, and is it still right.

Latency first. The took field on every response is the cheap signal, and the profile API breaks a slow query down by phase when you need to know where the time went. Measure percentiles under production-like concurrency. A single warm query on an idle cluster flatters every cache and proves nothing about p99.

Relevance second. Moving math from query time to index time is still a ranking change. The saturation curve shapes scores differently than the additive score_mode: sum, and stored scores lag live ones by the recompute cadence. Run the judged-query loop against the old and new queries and expect nDCG@10 to land within noise. When it doesn’t, retune boost and pivot until it does. Once it ships, the online metrics close the loop. Abandonment doesn’t distinguish between a bad page and a slow one. Shoppers leave either way.

The pattern under all of it is paying at write time for what you’d otherwise pay on every read. A search cluster serves far more queries than updates, so any constant you can move across that line gets amortized thousands of times over. The catalog will keep growing. With the ranking math baked into the index, the query doesn’t have to grow with it.