Building product search: a practical Elasticsearch guide
Most e-commerce teams reach a scaling point where database queries alone can't deliver search experiences that customers expect. At that threshold, moving to Elasticsearch becomes essential—but without a solid foundation, even a powerful search engine can return irrelevant results and frustrate users. We'll walk through a production-ready product search setup that handles the core challenges: relevance, filtering, faceting, and ranking.
Why Elasticsearch for product search
Relational databases can technically power search, but they lack the features that make search good. Elasticsearch gives us full-text analysis, fuzzy matching, synonym handling, and scoring algorithms that work at scale. For an e-commerce platform with thousands of SKUs, the difference is immediate: queries that took seconds on Postgres return in milliseconds on Elasticsearch, and users find what they're looking for instead of exact-match results or nothing at all.
A real e-commerce search setup typically handles product name searches (typos included), category filtering, price ranges, and popularity-based ranking—all simultaneously. Let's build that system step by step.
Mapping — Define your product structure
Before indexing a single document, we need a mapping that reflects how products behave in search. The mapping controls how Elasticsearch analyzes text, stores numbers, and enables filtering.
A typical e-commerce product mapping includes:
- name — searchable, analyzed text with high relevance weight
- description — searchable but lower weight than name
- brand — keyword for exact matching and filtering
- category — keyword, filterable, used for facets
- price — numeric, range-filterable
- stock — numeric, used for ranking (in-stock products rank higher)
Here's a production mapping for a product index:
{
"mappings": {
"properties": {
"id": {
"type": "keyword"
},
"name": {
"type": "text",
"analyzer": "standard",
"fields": {
"keyword": {
"type": "keyword"
}
}
},
"description": {
"type": "text",
"analyzer": "standard"
},
"brand": {
"type": "keyword"
},
"category": {
"type": "keyword"
},
"price": {
"type": "float"
},
"stock": {
"type": "integer"
},
"popularity_score": {
"type": "float"
},
"created_at": {
"type": "date"
}
}
}
}
Notice that name has both a text analyzer (for searching) and a keyword subfield (for exact matching and aggregations). category and brand are keywords because we filter on them as exact values. price and stock are numbers so range queries work correctly.
Query — Multi-match with boosts and fuzzy matching
A user types "blu wiriless speaker" into the search box. They meant "blue wireless speaker." A basic query would return zero results. We need to combine multiple text fields, boost certain fields (name matters more than description), and allow fuzzy matching to handle typos.
The multi_match query is our primary tool here:
{
"query": {
"multi_match": {
"query": "blu wiriless speaker",
"fields": [
"name^3",
"description^1",
"brand^2"
],
"fuzziness": "AUTO",
"operator": "or"
}
}
}
The ^ syntax boosts matching in specific fields: a hit in name is worth 3 times more than a hit in descriptionbrand is worth twice as much. fuzziness: AUTO automatically allows one or two character differences depending on term length, catching "wiriless" as "wireless." The operator: or means a product matches if it contains any of the terms—we'll refine this in the ranking phase.
For a production system, you might parameterize these boosts or even A/B test them. Some teams use slightly different boost values for category-specific queries (e.g., boost brand higher in categories where brand matters more).
Filters — Category and price with post_filter facets
After search returns results, users want to narrow down. A typical e-commerce flow has facets on the left: category checkboxes, a price slider, and maybe brand filters. This is where filter and post_filter come in.
filter clauses reduce the candidate set before ranking; post_filter reduces results after ranking and is used when you want facet counts on the original result set. In product search, we usually filter category and price in the query, then use aggs (aggregations) to compute facet counts:
{
"query": {
"bool": {
"must": [
{
"multi_match": {
"query": "wireless speaker",
"fields": ["name^3", "description^1"],
"fuzziness": "AUTO"
}
}
],
"filter": [
{
"term": {
"category": "audio"
}
},
{
"range": {
"price": {
"gte": 50,
"lte": 300
}
}
},
{
"range": {
"stock": {
"gt": 0
}
}
}
]
}
},
"aggs": {
"categories": {
"terms": {
"field": "category",
"size": 20
}
},
"brands": {
"terms": {
"field": "brand",
"size": 20
}
},
"price_ranges": {
"range": {
"field": "price",
"ranges": [
{ "to": 50 },
{ "from": 50, "to": 100 },
{ "from": 100, "to": 300 },
{ "from": 300 }
]
}
}
},
"size": 20
}
Here we filter by a specific category and price range, and we compute aggregations (facet counts) simultaneously. The filter reduces the search scope without affecting ranking. If a user then clicks "Bluetooth" as an additional filter, we add another filter clause for that brand. Stock is also filtered (only in-stock items)—a common e-commerce requirement.
The aggregations return counts for each category, brand, and price bucket, giving the frontend what it needs to render the facet UI.
Ranking — Function_score with popularity and stock
Two products match "wireless speaker" equally well from a text-search perspective. One has been purchased 10,000 times; the other, 50 times. The first should rank higher. This is where function_score applies business logic to search relevance.
We can boost scores based on popularity and stock level:
{
"query": {
"function_score": {
"query": {
"bool": {
"must": [
{
"multi_match": {
"query": "wireless speaker",
"fields": ["name^3", "description^1"],
"fuzziness": "AUTO"
}
}
],
"filter": [
{
"term": {
"category": "audio"
}
},
{
"range": {
"price": {
"gte": 50,
"lte": 300
}
}
},
{
"range": {
"stock": {
"gt": 0
}
}
}
]
}
},
"functions": [
{
"field_value_factor": {
"field": "popularity_score",
"factor": 1.2,
"modifier": "log1p"
}
},
{
"gauss": {
"stock": {
"origin": 100,
"scale": 50,
"decay": 0.5
}
}
}
],
"score_mode": "sum",
"boost_mode": "multiply"
}
},
"size": 20
}
Here's what's happening:
- field_value_factor on
popularity_scoreboosts highly-purchased products. Thelog1pmodifier prevents extremely popular items from overshadowing all others. - gauss decay on
stockgives a gentle boost to products with healthy stock levels (around 100 units), decaying as stock moves away from that point. Low-stock items still appear but don't block higher-relevance results. score_mode: sumadds all function scores together.boost_mode: multiplymultiplies the base text-search score by the accumulated function score.
In production, you tune these factors continuously. Some teams use machine learning to learn ranking weights from click-through data, but a well-configured function_score often performs well and is easier to understand and adjust.
Common mistakes
Over-aggressive fuzzy matching: Setting fuzziness: 2 globally can return nonsensical results. Use AUTO and test with real queries.
Forgetting the stock filter: Showing out-of-stock products in search results confuses customers. Filter stock > 0 always, or at minimum in the sorting logic.
Mixing filter and must clauses incorrectly: A filter in a bool doesn't contribute to the score, only the result set. If a field should influence ranking, put it in a function_score; if it should only narrow results, use filter.
Not testing with real data: A mapping and query that work on 100 products might fail on 1 million. Index a representative sample and measure latency and relevance before deploying to production.
Ignoring aggregation performance: Deep or unbounded aggregations can slow queries significantly. Always set a `size` limit on facets and consider using composite aggregations for large facet sets.
Key takeaways
- Map product fields explicitly: use
textfor searchable content,keywordfor filtering and aggregation, and numeric types for ranges and scoring functions. - Use
multi_matchwith field boosts to prioritize matches in name and brand over description, and enablefuzziness: AUTOto handle typos. - Apply
filterclauses in aboolquery to narrow results without affecting score; compute facets withaggson the same filtered set. - Deploy
function_scorewithfield_value_factorand decay functions to incorporate business metrics like popularity and stock into ranking, keeping text relevance as the foundation. - Always filter out-of-stock products, and continuously tune boost values and function parameters with real search logs and user feedback.






Comments
Loading comments…