← Back to DevBytes

How to Build a Search Feature with Elasticsearch

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:

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

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:

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.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles