Layered Search Ranking
When writing your custom ranking function, most of your time will be spent splitting out your text relevance and business signals. Usually your formula won’t be too hard to figure out.
Let’s say you have a products index with documents like these about furniture:
{
"title": "Vinyl Record Cabinet",
"description": "Mid-century inspired storage cabinet for vinyl records with sliding doors and adjustable shelves.",
"date_added": "2024-10-01T00:00:00Z",
"price": 1249.0,
"materials": "walnut, iron",
"stock_available": 12,
"amount_sold": 184
}
With text relevance, you’re trying to figure out whether the user’s query is related to this product. Business signals help you figure out which of these products you want to boost. If you mix the two together too early, you’ll begin to see some funny things in your search results. Maybe your cheaper products begin to outrank good text matches, or something generic that a lot of people buy comes up higher than what they typed in.
My recommendation when working with OpenSearch is this:
- Allow BM25 to score your text fields first.
boostingqueries can help you penalize what you don’t want to see, without excluding it.- Then throw in any bounded signals you have (like popularity or freshness) using
function_score, and add stock availability to the mix as well.
Lexical relevance
The base query should just focus on text matching.
You should boost title higher than description for most catalogs, since titles usually have fewer words and get to the point. If a user is searching for record cabinet, they’d want a product that has it in the title, not one that has it somewhere in the description.
Something along these lines:
{
"multi_match": {
"query": "record cabinet",
"fields": ["title^4", "description"],
"type": "best_fields"
}
}
I tend to boost materials only when I’m able to. If you know what material the user is searching for, add a small should clause for it. If not, ignore it. Boosting it globally will mess with generic queries.
So we’d give walnut record cabinet a should clause for materials, but not record storage cabinet. This would generate a bool query like this:
{
"bool": {
"must": [
{
"multi_match": {
"query": "walnut record cabinet",
"fields": ["title^4", "description"],
"type": "best_fields"
}
}
],
"should": [
{
"match": {
"materials": {
"query": "walnut",
"boost": 2.0
}
}
}
]
}
}
Boosting
You can use boosting to still let documents match, but with a lower score.
Take a product that you don’t have in stock. If a user is looking for something very specific, it could be better to show it than nothing at all. However, if you do have a similar product in stock, you should show that one most of the time.
This is what boosting can do for you. Your document will still need to meet the positive query (in this case, the bool query above), but if it also meets the negative query, its score is multiplied by negative_boost.
{
"boosting": {
"positive": { ... },
"negative": {
"range": {
"stock_available": {
"lte": 0
}
}
},
"negative_boost": 0.2
}
}
A value of 0.2 for negative_boost will drop the score significantly. I like to use somewhere between 0.1 and 0.3 and compare the results on real queries. If your stock_available field is a boolean in your index rather than a count, you should use a term query for your negative query:
"negative": {
"term": {
"stock_available": false
}
}
Function score
Once you have a query that returns textually relevant results, you can begin to apply function_score for your business rules.
There are a few functions OpenSearch provides you:
weightallows you to multiply your score by a given number, which is good enough for simple rules.- You can use
field_value_factorto get a score from a number field. If you have something likeamount_sold, this can be helpful. - There are a few decay functions you can use, such as
gaussandexp. These will decrease the score the further a value is from the origin, which is good for dates or numbers. random_scorewill give you a score that’s somewhat random, which is good if you’re trying to build a discovery type of page.script_scorelets you compute a score using a script. This should be used as a last resort, as it can take longer to execute.
In retail search, you can usually get away with three signals:
- You want some diminishing returns for popularity. Item sales can be a very large number, so it’s best to apply
log1porsqrtas your modifier. - It’s nice to have some decay on freshness for items that are seasonal or cycle through fast.
- I’d recommend giving out-of-stock items a high demotion and in-stock items a small boost, rather than filtering them out in your default search path.
Price signals
price can be one of those attributes that you want to rank by, but it can also be abused quite easily.
If you simply apply a blanket boost to lower prices, your ranker may learn that a cheaper price is always better. That could work for your bargain page, but I don’t think you’ll see good results from your general search box. If I type in “solid walnut cabinet”, I most likely don’t want to see a cabinet just because it’s the cheapest one available.
It’s helpful to use price if you can tell that a user is looking for a certain price range (i.e., they selected a budget or price filter), or if you want to merchandise your page around a certain price point.
In those cases, you can use a decay function around a price anchor:
{
"_name": "price_fit",
"gauss": {
"price": {
"origin": 1200,
"scale": 300,
"decay": 0.5
}
},
"weight": 0.75
}
This just means being closer to the target price scores slightly higher. It doesn’t mean the cheapest always wins.
Usage
So how can we take all of this into consideration? We can create our own custom ranking function and search with it like this:
from opensearchpy import OpenSearch
client = OpenSearch(
hosts=[{"host": "localhost", "port": 9200}],
http_compress=True,
)
def build_product_query(
user_query: str,
material_hint: str | None = None,
price_anchor: float | None = None,
) -> dict:
positive_query = {
"bool": {
"must": [
{
"multi_match": {
"query": user_query,
"fields": ["title^4", "description"],
"type": "best_fields",
}
}
]
}
}
if material_hint:
positive_query["bool"]["should"] = [
{
"match": {
"materials": {
"query": material_hint,
"boost": 2.0,
}
}
}
]
functions = [
{
"_name": "in_stock_bonus",
"filter": {"range": {"stock_available": {"gt": 0}}},
"weight": 2.0,
},
{
"_name": "popular_products",
"field_value_factor": {
"field": "amount_sold",
"factor": 0.15,
"modifier": "log1p",
"missing": 0,
},
},
{
"_name": "fresh_products",
"gauss": {
"date_added": {
"origin": "now",
"offset": "7d",
"scale": "30d",
"decay": 0.5,
}
},
"weight": 1.25,
},
]
if price_anchor is not None:
functions.append(
{
"_name": "price_fit",
"gauss": {
"price": {
"origin": price_anchor,
"scale": max(price_anchor * 0.25, 10),
"decay": 0.5,
}
},
"weight": 0.75,
}
)
return {
"query": {
"function_score": {
"query": {
"boosting": {
"positive": positive_query,
"negative": {
"range": {
"stock_available": {
"lte": 0,
}
}
},
"negative_boost": 0.2,
}
},
"functions": functions,
"score_mode": "sum",
"boost_mode": "multiply",
"max_boost": 8,
}
}
}
response = client.search(
index="products",
body=build_product_query(
user_query="walnut record cabinet",
material_hint="walnut",
price_anchor=1200,
),
)
for hit in response["hits"]["hits"]:
print(f"{hit['_score']:.3f} - {hit['_source']['title']}")
Here are my reasons for these settings:
field_value_factorwill allow products that have a higheramount_soldto score higher, andlog1pwill keep a product that sells very well from overwhelming the rest.gaussondate_addedwill allow new products to pop up for a while, then decay.score_mode: "sum"will let us add all our business functions together rather than multiplying them, which is easier to reason about.boost_mode: "multiply"takes the text score and multiplies it by your business signals, meaning the product still has to be relevant to the text query.max_boostis the maximum value the combined function can return, which caps how much your merchandising signals can outweigh the text match.
OpenSearch gives you more options than you’ll probably need. I usually use sum or max for my score_mode. I’ve rarely needed boost_mode: "replace" on a text search system, since you lose the score from your query, which is usually not what you want.
Explaining scores
If you don’t know what your scoring is doing, it’s tough to optimize. You can add _name to your functions and have OpenSearch tell you why a sample hit was given its score:
debug_query = build_product_query(
user_query="walnut record cabinet",
material_hint="walnut",
)
debug_query["explain"] = True
response = client.search(index="products", body=debug_query)
top_hit = response["hits"]["hits"][0]
print(top_hit["_source"]["title"])
print(top_hit["_explanation"])
This will output your function names, so you can see if popular_products is the big one or fresh_products isn’t really doing anything. Here’s a truncated example:
{
"value": 17.24,
"description": "function score, product of:",
"details": [
{
"value": 3.91,
"description": "query score from multi_match(title^4, description)"
},
{
"value": 4.41,
"description": "function score, score mode [sum]",
"details": [
{
"value": 1.46,
"description": "field value function(_name: popular_products): log1p(doc['amount_sold'].value * factor=0.15)"
},
{
"value": 0.95,
"description": "Function for field date_added, _name: fresh_products"
},
{
"value": 2.0,
"description": "weight(_name: in_stock_bonus)"
}
]
}
]
}
Tuning
Tuning on feel is most likely going to make you fail at ranking.
Have some test queries that you can use to check your results against. You should have some common queries and some very specific ones that you’d expect to only show one item. Change each signal one at a time. If you find that your common queries are benefiting from a change but your exact queries aren’t, your popularity signal is probably too high. If you have new items that aren’t showing up, your freshness window is too short. If out-of-stock items are showing up too high, lower your negative boost.
boosting is used to decrease a document’s score, and function_score applies your business logic on top of your BM25 score. If you can describe why a document is in the first position, you’re most likely doing it right.