On search, part 3 of 4
Who’s In, and in What Order
Every search answers two questions: which results belong, and in what order. In Elasticsearch the first is the bool query’s four clauses, must, filter, should and must not, and the second is a sort or a score. Most bugs come from mixing them up.
The easiest bug to write in Elasticsearch doesn’t throw an error. Take a provider search like the one we built for Allē: providers within 25 miles who offer Botox or Juvéderm. Write the distance check as a filter and the two treatments as should clauses beside it, and the query runs, returns results and looks fine in a demo. It also returns every provider within 25 miles, whether they offer either treatment or not.
Nothing about that query is malformed. It does exactly what the rules say, and the rules are worth knowing cold. Every search answers two questions, which results belong and in what order. In Elasticsearch the first is almost always a bool query, and the second comes from one of two places, a relevance score computed from the query or a sort on a field you choose. Most search bugs come from mixing the two up.
The Four Slots
Elastic’s documentation defines all four in a sentence or two each (Elastic), and lined up side by side they look like this:
| Clause | Logic | Affects the score | Cached |
|---|---|---|---|
must |
AND | Yes | No |
filter |
AND | No | Yes |
should |
OR | Yes | No |
must_not |
NOT | No | Yes |
must is AND with scoring. “The clause (query) must appear in matching documents and will contribute to the score.” filter is AND without it. The clause must appear, “however unlike must the score of the query will be ignored,” and filter clauses “are considered for caching.” should is OR, where “each query defined under a should acts as a logical ‘OR’,” with a catch covered below. And must_not is NOT, run like a filter, unscored and cacheable.
Notice that two of the four are about logic and the other two are about scoring. Most mistakes come from treating the slots as purely logical, AND, OR and NOT, and forgetting that half of them also decide the order of the results.
Must or Filter
must and filter both mean AND. The difference is whether the clause is a question of relevance or a fact.
Searching provider names is relevance. A match query “returns documents that match a provided text,” and “the provided text is analyzed before matching” (Elastic), so a search for “glow aesthetics” should rank Glow Aesthetics above Aesthetic Glow Studio, and both above a practice that only mentions glowing skin in its description. That clause belongs in must, because how well it matches should decide the order.
Distance and treatment are facts. A provider is within 25 miles or it isn’t, and offers fillers or doesn’t. Scoring those clauses is wasted work, and it’s worse than wasted when something else is supposed to decide the order. They belong in filter, where Elasticsearch answers them yes or no, “without score calculation,” and “automatically caches frequently used filters” (Elastic). On a site where the same few treatments are searched in the same big cities all day, that cache is most of the performance.
The rule I use is simple. If a clause is a yes-or-no question, it’s a filter. Only put it in must if you want it to change the ranking.
OR Without Should
The most common OR in a search like this is several values of the same field, Botox or Juvéderm, and it doesn’t need should at all. A terms query “returns documents that contain one or more exact terms in a provided field” (Elastic). That’s an OR written as a single clause, and it can sit in filter with everything else:
{
"bool": {
"filter": [
{ "geo_distance": { "distance": "25mi", "location": { "lat": 33.66, "lon": -117.73 } } },
{ "terms": { "treatments": ["botox", "juvederm"] } }
]
}
}
That reads as within 25 miles AND (Botox OR Juvéderm), unscored and cached. It’s the right shape for most OR questions, and it sidesteps the trap entirely.
The Trap
When the OR spans different fields, say a treatment or a specialty, you need should. And should has a rule that isn’t obvious until it bites you. How many should clauses have to match is set by minimum_should_match, and its default depends on what else is in the query. “If the bool query includes at least one should clause and no must or filter clauses, the default value is 1. Otherwise, the default value is 0.”
Read that again with a distance filter in the query. The moment there’s a filter, the default drops to zero, and the should clauses become optional. They stop narrowing the results and only nudge the score. This query looks like within 25 miles AND (Botox OR injectables), and it isn’t:
{
"bool": {
"filter": [
{ "geo_distance": { "distance": "25mi", "location": { "lat": 33.66, "lon": -117.73 } } }
],
"should": [
{ "term": { "treatments": "botox" } },
{ "term": { "specialties": "injectables" } }
]
}
}
It returns every provider within 25 miles, with the ones that match Botox or injectables scored a little higher. If the results are sorted by something other than score, even that nudge disappears. The fix is to say what you mean, by nesting the OR inside the filter as its own bool with an explicit minimum:
{
"bool": {
"filter": [
{ "geo_distance": { "distance": "25mi", "location": { "lat": 33.66, "lon": -117.73 } } },
{
"bool": {
"should": [
{ "term": { "treatments": "botox" } },
{ "term": { "specialties": "injectables" } }
],
"minimum_should_match": 1
}
}
]
}
}
The inner bool has no filter or must of its own, so its default would already be 1, but I’d still write it. Somebody will add a clause to it someday, and the default will change underneath them.
Must Not
must_not is the simplest of the four. “The clause (query) must not appear in the matching documents,” and like a filter it’s unscored and considered for caching. It’s where exclusions go, like a provider who has paused bookings or a practice that’s been deactivated:
{
"bool": {
"filter": [ { "terms": { "treatments": ["botox", "juvederm"] } } ],
"must_not": [ { "term": { "status": "inactive" } } ]
}
}
The one thing worth knowing is what happens when a query has only exclusions. Because scoring is ignored, Elastic notes, “a score of 0 for all documents is returned.” That’s fine if you sort by a field, and surprising if you don’t.
Nesting Is the Whole Language
Any of those slots can hold another bool, so the four clauses add up to a full boolean language. ANDs of ORs, ORs of ANDs, NOTs inside either. It’s what lets a query builder turn a page full of search options into one query, with each option adding its own clause and nesting where it needs to.
It’s also what makes that builder worth testing harder than anything else in a search service. Every example above is correct on its own. The trap only appears when a second clause lands beside the first, which is exactly the case nobody writes a test for until it’s already shipped. A test that asserts the exact query for every combination of options catches it the day it’s written.
Then the Order
For Allē we ranked with a sort, not a score. Search worked in three tiers: we searched by closeness, ranked by quality and filtered by what a provider offered. Closeness and treatment were filters. Quality was reviews, and reviews were the sort.
Sorting Turns Off the Score
When you sort on a field, Elasticsearch stops computing relevance. “When sorting on a field, scores are not computed,” and if you want them anyway, “by setting track_scores to true, scores will still be computed and tracked” (Elastic).
That’s a feature when the sort is the ranking. Nothing about how a provider’s name or description happens to match the query can push them above a better-reviewed provider down the street. It also means every scored clause in the query is wasted work, which is one more reason the yes-or-no parts of a search belong in filter.
The Details in a Sort
A sort can take more than one key, and “order matters when defining multiple sort fields, because Elasticsearch attempts to sort on the first field in the array.” Ratings cluster. A lot of providers sit at 4.8 and 4.9, so a second key decides more of the order than it looks like it should. Distance is the natural tiebreaker, nearest first, and Elasticsearch can sort by it directly with _geo_distance:
"sort": [
{ "review_score": { "order": "desc", "missing": "_last" } },
{ "_geo_distance": { "location": { "lat": 33.66, "lon": -117.73 }, "order": "asc", "unit": "mi" } }
]
The missing setting matters too. A new provider has no reviews and therefore no value to sort on. Elastic’s default is _last, which is what you want for a ranking built on reviews, but it’s worth writing into the query anyway. A default you rely on and don’t write down is a default someone else will change.
The Blended Alternative
The other way to rank is to compute one score that weighs everything at once. Elasticsearch does this with function_score, which “allows you to modify the score of documents that are retrieved by a query” (Elastic). Two of its functions map directly onto a provider search. A decay function scores a document “with a function that decays depending on the distance” from an origin, and it works on geo points, so a provider’s score can fall off smoothly as they get farther away. And field_value_factor lets you “use a field from a document to influence the score,” like a review score:
{
"query": {
"function_score": {
"query": { "bool": { "filter": [ { "terms": { "treatments": ["botox"] } } ] } },
"functions": [
{ "gauss": { "location": { "origin": { "lat": 33.66, "lon": -117.73 }, "scale": "10mi", "offset": "2mi" } } },
{ "field_value_factor": { "field": "review_score", "missing": 3 } }
],
"score_mode": "multiply"
}
}
}
Now a 4.9-star provider fifteen miles away can lose to a 4.6 two miles away, or beat them, depending on where the curve sits. That’s closer to how a person actually weighs a choice, and it’s why marketplaces with a lot of ranking data tend to end up here.
The Case for Tiers
I’d still start with tiers. A blended score is harder to explain, and in a medical-aesthetics marketplace, explaining the order matters. A provider who asks why a competitor ranks above them should get an answer. With tiers, the answer is one sentence: within the distance you searched, offering what you asked for, ordered by reviews. With a blended score, the answer is a curve, a scale, an offset and a multiplication, and every one of those numbers is a decision somebody made and has to defend.
Tiers are also easier to cache and test. The filters are the same yes-or-no clauses for every search in a city, and the sort is one field. A query builder for tiers produces a small, predictable query whose expected output fits in a unit test. A blended score turns ranking into tuning, which needs evaluation data, judgments about which results are better, and someone whose job is to keep adjusting it.
None of that makes blending wrong. It makes it a later decision. Start with tiers you can explain, collect the clicks and bookings that tell you whether the order is actually serving people, and reach for a blended score when you have the data to tune it and a reason to think it will beat the sentence you can say out loud.
Takeaways
If I were building a search today, I’d put every yes-or-no question in filter, and only reach for must when a clause should change the ranking. I’d write ORs within a single field as terms before reaching for should, and when I did need should, I’d nest it inside a filter and always set minimum_should_match. I’d use must_not for exclusions and remember that it scores nothing. For the order, I’d start with a sort I can explain in a sentence, give it a tiebreaker because ratings cluster, and state what happens to documents with no value. I’d treat a blended function_score as a second version, earned with data. And I’d test the query builder on combinations, not on clauses, because that’s where these bugs live.
Filters decide who’s in. The order is the part someone will ask you to explain.
Sources
- Elastic, “Boolean query,” Elasticsearch documentation
- Elastic, “Match query,” Elasticsearch documentation
- Elastic, “Query and filter context,” Elasticsearch documentation
- Elastic, “Terms query,” Elasticsearch documentation
- Elastic, “Sort search results,” Elasticsearch documentation
- Elastic, “Function score query,” Elasticsearch documentation