NEW

Pulse 2025 Product Roundup: From Monitoring to AI-Native Control Plane

Elasticsearch script_score Query: Custom Scoring with Painless - Syntax, Example, and Tips

The Elasticsearch script_score query wraps an inner query and replaces its score with the value returned by a Painless script, evaluated per matching document. The script must return a non-negative double. It is the most flexible scoring tool in the DSL and supports built-in vector similarity functions (cosineSimilarity, dotProduct, l1norm, l2norm) for dense_vector fields. Use it when scoring requires custom math, field combinations, or a vector similarity contribution.

Syntax

{
  "query": {
    "script_score": {
      "query":     { /* inner query selects candidate docs */ },
      "script": {
        "source":  "<Painless expression returning a non-negative double>",
        "params":  { }
      },
      "min_score": 0.0,
      "boost":     1.0
    }
  }
}

Parameters

Parameter Type Default Description
query query - Inner query that selects candidate documents. Required.
script.source string - Painless expression returning a non-negative double. Required (or script.id).
script.params object {} Variables referenced inside the script as params.x. Keeps source stable so it caches.
min_score float - Exclude documents whose script score is below this value.
boost float 1.0 Multiplier applied to the final script score.

Inside the script:

  • _score - the inner query's BM25 score. Available only when the inner query produces a score.
  • doc['field'].value - doc-values access (cheap, columnar).
  • params._source.field - raw source access (expensive, avoid in scoring).
  • Vector functions: cosineSimilarity(params.queryVector, 'fieldName'), dotProduct(...), l1norm(...), l2norm(...).

Scripts that return negative values throw at query time. Wrap subtractions with Math.max(0, ...) if necessary.

Examples

Combine BM25 score with a field value:

GET /articles/_search
{
  "query": {
    "script_score": {
      "query":  { "match": { "title": "elasticsearch" } },
      "script": {
        "source": "_score + Math.log(1 + doc['views'].value)"
      }
    }
  }
}

Parameterized weighted blend:

GET /articles/_search
{
  "query": {
    "script_score": {
      "query":  { "match": { "title": "elasticsearch" } },
      "script": {
        "source": "params.a * _score + params.b * doc['popularity'].value",
        "params": { "a": 1.2, "b": 0.5 }
      }
    }
  }
}

Vector similarity over a dense_vector field with a +1.0 offset (scores must be non-negative):

GET /docs/_search
{
  "query": {
    "script_score": {
      "query":  { "match_all": {} },
      "script": {
        "source": "cosineSimilarity(params.query_vector, 'embedding') + 1.0",
        "params": { "query_vector": [0.1, 0.2, 0.3, /* ... */] }
      }
    }
  }
}

Time decay:

GET /events/_search
{
  "query": {
    "script_score": {
      "query":  { "match_all": {} },
      "script": {
        "source": "decayDateGauss(params.origin, params.scale, params.offset, params.decay, doc['ts'].value)",
        "params": {
          "origin": "now",
          "scale":  "30d",
          "offset": "0",
          "decay":  0.5
        }
      }
    }
  }
}

Performance and Use Notes

script_score runs the script once per document that matches the inner query. Cost is linear in inner-query match count, so the inner clause should already narrow the candidate set. Prefer doc values (doc['x'].value) over params._source - doc values are columnar and live in OS cache; _source parses JSON per document.

Always pass per-request values via params. Painless compiles each distinct source string and caches it (default 200 entries per context). If you template values into the script body, every request looks new and Elasticsearch will eventually throw circuit_breaking_exception for script compilation rate. With params, the same compiled script handles every request.

For simple boosts (decay, popularity, weight), the function_score query is faster and easier to maintain - it uses native functions instead of a script. Reach for script_score when scoring genuinely requires arbitrary math or vector similarity.

Custom scoring is a common origin of latency regressions and CPU spikes in Elasticsearch clusters. The manual triage - matching slow-log entries to deployed scoring scripts, checking whether each script is parameterized, deciding which could collapse into function_score recipes - is exactly the loop Pulse runs continuously.

Common Mistakes

  1. Returning a negative score - the script throws and the entire query fails. Use Math.max(0, ...) or shift the score by adding a constant (e.g. cosineSimilarity(...) + 1.0).
  2. Building the script source dynamically per request and triggering compilation rate limits.
  3. Reading params._source.field instead of doc['field'].value.
  4. Using _score inside the script when the inner query is match_all or in a filter context - _score is not meaningful there.
  5. Solving with script_score what function_score covers with a native function (decay, field_value_factor).

Find Slow script_score Queries with Pulse

Pulse is an AI DBA for Elasticsearch and OpenSearch that continuously profiles production query traffic. For script_score queries specifically, Pulse:

  • Identifies script_score queries whose inner clause matches too many documents, so the Painless body runs per-doc over an inflated candidate set
  • Flags scripts that template per-request values directly into source instead of using params, the pattern that thrashes the compiled-script cache and triggers circuit_breaking_exception at the script compilation rate limit
  • Detects params._source.field access on fields that have doc values available, plus negative-score throws caused by missing Math.max(0, ...) guards or vector helpers used without a +1.0 offset
  • Traces each slow script_score back to the calling service via slow-log and APM correlation
  • Recommends concrete rewrites: parameterize the script, collapse the scoring into function_score with field_value_factor or a decay function, narrow the inner query with a bool.filter, or move vector scoring into a dedicated kNN query with script_score reserved for rescoring
  • Tracks latency and script-compilation-rate improvement after the change

This converts the manual scoring-debug loop into a continuous optimization workflow.

Try Pulse on your cluster.

Frequently Asked Questions

Q: How do I access the inner query's BM25 score inside a script_score script?
A: Use the _score variable in Painless. It evaluates to the BM25 score from the inner query. If the inner query is match_all, _score is 1.0 and provides no useful signal.

Q: What is the difference between script_score query and function_score query?
A: function_score composes a small set of native scoring functions (decay, field_value_factor, random_score, weight, script_score) with explicit score_mode and boost_mode. The standalone script_score query lets a Painless script compute the score directly. Use function_score for standard recipes; use script_score when scoring is pure custom math or includes vector similarity.

Q: Can script_score return a negative number?
A: No. Elasticsearch requires non-negative scores. If your math can go negative, wrap with Math.max(0, ...) or shift by adding a constant. The vector similarity helpers (cosineSimilarity, dotProduct) can return negative values, so callers typically add +1.0.

Q: How can I avoid script compilation rate-limit errors with script_score?
A: Move all per-request values into params so the source string stays constant across requests. Elasticsearch caches a limited number of compiled scripts per context; changing source on every request thrashes the cache and triggers compilation circuit breaker exceptions.

Q: Does script_score support vector similarity?
A: Yes. Painless exposes cosineSimilarity, dotProduct, l1norm, and l2norm for dense_vector fields. For pure vector retrieval, the dedicated knn query is faster because it uses HNSW; use script_score for vector-based rescoring of a smaller candidate set.

Q: Why is my script_score query slow?
A: Typical causes are an inner query that matches too many documents, _source access inside the script, or a non-parameterized source that defeats compilation caching. Narrow the inner query, switch to doc values, and pass variables via params.

Q: What is the best tool to find slow script_score queries in production?
A: Pulse profiles Elasticsearch and OpenSearch slow logs, isolates script_score queries whose inner clause matches too many documents or whose source is not parameterized, attributes each to the calling service, and recommends a params rewrite, a function_score replacement, or a narrower inner query.

Subscribe to the Pulse Newsletter

Get early access to new Pulse features, insightful blogs & exclusive events , webinars, and workshops.

We use cookies to provide an optimized user experience and understand our traffic. To learn more, read our use of cookies; otherwise, please choose 'Accept Cookies' to continue using our website.