All lessons
DataAdvanced13 min readWorth knowing

Elasticsearch

Full-text search and analytics on an inverted index, distributed across shards.

  • Inverted index
  • Analyzers
  • Shards
  • OpenSearch
Step 1/5
A search engine keeps an inverted index: like the index at the back of a book, every word points to the documents that contain it.

In 30 seconds

Elasticsearch is a distributed search engine built on Apache Lucene. It turns text into terms with analyzers and stores them in an inverted index, so it can find and rank matching documents in milliseconds across millions of records. It also does aggregations, logs and, more recently, vector and hybrid search. The current major line is 9.x; OpenSearch is its open-source fork.

Not in my daily stack — I know the concepts and would learn the details when a project needs it.

Why it matters

  • Users expect search that forgives typos, understands word forms and ranks the best result first. A SQL LIKE query cannot do that at scale; an inverted index with relevance scoring can.
  • It is the backbone of many log and observability stacks (the Elastic Stack), where fast filtering and aggregation over huge volumes matters more than transactions.
  • Teams pick it when they need full control over search: custom analyzers per language, synonyms, boosting rules and faceted navigation, self-hosted or managed.
  • It contrasts with hosted search services such as Algolia: more setup and operations work, but more flexibility, no per-search pricing and the option to keep data in your own infrastructure.

How it works

01The idea: an inverted index

A book index lists words with the pages they appear on. An inverted index does the same for documents: for every term, it stores the list of document IDs that contain it, plus positions and frequencies. To search “red running shoes”, Elasticsearch looks up three short lists and intersects or merges them, instead of reading every document. Relevance is calculated with BM25 by default: a term counts more if it is rare across the index and frequent in the document, and less in very long fields. Elasticsearch is near real-time: new documents become searchable after a refresh, which happens every second by default.

  • Index: a logical collection of documents with one mapping, like a table.
  • Document: a JSON object, the unit you index and search.
  • Term: a normalised token produced by the analyzer, for example “running” becomes “run”.

02Mappings and analyzers

A mapping defines the type of each field. The most important distinction is text versus keyword. A text field is analyzed: split into tokens, lowercased, maybe stemmed, so it is good for full-text search but not for sorting or exact filters. A keyword field is stored as one exact value, good for filters, sorting and aggregations (brand, status, SKU). Often you map a field both ways with a sub-field. An analyzer is a pipeline: character filters, a tokenizer and token filters (lowercase, stop words, stemming, synonyms). Choose language analyzers (english, german, russian…) per field. Define mappings explicitly; dynamic mapping guesses types and often guesses wrong.

json
PUT /products
{
  "settings": { "number_of_shards": 1, "number_of_replicas": 1 },
  "mappings": {
    "dynamic": "strict",
    "properties": {
      "name":       { "type": "text", "analyzer": "english",
                      "fields": { "raw": { "type": "keyword" } } },
      "brand":      { "type": "keyword" },
      "price":      { "type": "scaled_float", "scaling_factor": 100 },
      "in_stock":   { "type": "boolean" },
      "created_at": { "type": "date" }
    }
  }
}

03Querying: query context and filter context

Search uses the Query DSL, a JSON language. The bool query combines clauses: must and should contribute to the relevance score, filter and must_not only include or exclude. Filters are cached and do not compute scores, so put every yes/no condition (in stock, brand, price range) in filter and keep must for what the user typed. multi_match searches several fields and lets you boost one, such as name over description. fuzziness: "AUTO" tolerates typos. Aggregations run on the same request to build facets, such as counts per brand, so one call returns both results and filters for the sidebar.

json
GET /products/_search
{
  "query": {
    "bool": {
      "must":   { "multi_match": { "query": "red runing shoes",
                  "fields": ["name^3", "description"], "fuzziness": "AUTO" } },
      "filter": [
        { "term":  { "in_stock": true } },
        { "range": { "price": { "lte": 120 } } }
      ]
    }
  },
  "aggs": { "brands": { "terms": { "field": "brand", "size": 10 } } },
  "size": 20
}

04Cluster, shards and replicas

An index is split into primary shards, each a self-contained Lucene index, and every primary can have replica copies on other nodes. Shards are how Elasticsearch spreads data and parallelises queries; replicas provide failover and extra read capacity. A query fans out to one copy of every shard, each returns its top hits, and the coordinating node merges them. The number of primary shards is fixed at index creation (changing it needs a split, shrink or reindex), so size it from expected data: Elastic's guidance is roughly 10–50 GB per shard and not more than about 200 million documents. Too many tiny shards waste memory; a few huge ones are slow to recover.

  • Cluster health green: all shards assigned; yellow: replicas missing; red: a primary is missing.
  • Node roles: master-eligible, data (hot, warm, cold, frozen), ingest, coordinating, ml.
  • Use three master-eligible nodes for a production cluster to avoid split decisions.

05Indexing from an application, safely

Your primary database stays the source of truth; Elasticsearch is a derived, searchable copy. Index documents in batches with the _bulk API, not one request per document. Use your database ID as the Elasticsearch _id so re-indexing is idempotent. For changes, either queue an indexing job after each write (an outbox or queue keeps it reliable) or use change data capture. Always read and write through an alias, not the real index name: when the mapping needs to change, build a new index, reindex into it and switch the alias atomically. Security is on by default since 8.0 (TLS and authentication), so use an API key with only the privileges the app needs.

json
POST /_bulk
{ "index": { "_index": "products", "_id": "1001" } }
{ "name": "Trail running shoe", "brand": "acme", "price": 89.9, "in_stock": true }
{ "index": { "_index": "products", "_id": "1002" } }
{ "name": "Road running shoe", "brand": "acme", "price": 99.0, "in_stock": false }
 
POST /_aliases
{ "actions": [
  { "remove": { "index": "products_v1", "alias": "products_live" } },
  { "add":    { "index": "products_v2", "alias": "products_live" } }
] }

06Licences, OpenSearch and managed options

In 2021 Elastic moved from Apache 2.0 to the SSPL and Elastic License, and AWS forked the last open version into OpenSearch, now governed by the OpenSearch Software Foundation under the Linux Foundation. In 2024 Elastic added AGPLv3 as a licence option for the source code. The two projects share roots and much of the Query DSL, but they have diverged in features, plugins and client libraries, so pick one and use its own client. Hosting options include Elastic Cloud (including a serverless offering), Amazon OpenSearch Service, or self-managed clusters on VMs or Kubernetes (with the ECK operator for Elasticsearch).

Use it when

  • Product, document or site search that needs relevance ranking, typo tolerance, synonyms and facets.
  • Log, metric and event analytics where you filter and aggregate large volumes in near real time.
  • Autocomplete and search-as-you-type over large catalogues.
  • Hybrid search that combines keyword relevance with vector similarity in one engine.

Skip it when

  • As the primary database for transactional data; it has no multi-document transactions and is eventually consistent.
  • For a small catalogue where PostgreSQL full-text search or a hosted service covers the need with far less operations work.
  • Heavy relational joins or strict consistency requirements.

Alternatives

  • OpenSearch

    The Apache 2.0 fork; similar API, strong AWS integration, its own plugin ecosystem.

  • Algolia

    Hosted search-as-a-service with very fast setup and great typo tolerance; pricing grows with records and requests.

  • Meilisearch / Typesense

    Lightweight open-source search engines that are easy to run, suited for small and medium datasets.

  • PostgreSQL full-text search

    tsvector and GIN indexes inside your database; good enough for many apps without a second system.

Best practices

  1. Define explicit mappings (dynamic: strict or false) and map strings as text, keyword, or both deliberately.
  2. Put yes/no conditions in filter context so they are cached and do not affect scoring.
  3. Always access indices through aliases so reindexing and mapping changes need no downtime.
  4. Size shards at roughly 10–50 GB, use one primary shard for small indices, and avoid thousands of tiny shards.
  5. Index with the _bulk API and use your database ID as _id for idempotent updates.
  6. Use data streams and index lifecycle management (ILM) for logs so old data rolls to cheaper tiers and is deleted on schedule.
  7. Keep security enabled, use scoped API keys and take regular snapshots to object storage.

Common mistakes

  • Relying on dynamic mapping, which creates text plus keyword for every string and maps numbers or dates inconsistently.
  • Using deep pagination with from and size for thousands of pages instead of search_after with a point in time.
  • Treating Elasticsearch as the source of truth and losing data that exists nowhere else.
  • Creating a new index per day per customer and ending up with tens of thousands of shards.
  • Running a term query on a text field, which fails because the stored terms are analysed and lowercased.

Deep dive

07Segments, refresh, flush and merges

Inside each shard, Lucene writes data into immutable segments. New documents go to an in-memory buffer and the translog (a write-ahead log). A refresh (every 1 second by default, only on indices that were searched recently) turns the buffer into a new searchable segment; a flush commits segments to disk and trims the translog. Because segments are immutable, updates are really delete plus insert, and deleted documents are only removed when background merges combine small segments into bigger ones. For bulk loads, set refresh_interval to -1 and replicas to 0, load, then restore both; it can multiply indexing speed. Avoid frequent single-document updates on the same IDs, which create many tiny segments and deleted docs.

json
PUT /products_v2/_settings
{ "index": { "refresh_interval": "-1", "number_of_replicas": 0 } }
 
// ...run the bulk load, then:
PUT /products_v2/_settings
{ "index": { "refresh_interval": "1s", "number_of_replicas": 1 } }
POST /products_v2/_refresh

08Vector, semantic and hybrid search

Elasticsearch 8 and 9 support dense_vector fields with approximate kNN search using HNSW graphs, with quantization (int8, int4 and BBQ, a binary quantization) to cut memory. The semantic_text field type goes further: you attach an inference endpoint and Elasticsearch chunks the text and creates embeddings at index time. Retrievers let you combine a standard BM25 query with a kNN query using reciprocal rank fusion (RRF), which merges two ranked lists by position instead of raw score. Hybrid search usually beats either method alone: keywords catch exact SKUs and names, vectors catch meaning. Measure with a labelled query set before and after.

json
GET /products/_search
{
  "retriever": { "rrf": { "retrievers": [
    { "standard": { "query": { "match": { "name": "waterproof hiking boots" } } } },
    { "knn": { "field": "name_vector", "k": 20, "num_candidates": 100,
               "query_vector_builder": { "text_embedding": {
                 "model_id": "my-embedding-endpoint", "model_text": "waterproof hiking boots" } } } }
  ] } }
}

09Deep pagination and consistent scrolling

from + size pagination makes every shard collect from + size hits and the coordinator sort them all, so page 500 is very expensive; the default limit (index.max_result_window) is 10,000 results for that reason. For user-facing infinite scrolling or exports, use search_after with a sort that ends in a unique tiebreaker, combined with a point in time (PIT) so the view does not shift while new documents arrive. The older scroll API is still available but is no longer recommended for deep pagination. For exports of very large result sets, consider sliced PIT searches in parallel.

10Operating a cluster: memory, tiers and snapshots

Give the JVM heap no more than about half the RAM and stay below the compressed-pointer threshold (around 30 GB); the rest is used by the OS file cache, which Lucene relies on heavily. Recent versions size the heap automatically, which is usually right. Use hot, warm, cold and frozen data tiers with ILM for time-based data so old indices move to cheaper storage and are eventually deleted. Snapshot regularly to object storage (S3, GCS, Azure) with snapshot lifecycle management; snapshots are incremental and are your real backup, because replicas do not protect against deleting an index. Upgrade with rolling restarts within a major, and check the deprecation API before major upgrades.

Real-world scenarios

01

Search returns nothing for exact SKU

Problem

Users paste an SKU like “AB-1234-XL” into search and get no results, though the product exists. The SKU is mapped as a text field with the standard analyzer.

How to solve it

The standard analyzer splits the SKU on hyphens and lowercases it, so the exact string never exists as one term. Add a keyword sub-field (sku.raw) with a lowercase normalizer, reindex behind an alias, and in the search request add a should clause with a term query on sku.raw boosted high, alongside the normal multi_match. Optionally detect SKU-like input with a regex in the app and run an exact lookup first. Add a regression test with real SKUs to the relevance test set so the problem does not return after future mapping changes.

02

Cluster turns red after a node dies

Problem

A three-node cluster had indices with zero replicas to save disk. One node failed overnight and several indices are now red, with search errors in production.

How to solve it

Red means a primary shard has no available copy, so its data is offline until that node returns or you restore. Bring the node back if the disk is intact; otherwise restore the affected indices from the latest snapshot, or rebuild them from the source database if Elasticsearch is a derived index. Use the cluster allocation explain API to see why shards are unassigned. Afterwards, set at least one replica on all production indices, add disk capacity or ILM deletion to pay for it, configure snapshot lifecycle management and alert on cluster health yellow, not only red.

03

Slow faceted search on a large catalogue

Problem

Searches with five facets take 1.5 seconds on a ten-million-product index. Profiling shows most time is spent in aggregations and scoring.

How to solve it

Move all facet selections into filter context so they are cached and not scored. Use keyword fields with doc_values for the terms aggregations and keep their size small. For facets that should show counts across other selections, use post_filter so aggregations run on the broader set only once. Check shard size: an index with 30 shards of 2 GB fans out needlessly, so shrink to fewer shards. Enable the request cache for repeated aggregation-only requests. Use the profile API to confirm each change and compare p95 latency before and after with a replay of real queries.

Cheat sheet

Cluster health
GET /_cluster/health?pretty
List indices with sizes
GET /_cat/indices?v&s=store.size:desc
Check how text is analysed
POST /products/_analyze { "field": "name", "text": "Running Shoes" }
Explain why a shard is unassigned
GET /_cluster/allocation/explain
Reindex into a new index
POST /_reindex { "source": { "index": "products_v1" }, "dest": { "index": "products_v2" } }
Atomic alias switch
POST /_aliases { "actions": [ { "remove": {...} }, { "add": {...} } ] }
Profile a slow query
GET /products/_search { "profile": true, "query": { ... } }
Open a point in time
POST /products/_pit?keep_alive=1m
Create an API key
POST /_security/api_key { "name": "app-search", "role_descriptors": { ... } }

Questions & answers

Q1What is the difference between text and keyword fields?

A text field is analyzed at index time: the analyzer splits it into tokens, lowercases them and may remove stop words or stem them, so “Running Shoes” becomes the terms run and shoe. That is what you want for full-text search. A keyword field is stored as a single exact value, which is what you need for filters, sorting and aggregations, for example brand or status. A term query on a text field usually fails because it looks for the exact unanalysed value. The common pattern is a text field with a keyword sub-field, so you can search and also sort or facet.

Q2How does Elasticsearch rank results?

By default it uses BM25, a probabilistic relevance model. For each query term, it considers term frequency in the document (more is better, with diminishing returns), inverse document frequency (rare terms count more) and field length (a match in a short title outweighs one in a long body). Scores from clauses in must and should are combined. You shape relevance with field boosts, function_score or rank_feature for business signals like popularity, and synonyms. Filter clauses do not affect the score. For semantic relevance you can add vector search and combine both lists with reciprocal rank fusion.

Q3How many shards should an index have?

Fewer than most people think. Each shard is a Lucene index with its own memory and file overhead, and every search touches one copy of each shard. Elastic's guidance is to aim for shards of roughly 10 to 50 GB and keep the total shard count per node moderate. A catalogue of a few gigabytes should have one primary shard and one replica. For growing log data, use data streams with rollover by size or age, so each backing index gets a sensible size automatically. If you get it wrong, you can split or shrink an index or reindex behind an alias.

Q4How do you change a mapping without downtime?

You cannot change the type of an existing field in place, because the data is already indexed in that form. The safe process is: create a new index (products_v2) with the new mapping, reindex from the old index or from the source database, keep both in sync during the switch (for example by writing to both or replaying changes), then atomically move the alias from v1 to v2 with one _aliases request. Applications always talk to the alias, so they never notice. Keep the old index for a short rollback window and then delete it. Adding new fields is allowed without reindexing.

