Databases No. 198

Elasticsearch Search skill for Claude Code

Explicit mappings, measured relevance, sane shards. Claude writes strict index templates instead of trusting dynamic mapping, maps each field for how it is queried, and puts every exact constraint in filter context. Mapping changes go through a versioned index, _reindex, and an atomic alias swap. Relevance changes are judged with _rank_eval, and red or yellow clusters are diagnosed with allocation explain before anyone restarts a node.

  • nameelasticsearch-search
  • size198 lines · ≈2,250 tokens
  • files2
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Builds and operates Elasticsearch and OpenSearch indices - explicit mappings (text vs keyword, multi-fields, dynamic templates), custom analyzers and synonyms, bool queries with filter context, relevance tuning with function_score and the rank eval API, aggregations, aliases with zero-downtime reindexing, shard sizing, ILM/ISM and data streams, and cluster health triage. Use when designing an index mapping, search results are irrelevant or slow, aggregations are wrong, a cluster is yellow or red, shards are oversized, or a mapping change requires a reindex.

Try asking

  • Design the Elasticsearch mapping for our product catalog with autocomplete
  • The cluster went red after a disk alert - figure out what is unassigned
  • Change the title analyzer without downtime for the search API

Use it when

  • Creating or reviewing an index mapping or template
  • Search results are irrelevant, missing, or slow
  • A field type change requires reindexing behind an alias
  • The cluster is yellow or red, or shards are badly sized

Skip it when

How to check the result

GET _mapping matches the template, _rank_eval scores improve on the judged query set, shard sizes sit in the 10-50 GB band, and cluster health is green.

The common mistake it prevents

Running a term query against a text field: the indexed tokens are lowercased and stemmed, so the exact value never matches and results silently come back empty.

~/.claude/skills/elasticsearch-search/SKILL.md

--- name: elasticsearch-search description: Builds and operates Elasticsearch and OpenSearch indices - explicit mappings (text vs keyword, multi-fields, dynamic templates), custom… license: MIT ---

An index is done when its mapping is explicit (no surprise dynamic fields), every field is mapped for how it is queried, the application talks to an alias rather than a concrete index, relevance is measured against a judged query set, and shards land in the 10-50 GB range. The same guidance applies to OpenSearch unless a note says otherwise.

Version notes

  • Elasticsearch 8.x/9.x and OpenSearch 2.x/3.x share the core query DSL, mappings, analyzers, aliases, _reindex, and aggregations covered here.
  • Lifecycle: Elasticsearch uses ILM (_ilm/policy); OpenSearch uses ISM (_plugins/_ism/policies). Security, alerting, and vector features diverge - check the engine before writing those APIs.
  • Use the official client for the engine and major version in use; mismatched clients (an ES 9 client against OpenSearch) fail on product checks.

Workflow

- [ ] 1. Inventory queries: search box, filters, sorts, aggregations, autocomplete
- [ ] 2. Write an explicit mapping and index template
- [ ] 3. Choose analyzers; test them with _analyze
- [ ] 4. Create a versioned index behind an alias; bulk load
- [ ] 5. Write queries: filter context for exact constraints, scoring only for text
- [ ] 6. Build a judged query set and measure relevance
- [ ] 7. Size shards, set lifecycle, check cluster health

2. Mapping

PUT _index_template/products
{
  "index_patterns": ["products-v*"],
  "template": {
    "settings": { "number_of_shards": 1, "number_of_replicas": 1, "refresh_interval": "1s" },
    "mappings": {
      "dynamic": "strict",
      "properties": {
        "sku":        { "type": "keyword" },
        "title":      { "type": "text", "analyzer": "english",
                        "fields": { "raw": { "type": "keyword", "ignore_above": 256 } } },
        "brand":      { "type": "keyword" },
        "price":      { "type": "scaled_float", "scaling_factor": 100 },
        "in_stock":   { "type": "boolean" },
        "created_at": { "type": "date" },
        "tags":       { "type": "keyword" },
        "attributes": { "type": "flattened" }
      }
    }
  }
}
Field is used forMap as
Full-text searchtext with a language or custom analyzer
Exact filter, terms aggregation, sortkeyword (add as .raw multi-field on text)
IDs, status codes, enumskeyword even when numeric - range queries on IDs are rare
Range filters and numeric aggregationsinteger/long/scaled_float/date
Arbitrary user key/value bagsflattened (one field, keyword semantics) to avoid mapping explosion
Objects whose fields must match together in one array elementnested (costly; each element is a hidden doc)
Not queried at all"enabled": false on the object, or "index": false on the field

