---
name: elasticsearch
description: "Designs and tunes Elasticsearch mappings, queries, search relevance and indexes."
license: MIT (upstream attributions in NOTICE.md)
metadata:
  author: HyperSkills
  version: "2026.09.30"
  category: framework
---

# elasticsearch

## Scope

Covers Elasticsearch itself: index and mapping design, text analysis, the Query DSL, ES|QL,
relevance and its measurement, aggregations, vector and semantic retrieval, data streams and the
index lifecycle, reindexing, shard and heap capacity, cluster allocation, diagnostics, and
cluster-level security.

The body is written against **Elasticsearch 9.5** (9.5.3 at time of writing; 8.19 is the last 8.x
series). Rules that need a particular version carry a gate such as `(9.4+)`. Facts marked as
measured were run against a single-node 9.5.3 instance on a basic licence.

Not covered:

- Relational schema, SQL and plan tuning. Use the `postgres` skill.
- Document-database modelling and aggregation pipelines in MongoDB. Use the `mongodb` skill.
- Key-value caching, expiry and eviction. Use the `redis` skill.
- Log and metric collection pipelines, agents and shippers, alerting rules, SLOs, and working back
  from a production alert to a code path. Use the `observability` skill. Index and query design
  for data that happens to be logs is in scope; getting the
  logs there and paging someone about them is not.
- Kibana: dashboards, Lens, Discover, saved objects, Agent Builder, workflows. Not covered by any
  skill here.
- Managed control planes — Elastic Cloud provisioning, deployment sizing, AWS OpenSearch Service
  domains, billing and autoscaling APIs. The index- and query-level rules still apply on managed
  hosting; node settings, `elasticsearch.yml` and JVM options usually will not.
- OpenSearch-only APIs. It forked from 7.10 and has diverged: `neural` queries, ml-commons,
  search pipelines and PPL do not exist here, and advice written for one is not valid for the
  other without checking.

Paths below are relative to this skill's directory.

## Core rules

1. Establish the version and the licence tier before quoting a rule or designing anything:
   `GET /` and `GET /_license`. Half the guidance in circulation is written for 7.x, and several
   headline retrieval features are above the basic tier.
2. Read the mapping before proposing a query, and the access patterns before proposing a mapping
   (`GET /{index}/_mapping`). Both directions of guessing produce work that cannot be undone
   without a reindex.
3. `text` matches, `keyword` filters. A `text` field is analysed into tokens and a `term` query is
   not analysed, so `term` on `text` returns HTTP 200 with zero hits and no error anywhere.
4. Aggregate and sort on `keyword`, never on `text`. The failure is
   `Fielddata is disabled on [f]`; the fix is the `.keyword` sub-field. Setting `fielddata: true`
   uninverts every distinct term into heap and is the most reliable way to break a cluster.
5. A `keyword` sub-field on a long text body needs `ignore_above`, or the whole string is indexed
   as one term. A field that is only searched should not have the sub-field at all.
6. Dynamic mapping guesses from the first document, and the guess is permanent. Measured on 9.5.3
   a JSON string becomes `text` plus a `keyword` sub-field with `ignore_above: 256`, and `9.99`
   becomes `float`, not `double`. Prefer explicit mappings with `"dynamic": "strict"`.
7. Mappings are append-only. Adding a field is fine; changing a field's type, analyzer, `index` or
   `doc_values` flag is impossible in every version. The path is new index → `_reindex` → alias
   swap, which is why every index that matters is created behind an alias on day one.
8. A dynamic `object` over unbounded keys (Kubernetes labels, headers, experiment buckets) becomes
   `Limit of total fields [1000] has been exceeded`. Map it `flattened`. Raising
   `index.mapping.total_fields.limit` postpones the outage and grows cluster state.
9. If dropping a clause would change *which* documents come back rather than their *order*, it
   belongs in `bool.filter`. Filter context skips scoring and is cacheable; the rewrite never
   changes the result set.
10. An object array flattens and loses per-item association. Measured: with `users` as `object`,
    filtering `users.first: Alice` and `users.last: Jones` matches a document containing Alice
    Smith and Bob Jones. Use `nested` only when a query must combine two fields of one element.
11. A leading `*` cannot use the term dictionary. Measured, a `wildcard: "*ctiv*"` profiles 47×
    slower than the equivalent `TermQuery` on a one-document index. Use `match` on the analysed
    field, the `wildcard` field type, or index-time ngrams.
12. An analyzer that *adds* tokens (`edge_ngram`, index-time synonyms, shingles) must not run at
    search time. Measured, an `edge_ngram` analyzer turns the query `elastic` into
    `[el, ela, elas, elast, elasti, elastic]`, so everything matches everything. Set
    `search_analyzer: standard`.
13. `from + size` beyond `index.max_result_window` (10 000) is a hard error, and every page below
    it makes each shard sort and ship `from + size` hits. User-facing paging is `search_after`
    plus a point-in-time; exports are PIT scans or `_reindex`.
14. A `sort` clause replaces relevance ordering entirely. When every `_score` is 1.0 or `null`, no
    boost anywhere in the query can change anything — remove the sort before tuning relevance.
15. Query rules pin documents; boosts rank them. Creating a ruleset pins nothing: the search body
    must carry a `rule` query whose `match_criteria` supplies every metadata key the criteria use.
16. Every `terms` aggregation is approximate. Report `doc_count_error_upper_bound` and
    `sum_other_doc_count` alongside the buckets; use `composite` when the requirement is to
    enumerate everything rather than to rank the top N.
17. `cardinality` and `percentiles` are estimates (HyperLogLog++ and TDigest). Percentiles are not
    additive — never average a p99 across shards, days or services.
18. `dense_vector` is quantized by default in 9.x. Measured: `int8_hnsw` up to 320 dimensions,
    `bbq_hnsw` with `rescore_vector.oversample: 3.0` from 384 up. Recall is a number you measure
    against exact search, not an assumption; the query-side knob is `num_candidates` and there is
    no `ef_search`. Vectors are also excluded from `_source` by default
    (`index.mapping.exclude_source_vectors`), so code reading an embedding back out of a hit
    silently gets nothing.
19. The Inference API and the `rrf` and `linear` retrievers are Enterprise-tier: measured, all
    three return HTTP 403 `current license is non-compliant` on a basic licence. So on basic
    there is no in-cluster embedding, no ELSER, no semantic reranking and no fusion operator —
    embed outside the cluster, and fuse with a `knn` clause beside a `match` clause in
    `bool.should`, or by reciprocal rank in the application.
20. Licence failures for inference are delayed: a `semantic_text` mapping is accepted and
    `GET /_inference/_all` lists the preconfigured endpoints, and only the first document indexed
    returns the 403. Prove a gated feature end to end, not by creating the index.
21. Shard count comes from data volume: `max(1, ceil(primary_gb / target_shard_size_gb))` with a
    10–50 GB target, never from node count. Budget 20 shards per GB of heap; the hard stop is
    `cluster.max_shards_per_node` (1000, replicas counted).
22. Heap is `min(RAM/2, 31g)`. The other half is the page cache Lucene reads from, and above
    roughly 32 GB the JVM loses compressed object pointers, so 48 GB of heap addresses fewer
    objects than 31 GB.
23. Yellow means a replica is unassigned — degraded redundancy, not data loss. Red means a primary
    is unassigned. On a single node, yellow is the expected state. `unassigned_info.reason` is the
    trigger; the decider with `decision: "NO"` in `_cluster/allocation/explain` is the cause.
24. `search_phase_execution_exception` is a wrapper: the real error is in
    `failed_shards[].reason`. A `_bulk` request likewise returns HTTP 200 while individual items
    fail, so branch on the response's `errors` flag every time — and
    `es_rejected_execution_exception` is the ceiling you were looking for, not a transient to
    retry harder against.
25. Finish with evidence: re-run under `"profile": true` and quote the top node before and after,
    or re-run `_rank_eval` against the judgement set and quote the metric. A `took` that moved
    without the profile changing is a warm cache.

## Workflows

### design-or-review-a-mapping

- [ ] Collect the access pattern per field — searched as prose, filtered exactly, aggregated,
      sorted, or only returned. Missing access patterns are a blocker, not a guess
      (`references/mapping-and-types.md`).
