NoSQL · 05

Elastic search

A relational database finds rows by key; a search engine finds documents by words and ranks them. This page builds Elasticsearch from the ground up: the inverted index, how text is analysed, how mappings and the Query DSL work, how BM25 ranks results, how shards, segments, refresh and the translog behave, how to size and operate a cluster, why it must not be your system of record, and how to keep it in sync, finishing with a Docker lab and a Java client example.

13
Chapters
9.x
Elasticsearch version
BM25
Default scoring
1
Docker lab
01
Chapter One - Why It Exists

What a Search Engine Solves

Imagine a products table with ten million rows and a search box on top. A user types waterproof hiking boots. In a relational database the obvious query is WHERE name LIKE '%waterproof%'. It has four problems: a leading wildcard cannot use a normal B-tree index, so the database scans every row; LIKE knows nothing about words (boot does not match boots, Boots needs case handling); it returns rows in no meaningful order; and it cannot say which product is the best match.

A search engine is built for exactly that job: find the documents that contain these words, rank them by relevance, and do it in milliseconds over millions of documents. Elasticsearch is a distributed search and analytics engine built on the Apache Lucene library. You send it JSON documents over a REST API, and it builds the data structures that make text search and aggregations fast.

NeedRelational databaseSearch engine (Elasticsearch)
Exact lookup by keyExcellent (B-tree)Possible, but not what it is for
Transactions across rowsYes (ACID)No multi-document transactions
Full text: words, stems, synonyms, typosBasic (PostgreSQL has built-in full-text search, see page 07)Core feature: analyzers, fuzziness, phrase queries
Rank results by relevanceRarelyBuilt in (BM25 by default, chapter 06)
Facets and counts ("brand: Acme (120)")GROUP BY, can be slow at scaleAggregations, designed for it
Search over logs / events at huge volumePoor fitStrong fit (data streams, chapter 09)
Source of truth for business dataYesNo (chapter 10)
Key insight: A database is a filing cabinet organised by the label on each folder. A search engine is the index at the back of a book: for every word it lists the pages where that word appears. Finding a word never means reading the book.

Typical uses: product and site search, log and event analytics (the "ELK" pattern), application and security observability, autocomplete, and in recent versions vector search for semantic retrieval (chapter 09). Across all of them the pattern is the same: a copy of data from somewhere else, shaped for fast reading.

Scope and version

This page targets Elasticsearch 9.x (the Elastic docs list 9.5.5 as the current release on 2026-10-31). Most concepts are identical in 8.x; where a behaviour differs by version it is stated. The brief reference docs are at elastic.co/docs.

02
Chapter Two - The Core Idea

The Inverted Index

The data structure behind every search engine is the inverted index. A normal ("forward") index maps document to its words. The inverted index flips it: it maps each term to the list of documents that contain it. That list is called a postings list.

Inverted index: from documents to terms to postings lists
Documents (what you index) doc 1: "Red hiking boots" doc 2: "Waterproof boots" doc 3: "Red rain jacket" Analysis (lowercase, split) turns each text into terms before they are stored. invert Term dictionary (sorted) Postings list boots doc 1, doc 2 hiking doc 1 jacket doc 3 rain doc 3 red doc 1, doc 3 waterproof doc 2 Query "red boots": look up two terms, intersect postings: {doc 1, doc 3} AND {doc 1, doc 2} = {doc 1} Real postings also store term frequency and positions.

Lucene stores a sorted term dictionary (so a term can be found by binary-search-like lookups, and prefix or range of terms can be enumerated) and, for every term, a compressed postings list of document ids. To answer red boots it looks up two terms and intersects two sorted integer lists, which is very cheap. The cost is paid at index time, when every document is broken into terms.

Stored per termWhat it is used for
Postings: the ids of documents containing the termDeciding which documents match
Term frequency: how often the term occurs in each documentRelevance scoring (a document mentioning "boots" 5 times scores higher than one mentioning it once, with diminishing returns)
Positions: where in the field each occurrence isPhrase queries: "red boots" must be adjacent
Document frequency: in how many documents the term occursRare terms matter more than common ones (the IDF idea)
What else is stored besides the inverted index

Search needs more than "which documents match". Lucene keeps several structures per field, and which ones exist depends on the field type (chapter 04):

  • Inverted index - term to documents, for matching text and exact values.
  • Doc values - a column-oriented, on-disk structure (document to value) used for sorting and aggregations. Most field types except analyzed text have it on by default.
  • **Stored fields / _source** - the original JSON, returned in search hits. Elasticsearch stores the original document in the _source field.
  • Vectors and BKD trees - used for dense vector search and for numeric/geo range queries respectively.
⚠ Thinking of Elasticsearch as "a database with a text column"
  • Each field you index creates several on-disk structures, so the same data costs more space and write time than in a table. Index only what you search or aggregate on, and disable what you do not need (for example index: false or doc_values: false on a field you only display).
03
Chapter Three - Text Becomes Terms

Analysis: Character Filters, Tokenizer, Token Filters

Before text reaches the inverted index it goes through analysis. An analyzer is a pipeline of exactly three kinds of component, in this order:

The analysis chain: text in, terms out
Input text "The QUICK foxes!" 1. Character filters 0 or more (strip HTML) 2. Tokenizer exactly one: split text 3. Token filters 0 or more: lowercase... tokenizer: The | QUICK | foxes lowercase filter: the | quick | foxes Same analyzer must run on the document at index time and on the query text at search time, otherwise the query terms are not the terms stored in the index. Output terms are what the inverted index stores: the, quick, foxes
StageHow manyJobExamples
Character filters0 or moreClean the raw character stream before tokenisinghtml_strip, mapping, pattern_replace
TokenizerExactly 1Split text into tokens and record their positions and offsetsstandard, whitespace, keyword (one token), ngram, edge_ngram
Token filters0 or moreAdd, remove or modify tokenslowercase, stop, stemmer, synonym, asciifolding

If you do not choose an analyzer, a text field uses the **standard analyzer**: the Unicode-text-segmentation standard tokenizer plus the lowercase token filter (no stop words by default in current versions, no stemming). That is why Quick and quick match each other by default but foxes and fox do not.

Try it: the _analyze API

The _analyze API is the single most useful debugging tool in Elasticsearch: it shows exactly which terms an analyzer produces. Request (Kibana Dev Tools style):

Request - what does the standard analyzer do?
POST /_analyze { "analyzer": "standard", "text": "The 2 QUICK Brown-Foxes jumped!" }

The response lists one entry per token with its position and character offsets. Abridged and shown in a compact layout; this is the documented shape, not a captured run:

Response (abridged, illustrative)
{ "tokens": [ { "token": "the", "start_offset": 0, "end_offset": 3, "type": "<ALPHANUM>", "position": 0 }, { "token": "2", "start_offset": 4, "end_offset": 5, "type": "<NUM>", "position": 1 }, { "token": "quick", "start_offset": 6, "end_offset": 11, "type": "<ALPHANUM>", "position": 2 }, { "token": "brown", "start_offset": 12, "end_offset": 17, "type": "<ALPHANUM>", "position": 3 }, { "token": "foxes", "start_offset": 18, "end_offset": 23, "type": "<ALPHANUM>", "position": 4 }, { "token": "jumped", "start_offset": 24, "end_offset": 30, "type": "<ALPHANUM>", "position": 5 } ] }

Note the hyphen split Brown-Foxes into two tokens, the digit became a token, the punctuation disappeared, and everything was lower-cased. The stop word The was kept: the standard analyzer does not remove stop words unless you configure it.

Building a custom analyzer
Request - html_strip, standard tokenizer, lowercase, stop words, English stemmer
PUT /products-v1 { "settings": { "analysis": { "analyzer": { "english_clean": { "type": "custom", "char_filter": ["html_strip"], "tokenizer": "standard", "filter": ["lowercase", "english_stop", "english_stemmer"] } }, "filter": { "english_stop": { "type": "stop", "stopwords": "_english_" }, "english_stemmer": { "type": "stemmer", "language": "english" } } } } }

With this analyzer, <b>Running</b> shoes is indexed as the terms run and shoe, and a query for runs shoe produces the same terms, so they match. Test it with POST /products-v1/_analyze and "analyzer": "english_clean" before you rely on it.

Index time: "Running shoes" with the english_clean analyzer
▶
Terms run, shoe are written to the inverted index
Search time: match query "runs shoe" on the same field
▶
The query text is analysed with the field's analyzer: terms run, shoe, both found, document matches
Search time: term query for "Running"
▶
A term query is not analysed. It looks for the literal term Running, which is not in the index (the stored term is run), so it finds nothing
⚠ Using a term query on a text field
  • The most common beginner bug: term searches for the exact token, but a text field stores analysed (lower-cased, stemmed) tokens. {"term": {"title": "Red Boots"}} finds nothing because no stored term contains a capital or a space. Use match for text fields and term for keyword, numbers and dates.
04
Chapter Four - Schema

Mappings: text vs keyword, Multi-Fields, Dynamic Mapping

A mapping defines the fields of an index and how each is stored and indexed. Elasticsearch is "schemaless" only in the sense that it will guess a mapping if you do not give one. In production you should define it.

TypeAnalysed?Use it forQuery with
textYes (analyzer)Full-text search: titles, descriptions, messagesmatch, match_phrase
keywordNo (exact value, optionally a normalizer)IDs, enums, tags, emails, status, anything you filter, sort or aggregate onterm, terms, prefix, aggregations
long / integer / double / scaled_floatNoNumbers, pricesterm, range
dateNoTimestampsrange, date_histogram
booleanNoFlagsterm
object / nestedn/aStructured sub-documents (nested keeps array elements separate)Normal queries; nested query for nested
dense_vectorNoEmbeddings for vector searchknn
Request - an explicit mapping with a multi-field
PUT /products-v1 { "mappings": { "dynamic": "strict", "properties": { "sku": { "type": "keyword" }, "name": { "type": "text", "fields": { "raw": { "type": "keyword", "ignore_above": 256 } } }, "description": { "type": "text" }, "brand": { "type": "keyword" }, "price": { "type": "scaled_float", "scaling_factor": 100 }, "in_stock": { "type": "boolean" }, "created_at": { "type": "date" }, "tags": { "type": "keyword" } } } }
Multi-fields: the same value indexed two ways

The name field above is indexed as text for searching and as name.raw (keyword) for sorting, exact matching and aggregations. This is the standard answer to "I need both full text and exact": one source value, two indexed fields. You search name, you sort and aggregate on name.raw. Sorting or aggregating on an analysed text field is not allowed by default (it has no doc values).

Dynamic mapping and its risks

If you index a document with an unknown field and dynamic is true (the default), Elasticsearch adds the field to the mapping, guessing the type from the first value it sees. Strings typically become a text field with a keyword sub-field; numbers and dates are detected by JSON shape and date-pattern rules. Three risks follow:

RiskWhat happensMitigation
Wrong type, locked inThe first document decides. A field that first appears as "1" may become a type you did not want, and an existing field type cannot be changed in placeExplicit mappings; dynamic: strict (reject unknown fields) or false (ignore them for indexing)
Mapping explosionFree-form keys (user-supplied maps, tag names as keys, request headers) create thousands of fields. The cluster state grows, memory use grows, and indexing slowsDo not use data as keys. The index.mapping.total_fields.limit setting is a safety net; the flattened type stores a whole object as one field
Double cost for stringsEvery string gets both text and keywordDeclare which one you need

Elasticsearch counts every mapper toward index.mapping.total_fields.limit: object levels, leaf fields and each multi-field sub-field all count individually (Elastic documentation, 9.x). The limit exists precisely to stop accidental explosions; treat hitting it as a modelling bug, not as a setting to raise.

Changing a mapping means reindexing
  • You can add fields to an existing mapping, but you cannot change the type of an existing field or its analyzer. The fix is to create a new index (products-v2), copy data with the _reindex API, and switch an alias (products) from the old index to the new one. Using an alias from day one makes that switch invisible to clients.
05
Chapter Five - Searching

The Query DSL

Searches are JSON documents sent to GET /<index>/_search. The Query DSL is a tree of query clauses. Leaf clauses look at one field; compound clauses (mostly bool) combine them.

QueryAnalysed input?MeaningTypical field
matchYesAnalyse the text, match documents containing any (default or) of the terms; operator: and requires alltext
match_phraseYesThe terms must appear adjacent and in order (uses positions)text
multi_matchYesmatch over several fields, with optional booststext
term / termsNoThe exact stored term (or any of several)keyword, number, date, boolean
rangeNogt, gte, lt, lte on numbers, dates, keywordsnumber, date
existsNoThe field has a valueany
prefix, wildcard, regexpNoPattern match on terms (can be slow, see chapter 10)keyword
booln/aCombines clauses with must, should, filter, must_notcompound
The bool query
Request - a typical product search
GET /products-v1/_search { "query": { "bool": { "must": [ { "match": { "name": "waterproof hiking boots" } } ], "filter": [ { "term": { "brand": "acme" } }, { "term": { "in_stock": true } }, { "range": { "price": { "lte": 200 } } } ], "should": [ { "match_phrase": { "name": "hiking boots" } } ], "must_not": [ { "term": { "tags": "discontinued" } } ] } }, "sort": [ "_score", { "created_at": "desc" } ], "size": 20 }
ClauseMust match?Affects score?Cached?
mustYes (AND)YesNo
shouldOptional (at least one required if no must or filter; otherwise only boosts the score, tune with minimum_should_match)YesNo
filterYes (AND)No (yes/no only)Eligible for caching
must_notMust not matchNoEligible for caching

Read the example as a sentence: find products whose name matches the words, that are Acme, in stock and at most 200, prefer those with the exact phrase "hiking boots", excluding discontinued ones. Only must and should contribute to the relevance score.

Query context vs filter context

Every clause runs in one of two contexts. In query context the question is "how well does this document match?" and the answer is a score. In filter context the question is "does this document match, yes or no?" and no score is computed. Clauses in bool.filter, bool.must_not, and the filter parameter of some queries run in filter context.

  • Use filter for everything that is not relevance: brand, status, price range, date window, tenant id. It is cheaper because no scoring is done.
  • Filter results can be cached per segment (a bitset of matching documents) and reused by later queries. Elasticsearch decides what to cache based on how often a filter is used; you do not control it per query.
  • Put the text clause in must and the rest in filter: the standard shape of a search request.
⚠ Putting exact conditions in must
  • A term or range inside must still works but computes a score you do not need and cannot be cached the same way. Moving it to filter gives the same hits with less work. A search that returns correct results but is slow often has its structured conditions in the wrong clause.
06
Chapter Six - Ranking and Analytics

Relevance (BM25) and Aggregations

Results are sorted by _score unless you say otherwise. The default scoring function is BM25: the Elastic similarity docs list BM25 as the default similarity (it replaced classic TF/IDF in Elasticsearch 5.0). You do not need the formula to use it, but you should know the three ideas it combines.

BM25 intuition: three ingredients multiply into a score
Term frequency (TF) how often the term is in THIS document more = better, but saturates Inverse doc frequency how RARE the term is across ALL documents rare terms count more Field length short fields score higher than long ones a title match beats a long body score(term, doc) is roughly IDF(term) x saturating_TF(term, doc, field length) The score of a match query is the sum over its terms. Scores are only comparable inside one query.
IdeaWhy it makes senseTuning knob
Term frequency saturatesThe 10th mention of "boots" adds much less than the 1stk1 in the similarity settings
Rare terms weigh more"waterproof" tells you more than "the"None needed; comes from index statistics
Field length normalisationA hit in a 3-word title is stronger than in a 3000-word bodyb in the similarity settings
Boosts you controlA title match matters more than a description matchname^3 in multi_match, boost on a clause

Use "explain": true on a search to see how a score was computed, term by term. Use the Profile API to see where query time was spent.

