What is Elasticsearch?
Elasticsearch is a distributed, open-source search and analytics engine built on top of Apache Lucene. It allows you to store, search, and analyze large volumes of data in near real-time. Unlike traditional databases that rely on exact matching, Elasticsearch excels at full-text search, fuzzy matching, relevance scoring, and complex aggregations — making it the backbone of modern search features in applications of all sizes.
At its core, Elasticsearch stores data as structured documents (JSON objects) grouped into indices. Each index is split into shards for horizontal scalability, and each shard can have replicas for high availability. When you submit a search query, Elasticsearch distributes the work across shards, gathers results, ranks them by relevance, and returns them to you — all in milliseconds.
Why Elasticsearch Matters for Search Features
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →Building a search feature with a standard SQL database often leads to pain points: slow LIKE queries, no relevance ranking, poor handling of typos, and no support for stemming or synonyms. Elasticsearch solves these problems out of the box:
- Lightning-fast full-text search — Built on inverted indices, it scans millions of documents in milliseconds
- Relevance scoring — Uses BM25 (TF-IDF variant) to rank results by how well they match the query
- Fuzzy matching — Handles typos and approximate matches with Levenshtein distance algorithms
- Tokenization and stemming — Breaks text into searchable tokens, normalizes words to root forms (e.g., "running" → "run")
- Aggregations — Computes faceted counts, histograms, and statistics for features like filters and drill-downs
- Scalability — Distributes data across nodes, handles terabytes of data and thousands of concurrent queries
Getting Started: Installation and Setup
You can run Elasticsearch locally using Docker, which is the fastest way to start developing:
# Pull the official Elasticsearch Docker image
docker pull docker.elastic.co/elasticsearch/elasticsearch:8.11.0
# Run a single-node cluster (disable security for local development)
docker run -d --name elasticsearch \
-p 9200:9200 \
-e "discovery.type=single-node" \
-e "xpack.security.enabled=false" \
docker.elastic.co/elasticsearch/elasticsearch:8.11.0
Verify the instance is running with a simple health check:
curl http://localhost:9200/_cluster/health
# Expected response:
# {"cluster_name":"docker-cluster","status":"yellow","timed_out":false,...}
For production deployments, consider using Elastic Cloud, a managed service, or setting up a multi-node cluster with proper security configurations.
Creating an Index with Proper Mappings
Before you can search, you need an index. Think of an index as a logical namespace that holds documents. More importantly, you should define a mapping — this tells Elasticsearch how to interpret each field in your documents. Without explicit mappings, Elasticsearch guesses types dynamically, which can lead to unexpected behavior.
Let's create an index for a product catalog search feature:
PUT /products
{
"settings": {
"number_of_shards": 1,
"number_of_replicas": 0,
"analysis": {
"analyzer": {
"custom_english_analyzer": {
"type": "standard",
"stopwords": "_english_",
"stemmer": "english"
}
}
}
},
"mappings": {
"properties": {
"id": {
"type": "keyword"
},
"name": {
"type": "text",
"analyzer": "custom_english_analyzer",
"fields": {
"keyword": {
"type": "keyword",
"ignore_above": 256
}
}
},
"description": {
"type": "text",
"analyzer": "custom_english_analyzer"
},
"category": {
"type": "keyword"
},
"price": {
"type": "double"
},
"in_stock": {
"type": "boolean"
},
"tags": {
"type": "keyword"
},
"created_at": {
"type": "date",
"format": "yyyy-MM-dd HH:mm:ss||yyyy-MM-dd||epoch_millis"
},
"review_count": {
"type": "integer"
},
"rating": {
"type": "float"
}
}
}
}
Understanding Field Types
text— Full-text searchable fields. These are analyzed (tokenized, stemmed) when indexedkeyword— Exact-value fields for filtering, sorting, and aggregation. Not analyzeddouble/float— Numeric types for range queries and sortingboolean— True/false values for filteringdate— Date fields with configurable formats for range queriesinteger— Whole number fields
The fields parameter on name creates a multi-field: the main field is analyzed text, while name.keyword stores the raw value for exact matching and sorting.
Indexing Documents
Now let's populate our index with some sample product data. You can index documents one at a time or use the bulk API for efficiency:
Single Document Indexing
PUT /products/_doc/1
{
"id": "1",
"name": "Wireless Bluetooth Headphones",
"description": "Premium noise-canceling headphones with 30-hour battery life. Perfect for travel and work.",
"category": "electronics",
"price": 79.99,
"in_stock": true,
"tags": ["wireless", "bluetooth", "audio"],
"created_at": "2024-01-15 10:30:00",
"review_count": 245,
"rating": 4.5
}
Bulk Indexing for Multiple Documents
POST /_bulk
{"index": {"_index": "products", "_id": "2"}}
{"id": "2", "name": "Running Shoes UltraFlex", "description": "Lightweight running shoes with responsive cushioning and breathable mesh upper.", "category": "sports", "price": 120.00, "in_stock": true, "tags": ["running", "shoes", "fitness"], "created_at": "2024-02-20 14:00:00", "review_count": 89, "rating": 4.2}
{"index": {"_index": "products", "_id": "3"}}
{"id": "3", "name": "Organic Coffee Beans", "description": "Freshly roasted organic coffee beans from Colombia. Rich, smooth flavor with chocolate notes.", "category": "food", "price": 14.50, "in_stock": false, "tags": ["coffee", "organic", "food"], "created_at": "2024-03-10 08:15:00", "review_count": 512, "rating": 4.8}
{"index": {"_index": "products", "_id": "4"}}
{"id": "4", "name": "USB-C Hub Adapter", "description": "7-in-1 USB-C hub with HDMI, USB 3.0, and SD card reader. Compatible with MacBook and laptops.", "category": "electronics", "price": 34.99, "in_stock": true, "tags": ["usb-c", "adapter", "electronics"], "created_at": "2024-01-30 16:45:00", "review_count": 178, "rating": 4.1}
{"index": {"_index": "products", "_id": "5"}}
{"id": "5", "name": "Yoga Mat Premium", "description": "Extra thick yoga mat with non-slip surface. Includes carrying strap. Perfect for home workouts.", "category": "sports", "price": 45.00, "in_stock": true, "tags": ["yoga", "mat", "fitness"], "created_at": "2024-04-05 09:00:00", "review_count": 67, "rating": 4.6}
The bulk API uses newline-delimited JSON (NDJSON). Each action line specifies the operation and target, followed by the document body on the next line. This is far more efficient than individual requests when dealing with large datasets.
Basic Search: The Query DSL
Elasticsearch uses a JSON-based Query Domain Specific Language (DSL) for search. The most common entry point is the match query, which performs full-text search on analyzed fields:
GET /products/_search
{
"query": {
"match": {
"description": "wireless headphones noise canceling"
}
}
}
This query breaks the search string into tokens, applies the same analyzer used at index time, and finds documents whose description field contains matching tokens. Results are ranked by relevance score (_score), with more matching tokens and rarer terms contributing to higher scores.
Understanding the Response
{
"took": 3,
"timed_out": false,
"_shards": {
"total": 1,
"successful": 1,
"skipped": 0,
"failed": 0
},
"hits": {
"total": {
"value": 1,
"relation": "eq"
},
"max_score": 1.456,
"hits": [
{
"_index": "products",
"_id": "1",
"_score": 1.456,
"_source": {
"id": "1",
"name": "Wireless Bluetooth Headphones",
"description": "Premium noise-canceling headphones with 30-hour battery life.",
"category": "electronics",
"price": 79.99,
"in_stock": true
}
}
]
}
}
Key response fields: took (execution time in ms), hits.total (number of matching documents), hits.hits (the actual document array), and _score (relevance score for ranking).
Advanced Search Techniques
Multi-Field Search
Search across multiple fields simultaneously with multi_match. You can control how scores from different fields are combined:
GET /products/_search
{
"query": {
"multi_match": {
"query": "wireless audio headphones",
"fields": ["name^3", "description", "tags^2"],
"type": "best_fields",
"operator": "or"
}
}
}
The caret symbol (^3, ^2) boosts the importance of specific fields. Here, matches in name are three times more influential than matches in description. The best_fields type takes the best matching field's score (useful when you want the strongest single-field match). Other types include most_fields (sums scores across all fields) and cross_fields (treats all fields as one combined field).
Filtering with Boolean Queries
Combine full-text search with precise filters using the bool query. This is the most powerful and commonly used query type:
GET /products/_search
{
"query": {
"bool": {
"must": [
{
"multi_match": {
"query": "wireless headphones",
"fields": ["name", "description"]
}
}
],
"filter": [
{ "term": { "in_stock": true } },
{ "range": { "price": { "gte": 10, "lte": 100 } } },
{ "term": { "category": "electronics" } }
],
"should": [
{ "term": { "tags": { "value": "bluetooth", "boost": 2 } } }
],
"must_not": [
{ "range": { "review_count": { "lt": 10 } } }
]
}
}
}
Breaking down the boolean clauses:
must— Required conditions that contribute to the relevance score. Documents must match thesefilter— Required conditions that do NOT affect the score. These are cached for performanceshould— Optional conditions that boost the score if matched. Useminimum_should_matchto make some requiredmust_not— Exclusion conditions. Documents matching these are removed from results
Fuzzy Search for Typo Tolerance
Users make typos. Fuzzy search handles misspellings gracefully:
GET /products/_search
{
"query": {
"match": {
"name": {
"query": "hedphones",
"fuzziness": "AUTO",
"prefix_length": 2
}
}
}
}
fuzziness: "AUTO" automatically adjusts the edit distance based on term length (0 edits for 1-2 character terms, 1 for 3-5, 2 for longer terms). prefix_length requires the first N characters to match exactly, improving performance by reducing the candidate set.
Phrase Search and Proximity
Search for exact phrases or words that appear near each other:
GET /products/_search
{
"query": {
"match_phrase": {
"description": {
"query": "noise canceling headphones",
"slop": 2
}
}
}
}
slop allows up to N transpositions or intervening words between the search terms. A slop of 0 requires the exact phrase, while slop of 2 allows up to two edits (insertions, deletions, or swaps) in the word sequence.
Aggregations: Building Faceted Search
Aggregations are essential for building filter panels, category counts, price ranges, and analytics alongside search results:
GET /products/_search
{
"size": 10,
"query": {
"match_all": {}
},
"aggs": {
"category_counts": {
"terms": {
"field": "category",
"size": 10
}
},
"price_ranges": {
"range": {
"field": "price",
"ranges": [
{ "key": "under $25", "to": 25 },
{ "key": "$25 to $50", "from": 25, "to": 50 },
{ "key": "$50 to $100", "from": 50, "to": 100 },
{ "key": "over $100", "from": 100 }
]
}
},
"average_rating": {
"avg": {
"field": "rating"
}
},
"in_stock_count": {
"filter": {
"term": { "in_stock": true }
}
}
}
}
You can nest aggregations, combine them with queries (to get filtered facet counts), and use them to dynamically build UI filter controls that show available options with counts.
Highlighting Search Results
Show users why a result matched by highlighting matching terms in the context:
GET /products/_search
{
"query": {
"match": {
"description": "noise canceling battery"
}
},
"highlight": {
"fields": {
"description": {
"number_of_fragments": 3,
"fragment_size": 150,
"pre_tags": [""],
"post_tags": [""]
}
}
}
}
The response includes a highlight object for each hit with the marked-up text fragments, ready to render directly in your UI.
Implementing Search from Your Application
Here's a practical Node.js example using the official Elasticsearch client. This demonstrates a complete search function you might use in a product listing API:
// Install: npm install @elastic/elasticsearch
const { Client } = require('@elastic/elasticsearch');
const client = new Client({
node: 'http://localhost:9200',
// For production, add cloud configuration or authentication
});
async function searchProducts({
query = '',
category = null,
minPrice = null,
maxPrice = null,
inStock = null,
tags = [],
sortBy = 'relevance',
page = 1,
pageSize = 20
}) {
// Build the boolean query dynamically
const mustClauses = [];
const filterClauses = [];
// Full-text search if query provided
if (query.trim()) {
mustClauses.push({
multi_match: {
query: query,
fields: ['name^3', 'description', 'tags^2'],
fuzziness: 'AUTO'
}
});
}
// Category filter
if (category) {
filterClauses.push({ term: { category } });
}
// Price range filter
if (minPrice !== null || maxPrice !== null) {
const range = {};
if (minPrice !== null) range.gte = minPrice;
if (maxPrice !== null) range.lte = maxPrice;
filterClauses.push({ range: { price: range } });
}
// Stock filter
if (inStock !== null) {
filterClauses.push({ term: { in_stock: inStock } });
}
// Tags filter (match any of the specified tags)
if (tags.length > 0) {
filterClauses.push({
terms: { tags }
});
}
// Construct the full body
const body = {
from: (page - 1) * pageSize,
size: pageSize,
query: {
bool: {
must: mustClauses.length > 0 ? mustClauses : [{ match_all: {} }],
filter: filterClauses
}
},
aggs: {
categories: {
terms: { field: 'category', size: 50 }
},
price_stats: {
stats: { field: 'price' }
},
popular_tags: {
terms: { field: 'tags', size: 20 }
}
},
highlight: {
fields: {
name: {},
description: {
fragment_size: 150,
number_of_fragments: 3
}
},
pre_tags: [''],
post_tags: ['']
}
};
// Add sorting
if (sortBy !== 'relevance') {
switch (sortBy) {
case 'price_asc':
body.sort = [{ price: { order: 'asc' } }];
break;
case 'price_desc':
body.sort = [{ price: { order: 'desc' } }];
break;
case 'rating':
body.sort = [{ rating: { order: 'desc' } }];
break;
case 'newest':
body.sort = [{ created_at: { order: 'desc' } }];
break;
}
}
try {
const response = await client.search({
index: 'products',
body
});
// Transform the response into a clean format for your API consumers
return {
total: response.hits.total.value,
page,
pageSize,
results: response.hits.hits.map(hit => ({
id: hit._id,
score: hit._score,
...hit._source,
highlights: hit.highlight || {}
})),
aggregations: {
categories: response.aggregations.categories.buckets.map(b => ({
key: b.key,
count: b.doc_count
})),
priceStats: {
min: response.aggregations.price_stats.min,
max: response.aggregations.price_stats.max,
avg: response.aggregations.price_stats.avg
},
popularTags: response.aggregations.popular_tags.buckets.map(b => ({
tag: b.key,
count: b.doc_count
}))
}
};
} catch (error) {
console.error('Search error:', error.meta?.body || error.message);
throw error;
}
}
// Example usage
async function exampleSearch() {
const results = await searchProducts({
query: 'wireless headphones',
category: 'electronics',
minPrice: 20,
maxPrice: 100,
inStock: true,
page: 1,
pageSize: 10
});
console.log(JSON.stringify(results, null, 2));
}
exampleSearch();
This function demonstrates several production-ready patterns: dynamic query building, proper pagination with from/size, aggregations for filter panels, result highlighting, and multiple sort options. The response transformation step is crucial — it decouples your API shape from Elasticsearch's internal format.
Best Practices for Production Search
1. Design Your Mappings Before Indexing
Mappings are difficult to change after data is indexed. Plan your field types, analyzers, and multi-fields upfront. For text fields that need both search and sorting, always add a .keyword sub-field. Use keyword for categories, statuses, IDs, and any field used in filters or aggregations.
2. Use Filters Instead of Must for Non-Scoring Conditions
Every clause in must contributes to the relevance score calculation, which adds overhead. Filters are cached at the shard level and reused across queries. Put all exact-match, range, and boolean conditions in filter context. Reserve must for full-text search clauses where scoring matters.
3. Avoid Deep Pagination
Elasticsearch's from/size pagination becomes expensive beyond ~10,000 results because it must track the entire result window across shards. For deep pagination, use search_after with cursor-based pagination:
GET /products/_search
{
"size": 20,
"sort": [
{ "rating": "desc" },
{ "_id": "asc" }
],
"search_after": [4.5, "3"],
"query": {
"match_all": {}
}
}
Pass the sort values from the last result as search_after for the next page. This is stateless and scales to arbitrary depths.
4. Monitor and Tune Relevance
Use the Explain API to understand exactly why a document matched and how its score was computed:
GET /products/_explain/1
{
"query": {
"match": {
"description": "wireless headphones"
}
}
}
Regularly review search queries with low click-through rates and adjust boosts, analyzers, or synonyms to improve result quality.
5. Implement Index Aliases for Zero-Downtime Migrations
Never point your application directly at a concrete index name. Use aliases so you can reindex into a new index and swap the alias atomically:
# Create alias pointing to current index
POST /_aliases
{
"actions": [
{ "add": { "index": "products_v1", "alias": "products" } }
]
}
# Later, reindex and swap
POST /_reindex
{
"source": { "index": "products_v1" },
"dest": { "index": "products_v2" }
}
POST /_aliases
{
"actions": [
{ "remove": { "index": "products_v1", "alias": "products" } },
{ "add": { "index": "products_v2", "alias": "products" } }
]
}
6. Use Synonyms for Better Recall
Users may search for "cell phone" when your products say "mobile phone." Synonyms bridge this gap. Define them in index settings:
PUT /products
{
"settings": {
"analysis": {
"filter": {
"synonym_filter": {
"type": "synonym",
"synonyms": [
"cell phone, mobile phone, smartphone",
"tv, television, smart tv => television",
"headphones, earphones, earbuds",
"laptop, notebook, macbook => laptop"
]
}
},
"analyzer": {
"synonym_analyzer": {
"tokenizer": "standard",
"filter": ["lowercase", "synonym_filter", "stemmer"]
}
}
}
}
}
The => syntax maps multiple terms to a single canonical term, reducing index size while maintaining broad matching.
7. Set Up Proper Error Handling and Timeouts
Network issues, cluster overload, and malformed queries can cause failures. Implement retries with exponential backoff, circuit breakers, and timeouts:
const client = new Client({
node: 'http://localhost:9200',
maxRetries: 3,
requestTimeout: 5000, // 5 seconds
sniffOnStart: true, // Discover cluster nodes
sniffInterval: 300000 // Refresh node list every 5 minutes
});
8. Secure Your Cluster
In production, enable authentication, encrypt communications with TLS, and restrict access with firewall rules. Use API keys or service tokens for application access. Never expose Elasticsearch directly to the public internet without authentication.
9. Benchmark and Scale
Use tools like Rally (Elastic's benchmarking toolkit) to test your cluster with realistic workloads. Monitor heap usage, garbage collection, and query latency. Scale horizontally by adding nodes rather than vertically upgrading hardware — Elasticsearch is designed for horizontal scaling.
Conclusion
Building a search feature with Elasticsearch transforms a basic database lookup into a sophisticated, user-friendly experience capable of handling typos, understanding language nuances, and delivering instant results at scale. The journey from a simple match query to a production-grade search implementation involves thoughtful mapping design, strategic use of boolean queries with filters, relevance tuning, and robust application integration patterns.
The key takeaways are: invest time in designing your mappings and analyzers early, use filters liberally to keep queries fast, implement cursor-based pagination for large result sets, leverage aggregations to build rich filter experiences, and always use index aliases for operational flexibility. With these foundations, you'll have a search feature that not only works reliably but delights users with its speed and accuracy.