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.