05d — Custom relevance scoring

BM25 measures textual similarity. Business relevance often adds non-textual signals: product rating, recency, geographic proximity, promotion priority. This part covers function_score and script_score — the two tools for blending text relevance with business logic.


Goals for this part


5.6 function_score and script_score — custom relevance

5.6.1 Why custom relevance?

BM25 measures textual similarity. Business relevance often adds non-textual signals: product rating, recency, geographic proximity, promotion priority, popularity. function_score and script_score let you blend these signals mathematically.

5.6.2 function_score

function_score wraps a base query and applies one or more scoring functions. The final score is controlled by boost_mode (how function scores combine with the base _score) and score_mode (how multiple functions combine with each other).

GET /shop-orders-demo/_search
{
  "query": {
    "function_score": {
      "query": {
        "match": { "notes": "damaged packaging" }
      },
      "functions": [
        {
          "field_value_factor": {
            "field": "rating",
            "factor": 1.5,
            "modifier": "log1p",
            "missing": 1
          }
        },
        {
          "gauss": {
            "created_at": {
              "origin": "now",
              "scale":  "30d",
              "offset": "7d",
              "decay":  0.5
            }
          }
        },
        {
          "gauss": {
            "delivery.location": {
              "origin": "50.0755,14.4378",
              "scale":  "50km",
              "offset": "5km",
              "decay":  0.5
            }
          }
        },
        {
          "filter": { "term": { "tags": "priority" } },
          "weight": 3.0
        }
      ],
      "score_mode": "multiply",
      "boost_mode": "multiply",
      "max_boost": 10
    }
  }
}

field_value_factor parameters:

Parameter Effect
field Numeric field to use
factor Multiplier applied to the field value
modifier none, log, log1p, log2p, ln, sqrt, square, reciprocal — guards against extreme values
missing Value to use when the field is absent

Decay functions (gauss, linear, exp):

Function Decay shape Use case
gauss Bell curve Smooth falloff around origin; natural for recency and proximity
linear Straight line to zero Hard cutoff at scale * 2
exp Exponential decay Faster initial falloff; punishes distance more aggressively

score_mode (how the list of function scores combine): multiply, sum, avg, first, max, min.

boost_mode (how combined function score interacts with base _score): multiply, replace, sum, avg, max, min.

Tip: Use "boost_mode": "sum" when you want the function score to add to relevance rather than override it — useful when text match should still matter even if the item is not perfectly boosted.

5.6.3 script_score

When the built-in functions are not expressive enough, use script_score with a Painless script:

GET /shop-orders-demo/_search
{
  "query": {
    "script_score": {
      "query": {
        "match": { "notes": "missing item" }
      },
      "script": {
        "source": """
          double rating = doc['rating'].size() > 0 ? doc['rating'].value : 1.0;
          double recencyBoost = 1.0;
          if (doc['created_at'].size() > 0) {
            long ageMs = System.currentTimeMillis() - doc['created_at'].value.toInstant().toEpochMilli();
            long ageDays = ageMs / 86400000L;
            recencyBoost = Math.max(0.1, 1.0 / (1.0 + ageDays / 30.0));
          }
          return _score * Math.log(2 + rating) * recencyBoost;
        """
      }
    }
  }
}

Rules for performant Painless scripts:

Rule Reason
Use doc['field'].value, not params._source['field'] doc values are loaded from in-memory columnar storage; _source requires JSON parsing per document
Always guard missing values with .size() > 0 Accessing .value on an empty field throws a runtime exception and fails the query
Avoid complex string manipulation in scripts String ops disable query caching and are CPU-intensive
Pre-compute constants into params Script is compiled once per unique parameter set; parameterize values instead of hardcoding

Parameterized version (recommended):

{
  "query": {
    "script_score": {
      "query": { "match": { "notes": "missing item" } },
      "script": {
        "source": "return _score * Math.log(2 + (doc['rating'].size() > 0 ? doc['rating'].value : params.default_rating));",
        "params": {
          "default_rating": 1.0
        }
      }
    }
  }
}

5.6.4 function_score vs script_score — when to use which

function_score script_score
Best for Standard boosting patterns (recency, geo, popularity) Arbitrary multi-signal combinations, business-specific formulas
Performance Faster — built-in functions are JVM-native Slower — Painless script interpreted per document
Caching Built-in functions participate in query cache Scripts with params are cached; ad-hoc scripts are not
Maintainability Declarative JSON, easier to audit Code, requires testing and review