Q5Is Elasticsearch a database?

It stores data durably and can be queried, but I would not use it as a primary database for business records. It has no multi-document transactions, writes are visible only after a refresh, and some operations, like mapping changes, require reindexing from somewhere. Cluster problems can also make recovery harder than restoring a relational backup. The robust pattern is to keep the source of truth in PostgreSQL, MySQL or MongoDB and treat Elasticsearch as a derived index you can rebuild at any time. For logs and observability, where data is append-only, it is acceptable as the main store with snapshots.

Q6Elasticsearch or a hosted service like Algolia?

Algolia gives excellent typo tolerance, instant results and a polished dashboard within a day, and you never run servers. The trade-offs are pricing that grows with records and search requests, less control over ranking internals, and data living in a third party. Elasticsearch, self-managed or on Elastic Cloud, needs more setup and relevance work, but offers custom analyzers, aggregations for analytics, vector search and predictable infrastructure costs at scale. For a small or medium store with a small team, hosted search is often the pragmatic choice; for large catalogues, complex facets or logs, Elasticsearch or OpenSearch wins.

Senior-level questions

S1How would you keep Elasticsearch in sync with a relational database at scale?

Dual writes in the request path are fragile: the database commit can succeed while the index call fails. I prefer an asynchronous pipeline. Either the app writes an outbox row in the same transaction and a worker indexes from the outbox, or a change data capture tool such as Debezium reads the binlog or WAL and publishes changes that an indexer consumes. The indexer uses the database ID as _id, external versioning (version_type external with an updated_at or sequence number) so out-of-order events cannot overwrite newer data, and _bulk for throughput. I monitor lag, and keep a full reindex job behind an alias for disaster recovery and mapping changes. The trade-off is eventual consistency of seconds, which search usually tolerates.

S2How do you measure and improve search relevance?

Relevance is a product metric, so treat it like one. Build a judgement list: a few hundred real queries from logs with the documents that should rank high, graded by people who know the domain. Use the rank evaluation API to compute metrics such as nDCG or MRR for the current configuration. Then change one thing at a time: analyzers, synonyms, field boosts, function_score with popularity or margin, or adding a vector retriever with RRF. Re-run the evaluation and keep changes that improve it without breaking known queries. Online, track click-through, zero-result rate and search exits, and A/B test bigger changes. Without this loop, relevance tuning becomes opinion and every fix breaks another query.

S3Elasticsearch vs OpenSearch for a new project in 2026?

Both are mature Lucene-based engines with similar core APIs, so the decision is mostly about ecosystem, licence and hosting. Elasticsearch moves faster on features such as semantic_text, retrievers and vector quantization, and Elastic Cloud is a strong managed option; its source is available under AGPL, SSPL or the Elastic License. OpenSearch is Apache 2.0 under a foundation, integrates tightly with AWS, and has its own plugins for security, alerting and vector search. If you are deep in AWS and want a managed service with a permissive licence, OpenSearch is natural. If you want the newest search features or the Elastic observability stack, choose Elasticsearch. Do not mix their clients: the APIs have diverged enough to cause subtle bugs.

Glossary

Inverted index
A map from each term to the list of documents containing it, which makes text search fast.
Analyzer
The pipeline that turns text into terms: character filters, a tokenizer and token filters.
Mapping
The schema of an index: field names, types and analyzers.
Shard
A slice of an index, itself a complete Lucene index; primaries hold data, replicas copy it.
BM25
The default relevance formula, based on term frequency, term rarity and field length.
Refresh
The operation that makes newly indexed documents searchable, every second by default.
Alias
A stable name pointing to one or more indices, used to swap indices without downtime.
ILM
Index lifecycle management: automatic rollover, tiering and deletion of time-based indices.
RRF
Reciprocal rank fusion: merging ranked lists by position, used for hybrid keyword and vector search.

Have a product in mind?Let's build it.

Tell me about your idea, your product or your team. I usually reply within one working day.