- [ ] Assign the minimal type set: `text` for prose, `keyword` for exact, the multi-field only when
      one logical field is genuinely both, `flattened` for unbounded key spaces, `scaled_float`
      for bounded-precision metrics, `ip`/`version`/`date` where they apply.
- [ ] Strip waste: `doc_values: false` on return-only fields, no `.keyword` on fields nobody
      facets, `ignore_above` on every `keyword` sub-field of a long body.
- [ ] Decide the dynamic policy explicitly — `strict` for anything user-facing, `runtime` when new
      fields must remain queryable without being indexed.
- [ ] Check the array-of-objects fields: does any query need two of their fields to match the same
      element? Only then is `nested` justified.
- [ ] Settle the analysis chain and check the index/search asymmetry for anything that expands
      tokens (`references/analysis.md`).
- [ ] Decide index shape: time-based append-only data is a data stream with rollover, not a hand-
      rolled daily index; everything else is an ordinary index behind an alias
      (`references/data-lifecycle.md`).
- [ ] **Gate — the mapping does what you said:** create it on a scratch index, index one real
      document, then `GET /{index}/_mapping` to confirm nothing was inferred, run one `term`, one
      aggregation and one search against the fields you claimed support them, and confirm each
      works.

### fix-a-query-that-returns-the-wrong-documents

- [ ] `GET /{index}/_doc/{id}` — does the document exist? If not, this is ingest, not search
      (`references/diagnostics.md`).
- [ ] `GET /{index}/_validate/query?explain=true` — does it parse and rewrite to what you meant?
- [ ] `POST /{index}/_analyze` on the field and on the query text — are the terms the same terms?
      This is where `term` on `text` and the index/search analyzer mismatch both surface.
- [ ] Check the mapping for every field the query names: a clause on a sub-field that does not
      exist matches nothing, silently, whatever its boost.
- [ ] `GET /{index}/_explain/{id}` with the query body for one document that should have matched —
      it names the failing clause.
- [ ] Check `bool` semantics: `should` beside a `must` is optional, `must_not` alone matches
      everything else, `minimum_should_match` is not what you assumed (`references/query-dsl.md`).
- [ ] **Gate — the failing case passes:** re-run the original query, show the document in the
      hits, and show that the documents that should not match still do not.

### tune-relevance

- [ ] Remove anything that overrides scoring first — a `sort` clause, `constant_score`, an
      all-filter query. Every `_score` at 1.0 means nothing downstream can help
      (`references/relevance.md`).
- [ ] Build a judgement set before changing anything: 30–50 real queries from the log, graded 0–3,
      plus the queries behind the last few complaints. Record the baseline with `_rank_eval`.
- [ ] Fix structure before boosts: filters out of `must`, `multi_match` across the right fields
      with a deliberate `type`, `minimum_should_match` stated.
- [ ] Read one bad result with `"explain": true` — idf that shows the term is in most documents,
      or a length norm that is doing the ranking, changes what you would have boosted.
- [ ] Apply one lever at a time: field boosts for importance, `rank_feature` for popularity,
      `distance_feature` or a decay for recency, query rules for "this document must be first".
- [ ] Consider a second phase only after the first is right: `rescore` with a phrase query, or a
      `text_similarity_reranker` over the top N.
- [ ] **Gate — the metric moved:** re-run `_rank_eval` with the same judgement set and quote
      nDCG@10 before and after. A change with no number is a guess.

### diagnose-a-slow-search

- [ ] Separate slow from blocked: `GET /_cluster/health` and `GET /_cat/thread_pool/search,write`.
      Remember `rejected` is cumulative since node start — take two samples
      (`references/diagnostics.md`).
- [ ] Run the real query with `"profile": true` and name the top node by `time_in_nanos`. Do not
      propose capacity changes before this exists.
- [ ] Read the `breakdown`: `next_doc` dominating is iteration (a wildcard, a scan);
      `rewrite_time` is term enumeration; `build_scorer` is a huge clause list.
- [ ] Apply the rewrite the profile points at: leading wildcard → `match` on the analysed field;
      non-scoring clauses → `filter`; `match` on a `keyword` → `term`; unrounded `now` → rounded
      date maths so the request cache can work (`references/query-dsl.md`).
