Query DSL 1: match, multi_match, term, bool Queries, Filter Context and Pagination
Send what the user typed to text fields with match, exact values to keyword fields with term, and every clicked filter to the bool query's filter section; a filter in must inflates scores and loses caching. With real output: multi_match's best_fields, most_fields and cross_fields types, nested author search, Bookstore's /books/search endpoint, the 10,000 limit of from/size, search_after with a PIT, and simple_query_string instead of query_string.
Version note: The examples in this post were run with Elasticsearch 9.5.6, .NET 10 and Elastic.Clients.Elasticsearch 9.5.3, in the lab we built in post 2. The books index is in the state we left it at the end of post 5: 20,035 books synced from SQL Server.
Guess First
We are starting the backend of the search page for Bookstore, the online bookstore used as the shared example across this site's series. We no longer need the books-load test index from post 5; delete it in Dev Tools with DELETE /books-load so it does not leak into searches (we will see why in post 10). First, three small queries in Kibana Dev Tools:
GET /books/_search
{ "query": { "term": { "title": "İnce Memed" } } }
GET /books/_search
{ "query": { "term": { "title": "ince" } } }
GET /books/_search
{ "query": { "term": { "language": "TR" } } }
Our catalog has a book called "İnce Memed". Which of these three queries return results? And think about this one too: what does a user who types suç ve ceza (rusça into the search box get back from an API that builds the query with query_string?
match and term: Analyzed and Not Analyzed
The answers are 0, 2 and 0 results respectively:
GET /books/_search?filter_path=hits.total,hits.hits._score,hits.hits._source.title
{ "query": { "term": { "title": "İnce Memed" } } }
{"hits":{"total":{"value":0,"relation":"eq"}}}
GET /books/_search?filter_path=hits.total,hits.hits._score,hits.hits._source.title
{ "query": { "term": { "title": "ince" } } }
{"hits":{"total":{"value":2,"relation":"eq"},"hits":[{"_score":10.77075,"_source":{"title":"İnce Memed"}},{"_score":9.605565,"_source":{"title":"İnce Memed 2"}}]}}
GET /books/_search?filter_path=hits.total,hits.hits._score,hits.hits._source.title
{ "query": { "term": { "title.keyword": "İnce Memed" } } }
{"hits":{"total":{"value":1,"relation":"eq"},"hits":[{"_score":8.99099,"_source":{"title":"İnce Memed"}}]}}
A term query does not analyze the value you give it; it looks for exactly that term in the index. The title field was indexed with the standard analyzer, so the index has no term "İnce Memed"; it has two terms, "ince" and "memed". The second query finds the books because "ince" happens to be exactly the indexed term; that is luck, and had the user typed "İnce" it would have come back empty again. title.keyword, on the other hand, stores the value as is, and the full title matches. The same logic applies to language: in a keyword field "TR" and "tr" are different values, and the query returns nothing.
A match query first runs the text through the field's search analyzer and searches with the resulting terms:
GET /books/_search?filter_path=hits.total,hits.hits._score,hits.hits._source.title
{ "query": { "match": { "title": "İnce Memed" } } }
{"hits":{"total":{"value":2,"relation":"eq"},"hits":[{"_score":21.5415,"_source":{"title":"İnce Memed"}},{"_score":19.21113,"_source":{"title":"İnce Memed 2"}}]}}
The rule is simple: text the user typed goes to text fields with the match family; exact values the system knows (a language code, a category, an ISBN, an order status) go to keyword fields with term/terms. Using term on a text field means trying to guess the analyzed form inside the index; that is why we built the mapping with two fields in post 3.
Word Order and Word Count
match works with or by default: every document containing any of the terms is a hit, and order does not matter. If order matters there is match_phrase; slop says how many positions the words may move:
GET /books/_search
{ "query": { "match": { "title.folded": "mantolu kurk" } } }
{"hits":{"total":{"value":1},"hits":[{"_score":19.785675,"_source":{"title":"Kürk Mantolu Madonna"}}]}}
GET /books/_search
{ "query": { "match_phrase": { "title.folded": "mantolu kurk" } } }
{"hits":{"total":{"value":0}}}
GET /books/_search
{ "query": { "match_phrase": { "title.folded": { "query": "mantolu kurk", "slop": 2 } } } }
{"hits":{"total":{"value":1},"hits":[{"_score":10.090446,"_source":{"title":"Kürk Mantolu Madonna"}}]}}
There is one more setting between or and and: minimum_should_match. Saying "at least two of three words" is the easiest way to prune irrelevant hits from long, loose queries:
GET /books/_search
{ "size": 3, "query": { "match": { "description.tr": { "query": "istanbul aşk savaş" } } } }
{"hits":{"total":{"value":9},"hits":[{"_score":15.448057,"_source":{"title":"Huzur"}},{"_score":8.433546,"_source":{"title":"Savaş ve Barış"}},{"_score":8.376261,"_source":{"title":"Kürk Mantolu Madonna"}}]}}
GET /books/_search
{ "size": 3, "query": { "match": { "description.tr": { "query": "istanbul aşk savaş", "minimum_should_match": 2 } } } }
{"hits":{"total":{"value":1},"hits":[{"_score":15.448057,"_source":{"title":"Huzur"}}]}}
One of nine results is left; Huzur's description contains all three words. The value can be a number (2), a percentage ("75%") or an expression that varies with the number of words.
multi_match Types: best_fields, most_fields, cross_fields
The search command from post 4 searched eight fields with one multi_match, and we never set its type; the default is best_fields. The quickest way to see how the types differ is _validate/query?rewrite=true: Elasticsearch turns the query into what Lucene will execute and shows it. The user types "dune arrakis" and we want both words to match:
GET /books/_validate/query?rewrite=true
{
"query": {
"multi_match": {
"query": "dune arrakis", "type": "best_fields", "operator": "and",
"fields": ["title.folded", "description.folded"]
}
}
}
+((+title.folded:dune +title.folded:arrakis) | (+description.folded:dune +description.folded:arrakis)) #FieldExistsQuery [field=_primary_term]
"type": "cross_fields"
+(title.folded:dune | description.folded:dune) +(title.folded:arrakis | description.folded:arrakis) #FieldExistsQuery [field=_primary_term]
With best_fields, and is applied inside each field: "dune" and "arrakis" must appear in the same field. The Turkish Dune has "dune" in its title and "arrakis" in its description; no field contains both, so the result is 0. most_fields applies the same rule per field, and the result is 0 again. cross_fields treats the fields as one big field: every word must appear in at least one field. The result is the Turkish Dune, scoring 20.04.
The types also combine scores differently. Running the same query with or (the default) brings back both Dunes; I wrote only the relevant parts of the three runs and the _explain response one under the other:
"type": "best_fields" → Dune (tr) 11.964923, Dune (en) 11.964923
"type": "most_fields" → Dune (tr) 20.039944, Dune (en) 11.964923
"type": "best_fields", "tie_breaker": 0.3 → Dune (tr) 14.387429, Dune (en) 11.964923
GET /books/_explain/9781000000191
"max plus 0.3 times others of:" 11.964923 (title.folded), 8.075021 (description.folded)
best_fields takes the score of the best matching field; "arrakis" in the description earns the Turkish Dune nothing, and the two books tie. most_fields adds up the scores of all fields. tie_breaker: 0.3 sits in between: the best field plus 0.3 times the others (11.96 + 0.3 × 8.08 = 14.39). _explain shows this calculation per document; the answer to "why is this book on top?" is always there.
As a rule: if the concept lives in one field (title or description, whichever matches better), use best_fields with a small tie_breaker; for subfields that index the same text with different analyzers (title, title.tr, title.folded), use most_fields; if the information is split across fields (first and last name, title and description), use cross_fields.
cross_fields has one condition: it groups fields by analyzer and only treats fields that share an analyzer as one field. When I ran the same query on title (standard) and description (search_synonyms at search time), the rewrite came out exactly the same as the best_fields one and the result was 0 again. That is why the example uses the folded subfields, which both use tr_folded.
Then there is author search. Adding authors.name to the multi_match for "sabahattin ali" does not work: 0 results. In post 3 we made authors nested; each author is a separate hidden Lucene document and a query at the root does not see them. To search title and author together, we combine two queries in a bool's should:
GET /books/_search
{
"size": 5,
"query": {
"bool": {
"should": [
{ "multi_match": { "query": "sabahattin ali kürk", "fields": ["title", "title.folded"] } },
{ "nested": { "path": "authors", "score_mode": "max",
"query": { "match": { "authors.name": "sabahattin ali kürk" } } } }
]
}
}
}
{"hits":{"total":{"value":3},"hits":[{"_score":25.683447,"_source":{"title":"Kürk Mantolu Madonna"}},{"_score":15.790609,"_source":{"title":"Kuyucaklı Yusuf"}},{"_score":15.790609,"_source":{"title":"İçimizdeki Şeytan"}}]}}
"minimum_should_match": 2
{"hits":{"total":{"value":1},"hits":[{"_score":25.683447,"_source":{"title":"Kürk Mantolu Madonna"}}]}}
Kürk Mantolu Madonna, which matches in both the title and the author, adds the two scores and moves to the top; with minimum_should_match: 2 it is the only one left. The way to make the author name searchable at the root as well is to add a field to the mapping with copy_to, or include_in_root; both need reindexing, so we leave them for the aliased reindex in post 11.
The bool Query: must, should, filter, must_not
bool has four sections: must (must match and contributes to the score), should (raises the score if it matches; with no must or filter, at least one must match), filter (must match but does not contribute to the score) and must_not (must not match, no score contribution). The first two run in query context, the last two in filter context. Let's see the difference by writing the same query two ways: Turkish books cheaper than 250 lira whose description contains "roman".
GET /books/_search
{
"size": 4,
"query": {
"bool": {
"must": [ { "match": { "description.tr": "roman" } } ],
"filter": [
{ "term": { "language": "tr" } },
{ "range": { "price": { "lte": 250 } } }
]
}
}
}
{"hits":{"total":{"value":5},"hits":[{"_score":6.9856987,"_source":{"title":"Yaprak Dökümü","price":175.0}},{"_score":6.9856987,"_source":{"title":"Yaban","price":155.0}},{"_score":6.9856987,"_source":{"title":"Bin Dokuz Yüz Seksen Dört","price":195.0}},{"_score":6.713835,"_source":{"title":"İçimizdeki Şeytan","price":190.0}}]}}
"must": [ match, term, range ]
{"hits":{"total":{"value":5},"hits":[{"_score":14.09749,"_source":{"title":"Yaprak Dökümü","price":175.0}},{"_score":14.09749,"_source":{"title":"Yaban","price":155.0}},{"_score":14.09749,"_source":{"title":"Bin Dokuz Yüz Seksen Dört","price":195.0}},{"_score":13.825626,"_source":{"title":"İçimizdeki Şeytan","price":190.0}}]}}
The result set is the same, and in this example so is the order; but the score went from 6.99 to 14.10. None of the roughly 7 added points have anything to do with what the user typed: only 21 of the 20,035 books are Turkish, so the language: tr term gets a high IDF, and the range adds a constant 1. That constant breaks the ranking as soon as you combine the score with other signals (function_score and boosts in post 7). The second difference is performance: queries in filter context do not compute scores, and Elasticsearch can keep the results of frequently used filters as bitsets in the node-level query cache. In a query made only of filters, every score is 0:
GET /books/_search
{ "size": 2, "query": { "bool": { "filter": [ { "term": { "language": "tr" } }, { "range": { "price": { "lte": 250 } } } ] } } }
{"hits":{"total":{"value":11},"hits":[{"_score":0.0,"_source":{"title":"Kuyucaklı Yusuf","price":210.0}},{"_score":0.0,"_source":{"title":"İçimizdeki Şeytan","price":190.0}}]}}
Practical rule: the text the user typed goes into must/should; everything the user clicks (category, language, price range, in stock) goes into filter. exists (is the field set) and range are mostly filters too.
Bookstore's Search Endpoint
We move these decisions into a new GET /books/search endpoint in Bookstore.Api. The query is built in BookSearchEndpoints.BuildQuery with the object initializer syntax; how it differs from the fluent syntax is the subject of post 10:
public static Query BuildQuery(string? text, string? category, string? language, decimal? minPrice, decimal? maxPrice)
{
// Query context: what the user typed decides the score.
Query textQuery = string.IsNullOrWhiteSpace(text)
? new MatchAllQuery()
: new BoolQuery
{
Should =
[
new MultiMatchQuery { Query = text, Fields = Fields.FromStrings(TextFields) },
// Authors are nested (post 03): a root-level multi_match cannot see them.
new NestedQuery
{
Path = "authors",
ScoreMode = ChildScoreMode.Max,
Query = new MatchQuery { Field = "authors.name", Query = text }
}
],
MinimumShouldMatch = 1
};
// Filter context: yes/no conditions, no score, cacheable.
List<Query> filters = [];
if (!string.IsNullOrWhiteSpace(category))
{
filters.Add(new TermQuery { Field = "categories", Value = category });
}
if (!string.IsNullOrWhiteSpace(language))
{
filters.Add(new TermQuery { Field = "language", Value = language });
}
if (minPrice is not null || maxPrice is not null)
{
filters.Add(new NumberRangeQuery
{
Field = "price",
Gte = minPrice is null ? null : (double)minPrice.Value,
Lte = maxPrice is null ? null : (double)maxPrice.Value
});
}
return new BoolQuery { Must = [textQuery], Filter = filters };
}
The endpoint validates the paging parameters, runs the query and maps the result to a small record that the API returns:
int from = (currentPage - 1) * size;
if (from + size > MaxResultWindow)
{
return Results.Problem("Only the first 10,000 results can be paged. Narrow the search with filters.", statusCode: StatusCodes.Status400BadRequest);
}
SearchResponse<Book> response = await client.SearchAsync<Book>(s => s
.From(from)
.Size(size)
.TrackTotalHits(new TrackHits(true))
.Query(BuildQuery(q, category, language, minPrice, maxPrice)), cancellationToken);
dotnet run --project src/Bookstore.Api
curl "http://localhost:5080/books/search?q=kurk%20sabahattin&pageSize=2"
{"total":3,"page":1,"pageSize":2,"items":[{"isbn":"9781000000030","title":"Kürk Mantolu Madonna","authors":["Sabahattin Ali"],"language":"tr","price":199.00,"score":27.68098},{"isbn":"9781000000047","title":"Kuyucaklı Yusuf","authors":["Sabahattin Ali"],"language":"tr","price":210.00,"score":7.8953047}]}
curl "http://localhost:5080/books/search?q=roman&language=tr&maxPrice=250&pageSize=3"
{"total":5,"page":1,"pageSize":3,"items":[{"isbn":"9781000000078","title":"Yaprak Dökümü","authors":["Reşat Nuri Güntekin"],"language":"tr","price":175.00,"score":7.4347095},{"isbn":"9781000000139","title":"Yaban","authors":["Yakup Kadri Karaosmanoğlu"],"language":"tr","price":155.00,"score":7.1454363},{"isbn":"9781000000214","title":"Bin Dokuz Yüz Seksen Dört","authors":["George Orwell"],"language":"tr","price":195.00,"score":6.9856987}]}
curl "http://localhost:5080/books/search?category=Mystery&minPrice=800&pageSize=2"
{"total":395,"page":1,"pageSize":2,"items":[{"isbn":"9782000000075","title":"A Bridge in Road 36","authors":["Ken Walker"],"language":"en","price":808.90,"score":1},{"isbn":"9782000000532","title":"The Quiet Station","authors":["Alan Hayes"],"language":"en","price":826.90,"score":1}]}
"kurk" without Turkish characters matched the title's folded subfield, "sabahattin" matched in the nested author query, and the two scores were added. In the third request, which has no search text, match_all gives every book 1; ranking is meaningless then, and you need to sort by a field (price, publication date). Sorting and facets will be added to this endpoint in post 9.
Pagination: from/size, the 10,000 Limit, search_after and PIT
from/size paging is the same offset pagination as in the ASP.NET Core Web API series post "Pagination, Filtering and Sorting in ASP.NET Core Web API: Returning Large Lists Safely", and it has the same cost: to serve page 500, every shard has to sort its first 10,000 results and send them to the coordinating node. That is why Elasticsearch limits from + size to 10,000 by default:
GET /books/_search
{ "from": 9990, "size": 20, "query": { "match_all": {} } }
{"error":{"root_cause":[{"type":"illegal_argument_exception","reason":"Result window is too large, from + size must be less than or equal to: [10000] but was [10010]. See the scroll api for a more efficient way to request large data sets. This limit can be set by changing the [index.max_result_window] index level setting."}], ...},"status":400}
You can raise the limit with index.max_result_window, but that is not a fix; memory and CPU cost grow with the page number. Our endpoint does the same check before calling Elasticsearch and rejects page 501 (of 20) with a 400. For a user interface the limit is not a problem: nobody pages forward 500 times, they narrow down with filters.
The total also comes with a limit. By default, track_total_hits counts exactly up to 10,000 and stops counting after that:
GET /books/_search?filter_path=hits.total
{ "size": 0, "query": { "match_all": {} } }
{"hits":{"total":{"value":10000,"relation":"gte"}}}
GET /books/_search?filter_path=hits.total
{ "size": 0, "track_total_hits": true, "query": { "match_all": {} } }
{"hits":{"total":{"value":20035,"relation":"eq"}}}
relation: gte means "at least this many". For a UI that says "10,000+ results" the default is enough and faster; we wanted an exact number in the endpoint, so we passed TrackHits(true). On large indices, measure that as a cost.
If you need to walk the whole index (an export, a bulk load into another system, a mass mailing list), the right tool is search_after with a point in time. While the PIT is open, incoming writes do not shift the pages; search_after says "give me what comes after the last sort value of the previous page", so every page costs the same. Elastic's documentation now recommends this pair rather than the scroll API for deep pagination (even though the error message still mentions scroll). We added an export command to the lab console:
OpenPointInTimeResponse pit = await client.OpenPointInTimeAsync(SearchClientFactory.BooksIndex, p => p.KeepAlive("1m"));
string pitId = pit.Id;
ICollection<FieldValue>? after = null;
try
{
while (true)
{
// No index name with a PIT: the PIT already pins the index and its state.
SearchResponse<Book> page = await client.SearchAsync<Book>(s =>
{
s.Size(pageSize)
.Pit(p => p.Id(pitId).KeepAlive("1m"))
.Sort(so => so.Field("isbn", f => f.Order(SortOrder.Asc)))
.TrackTotalHits(new TrackHits(false));
if (after is not null)
{
s.SearchAfter(after);
}
});
if (page.Hits.Count == 0)
{
break;
}
pages++;
books += page.Hits.Count;
Hit<Book> last = page.Hits.Last();
after = last.Sort!.ToList();
lastIsbn = last.Source!.Isbn;
pitId = page.PitId ?? pitId;
}
}
finally
{
await client.ClosePointInTimeAsync(c => c.Id(pitId));
}
dotnet run --project src/Bookstore.SearchLab -- export
Exported 20,035 books in 21 pages of 1,000, last ISBN 9782000199991, 664 ms
Three details matter. The sort must be unique; isbn differs for every book. If you have no unique field, the _shard_doc sort that comes with a PIT can serve as a tie-breaker. keep_alive is not for the whole operation but for the time until the next request; it is extended on every request. A PIT holds resources (it keeps old segments from being deleted), so it is closed in finally. Opening a PIT only needs the read privilege on the index; our application API key, which has no cluster privileges, had no problem.
For a user-facing "load more" list you do not need a PIT: using search_after on its own with the last hit's sort values (score and isbn) is enough; a refresh in between can shift the order slightly, which is acceptable for a search page. The Solution Patterns series post "Pagination Strategies in Depth: Offset, Keyset and Opaque Cursor Tokens" covers handing this cursor to the client as an opaque token.
User Input: simple_query_string Instead of query_string
query_string is powerful: AND/OR, parentheses, field names, wildcards, regex, ranges. It was written for screens aimed at expert users, like Kibana's search bar. Passing the search box text straight to it causes three problems:
GET /books/_search
{ "query": { "query_string": { "query": "suç ve ceza (rusça", "fields": ["title", "description"] } } }
{"error":{"root_cause":[{"type":"query_shard_exception","reason":"Failed to parse query [suç ve ceza (rusça]", ...}], ...},"status":400}
GET /books/_search
{ "query": { "query_string": { "query": "stockQuantity:[0 TO 10]", "fields": ["title", "description"] } } }
{"hits":{"total":{"value":1060},"hits":[{"_source":{"title":"Yaban"}},{"_source":{"title":"Domain-Driven Design"}},{"_source":{"title":"Harbour of the Last Island"}}]}}
GET /books/_search
{ "size": 1, "query": { "query_string": { "query": "*a*", "fields": ["title", "description"] } } }
{"hits":{"total":{"value":10000},"hits":[{"_source":{"title":"İnce Memed"}}]}}
An unclosed parenthesis returns 400 and the user sees "search is broken". In the second query, despite fields, the user typed a field name and listed the 1,060 books with fewer than 10 in stock; letting someone query a field you never meant to expose is an information leak. In the third, the leading wildcard produces an expensive query that walks the whole term dictionary. simple_query_string does not fail on the same input; it ignores the invalid parts and does not understand field name syntax:
GET /books/_search
{ "size": 3, "query": { "simple_query_string": { "query": "suç ve ceza (rusça", "fields": ["title", "description"] } } }
{"hits":{"total":{"value":14},"hits":[{"_source":{"title":"Suç ve Ceza"}},{"_source":{"title":"Savaş ve Barış"}},{"_source":{"title":"İnce Memed 2"}}]}}
It has one trap: "-" means NOT, and with default_operator: or the search "hobbit -çeviri" becomes "contains hobbit, or does not contain çeviri":
{ "simple_query_string": { "query": "hobbit -çeviri", "fields": ["title", "description"] } }
{"hits":{"total":{"value":10000},"hits":[{"_source":{"title":"Hobbit"}},{"_source":{"title":"The Hobbit"}},{"_source":{"title":"İnce Memed"}}]}}
"default_operator": "and"
{"hits":{"total":{"value":1},"hits":[{"_source":{"title":"The Hobbit"}}]}}
"flags": "PHRASE|AND|OR|PRECEDENCE"
{"hits":{"total":{"value":6},"hits":[{"_source":{"title":"Hobbit"}},{"_source":{"title":"Savaş ve Barış"}},{"_source":{"title":"The Hobbit"}}]}}
If operator syntax means nothing to your users (true for most bookstore customers), the safest choice is a plain multi_match like the one in our endpoint. If you want to offer a few operators such as quoted phrases, restrict simple_query_string with flags and choose default_operator deliberately. In the last example NOT is disabled, so "-çeviri" became an ordinary word, the synonyms on the description field (çeviri, translated, translation) kicked in, and translated books came back too.
Production Notes
When logging queries, log the shape, not the text. A user's search text may contain personal data; which filters were used, how many hits came back and the duration (took) are enough for most analysis. Track the number of zero-result searches separately; in post 7 we will feed the "did you mean?" suggestion from that list.
Measure the cost of track_total_hits: true. An exact count means counting every matching document; on millions of documents it can noticeably lengthen the query. If "10,000+ results" is enough for the UI, use the default or give the limit as a number.
Limit page size and page count on the server. A client asking for pageSize=10000 can make you fetch the _source of 10,000 documents in one request. That is why the endpoint has an upper bound of 50 and the 10,000 window check.
Keep filters in filter so they get cached. The node query cache stores the results of frequently repeated filters as bitsets per segment; a filter that repeats on every search, like language: tr, is almost free from the second time on.
Common Pitfalls
Using term on a text field. The result is either empty or right by accident; do not try to guess the analyzed term, use match or go to the keyword subfield.
Putting filters in must. The result set stays the same, but the score is inflated by a constant unrelated to the user's text and nothing is cached.
Using cross_fields on fields with different analyzers and assuming it works. Elasticsearch groups fields by analyzer; between groups the behavior is best_fields. Check with _validate/query?rewrite=true.
Searching a nested field from a root query. Adding authors.name to a multi_match does not fail; it silently finds nothing.
Raising max_result_window for deep paging. Change the method, not the limit: filters in the UI, PIT and search_after in batch jobs.
Passing user input to query_string. Syntax errors turn into 400s, field names into information leaks, and leading wildcards into expensive queries.
A Small Experiment
★☆☆ Add an "in stock only" filter to the /books/search endpoint (inStock=true → a range on stockQuantity with gt: 0). Is the filter in filter? Do the scores change?
★★☆ Run the eight-field multi_match from the search command with best_fields, most_fields and tie_breaker: 0.3. How do the top five results and scores change for "ince memed" and "novel"? Which makes more sense for subfields that index the same text with different analyzers?
★★★ Add "load more" paging to the endpoint with an after parameter: return the last hit's sort values (_score, isbn) as a Base64 token in the response and pass it to search_after on the next request. If you change a book's price in between and a refresh happens, do the pages shift?
In the next post we take this endpoint's scores seriously: tuning the guessed ^3/^2 weights by measuring them with _rank_eval, adding rating and recency signals with function_score, fuzziness for typos, highlighting to mark the matched words, and a "did you mean?" suggestion for zero-result searches.