⚠ Treating scores as probabilities or comparing them across queries
  • A score of 12.4 has no absolute meaning. It depends on the query and the index statistics (and, per shard, on that shard's local statistics, so tiny indices can score oddly). Use scores only to order the results of one query, and never as a "confidence" threshold.
Aggregations

Aggregations compute summaries over the documents that match the query: counts per category, histograms over time, averages. They are the engine behind facets and dashboards. They run on doc values, which is why you aggregate on keyword, number and date fields, not on analysed text.

FamilyExamplesGives you
Bucketterms, date_histogram, range, filtersGroups of documents with a count each
Metricsum, avg, min, max, stats, cardinality, percentilesA number computed over a bucket or all hits
Pipelinederivative, cumulative_sum, bucket_sortComputations over other aggregations' results
Request - orders of the last 30 days: revenue per day, top brands, average order
GET /orders/_search { "size": 0, "query": { "range": { "created_at": { "gte": "now-30d/d" } } }, "aggs": { "per_day": { "date_histogram": { "field": "created_at", "calendar_interval": "day" }, "aggs": { "revenue": { "sum": { "field": "total" } } } }, "top_brands": { "terms": { "field": "brand", "size": 5 } }, "avg_order": { "avg": { "field": "total" } } } }

"size": 0 means "no hits, aggregations only". Buckets nest: the revenue sum above runs once per daily bucket. Two accuracy caveats worth knowing: terms on a high-cardinality field across many shards is approximate (each shard returns its top terms, then they are merged), and cardinality is an estimate by design.

07
Chapter Seven - The Cluster

Nodes, Indices, Shards and Replicas

An Elasticsearch cluster is a set of nodes (JVM processes). Data is stored in indices. An index is split into shards, and each shard is a self-contained Lucene index. A shard is the unit of distribution: shards are spread across nodes, so an index can be larger than any one machine and a search can use many machines in parallel.

Cluster layout: one index, 3 primary shards, 1 replica each, 3 data nodes
Data node 1 Data node 2 Data node 3 products P0 (primary) products R2 (replica of P2) products P1 (primary) products R0 (replica of P0) products P2 (primary) products R1 (replica of P1) Each shard is a complete Lucene index. A replica never sits on its primary's node. Any node can coordinate a request: it fans the query out to one copy of every shard, then merges the per-shard top results. Lose one node: replicas of its primaries are promoted. Number of primary shards is fixed at index creation. Number of replicas can change any time.
TermMeaningNotes
Primary shardThe shard that receives a write firstCount fixed at index creation (number_of_shards)
Replica shardA copy of a primary on a different nodenumber_of_replicas defaults to 1 (Elastic docs); changeable at any time. Serves reads and gives failover
IndexA logical collection of documents with one mappingMade of N primary shards plus replicas
AliasA name pointing to one or more indicesLets you swap indices without client changes
Document routingWhich shard a document lives onChosen from hash(_routing) % number_of_shards, default _routing is the document _id
Node roles

A node can have one or more roles, set with node.roles. A small cluster often gives every node several roles; a large one specialises them.

RoleJob
master (master-eligible)Manages cluster state: index creation and deletion, shard allocation. One elected master at a time. Keep it light and stable
data (and tiered: data_hot, data_warm, data_cold, data_frozen)Holds shards, executes searches and indexing. Tiers put newer data on faster hardware (chapter 09)
ingestRuns ingest pipelines that transform documents before indexing
mlMachine learning jobs and inference
remote_cluster_client, transformCross-cluster search or replication; continuous transforms
(coordinating)Every node can act as a coordinating node: it receives a request, fans it out and merges results. A node with no roles is a dedicated coordinating node
How a search is executed
Client sends a search to any node
▶
That node becomes the coordinating node for this request
Query phase
▶
The coordinator sends the query to one copy (primary or replica) of every shard. Each shard returns the ids and scores of its own top from + size hits
Merge
▶
The coordinator merges the per-shard lists into the global top hits
Fetch phase
▶
It asks the owning shards for the full _source of only the winning documents and returns them

Because every shard must return its top from + size hits, deep pagination is expensive (chapter 09), and searching 1000 tiny shards is slower than searching 10 sized shards (chapter 09).

⚠ Choosing the primary shard count carelessly
  • You cannot change number_of_shards of an existing index except by creating a new index (reindex, or the split and shrink APIs under rules). Too few shards limits how far one index can scale; too many wastes memory and slows searches. Chapter 09 gives the sizing guidance.
08
Chapter Eight - The Write Path

Segments, Refresh, Translog, Flush and Merges

Understanding the write path explains most surprises: why a document is not searchable instantly, why updates are costly, why deleted data does not free space at once, and why heavy indexing competes with search.

Near-real-time indexing path: buffer, segment, translog, commit
Index request (a document) Primary shard In-memory indexing buffer Translog (disk, fsync) Replica shards (same steps) Ack to client only after translog is fsynced on primary and replicas. refresh (default 1s) New segment: searchable, still in the OS file cache flush: fsync + new commit point Segments on disk (Lucene commit point) Search sees data only after a refresh. That gap is "near" real time. Merges combine small segments in the background Translog is replayed after a crash to recover operations not yet in a commit point.
StepWhat happensDefault / note
Index requestThe document is routed to its primary shard, added to an in-memory buffer and written to the translogRequest returns after the translog is fsynced on primary and replicas (index.translog.durability: request, the default)
RefreshThe buffer is written as a new segment that becomes searchable. Not yet fsynced to diskindex.refresh_interval default 1s (see note below)
FlushSegments are fsynced, a Lucene commit point is written, and the translog is trimmedTriggered automatically; index.translog.flush_threshold_size defaults to 10 GB
MergeSmall segments are merged into larger ones in the background; deleted documents are dropped for realAutomatic; throttled

A segment is an immutable Lucene index fragment. Once written, it is never modified. A shard is made of many segments, and a search must look in all of them and combine the results, which is why having fewer, larger segments is better for search and why merges exist.

The refresh_interval note

The Elastic docs for index.refresh_interval say that if you do not set it explicitly, shards that have received **no search request for at least index.search.idle.after (default 30s)** skip background refreshes until a search arrives; a search on an idle shard triggers a refresh itself. Setting any explicit value, even 1s, turns this idle optimisation off. The default value is 1s in the Elastic Stack (checked against the 9.x docs).

Why an update is delete plus insert

Segments are immutable, so there is no way to change a field inside an existing document. To update a document Elasticsearch marks the old version as deleted (a bit in a per-segment deleted-documents set) and indexes a new complete document into the buffer. The old version still occupies disk space until a merge rewrites that segment. Consequences:

  • An update of one field reindexes the whole document (the _update API fetches _source, applies the change and reindexes it). Frequently updating tiny fields of big documents is expensive.
  • Deleting or updating many documents does not shrink the index immediately; space is reclaimed by merges.
  • The same document id after an update gets a new internal version; concurrency control uses _seq_no and _primary_term (if_seq_no, if_primary_term) for optimistic locking.
Making a write visible immediately

Indexing APIs take a refresh parameter. refresh=true refreshes the affected shards immediately, which is expensive if used per request. refresh=wait_for makes the request wait until the next regular refresh makes the change visible. The Elastic docs advise using neither by default and designing the application to tolerate a short delay; if you need a read-your-own-write, fetch the document by id with the get API, which is real-time.

Routing

By default a document goes to shard hash(_id) % number_of_shards, which spreads data evenly but means a search must ask all shards. With custom routing (?routing=customer-42) all documents of one customer land on one shard, and a search that passes the same routing value asks only that shard. It is a strong optimisation for multi-tenant data, at the price of possible uneven shard sizes. In Elasticsearch 9.4 and later the docs describe the routing function for new indices as hash(_routing) % number_of_shards; indices created earlier keep the legacy function.

⚠ Calling refresh after every write
  • Forcing a refresh per request produces a flood of tiny segments, high merge load, and slower indexing and search for everyone. For bulk loads, increase the refresh interval (or set it to -1) during the load and restore it afterwards, and always use the Bulk API instead of one request per document.
09
Chapter Nine - Running It

Shard Sizing, Data Streams, ILM, Pagination and Vector Search

Shard sizing guidance

The Elastic documentation (shard sizing, 9.x) gives these as guidance, not hard limits:

GuidanceValue (from the Elastic docs)Why
Target shard size10 GB to 50 GB for many use casesSmaller shards add per-shard overhead; very large shards recover and rebalance slowly
Documents per shardKeep below about 200 millionSearch latency and merge cost grow with document count
Heap per master-eligible nodeAbout 1 GB of heap per 3000 indicesCluster state lives in master heap
Delete whole indices, not documentsPrefer deleting an index over deleting documentsDeletes only mark documents; whole-index deletion frees space at once
Hard capA single index may not exceed 1024 primary shards by defaultSafety limit (index.max_number_of_shards system property)

Each shard costs memory and file handles whether it holds data or not. The cluster also enforces cluster.max_shards_per_node; hitting it produces a "maximum shards open" error. Hundreds of tiny shards is the single most common cause of an unhealthy cluster: fewer, bigger shards (within the range above) beat many small ones.

Time series data: data streams and ILM

Logs and metrics are append-only and have a retention period. A data stream is a named, append-only abstraction over a series of hidden backing indices: writes go to the newest index, reads search all of them. Index Lifecycle Management (ILM) moves backing indices through phases (hot, warm, cold, frozen, delete) and rolls over to a new backing index when the current one reaches a size or age. Retention then becomes "delete the old index", which is instant and cheap.

Request - a data stream with a rollover and delete policy
PUT _index_template/app-logs { "index_patterns": ["app-logs*"], "data_stream": {}, "template": { "settings": { "index.lifecycle.name": "app-logs-policy" } } } PUT _ilm/policy/app-logs-policy { "policy": { "phases": { "hot": { "actions": { "rollover": { "max_primary_shard_size": "50gb", "max_age": "7d" } } }, "warm": { "min_age": "30d", "actions": { "forcemerge": { "max_num_segments": 1 } } }, "delete": { "min_age": "90d", "actions": { "delete": {} } } } } }

The rollover threshold max_primary_shard_size is how you keep shards in the 10-50 GB band automatically. The Elastic docs recommend this over choosing a fixed index per day.

Deep pagination

from + size paging works by having every shard return from + size hits, so the work grows with the page number. By default you cannot page past 10,000 hits with from and size: the limit is the index setting index.max_result_window, default 10000. For more, use **search_after with a point in time (PIT). The PIT freezes the view of the index so that pages are consistent even if a refresh happens between requests, and the sort values of the last hit tell Elasticsearch where to continue. The Elastic docs state that the scroll API is no longer recommended** for deep pagination.

Request - next page with search_after and a PIT (sort must be deterministic)
GET /products-v1/_search { "size": 100, "query": { "match": { "name": "boots" } }, "pit": { "id": "<pit id from POST /products-v1/_pit?keep_alive=1m>", "keep_alive": "1m" }, "sort": [ { "created_at": "desc" }, { "sku": "asc" } ], "search_after": [ 1761947012345, "bk-1042" ] }

Always include a tie-breaker field (unique per document, such as an id) in the sort, otherwise documents with equal sort values can be skipped or repeated. Users almost never need page 500 of a search; offering "next page" and refining filters is a better design.

Vector (kNN) search overview

Elasticsearch can store embeddings in a **dense_vector field and answer k-nearest-neighbour** queries: given a query vector, return the documents with the most similar vectors. This is the basis of semantic search and retrieval-augmented generation (RAG). The Elastic docs (9.x) describe dense_vector with the knn search option and approximate search using the HNSW algorithm. Indexing is on by default for dense vectors, and the docs list automatic quantization defaults that vary by version (for example, int8_hnsw in 9.0; bbq_hnsw for vectors of 384 or more dimensions from 9.1; bbq_disk as default from 9.4 where the licence allows). Quantization trades a little accuracy for much lower memory use.

Request - approximate kNN search
GET /products-v1/_search { "knn": { "field": "embedding", "query_vector": [0.12, -0.45, 0.88], "k": 10, "num_candidates": 100 } }

You can combine knn with a text query for hybrid search. Some vector features are marked preview or have licence requirements in the docs: check the page for the exact version you run before relying on them. Two practical notes: vectors are excluded from _source in search responses by default, and embeddings must come from a model you run or call yourself (or an inference endpoint Elasticsearch hosts).

10
Chapter Ten - In Your Architecture

Not a Primary Database, Staying in Sync, and OpenSearch

Most Elasticsearch trouble is not a bug in Elasticsearch but a mismatch between the tool and the job. The diagram lists the five recurring mistakes with their fixes.

Five ways Elasticsearch deployments go wrong
1. Dynamic mapping left on Wrong types, mapping explosion Fix: explicit mapping, dynamic strict 2. Too many tiny shards Heap pressure, slow searches Fix: 10-50 GB shards, rollover 3. Leading wildcards "*boots" has no term prefix to seek Fix: ngram / reverse analyzer 4. Used as the database No transactions, reindex pain Fix: source of truth elsewhere 5. Deep from+size paging Memory per page, 10,000 cap Fix: search_after + PIT
Why it is not a primary database

Elasticsearch stores your documents durably enough for a search index, but it is designed as a derived, rebuildable view. Reasons to keep the authoritative copy elsewhere:

ConcernWhat Elasticsearch gives youWhy that is weaker than a system of record
TransactionsAtomic single-document writes onlyNo multi-document or multi-index transactions, no rollback
VisibilityNear real time (chapter 08)A write is not searchable until a refresh; reads by id are real time
UpdatesDelete plus insert of the whole documentCost grows with document size; no cheap partial update
Schema changeFields cannot change type in placeAny such change means building a new index and reindexing everything
DurabilityTranslog fsync by default, replicas for copiesOperational mistakes (replicas set to 0, async translog, lost disks) lose data; the engine assumes you can rebuild
Consistency under failureReplica-based, designed for availability of searchNot a substitute for a database with a documented, tested recovery story for the business data

A good test: if the whole cluster vanished, could you rebuild it from another store? If yes, you are using it correctly. If no, you have made it your database.

Keeping it in sync with the source of truth

Because Elasticsearch holds a copy, you need a reliable way to push changes from the system of record. Options, from weakest to strongest:

ApproachHowProblem or strength
Dual writeThe application writes to the database and then to ElasticsearchIf the second write fails, the two disagree forever. No atomicity between two systems
Batch re-syncA scheduled job copies recent changesSimple and a good safety net; stale until the next run; deletes are hard to detect
Transactional outboxWrite the business row and an event row in one database transaction; a relay publishes events (to Kafka) and a consumer updates the indexAtomic on the database side; delivery is at-least-once, so the consumer must be idempotent
Change data capture (CDC)Read the database's transaction log (for example with Debezium) and stream the changesNo application changes; captures every change including deletes

Both outbox and CDC rely on a durable log between the database and the index; page 07 of the messaging section, Event-Driven Patterns, covers the outbox pattern, idempotent consumers and the dual-write problem in detail. The Elasticsearch side has one nice property: **indexing a document with a fixed _id is idempotent**. Replaying the same event just overwrites the same document. Use the database primary key as _id, and use version_type=external with a version taken from the source row when events can arrive out of order.

Rebuild by replay
  • Design the pipeline so the index can be rebuilt from scratch: create products-v2, bulk-load from the database (or replay the Kafka topic), run both in parallel, then flip the alias. Page 07 of this section, Polyglot Persistence, uses exactly this pattern for projections.
Slow queries to avoid
  • Leading wildcard (*boots, *oo*): the term dictionary is sorted by the first characters, so a leading wildcard cannot seek and must test every term. Index with edge_ngram or the wildcard field type if you need it.
  • **script queries and scripted sorting** on large result sets: the script runs per document.
  • **Deep from + size** and very large size values (chapter 09).
  • Aggregations on high-cardinality fields over huge result sets: heavy on memory.
Elasticsearch and OpenSearch

Both are search engines with a shared ancestry. Facts that are verifiable from the projects' own sites:

  • OpenSearch describes itself as a fully open source search and analytics suite; its FAQ states that OpenSearch is derived from Elasticsearch 7.10.2 and OpenSearch Dashboards from Kibana 7.10.2.
  • Elastic changed Elasticsearch's licence from Apache 2.0 to a dual SSPL / Elastic License 2.0 in 2021 (which is the background of the fork). Elastic later announced it would add the AGPL as another licence option alongside those two, and described Elasticsearch as open source again (Elastic blog, "Elasticsearch Is Open Source. Again!").
  • Since the fork the two have diverged: APIs, query features, clients and vector search implementations are not interchangeable. Pick one, use its own client library and docs, and do not assume a feature or a setting from this page exists in OpenSearch.

The licence terms matter if you host the software for others or modify it; read the current licence text rather than relying on a summary, including this one.

⚠ Using Elasticsearch to hold the only copy of important data
  • Teams start by logging "just a few documents" and end with orders or user profiles that exist only in an index. Then a mapping change, an accidental DELETE /index, or a cluster loss turns into a business incident. Keep the data in a database, treat the index as a cache with a rebuild procedure, and snapshot it anyway.
11
Chapter Eleven - Hands-On

A Single-Node Lab with Docker, and the Java Client

This lab follows the official single-node Docker instructions for the Elastic Stack 9.x (the docs list image tag 9.5.5 as the latest on 2026-10-31). It is for development and learning only; the Elastic docs state it is not suitable for production. Commands below are from the docs and were not all re-run for this page; the output is described instead of pasted.

1. Start Elasticsearch
Shell - create a network and start one node
$ docker network create elastic $ docker run --name es01 --net elastic -p 9200:9200 -it -m 1GB \ docker.elastic.co/elasticsearch/elasticsearch:9.5.5
  • -m 1GB caps container memory; the docs note this means you do not have to set the JVM heap by hand. Machine-learning features need more memory.
  • Security is on by default since Elasticsearch 8.0 (xpack.security.enabled defaults to true). On the first start the node auto-configures security: it generates TLS certificates for the transport and HTTP layers and prints a generated password for the elastic user and an enrollment token for Kibana to the console. These are shown only on first start.
  • Copy the password. If you lose it, reset it with docker exec -it es01 /usr/share/elasticsearch/bin/elasticsearch-reset-password -u elastic.
⚠ Disabling security to "make curl easier"
  • Setting xpack.security.enabled=false removes authentication and TLS; the docs call disabling security not recommended. It is tempting in a lab but becomes a habit, and an open Elasticsearch on a network is a classic data leak. Keep the generated password and use the CA certificate as below.
2. Connect and smoke-test
Shell - copy the CA certificate out of the container and call the root endpoint
$ docker cp es01:/usr/share/elasticsearch/config/certs/http_ca.crt . $ export ELASTIC_PASSWORD="<the password printed at first start>" $ curl --cacert http_ca.crt -u elastic:$ELASTIC_PASSWORD https://localhost:9200

The response is a small JSON document with the cluster name and the version number. --cacert makes curl trust the self-signed CA that the node generated, so TLS is verified instead of disabled with -k.

3. Create an index, load data and search

Use the mapping from chapter 04 saved as mapping.json (the body of the PUT, without the PUT /products-v1 line) and a two-document bulk file. The Bulk API takes newline-delimited JSON: an action line, then the document line, each ending with a newline.

products.ndjson - Bulk API body
{ "index": { "_id": "bk-1" } } { "sku": "bk-1", "name": "City bike", "brand": "acme", "price": 123.0, "in_stock": true, "created_at": "2026-10-01T10:00:00Z", "tags": ["bikes"] } { "index": { "_id": "bt-7" } } { "sku": "bt-7", "name": "Waterproof hiking boots", "brand": "acme", "price": 89.5, "in_stock": true, "created_at": "2026-10-02T09:30:00Z", "tags": ["footwear"] }
Shell - create, bulk load, search, inspect shards and health
$ curl --cacert http_ca.crt -u elastic:$ELASTIC_PASSWORD -X PUT https://localhost:9200/products-v1 \ -H 'Content-Type: application/json' -d @mapping.json $ curl --cacert http_ca.crt -u elastic:$ELASTIC_PASSWORD -X POST 'https://localhost:9200/products-v1/_bulk?refresh=wait_for' \ -H 'Content-Type: application/x-ndjson' --data-binary @products.ndjson $ curl --cacert http_ca.crt -u elastic:$ELASTIC_PASSWORD 'https://localhost:9200/products-v1/_search?q=name:boots&pretty' $ curl --cacert http_ca.crt -u elastic:$ELASTIC_PASSWORD 'https://localhost:9200/_cat/shards/products-v1?v' $ curl --cacert http_ca.crt -u elastic:$ELASTIC_PASSWORD 'https://localhost:9200/_cluster/health?pretty'

A single node with the default number_of_replicas: 1 reports yellow cluster health: the replica shard has no other node to live on, so it is unassigned. That is expected in a one-node lab. Set the replicas to 0 with PUT /products-v1/_settings and {"index": {"number_of_replicas": 0}} to see green, and read this as a demonstration of why replicas exist, not as a setting to copy to production.

4. From Java

The official Elasticsearch Java API Client is published as co.elastic.clients:elasticsearch-java on Maven Central (its latest release at the time of writing is 9.5.5; the docs example uses 9.5.0 and requires Java 17 or later). Match the client's major version to the server.

pom.xml
<dependency> <groupId>co.elastic.clients</groupId> <artifactId>elasticsearch-java</artifactId> <version>9.5.0</version> </dependency>
Java - connect with TLS, index, and a bool search (written against the documented API; not compiled here)
import co.elastic.clients.elasticsearch.ElasticsearchClient; import co.elastic.clients.elasticsearch._types.query_dsl.Query; import co.elastic.clients.elasticsearch.core.SearchResponse; import co.elastic.clients.elasticsearch.core.search.Hit; import co.elastic.clients.transport.TransportUtils; import javax.net.ssl.SSLContext; public class ProductSearch { record Product(String sku, String name, double price) {} public static void main(String[] args) throws Exception { String fingerprint = System.getenv("ES_CA_FINGERPRINT"); // sha-256 of http_ca.crt SSLContext ssl = TransportUtils.sslContextFromCaFingerprint(fingerprint); ElasticsearchClient es = ElasticsearchClient.of(b -> b .host("https://localhost:9200") .usernameAndPassword("elastic", System.getenv("ELASTIC_PASSWORD")) .sslContext(ssl)); // index one document; the id makes the call idempotent es.index(i -> i.index("products-v1").id("bk-1") .document(new Product("bk-1", "City bike", 123.0))); // match on text, filter on price (filter context, no score) Query byName = Query.of(q -> q.match(m -> m.field("name").query("bike"))); Query maxPrice = Query.of(q -> q.range(r -> r.number(n -> n.field("price").lte(200.0)))); SearchResponse<Product> res = es.search(s -> s .index("products-v1") .query(q -> q.bool(b -> b.must(byName).filter(maxPrice))) .size(10), Product.class); for (Hit<Product> hit : res.hits().hits()) { System.out.println(hit.score() + " " + hit.source()); } es.close(); } }

The builder-lambda style (b -> b.host(...)) and the nested query builders mirror the JSON DSL of chapter 05. The API shape for connecting (host, usernameAndPassword, sslContext, TransportUtils.sslContextFromCaFingerprint), index, search with match and range(r -> r.number(...)) follows the Elastic Java client documentation; compile it against the client version you choose, because generated builders can vary slightly between releases. In production prefer an API key (.apiKey(...)) over the elastic superuser.

Spring Data Elasticsearch

If your application is built on Spring, Spring Data Elasticsearch offers the familiar repository and template abstractions (ElasticsearchOperations, repository interfaces, entity annotations) on top of the Elasticsearch client. It is released on the Spring Data train (the Spring docs list 6.1.x as the current stable line at the time of writing). Each Spring Data Elasticsearch line supports particular Elasticsearch versions, so check the version compatibility matrix in its reference documentation before choosing a Spring Boot and server pair. See also Spring Data and persistence for the wider Spring picture.

12
Chapter Twelve - Interview

Questions You Should Be Able to Answer

Crisp answers for the questions that come up in interviews on this topic.

  • Q: What problem does Elasticsearch solve that a relational database does not?
    Fast, relevance-ranked full-text search and analytics over large volumes. A database LIKE scan is slow and unranked; Elasticsearch uses an inverted index so it looks up terms instead of scanning rows, and it scores results with BM25.
  • Q: What is an inverted index?
    A mapping from each term to the list of documents containing it (a postings list), along with frequencies and positions. A query looks up its terms and combines the postings lists.
  • Q: What does an analyzer consist of?
    Zero or more character filters, exactly one tokenizer and zero or more token filters, run in that order. The default for text fields is the standard analyzer (standard tokenizer plus lowercase). The same analysis is applied to the query text for match queries.
  • Q: text vs keyword?
    text is analysed for full-text search and cannot be sorted or aggregated by default. keyword stores the exact value for filtering, sorting and aggregations. A multi-field indexes one value both ways.
  • Q: Why does a term query on a text field often return nothing?
    Because term is not analysed and a text field stores lower-cased, tokenised terms. Use match for text, or query a keyword sub-field.
  • Q: Query context vs filter context?
    Query context computes a relevance score; filter context is yes/no, computes no score and can be cached. Put relevance clauses in must/should and structured conditions in filter.
  • Q: What is the default scoring algorithm and what does it consider?
    BM25. It combines term frequency (with saturation), inverse document frequency (rarer terms matter more) and field-length normalisation.
  • Q: What is a shard and how many should I have?
    A shard is a Lucene index and the unit of distribution. Primary count is fixed at creation. Elastic guidance: shards of roughly 10-50 GB and below about 200 million documents; avoid many tiny shards; use rollover for time series.
  • Q: Why is Elasticsearch "near real time"?
    New documents sit in an in-memory buffer and the translog until a refresh writes them as a searchable segment. The default refresh interval is 1s. A GET by id is real time.
  • Q: What is the translog for?
    It records operations not yet in a Lucene commit point so they can be replayed after a crash. With the default request durability it is fsynced before the write is acknowledged.
  • Q: Why are updates expensive?
    Segments are immutable. An update marks the old document deleted and indexes a whole new one; space is reclaimed on merge. Updating a tiny field of a large document reindexes all of it.
  • Q: How do you page beyond 10,000 results?
    from + size is capped by index.max_result_window (10000). Use search_after with a point in time and a unique tie-breaker in the sort. Scroll is no longer recommended for this.
  • Q: Why should Elasticsearch not be your primary database?
    No multi-document transactions, near-real-time visibility, delete-plus-insert updates, fixed field types requiring reindexing for changes, and it is designed as a rebuildable derived view. Keep the source of truth in a database and sync to it.
  • Q: How do you keep Elasticsearch in sync with a database?
    Avoid dual writes. Use a transactional outbox or CDC (such as Debezium) through a log like Kafka, with an idempotent consumer that indexes with the database key as the _id. Support a full rebuild by reindexing into a new index and switching an alias.
  • Q: Your mapping has a wrong field type. What now?
    You cannot change an existing field type in place. Create a new index with the right mapping, _reindex into it, and move the alias.
  • Q: What is a mapping explosion?
    Too many distinct fields, usually from using data values as field names under dynamic mapping. It bloats cluster state and memory. Use explicit or strict mappings, the flattened type, and the field limit as a safety net.
  • Q: Elasticsearch vs OpenSearch?
    OpenSearch is a fork derived from Elasticsearch 7.10.2 after Elastic moved from Apache 2.0 to SSPL/ELv2. Elastic later added AGPL as an option. They have diverged in APIs and features, so do not assume compatibility.
Design scenarios
  • "Add search to our product catalogue." PostgreSQL stays the system of record; an outbox or CDC stream feeds an products alias on Elasticsearch; text in must, filters in filter; facets via aggregations; a reindex runbook; the page degrades to database queries (or a clear message) if search is down.
  • "Store 1 TB of logs per day with 30-day retention." Data stream with ILM rollover at about 50 GB primary shard size, hot nodes for ingest, delete phase at 30 days, Bulk API with batching, and few fields mapped explicitly.
  • "Search results are slow after we added a tenant filter." Check query shape (filter context), shard count, and whether routing by tenant id can cut the shards searched.
13
Chapter Thirteen - Summary

Cheat Sheet and Recap

Cheat sheet
ConceptRememberDefault / value (Elasticsearch 9.x docs)
Core structureInverted index: term to postings listPlus doc values for sort and aggregations
Default analyzerStandard tokenizer plus lowercaseApplied to text fields and match query text
text vs keywordSearch vs filter/sort/aggregateUse a multi-field for both
Filter vs mustFilter: no score, cacheableStructured conditions go in filter
ScoringBM25: TF (saturating), IDF, field lengthDefault similarity
ReplicasCopies on other nodesnumber_of_replicas = 1
RefreshMakes new docs searchablerefresh_interval = 1s (idle-shard optimisation applies)
Translog durabilityFsync before ackrequest; flush threshold 10 GB
Shard size guidance10-50 GB, under about 200M docsGuidance, not a limit
from + size limitUse search_after + PIT beyond itindex.max_result_window = 10000
UpdatesDelete + insert, space freed by mergeWhole document is reindexed
Security on first start (8.x and later)Auto-configured TLS and elastic passwordxpack.security.enabled = true
Java clientco.elastic.clients:elasticsearch-javaJava 17+; docs example 9.5.0
∑ Elasticsearch - what to remember
  • It is an index, not a database. The source of truth lives elsewhere and the index can be rebuilt.
  • Analysis decides what you can find. Test it with _analyze; query text must be analysed like the indexed text.
  • Model with explicit mappings: text for search, keyword for exact, multi-fields for both, no dynamic surprises.
  • **must for relevance, filter for the rest.**
  • Writes are near real time: buffer, refresh to a segment, translog for safety, merges in the background.
  • Keep shards sized and few; use rollover and ILM for time series.
  • Sync with outbox or CDC and idempotent writes; deep paging with search_after and PIT.
Version note

Checked against the Elastic documentation (elastic.co/docs, Elasticsearch 9.x, current release listed as 9.5.5) on 2026-10-31: BM25 as default similarity, refresh_interval behaviour, translog defaults, max_result_window of 10000 and the PIT guidance, shard sizing guidance, number_of_replicas default, Docker 9.5.5 image and first-start security, dense vector quantization defaults, the Java client coordinates and API shape; and against the OpenSearch FAQ and the Elastic AGPL announcement for the fork and licence statements. Not run here: the Docker lab and the Java sample were written from the docs and not executed or compiled. Search results shown as responses are illustrative.

Next in this section