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.
- name
elasticsearch-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
- You want a hosted engine like Algolia, Meilisearch or Typesense - use search-integration
- Search fits inside Postgres with modest data - use postgres-full-text-search
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.
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 for | Map as |
|---|---|
| Full-text search | text with a language or custom analyzer |
| Exact filter, terms aggregation, sort | keyword (add as .raw multi-field on text) |
| IDs, status codes, enums | keyword even when numeric - range queries on IDs are rare |
| Range filters and numeric aggregations | integer/long/scaled_float/date |
| Arbitrary user key/value bags | flattened (one field, keyword semantics) to avoid mapping explosion |
| Objects whose fields must match together in one array element | nested (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 intotext+keywordand 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) withsynonym_graphso updates do not require a reindex. Elasticsearch 8.10+ has a synonyms API (synonyms_set); OpenSearch and older ES use inlinesynonymsorsynonyms_pathfiles that must exist on every node. Defineenglish_stemmer("type": "stemmer", "language": "english") in the samefilterblock. - Autocomplete:
search_as_you_typefield or anedge_ngramindex 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": 0on the destination, restore both afterwards, and useslices: 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
_counton 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. termon atextfield almost never matches (the indexed tokens are analyzed). Usematchon text,term/termson keyword.- Deep pagination:
from + sizeis capped at 10,000 byindex.max_result_window. Usesearch_afterwith a point in time (PIT) and a tiebreaker sort, not a larger window. - Boost freshness or popularity with
function_score(field_value_factorwithlog1p, or agaussdecay on date) rather than sorting by it, which discards relevance. termsaggregations on high-cardinality fields are approximate across shards; raiseshard_sizeor usecompositeaggregation 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: 50gbor age, then deletes or moves to warm/cold. - Bulk indexing: batches of 5-15 MB via
_bulk, checkerrors: truein 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_intervalcontrols 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/regexpqueries with a leading*scan the term dictionary; use n-grams or thewildcardfield 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/_mappingmatches the template; no unexpected fields._analyzeoutput for five tricky inputs (plurals, hyphens, accents, SKUs) is as intended._rank_evalscore recorded before and after relevance changes.GET _cat/shards/products*?v&h=index,shard,prirep,store,docsshows shard sizes in range.GET _cluster/healthis green; a search with"profile": trueshows 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
# 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.