Custom Scoring in Magento with Painless: Stock Level and Margin as Ranking Factors
AI generated
_doc
_index
Elasticsearch / Magento
Custom Scoring in Magento with Painless
Stock level and margin as ranking factors in storefront search

Elasticsearch's text based BM25 scoring evaluates relevance purely on text matching, ignoring business relevant signals such as stock level or margin. Painless scripts inside a function score query can close that gap, but only once the Magento integration point and the performance implications are properly understood.

12 min read Painless Function Score Magento CatalogSearch

1. Where Magento's default relevance scoring hits its limits

Magento uses BM25 based scoring for catalog search by default, taking text matching, field weighting, and term frequency into account, but knowing nothing about business metrics. Two products with identical text relevance end up in essentially random order in the result list, even when one of them is out of stock and the other carries strong availability plus a noticeably better margin.

For merchandising teams that is a recurring problem: out of stock or low margin products often land prominently in search results, while well stocked, high margin alternatives show up further down. A function score query with a Painless script lets you deliberately weave these signals into the ranking without pushing pure text relevance out of the picture entirely.

2. Painless basics: sandbox, typing, and performance characteristics

Painless is the scripting language shared by Elastic and OpenSearch, purpose built for search clusters with Java like syntax, running inside a strict sandbox that forbids access to the filesystem, the network, or arbitrary Java classes. That restriction is deliberate, so a faulty or malicious script cannot destabilize the cluster.

Unlike interpreted scripting languages, Painless compiles to bytecode on first execution and caches the result, so repeated calls to the same script come close to native Java performance. Accessing document fields happens through doc['fieldname'].value based on doc values, which is noticeably faster than reading through _source, but requires that field to actually have doc values enabled.

3. Structure of a function score query with script_score

A function score query wraps the original text search as query and adds one or more functions, of which script_score is the most flexible, since it can run arbitrary Painless logic against every document. boost_mode controls how the original text score combines with the script result, commonly multiply for a multiplicative boost or sum for an additive combination.

For Magento search results, multiply tends to work best in practice, because a product with high text relevance and good stock benefits noticeably more than a product with weak text relevance, even if both share the same stock factor. An additive combination would grant a constant bonus even to weak text relevance, diluting actual search relevance too much.


{
  "query": {
    "function_score": {
      "query": { "bool": { "must": [{ "match": { "name": "running shoe" } }] } },
      "functions": [{
        "script_score": {
          "script": {
            "source": "params.min_stock_boost + (doc['salable_quantity'].value > 0 ? 0.3 : 0.0)",
            "params": { "min_stock_boost": 1.0 }
          }
        }
      }],
      "boost_mode": "multiply",
      "score_mode": "sum"
    }
  }
}

4. Where the function score logic hooks into the Magento CatalogSearch query

Magento assembles the query sent to Elasticsearch or OpenSearch through \Magento\Elasticsearch\SearchAdapter\Query\Builder, driven by the configuration in search_request.xml. A dedicated function_score query is not a built in query type there by default, so the usual path is a plugin on the builder's build() method that wraps the already built query into a function score query afterwards.

The plugin approach has the advantage that the existing Magento query logic, meaning facets, filters, and layered navigation sorting, stays completely untouched, and only the final score component gets extended with the Painless script. Alternatively, a custom queryReference type can be registered in search_request.xml, offering more control but requiring noticeably more implementation effort.


<?php
declare(strict_types=1);

namespace Mironsoft\SearchScoring\Plugin;

use Magento\Elasticsearch\SearchAdapter\Query\Builder;

/**
 * Wraps the built Elasticsearch/OpenSearch query in a function_score query
 * that boosts salable, high margin products via a Painless script.
 */
class AddStockAndMarginScoringPlugin
{
    /**
     * Injects a script_score function around the query built by Magento.
     *
     * @param Builder $subject Original query builder.
     * @param array $result Query array as built by Magento core.
     * @return array Modified query array with function_score wrapper.
     */
    public function afterBuild(Builder $subject, array $result): array
    {
        $originalQuery = $result['query'] ?? ['match_all' => new \stdClass()];

        $result['query'] = [
            'function_score' => [
                'query' => $originalQuery,
                'functions' => [[
                    'script_score' => [
                        'script' => [
                            'source' => 'params.base + (doc[\'salable_quantity\'].value > 0 ? 0.3 : 0.0)'
                                . ' + (doc[\'margin_percent\'].size() > 0 ? doc[\'margin_percent\'].value / 100 : 0.0)',
                            'params' => ['base' => 1.0],
                        ],
                    ],
                ]],
                'boost_mode' => 'multiply',
                'score_mode' => 'sum',
            ],
        ];

        return $result;
    }
}

5. Practical example: stock level as a scoring factor

To use stock level as a ranking signal, the relevant field, such as salable_quantity, has to actually exist in the catalog index and be indexed with doc values, which in default Magento installations is usually already the case for salable quantity. The script then grants either a binary or a tiered bonus, depending on whether a finer gradation by stock depth is desired.

A tiered approach that weights products with more than ten units more heavily than products with just a single unit remaining reflects real availability risk more realistically than a plain yes-no bonus. It matters to cap the bonus at a sensible maximum value, so an extremely high stock level does not completely override text relevance.


// Painless script: tiered stock bonus instead of a binary evaluation
double stock = doc['salable_quantity'].size() > 0 ? doc['salable_quantity'].value : 0;
double stockBoost;
if (stock <= 0) {
    stockBoost = 0.0;
} else if (stock < 5) {
    stockBoost = 0.1;
} else if (stock < 20) {
    stockBoost = 0.25;
} else {
    stockBoost = 0.4;
}
return params.base + stockBoost;

6. Practical example: margin as a scoring factor combined with text relevance

Margin as an indexed attribute is more sensitive than stock level, since it is typically never meant for storefront output and should be secured via field-level security or a separate, non publicly readable role, provided the cluster uses access control at all. For scoring itself, a numeric attribute expressed as a percentage or a normalized value between zero and one is enough.

In practice, combining stock and margin boosts in a single script works well, with each factor only contributing a small fraction of the overall score, typically in the range of ten to thirty percent of the original text score. If the share is set too high, business signals dominate the search entirely and users stop finding the actually most relevant products for specific search terms.

7. Watching script caching and compile limits

Any inline script whose source text differs between requests, for example because parameters are embedded directly in the script text instead of in params, forces a recompilation and can exceed the cluster limit script.max_compilations_rate, causing errors across all search requests. The fix is to use only static scripts with variable params, as shown in the previous examples, so the same compiled bytecode gets reused across requests.

For frequently used scoring scripts it also pays to register a stored script once through the cluster's _scripts API and reference it by ID afterward, instead of sending it inline in the query body with every Magento request. That reduces network overhead and makes scoring changes centrally maintainable without touching the PHP code of the plugin.


# Register a stored script once on the cluster
curl -X PUT "https://search.example.com/_scripts/magento_stock_margin_boost" \
  -H "Content-Type: application/json" -d '{
    "script": {
      "lang": "painless",
      "source": "params.base + (doc[\"salable_quantity\"].value > 0 ? 0.3 : 0.0)"
    }
  }'

8. Performance caution for script based scoring on large catalogs

A script score runs for every single document that the original query already returns as a hit, not just for the results actually displayed. On a broad search request that returns several hundred thousand hits on a very large Magento catalog, the script can become the dominant cost factor of the whole request, even when a single execution only takes microseconds on its own.

It therefore matters to use as few fields as possible in the script, accessible exclusively through doc values, and to avoid complex computation such as string processing or deeply nested conditionals. In practice it pays to benchmark with the profiler API before a production rollout, to measure the actual extra time share of scoring versus pure text search, instead of relying on guesswork.

9. Testing and debugging custom scoring with the profiler and explain API

The explain API returns a complete breakdown for a single document of how the final score results from text relevance, the individual function score contributions, and the chosen boost_mode, which is essential when debugging unexpected rankings. Whenever a specific product complaint comes in about ranking too low, this lets you trace directly whether text relevance or the scoring script is the cause.

The profiler API adds detailed timing per query component, including the script share, and should be a fixed part of any load test before rolling out a new scoring script. A simple regression test that compares top hits for a set of representative search terms before and after a script change also prevents a well meant merchandising tweak from unintentionally degrading actual relevance for users.

Approach Text relevance considered Business signals Performance risk
Default BM25 without function score Yes, exclusively None No additional risk
Function score with field_value_factor Yes, combined multiplicatively A single numeric field Low, no script execution
Function score with script_score (Painless) Yes, combined with free configuration Arbitrarily complex logic, multiple fields Medium to high, depending on script complexity
Precomputed boost value as an index field Yes, combined via field_value_factor Arbitrarily complex, but computed at index time Low at search time, effort shifts to indexing

Mironsoft

Search index setup, relevance tuning, and Magento search

Magento search that shows the wrong products first?

We set up Elasticsearch or OpenSearch for Magento cleanly, tune relevance and facets to the actual catalog, and optimize indexing processes for large catalogs.

Relevance Tuning

Match search results and facets to actual customer needs.

Search Migration

Guide a clean migration from Solr or MySQL search to Elasticsearch/OpenSearch.

Index Performance

Make indexing processes for large catalogs reliable and performant.

10. Summary

Painless Custom Scoring

Magento entry point

Plugin on SearchAdapter\Query\Builder::build()

Recommended boost_mode

multiply, so text relevance does not get diluted

Most important performance rule

Only use doc values fields, parameterize scripts via params

Check before rollout

Explain and profiler API plus a regression test on top hits

11. FAQ: Painless Custom Scoring

1Do I have to use Painless for custom scoring?
For complex logic combining several fields, Painless is usually the only practical option. For simple cases involving just a single numeric field, the simpler field_value_factor function without a custom script is often enough.
2Where exactly do I hook function score logic into Magento?
A common approach is a plugin on the build() method of Magento\Elasticsearch\SearchAdapter\Query\Builder, which wraps the query generated by Magento into a function_score query afterward, without changing the existing filter and facet logic.
3How strong should the influence of stock level or margin be on scoring?
In practice a share of roughly ten to thirty percent of the original text score works well. A higher share lets business signals dominate actual search relevance too strongly.
4Why should I use params instead of hardcoded values in the script?
Only if the script source itself stays unchanged can Elasticsearch or OpenSearch reuse the compiled bytecode across requests. Hardcoded, changing values force a recompilation and can exceed the cluster's compile rate limit.
5What is the difference between doc['field'].value and accessing _source in a script?
doc['field'].value reads from doc values, a column oriented data structure optimized for scripts, and is noticeably faster than parsing _source, which requires JSON deserialization for every document.
6Can a faulty Painless script endanger the entire cluster?
No, the sandbox prevents access to the filesystem, the network, and arbitrary Java classes. A faulty script at worst causes an error on the affected search request, not instability of the cluster.
7Is a stored script worth it over an inline script?
Yes, especially for frequently used scoring scripts, a stored script reduces the data transferred per request and makes changes centrally maintainable without touching the PHP code of the Magento plugin.
8How do I actually measure the performance impact of a new scoring script?
The profiler API measures the script's time share against pure text search precisely for a single request. A load test with representative search terms before and after rollout shows the effect on the overall response time distribution.
9Can I secure margin data so it only feeds scoring but never appears in the search result?
Yes, the field gets evaluated for scoring inside the script but does not have to be part of the result fields returned to the storefront. Field-level security can additionally secure the field against direct cluster access.
10What is the alternative to function score if performance becomes a problem?
A precomputed boost value stored as its own numeric field during indexing can be included at search time through the noticeably cheaper field_value_factor function, shifting the computational cost from search time to indexing.