Pitfall: Scores from script_score must be non-negative. If your formula can produce negative values, Elasticsearch will throw an error. Guard with Math.max(0, ...).

Pitfall: function_score with random_score uses the shard ID and document ID by default. For consistent random ordering across paginated results, provide a fixed seed and field parameter — otherwise the order changes on each query.

{
  "random_score": {
    "seed": 42,
    "field": "_seq_no"
  }
}

Lab — Custom relevance with function_score and script_score

Goal: Build and compare custom scoring queries; inspect scores with the explain API.

Step 1 — Write a function_score query matching notes for “delayed” that boosts by: - field_value_factor on rating (modifier log1p, factor 2.0, missing: 1) - A gauss decay on created_at (origin now, scale 14d, offset 2d, decay 0.5) - A weight of 5.0 for documents tagged "priority"

Use "boost_mode": "sum" and "score_mode": "sum":

GET /shop-orders-demo/_search
{
  "query": {
    "function_score": {
      "query": {
        "match": { "notes": "delayed" }
      },
      "functions": [
        {
          "field_value_factor": {
            "field": "rating",
            "factor": 2.0,
            "modifier": "log1p",
            "missing": 1
          }
        },
        {
          "gauss": {
            "created_at": {
              "origin": "now",
              "scale":  "14d",
              "offset": "2d",
              "decay":  0.5
            }
          }
        },
        {
          "filter": { "term": { "tags": "priority" } },
          "weight": 5.0
        }
      ],
      "score_mode": "sum",
      "boost_mode": "sum"
    }
  },
  "size": 5
}

Note the _id of the top-ranked document.

Step 2 — Change boost_mode to "multiply" and compare the ranking. Which boost_mode makes the text relevance signal matter more?

Step 3 — Use the explain API to inspect the score breakdown for the top document from Step 1:

GET /shop-orders-demo/_explain/<_id-from-step-1>
{
  "query": {
    "function_score": {
      "query": {
        "match": { "notes": "delayed" }
      },
      "functions": [
        {
          "field_value_factor": {
            "field": "rating",
            "factor": 2.0,
            "modifier": "log1p",
            "missing": 1
          }
        },
        {
          "gauss": {
            "created_at": {
              "origin": "now",
              "scale":  "14d",
              "offset": "2d",
              "decay":  0.5
            }
          }
        },
        {
          "filter": { "term": { "tags": "priority" } },
          "weight": 5.0
        }
      ],
      "score_mode": "sum",
      "boost_mode": "sum"
    }
  }
}

Questions: 1. What was the BM25 base _score before function boosting? 2. What was the contribution of the decay function? 3. What was the final combined score?

Step 4 — Rewrite the same scoring logic as a script_score query in Painless. Guard all field accesses against missing values:

GET /shop-orders-demo/_search
{
  "query": {
    "script_score": {
      "query": {
        "match": { "notes": "delayed" }
      },
      "script": {
        "source": """
          double ratingBoost = doc['rating'].size() > 0
            ? Math.log1p(doc['rating'].value * params.rating_factor)
            : Math.log1p(params.default_rating * params.rating_factor);

          double decayBoost = 1.0;
          if (doc['created_at'].size() > 0) {
            long ageMs = System.currentTimeMillis() - doc['created_at'].value.toInstant().toEpochMilli();
            long ageDays = ageMs / 86400000L;
            if (ageDays > 2) {
              decayBoost = Math.pow(0.5, (ageDays - 2.0) / 14.0);
            }
          }

          return Math.max(0, _score + ratingBoost + decayBoost);
        """,
        "params": {
          "rating_factor": 2.0,
          "default_rating": 1.0
        }
      }
    }
  },
  "size": 5
}

Step 5 — Compare the top 5 results from the function_score query (Step 1) and the script_score query (Step 4). Are the orderings the same? If not, explain why (hint: the scoring formulas are mathematically similar but not identical).

Step 6 — Use _explain again on the same document with the script_score query. How does the explain output differ from the function_score explain in Step 3?

Takeaway: function_score covers most production relevance needs declaratively and faster. Reach for script_score only when you need logic that the built-in functions cannot express — and always profile the impact on query latency with "profile": true. Both require careful thought about whether text relevance (_score) should add to or multiply with business signals.