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_scoreandscript_score— the two tools for blending text relevance with business logic.
Goals for this part
- Understand when and why to override the default BM25 score
- Use
function_scorewithfield_value_factor, decay functions, and weight filters - Write a
script_scorequery in Painless with safe field access patterns - Choose between
function_scoreandscript_scorefor a given use case
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_scoremust be non-negative. If your formula can produce negative values, Elasticsearch will throw an error. Guard withMath.max(0, ...).
Pitfall:
function_scorewithrandom_scoreuses the shard ID and document ID by default. For consistent random ordering across paginated results, provide a fixedseedandfieldparameter — 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.