Notes
What it is
Elasticsearch is a distributed search and analytics engine built on Apache Lucene, a search library. You send it JSON documents over HTTP, it indexes every field, and you query with JSON. OpenSearch is an open-source fork started by AWS, now run under the Linux Foundation. With Logstash and Kibana it forms the well-known ELK stack for log search and dashboards.
- distributed search and
- analytics engine
- built on Apache Lucene
- stores JSON documents in
- indexes; REST + JSON queries
- fork: OpenSearch (AWS)
- part of the ELK stack
- (Elasticsearch, Logstash,
- Kibana) for logs
Inverted index
The core structure is the inverted index. Instead of storing which words each document contains, it stores, for every word, the list of documents that contain it, like the index at the back of a book. A search for red jacket looks up two short lists and combines them, instead of reading every document. That is why searching millions of documents takes milliseconds.
doc 1: "red running shoes" doc 2: "blue running jacket" doc 3: "red jacket" term → doc ids (postings) red → 1, 3 running → 1, 2 jacket → 2, 3 query "red jacket": 1 ∩ 3 → 3 best
Analyzers
Before indexing, text goes through an analyzer. It splits the text into tokens, lowercases them, reduces words to stems so running matches run, and can add synonyms. The same analyzer runs on the query, so both sides match. In the mapping, a text field is analyzed for search, while a keyword field is stored exactly for filters and sorting. Autocomplete usually uses edge n-grams, indexing r, ru, run and so on.
input: "Running Shoes, Men's"
1. tokenize Running | Shoes | Men's
2. lowercase running | shoes | men's
3. stem run | shoe | men
4. synonyms shoe → sneaker
mapping: "text" field = analyzed
"keyword" field = exact
autocomplete: edge n-grams Write path
Here is a write. The document id is hashed to pick a shard. The primary copy of that shard puts the document in an in-memory buffer and appends it to a transaction log for durability, then forwards it to replica shards. Every second, by default, a refresh turns the buffer into a new searchable segment. So search is near real-time: a new document appears about a second later. Segments are immutable and merged in the background.
- 1. route: hash(_id) % shards
- 2. primary shard writes to
- an in-memory buffer +
- translog (durability)
- 3. copy to replica shards
- 4. refresh (every 1 s):
- buffer → new segment,
- now searchable
- segments are immutable;
- background merges
Shards
An index is split into primary shards, here three, each with one replica on a different node, so any one node can fail. The number of primaries is set when you create the index; changing it means reindexing or a split operation. Replicas can be added any time for more read capacity. Each shard is a full Lucene index. A common guideline is ten to fifty gigabytes per shard, because too many tiny shards waste memory.
- index products: 3 primaries,
- 1 replica each = 6 shards
- primaries: fixed at creation
- (change = reindex or split)
- replicas: change any time,
- add read capacity + safety
- each shard = one Lucene
- index; aim 10-50 GB each
Query path
Searching is scatter and gather. The coordinating node sends the query to one copy of every shard. Each shard scores its matches and returns only the top ten ids and scores. The coordinator merges them into a global top ten, then fetches just those ten documents. Two phases keep network traffic small. Your latency is set by the slowest shard, so one overloaded node slows every search.
- query phase: each shard
- scores locally, returns top
- N ids + scores
- coordinator merges into a
- global top N
- fetch phase: get the full
- documents for those N
- latency = slowest shard
Relevance + aggregations
Results are ranked with BM25: a match on a rare word counts more than a common one, more matches score higher, and a match in a short field beats one in a long field. Boolean queries combine must, should and filter clauses; filters do not score and are cached, so put exact conditions there. Aggregations compute counts per brand or a price histogram, which is exactly the facet sidebar of a shop.
- BM25 score: rare terms and
- more matches score higher;
- long fields score lower
- bool query: must, should,
- filter (no score, cached)
- boost: title^3, recency
- aggregations: counts per
- brand, price histogram
- (the facets in a shop sidebar)
Keeping in sync
Elasticsearch should not be your source of truth. Keep data in the database and stream changes into the index: change data capture or an outbox publishes events to Kafka, and a worker writes them in bulk. Avoid having the app write to both, because one write will eventually fail and they drift. To change a mapping, build a new index from the database and switch an alias to it, with no downtime.
- never the only copy of data
- CDC (Debezium) or outbox
- → Kafka → indexer worker
- → _bulk API
- avoid dual writes from the
- app: they drift on failure
- rebuild: reindex from DB
- into a new index, swap alias
Pagination
Pagination has a trap. With from and size, page one thousand of ten results forces every shard to return ten thousand hits for the coordinator to sort, so deep pages get slow, and the default cap is ten thousand. Instead, use search after: pass the sort values of the last hit you saw and get the next page cheaply, at any depth. Combine it with a point in time so results do not shift while you page.
- from + size: page 1000 means
- each shard returns 10,000
- hits to merge; capped at
- 10,000 by default
- search_after: pass the sort
- values of the last hit;
- cheap at any depth
- point in time (PIT): a stable
- snapshot while you page
Great at
It shines at relevance-ranked text search with typo tolerance, stemming and synonyms, at autocomplete, at faceted filtering for shopping sites, and at searching and charting huge volumes of logs and events. It also does geo queries and vector nearest-neighbour search, so you can blend keyword and semantic ranking in one engine.
- • full-text search with
- typos, stems, synonyms
- • autocomplete
- • facets and filters on
- e-commerce listings
- • log and event search
- (ELK, observability)
- • geo queries, vector (kNN)
- search, hybrid ranking
Where it hurts
Where it hurts. No transactions and no real joins. Data is searchable only after a refresh. The primary shard count is fixed, so growth means reindexing. Every update rewrites the whole document internally. Letting users create arbitrary field names explodes the mapping. Clusters need care with memory, garbage collection and shard counts. And it is not built to be the only copy of your data.
- • no transactions or joins
- • ~1 s until searchable
- • primaries fixed: reindex
- • updates rewrite the doc
- • mapping explosion from
- arbitrary field names
- • JVM heap, GC, shard count
- • not a system of record:
- keep the DB as truth
Use it when
Use Elasticsearch when users type free text and relevance ranking matters, when you need facets or autocomplete, or when you search logs at volume. Avoid it as a primary store, for exact lookups by id that any database does, and for transactional data. For a small catalogue, Postgres full-text search with a GIN index may be enough, and is one less system to run.
- USE when
- users type free text
- relevance ranking matters
- facets, autocomplete
- searching logs at volume
- AVOID when
- exact lookups by id (DB)
- money, inventory (SQL)
- small data: Postgres full-
- text may be enough
Numbers
Numbers to remember. The refresh interval defaults to one second. Aim for shards of ten to fifty gigabytes. From plus size is capped at ten thousand. New indexes get one primary shard by default. Give the JVM heap about half the machine's memory but stay under about thirty-one gigabytes so pointers stay compressed. Typical searches take ten to a hundred milliseconds, and bulk requests of a few megabytes index fastest.
refresh interval 1 s (default) shard size target 10-50 GB max from + size 10,000 default primaries 1 (since 7.0) heap ≤ ~31 GB, 50% RAM search latency 10-100 ms typical bulk request size ~5-15 MB replicas 1+ in production
Say it like this
How to say it. Products live in Postgres, the source of truth. Change data capture streams updates through Kafka to an indexer that bulk-writes to Elasticsearch. Title and description are analyzed text fields; brand and category are keywords for filters and facets. Search results are about a second behind writes, which is acceptable for a shop. That answer shows the index, the sync and the trade-off.
- "Products live in Postgres.
- Debezium streams changes
- through Kafka to an indexer
- that bulk-writes to Elastic-
- search. Title and description
- are analyzed text, brand is
- a keyword for facets. Search
- is about a second behind,
- which is fine for a shop."