- [ ] Check what the response carries: `size` larger than anyone reads, unfiltered `_source`,
      `track_total_hits: true`, deep `from`.
- [ ] Only now look at shape: shard count against data volume, heap against the 31 GB ceiling,
      shards per node against the budget (`references/cluster-operations.md`).
- [ ] **Gate — the profile changed:** re-run with `"profile": true`, quote the old and new top
      node and their `time_in_nanos`, and say explicitly if the result set changed.

### add-vector-or-hybrid-search

- [ ] `GET /_license` first. On basic the Inference API, the `rrf` retriever and the `linear`
      retriever are all 403, so there is no in-cluster embedding and no fusion operator, and the
      design has to embed outside the cluster and fuse by hand
      (`references/vector-and-semantic-search.md`).
- [ ] Choose the method against the corpus: BM25 for exact and rare terms, ELSER or dense vectors
      for paraphrase, hybrid when both matter — which is most of the time.
- [ ] Where inference is available, prefer `semantic_text` unless you need a specific model,
      chunking strategy or quantization setting; it handles chunking, embedding and query
      embedding. Confirm ML-capable nodes exist, and confirm the licence by indexing one document
      rather than by creating the index — the mapping is accepted either way.
- [ ] For explicit `dense_vector`: pin `dims` to the model, match `similarity` to how the vectors
      were produced, read the mapping back to see which quantization you actually got, and decide
      whether `exclude_source_vectors` needs turning off.
- [ ] Put the filter inside the `knn` clause, not around it, so the `k` results are all filtered
      documents.
- [ ] Fuse: `rrf` retriever on Enterprise; on basic a `knn` clause and a lexical clause inside
      `bool.should` with boosts that you tune, or reciprocal-rank fusion in the application.
- [ ] **Gate — recall and relevance are numbers:** measure approximate-versus-exact overlap@k for
      the vector side, and nDCG@10 with `_rank_eval` for the fused result. Raise `num_candidates`
      until recall plateaus.

### change-a-mapping-on-a-live-index

- [ ] Classify the change: additive (a new field, a new multi-field) applies in place; anything
      touching an existing field's type, analyzer, `index` or `doc_values` needs a new index
      (`references/data-lifecycle.md`).
- [ ] For a data stream, check whether a manual rollover is enough — new fields land in the new
      backing index and old data stays readable, with no reindex at all.
- [ ] Create the destination with the complete corrected mapping, `number_of_replicas: 0` and
      `refresh_interval: -1` for the copy.
- [ ] `POST /_reindex` with `wait_for_completion=false` and `"slices": "auto"`; keep the task id
      and read the task's `failures` array rather than assuming success.
- [ ] Decide how writes arriving during the copy are handled — dual write, or a second delta
      reindex bounded by timestamp.
- [ ] Restore replicas and refresh interval, compare `_count` on both sides, then swap the alias in
      one `POST /_aliases` with `remove` and `add` together.
- [ ] **Gate — proven from the application's side:** run the application's real queries against the
      alias, confirm counts and top hits match the old index, and only then delete the source.

## Topic router

| Topic | Read when | File |
|---|---|---|
| Access-pattern-to-type table, dynamic mapping's actual output, multi-fields and `ignore_above`, object/array/`nested`/`join`, `flattened`, `doc_values`/`index`/`_source` modes, runtime fields, the hard limits, index and component templates, why a type change needs a reindex | Designing or reviewing a mapping, or deciding how to store something | `references/mapping-and-types.md` |
| The analysis chain, index versus search analyzer, autocomplete without breaking search, synonyms, per-language fields, keyword normalizers, `_analyze` and `_termvectors` | A query matches nothing, matches everything, or autocomplete behaves oddly | `references/analysis.md` |
| Filter context and caches, `bool` semantics, the query-type table with traps, wildcards and prefixes, pagination and point-in-time, `_source`/`fields`/`docvalue_fields`, percolator, the verification ladder | Writing or reviewing a Query DSL body | `references/query-dsl.md` |
| BM25 and its knobs, `multi_match` types, boosting that works, `rescore` and semantic reranking, query rules wiring, `_rank_eval` and judgement sets, reading an explain tree | Results rank badly, or a relevance change needs proving | `references/relevance.md` |
| Bucket/metric/pipeline families, `terms` error bounds, `composite`, `cardinality` and `percentiles` as estimates, date histograms and time zones, `nested` aggregations, bucket limits and breakers, the field-type mistakes | Building or debugging aggregations, or explaining why the numbers disagree | `references/aggregations.md` |
| The licence gate, retrieval-method comparison, `dense_vector` defaults and quantization, kNN and filters, `semantic_text`, ELSER, hybrid on basic and on Enterprise, chunking, measuring recall | Adding semantic, vector or hybrid retrieval | `references/vector-and-semantic-search.md` |
| When ES\|QL beats Query DSL, query shape, the implicit `LIMIT 1000`, the command table, full-text functions and `METADATA _score`, `LOOKUP JOIN` versus `ENRICH`, `FORK`/`FUSE`, `TS`, `SET` directives, DSL-to-ES\|QL translation | Writing an analytical query, or translating one | `references/esql.md` |
| Aliases and atomic swaps, data streams and their `create`-only rule, rollover conditions, ILM versus the data-stream lifecycle, index modes including `logsdb`, the reindex procedure, bulk indexing, ingest pipelines, snapshots | Setting up time-based data, changing a mapping, or loading in bulk | `references/data-lifecycle.md` |
| The shard-count formula, heap rules, node roles, allocation deciders, disk watermarks, thread pools, circuit breakers, refresh/flush/merge, rolling restarts and version compatibility, authentication and API keys | Sizing a cluster, or a shard will not allocate | `references/cluster-operations.md` |
| The triage ladder, reading a profile, the exception-to-cause table, the cluster-status call set, slow logs, isolating query cost from node cost, benchmarking honestly | Something is slow, failing, or yellow, and you need the cause | `references/diagnostics.md` |

## Output format

When reviewing a mapping, a query or a cluster change, group findings by file and lead each with
the location:

```
events-mapping.json:31 - breaking - `labels` is a dynamic object over unbounded k8s keys, so every
  new key becomes a mapped field; this is the source of `Limit of total fields [1000] has been
  exceeded`. Map it `flattened`; raising the limit grows cluster state instead of fixing it.
```

Order findings by blast radius: things that already fail or lose data first, then things that
require a reindex to fix later, then performance, then style. Separate the two classes explicitly —
what can be applied to the live index, and what needs a new index plus reindex plus alias swap —
because that distinction decides the release plan.

For a performance finding, quote the evidence (`time_in_nanos` and node type from the profile,
`doc_count_error_upper_bound`, the exception's `failed_shards[].reason`) rather than asserting that
something is slow, and give the before and after request bodies.

## Environment

- Reproduce against the same minor version as production, and say so when you cannot. Defaults
  move within a major: which quantization a `dense_vector` gets, whether vectors stay in
  `_source`, and which features the licence gates have all changed inside 9.x, so a finding from
  9.1 is not a finding about 9.5. A single node is enough for mapping behaviour, analysis, query
  semantics, profile shapes and licence gates:
  `docker run --rm -p 9200:9200 -e discovery.type=single-node -e xpack.security.enabled=false
  docker.elastic.co/elasticsearch/elasticsearch:9.5.3`. Expect yellow — the replica has nowhere to
  go — and set `number_of_replicas: 0` if that is in the way.
- `GET /_license` decides which retrieval features exist, and the check has to exercise the
  feature: a `semantic_text` mapping is accepted on any tier and `GET /_inference/_all` lists the
  preconfigured endpoints on any tier, but indexing a document returns
  `403 ... [inference]` below Enterprise.
- `semantic_text`, ELSER and reranking additionally need ML-capable nodes, independently of the
  licence.
- On Elastic Cloud Serverless (`build_flavor: "serverless"` in `GET /`), shard, node, allocation
  and JVM settings do not exist and version numbers must not be used for feature checks.
- Anything written for OpenSearch needs re-verifying here: the APIs diverged after the 7.10 fork.