Rules:

  • Set "dynamic": "strict" (fail on unknown fields) or "dynamic": "false" (store but do not index) for application indices. Default dynamic mapping turns every string into text + keyword and grows the mapping without review.
  • Field mappings cannot change type in place. Adding a new field is fine; changing an existing one means a new index and _reindex.
  • Keep total fields well under the 1,000 default index.mapping.total_fields.limit; raising the limit treats a symptom.

3. Analyzers

"settings": {
  "analysis": {
    "filter": {
      "product_synonyms": { "type": "synonym_graph", "synonyms_set": "products-synonyms", "updateable": true }
    },
    "analyzer": {
      "title_index":  { "tokenizer": "standard", "filter": ["lowercase", "asciifolding", "english_stemmer"] },
      "title_search": { "tokenizer": "standard", "filter": ["lowercase", "asciifolding", "product_synonyms", "english_stemmer"] }
    }
  }
}
  • Apply synonyms at search time (search_analyzer) with synonym_graph so updates do not require a reindex. Elasticsearch 8.10+ has a synonyms API (synonyms_set); OpenSearch and older ES use inline synonyms or synonyms_path files that must exist on every node. Define english_stemmer ("type": "stemmer", "language": "english") in the same filter block.
  • Autocomplete: search_as_you_type field or an edge_ngram index analyzer with a plain search analyzer. Never apply edge n-grams at search time.
  • Test before indexing: POST products-v3/_analyze {"analyzer": "title_index", "text": "Wi-Fi Routers"}.

4. Aliases and reindexing

The application reads and writes products (an alias), never products-v3.

PUT products-v4                                  // new mapping via template
POST _reindex?wait_for_completion=false
{ "source": { "index": "products-v3" }, "dest": { "index": "products-v4" } }
GET _tasks/<task_id>                             // poll until completed, check failures
POST _aliases
{ "actions": [
  { "remove": { "index": "products-v3", "alias": "products" } },
  { "add":    { "index": "products-v4", "alias": "products", "is_write_index": true } } ] }
  • For big reindexes: set "refresh_interval": "-1" and "number_of_replicas": 0 on the destination, restore both afterwards, and use slices: auto.
  • Writes that arrive during the reindex are lost unless the app dual-writes or you replay changes since the reindex start (from the source of truth by updated_at).
  • Compare _count on old and new before switching; ask the user before deleting the old index.

5. Queries

GET products/_search
{
  "query": {
    "bool": {
      "must":   [ { "multi_match": { "query": "wireless router", "fields": ["title^3", "description"], "type": "best_fields" } } ],
      "filter": [ { "term": { "in_stock": true } }, { "range": { "price": { "lte": 200 } } }, { "terms": { "brand": ["netgear", "tp-link"] } } ],
      "should": [ { "term": { "tags": "bestseller" } } ]
    }
  },
  "aggs": { "brands": { "terms": { "field": "brand", "size": 20 } } },
  "size": 20,
  "_source": ["sku", "title", "price"]
}
  • Put every yes/no constraint in filter: it skips scoring and is cached.
  • term on a text field almost never matches (the indexed tokens are analyzed). Use match on text, term/terms on keyword.
  • Deep pagination: from + size is capped at 10,000 by index.max_result_window. Use search_after with a point in time (PIT) and a tiebreaker sort, not a larger window.
  • Boost freshness or popularity with function_score (field_value_factor with log1p, or a gauss decay on date) rather than sorting by it, which discards relevance.
  • terms aggregations on high-cardinality fields are approximate across shards; raise shard_size or use composite aggregation to page through all buckets exactly.

6. Relevance measurement

Collect 30-100 real queries from logs with graded judgments (0-3) of the top results. Run _rank_eval with ndcg or mean_reciprocal_rank, change one thing (boosts, analyzer, synonyms), rerun, keep the change only if the metric improves without dropping important queries. Record the before/after table in the PR.

7. Shards, lifecycle, health

  • Target 10-50 GB per primary shard (under ~200M docs). A 2 GB catalog needs one shard. Oversharding (many tiny shards) costs heap and slows cluster state more than undersized clusters do.
  • Time-based data (logs, events): use data streams with a lifecycle policy that rolls over by max_primary_shard_size: 50gb or age, then deletes or moves to warm/cold.
  • Bulk indexing: batches of 5-15 MB via _bulk, check errors: true in the response and retry only failed items, multiple concurrent workers, refresh off during initial loads.

For red/yellow clusters, unassigned shards, heap pressure, or slow queries, follow references/cluster-triage.md.

Gotchas

  • refresh_interval controls visibility, not durability. Tests that index then search immediately need ?refresh=wait_for, not sleeps.
  • Updates rewrite the whole document; frequently updated counters (views) belong elsewhere or in a separate, small index.
  • wildcard/regexp queries with a leading * scan the term dictionary; use n-grams or the wildcard field type.
  • Scripts in queries (script_score, painless) are slow per document; precompute fields at index time.
  • Elasticsearch is not the source of truth. Keep the database authoritative and make the index rebuildable from it.
  • Mapping the same field name with different types in different indices behind one alias breaks sorting and aggregations across them.

Verification

  • GET products/_mapping matches the template; no unexpected fields.
  • _analyze output for five tricky inputs (plurals, hyphens, accents, SKUs) is as intended.
  • _rank_eval score recorded before and after relevance changes.
  • GET _cat/shards/products*?v&h=index,shard,prirep,store,docs shows shard sizes in range.
  • GET _cluster/health is green; a search with "profile": true shows no unexpected expensive clauses for the top three queries.

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

references/cluster-triage.md 76 lines · 3.7 KB
# Cluster triage (Elasticsearch / OpenSearch)

Work top-down: cluster status, then nodes, then indices and shards, then queries. Do not
restart nodes or change allocation settings before reading the explain output.

## 1. Status

```http
GET _cluster/health?pretty
GET _cat/nodes?v&h=name,node.role,heap.percent,ram.percent,cpu,load_1m,disk.used_percent
GET _cat/allocation?v
```

| Status | Meaning | First move |
|---|---|---|
| green | all primaries and replicas assigned | nothing, check latency instead |
| yellow | primaries fine, some replicas unassigned | normal on a single-node cluster (set replicas to 0 there); otherwise explain allocation |
| red | at least one primary unassigned - some data unavailable | explain allocation now; check disk and lost nodes |

## 2. Why is a shard unassigned

```http
GET _cat/shards?v&h=index,shard,prirep,state,unassigned.reason,node&s=state
GET _cluster/allocation/explain
{ "index": "products-v4", "shard": 0, "primary": true }
```

Common reasons in the explain output and fixes:

| Reason | Fix |
|---|---|
| Disk watermark exceeded (default low 85%, high 90%, flood stage 95%) | free disk, delete old indices (with approval), add nodes. Flood stage sets `index.blocks.read_only_allow_delete`; recent versions release it automatically once disk drops below the high watermark |
| Node left the cluster | bring it back; shards reallocate after `index.unassigned.node_left.delayed_timeout` (1m default) |
| Allocation filtering / awareness rules exclude every node | fix `index.routing.allocation.*` settings |
| Max retries exceeded after repeated failures | fix the cause, then `POST _cluster/reroute?retry_failed=true` |
| No valid copy of a primary (all copies lost) | last resort is restoring from snapshot; `allocate_stale_primary`/`allocate_empty_primary` lose data - require explicit user approval |

## 3. Heap and memory

- Heap at most 50% of RAM and below ~31 GB so compressed object pointers stay on; the rest
  of RAM is filesystem cache, which search depends on.
- Sustained `heap.percent` above 85% or long GC pauses: look at shard count per node
  (keep it in the low hundreds per node at most), fielddata on text fields
  (`GET _cat/fielddata?v`), large aggregations, and big `size` values.
- Circuit breaker exceptions (`CircuitBreakingException`) mean a request asked for too much
  memory; shrink the request rather than raising the breaker.

## 4. Slow searches

```http
GET _nodes/stats/thread_pool/search?filter_path=nodes.*.thread_pool.search
GET products/_search  { "profile": true, "query": { ... } }
PUT products/_settings { "index.search.slowlog.threshold.query.warn": "2s" }
```

- Rejections in the `search` thread pool: too many concurrent queries or too many shards
  per query. Reduce shards, add replicas on more nodes, or cache at the app layer.
- Profile output: look for `WildcardQuery`, `RegexpQuery`, script scoring, and very large
  `terms` aggregations.
- `"track_total_hits": false` (or a number) avoids exact counting on large result sets.

## 5. Slow indexing

- Check `_cat/thread_pool/write?v` for rejections; the client must back off and retry
  individual failed items from `_bulk`.
- During initial loads: `refresh_interval: -1`, replicas 0, then restore.
- Merges falling behind on slow disks shows as high `merges.current`; use faster storage
  rather than tuning merge policy.

## 6. Hygiene checks to report

- [ ] Shards per node and average shard size (`_cat/shards`)
- [ ] Indices with no recent writes or reads that could move to cold storage or be deleted
- [ ] Snapshots configured and a recent one succeeded (`GET _snapshot/_all`, `_cat/snapshots/<repo>`)
- [ ] Lifecycle policies attached to every time-based index
- [ ] Node versions identical across the cluster

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc