DS DevShelfHub Projects · AI tools
Cheatsheets / Elasticsearch
Cheatsheet · Dev tooling

Elasticsearch: Query DSL, Aggregations, kNN and Hybrid Search Reference Guide

By DevShelfHub

Mappings, analyzers, the Query DSL, aggregations, dense_vector + kNN, RRF hybrid retrievers, bulk indexing, ILM, and data streams — the Elasticsearch 8.14+ reference. Covers the Python client, REST API, and the modern retriever tree for hybrid BM25 + vector search.

90 items ◷ 9 min DSL kNN Aggregations

Start hereQuick start · 6 you’ll reach for daily

Cluster healthGET _cluster/health
List indicesGET _cat/indices?v
Index a docPOST blogs/_doc { ... }
SearchGET blogs/_search { "query": ... }
BulkPOST _bulk (NDJSON)
kNN"knn": { "field": "embedding", ... }

Target versions · paceVersions

Targets: elasticsearch ≥ 8.14 kibana matches server elasticsearch-py 8.x — major must match

Elasticsearch 8.x ships security on by default. Recent flagship features: dense_vector + HNSW kNN, RRF retriever for hybrid search, retriever tree, learned sparse ELSER, data streams + ILM. Pin the Python client major to the server major — cross-major calls will refuse to start.

Docker · clientSetup

bash
# Docker — single-node dev cluster with security off (do NOT do this in prod)
docker run -d --name es \
    -p 9200:9200 \
    -e "discovery.type=single-node" \
    -e "xpack.security.enabled=false" \
    docker.elastic.co/elasticsearch/elasticsearch:8.14.0

# Smoke test — should return cluster info
curl -s http://localhost:9200 | jq

# Cluster health
curl -s 'http://localhost:9200/_cluster/health?pretty'

# Kibana (optional dev UI)
docker run -d --name kib --link es \
    -p 5601:5601 \
    -e ELASTICSEARCH_HOSTS=http://es:9200 \
    docker.elastic.co/kibana/kibana:8.14.0

# Python client (matches the server major version)
pip install "elasticsearch>=8.14,<9"

python - <<'PY'
from elasticsearch import Elasticsearch
es = Elasticsearch("http://localhost:9200")
print(es.info()["version"]["number"])
PY

HTTP · CAT APIREST basics

GET /Cluster + version info.
GET _cluster/health?prettyStatus. green/yellow/red; yellow = unassigned replicas.
GET _cat/nodes?vNode list with roles, heap, load.
GET _cat/indices?v&s=indexAll indices, sorted alphabetically.
GET _cat/shards/blogs?vShard placement for a specific index.
GET _cat/aliases?vAliases → index mappings.
GET blogs/_countDoc count (cheap).
GET blogs/_stats · GET blogs/_settings · GET blogs/_mappingPer-index introspection.
HEAD blogs/_doc/123Existence check — returns 200 / 404.
DELETE blogsDrop an index. Disable action.destructive_requires_name=false on prod — refuses wildcards by default.

Schemas · tokenizationMappings & analyzers

PUT blogs { "mappings": { "properties": { ... } } }Create an index with an explicit mapping.
"type": "text"Tokenized + analyzed. Use for body / titles.
"type": "keyword"Preferred Exact match. Use for ids, enums, tags, sorting, terms agg.
"title": { "type":"text", "fields": { "raw": { "type":"keyword" } } }Multi-field: search title, sort/agg on title.raw.
"embedding": { "type":"dense_vector", "dims":768, "similarity":"cosine" }kNN field. similarity: cosine / dot_product / l2_norm.
"ts": { "type":"date", "format":"strict_date_optional_time||epoch_millis" }Date with explicit accepted formats.
"settings.analysis.analyzer": { "blog_text": { "tokenizer":"standard", "filter":["lowercase","english_stop","english_stemmer"] } }Custom analyzer = tokenizer + filter chain.
"settings.analysis.normalizer.lc_norm": { "filter":["lowercase","asciifolding"] }Normalizer — for keyword fields.
"index_options": "offsets"Required for unified highlighter snippets on a text field.
"dynamic": "strict"Reject documents with unknown fields at index time.
POST blogs/_analyze { "analyzer":"english", "text":"running shoes" }Preview the token stream a query / doc would produce.
json
# Explicit mapping for a blog index — custom analyzer + multi-fields + a dense_vector.
PUT blogs
{
  "settings": {
    "number_of_shards": 1,
    "number_of_replicas": 0,
    "analysis": {
      "filter": {
        "english_stop":  { "type": "stop",    "stopwords": "_english_" },
        "english_stem":  { "type": "stemmer", "language": "english"    }
      },
      "analyzer": {
        "blog_text": {
          "tokenizer": "standard",
          "filter": ["lowercase", "english_stop", "english_stem"]
        }
      },
      "normalizer": {
        "lc_norm": { "type": "custom", "filter": ["lowercase", "asciifolding"] }
      }
    }
  },
  "mappings": {
    "dynamic": "strict",
    "properties": {
      "title":   { "type": "text", "analyzer": "blog_text",
                   "fields": { "raw": { "type": "keyword", "normalizer": "lc_norm" } } },
      "body":    { "type": "text", "analyzer": "blog_text", "index_options": "offsets" },
      "tags":    { "type": "keyword" },
      "author":  { "type": "keyword" },
      "ts":      { "type": "date", "format": "strict_date_optional_time||epoch_millis" },
      "views":   { "type": "long" },
      "embedding": { "type": "dense_vector", "dims": 768, "similarity": "cosine" }
    }
  }
}

term · match · boolQuery DSL

{ "term": { "tags": "search" } }Exact value on a keyword/long. Not for text.
{ "terms": { "tags": ["a","b"] } }Multiple exact values (OR).
{ "match": { "body": "vector search" } }Full-text on an analyzed field.
{ "match_phrase": { "body": { "query":"vector search", "slop":1 } } }Tokens in order, up to slop swaps.
{ "multi_match": { "query":"...", "fields":["title^3","body"], "type":"best_fields" } }One query across N fields. ^3 boosts.
{ "range": { "ts": { "gte":"now-7d/d", "lt":"now/d" } } }Date math: now, /d rounds.
{ "prefix": { "title": "redi" } } / "wildcard" / "regexp"Prefix-family. Slow on large indices — index a search_as_you_type field instead.
{ "exists": { "field": "author" } }Field is present and non-null.
{ "bool": { "must":[...], "filter":[...], "must_not":[...], "should":[...] } }Combine clauses. filter is cacheable & doesn’t score.
{ "constant_score": { "filter": { ... } } }Drop scoring; score = 1. Fastest filter form.
{ "function_score": { ... "functions":[{"gauss":{"ts":{"scale":"7d"}}}] } }Custom scoring — decay, field value factor.
{ "script_score": { "query": {...}, "script": { "source":"..." } } }Score by a Painless script. Use sparingly.
json
# Hybrid bool query — full-text "must" scored by BM25,
# filters (don't affect score), recency decay, paginated + sorted.
POST blogs/_search
{
  "size": 20,
  "from": 0,
  "track_total_hits": false,
  "_source": ["title", "author", "ts"],
  "query": {
    "function_score": {
      "query": {
        "bool": {
          "must":   [ { "multi_match": { "query": "vector search redis",
                                         "fields": ["title^3", "body"], "type": "best_fields" } } ],
          "filter": [ { "term":  { "author": "abhishek" } },
                      { "range": { "ts": { "gte": "now-30d/d" } } } ],
          "must_not": [ { "term": { "tags": "draft" } } ]
        }
      },
      "functions": [
        { "gauss": { "ts": { "origin": "now", "scale": "7d", "decay": 0.5 } } }
      ],
      "score_mode": "multiply",
      "boost_mode": "multiply"
    }
  },
  "sort": [ "_score", { "ts": "desc" } ],
  "highlight": { "fields": { "title": {}, "body": {} } }
}

BM25 · fuzzy · decayFull-text scoring

"similarity": "BM25" (default)Per-field tunable via "k1", "b" in mapping.
{ "match": { "body": { "query":"...", "fuzziness":"AUTO" } } }Levenshtein distance. Defaults to 0/1/2 by term length.
{ "match_phrase": { "body": { "query":"...", "slop":2 } } }Phrase with allowed reorderings.
{ "function_score": { "gauss": { "ts": { "origin":"now", "scale":"7d", "decay":0.5 } } } }Recency decay (Gauss/Linear/Exp).
{ "field_value_factor": { "field":"views", "modifier":"log1p", "factor":1.2 } }Boost by a numeric field.
"boost": 2.0 on a clausePer-clause weight inside bool / multi_match.
"rescore": { "window_size":50, "query": { ... } }Re-rank top-N with an expensive function. Often the right place for script_score.

Bucket · metric · pipelineAggregations

"aggs": { "byTag": { "terms": { "field":"tags", "size":10 } } }Top-N bucket on a keyword field.
"aggs": { "daily": { "date_histogram": { "field":"ts", "calendar_interval":"day" } } }Time-bucket. Use fixed_interval for non-calendar steps.
"aggs": { "p": { "percentiles": { "field":"latency", "percents":[50,95,99] } } }Approximate percentiles (HDR).
"aggs": { "u": { "cardinality": { "field":"uid" } } }HLL approximate distinct.
"aggs": { "stats": { "stats": { "field":"price" } } }min / max / avg / sum / count in one.
aggs.byCat.aggs.nested: { ... }Sub-aggregations. Drill down inside a bucket.
"aggs": { "page": { "composite": { "size":1000, "sources":[{"a":{"terms":{"field":"a"}}}, ...], "after": {...} } } }Paginated terms agg. Use instead of huge size.
"aggs": { "byCat": { ..., "aggs": { "top": { "top_hits": {"size":3} } } } }Sample N docs per bucket.

dense_vector · HNSWkNN / vector search

"properties": { "vec": { "type":"dense_vector", "dims":768, "similarity":"cosine" } }Field config. HNSW index built per-segment.
POST idx/_search { "knn": { "field":"vec", "query_vector":[...], "k":10, "num_candidates":100 } }Top-k by similarity. num_candidates >= k controls recall.
{ "knn": { ..., "filter": { "term": { "category":"news" } } } }Pre-filter inside the kNN search.
{ "knn": [...], "query": { "match": {...} } }Combine with a BM25 query — both contribute to score.
{ "retriever": { "rrf": { "retrievers": [ { "knn": {...} }, { "standard": { "query": {...} } } ] } } }RRF retriever — cleanest hybrid pattern.
"index": false on a dense_vectorStore only; query via brute-force at read time.
json
# Hybrid search: BM25 + dense_vector kNN combined via Reciprocal Rank Fusion (RRF).
# Each retriever returns its own top-k; RRF blends the rankings — no score tuning required.
POST blogs/_search
{
  "size": 10,
  "_source": ["title", "tags", "ts"],
  "retriever": {
    "rrf": {
      "rank_window_size": 50,
      "rank_constant": 60,
      "retrievers": [
        { "standard": {
            "query": {
              "bool": {
                "must":   [ { "multi_match": { "query": "vector retrieval",
                                               "fields": ["title^3", "body"] } } ],
                "filter": [ { "term":  { "tags": "search" } },
                            { "range": { "ts": { "gte": "now-180d/d" } } } ]
              }
            }
        }},
        { "knn": {
            "field": "embedding",
            "query_vector": [/* 768 floats from your embedding model */],
            "k": 30,
            "num_candidates": 200,
            "filter": [ { "term": { "tags": "search" } } ]
        }}
      ]
    }
  }
}

NDJSON · refreshBulk indexing

POST _bulkNDJSON: action line + doc line per row.
{"index":{"_index":"blogs","_id":1}}\n{"title":"..."}Action line then doc. One pair per row. Always trailing newline.
?refresh=false (default)Async refresh = throughput. Doc invisible to search until next 1s tick.
?refresh=wait_forBlock until visible. Use in tests, not in prod.
?refresh=trueForce immediate refresh. Expensive — avoid in hot paths.
"_op_type": "index" | "create" | "update" | "delete"Per-row action. create errors if id exists.
python
from elasticsearch import Elasticsearch
from elasticsearch.helpers import bulk, scan

es = Elasticsearch("https://es.internal:9200", api_key="****", verify_certs=True)

# Stream actions; helpers.bulk batches them server-side.
def gen_actions(rows):
    for r in rows:
        yield {
            "_op_type": "index",          # index | create | update | delete
            "_index":   "blogs",
            "_id":      r["id"],          # deterministic ids => idempotent
            "_source":  {
                "title":     r["title"],
                "body":      r["body"],
                "tags":      r["tags"],
                "ts":        r["ts"],
                "embedding": r["embedding"],
            },
        }

ok, errors = bulk(
    es, gen_actions(rows),
    chunk_size=500,                       # per-batch
    request_timeout=60,
    raise_on_error=False,                 # let caller decide
    refresh=False,                        # async refresh = throughput
)
print(f"{ok} indexed, {len(errors)} errors")

# Stream every doc back out — uses scroll API under the hood.
for hit in scan(es, index="blogs", query={"query": {"match_all": {}}}, size=1000):
    handle(hit["_source"])

Lifecycle · aliases · reindexILM, aliases & reindex

PUT _ilm/policy/logs { "phases": { "hot": {...}, "warm": {...}, "cold": {...}, "delete": {...} } }Lifecycle policy. Phases run by age / size / docs.
PUT _index_template/logs { "index_patterns":["logs-*"], "data_stream":{}, "template": {...} }Data stream template — backs logs-* with auto-rollover.
POST logs-app/_rolloverManual rollover to the next backing index.
PUT blogs/_settings { "index.lifecycle.name":"logs" }Attach an ILM policy to a non-data-stream index.
POST _aliases { "actions": [ { "add": { "index":"blogs-v2", "alias":"blogs" } }, { "remove": { ... } } ] }Atomic alias swap — zero-downtime reindex pivot.
POST _reindex { "source": { "index":"src" }, "dest": { "index":"dst" } }Server-side copy. ?wait_for_completion=false for async.
POST _reindex?slices=autoParallelise the reindex across the source’s shards.
GET _tasks/<task_id>Watch a long-running reindex / update_by_query.
POST blogs/_shrink/blogs-shrunk { "settings":{...} }Reduce shard count. Index must be read-only first.
POST blogs/_forcemerge?max_num_segments=1Compact segments before snapshot. Read-mostly indices only.

Server-side transformsIngest pipelines

PUT _ingest/pipeline/p { "processors": [ { "set": { "field":"env", "value":"prod" } } ] }Define a pipeline of processors.
{ "grok": { "field":"msg", "patterns":["%{COMMONAPACHELOG}"] } }Grok parse a field into named subfields.
{ "geoip": { "field":"client_ip" } }IP → geo enrichment.
{ "script": { "source":"ctx.path = '/' + ctx.path" } }Painless transform.
POST _ingest/pipeline/_simulate { "pipeline": {...}, "docs": [...] }Dry-run a pipeline against sample docs.
POST blogs/_doc?pipeline=p { ... }Use at index time, or set index.default_pipeline.

elasticsearch-pyPython client

from elasticsearch import ElasticsearchSync client. AsyncElasticsearch for asyncio.
es = Elasticsearch("https://...", api_key="...")Auth via API key, basic auth, or cloud_id.
es.index(index="blogs", id=1, document={...})Single doc upsert (id deterministic = idempotent).
es.search(index="blogs", query={...}, size=10, source=["title"])Search. Use named kwargs — the body shape is the same.
from elasticsearch.helpers import bulk, scan, streaming_bulkBulk helper + scroll iterator.
bulk(es, actions, chunk_size=500, raise_on_error=False)Batches under the hood. Returns (ok, errors).
for hit in scan(es, query={"match_all":{}}, index="blogs"): ...Stream every doc via scroll. Cleans up the scroll on exhaustion.
es.indices.create(index="blogs", mappings={...}, settings={...})Admin namespace — mirrors the REST tree.

Full pipeline · ~30 linesEnd-to-end · Blog search

Creates an index with a text + dense_vector mapping, bulk-indexes two docs, runs a hybrid query (BM25 must + kNN) with a tag filter.

python
# End-to-end: create a blog index with text + vector fields,
# bulk-index a few rows, run a hybrid (BM25 + kNN) search with a filter.
from elasticsearch import Elasticsearch
from elasticsearch.helpers import bulk

es = Elasticsearch("http://localhost:9200")

# 1. Mapping with text + dense_vector
es.indices.delete(index="blogs", ignore_unavailable=True)
es.indices.create(index="blogs", mappings={
    "properties": {
        "title":     {"type": "text"},
        "body":      {"type": "text"},
        "tags":      {"type": "keyword"},
        "embedding": {"type": "dense_vector", "dims": 4, "similarity": "cosine"},
    }
})

# 2. Bulk index
docs = [
    {"id": 1, "title": "Vector search", "body": "kNN in Elasticsearch.",
     "tags": ["search"], "embedding": [0.1, 0.2, 0.3, 0.4]},
    {"id": 2, "title": "Redis caching", "body": "Hot keys, TTLs.",
     "tags": ["cache"],  "embedding": [0.0, 0.1, 0.0, 0.9]},
]
bulk(es, ({"_index": "blogs", "_id": d["id"], "_source": d} for d in docs), refresh="wait_for")

# 3. Hybrid query — BM25 must + kNN, filter by tag
hits = es.search(index="blogs", size=5, query={
    "bool": {
        "must":   [{"match": {"body": "knn"}}],
        "filter": [{"term":  {"tags": "search"}}]
    }
}, knn={
    "field":          "embedding",
    "query_vector":   [0.1, 0.2, 0.25, 0.5],
    "k":              5, "num_candidates": 20
})
for h in hits["hits"]["hits"]:
    print(h["_score"], h["_source"]["title"])

Best practiceGood to know

Put filters in filter, not must. filter clauses don’t score and are cached at the segment level. Reserve must for the part that should affect ranking.
Use the RRF retriever for hybrid search. Reciprocal Rank Fusion sidesteps score-normalisation entirely — you just blend two top-k lists. Drops in cleanly over match + knn.
Pin Python client major to server major. elasticsearch 8.x talks only to ES 8.x. Cross-major requests refuse with a compatibility error — not just a warning. Use pip install "elasticsearch>=8,<9".

Common trapsWatch out for

Don’t use term on a text field. text is analysed at index time, so term won’t match the un-analysed query. Add a .keyword multi-field for exact lookups, or use match.
Deep from/size pagination is a footgun. Cost grows with from + size. Use search_after with a sortable tiebreaker, or a point-in-time + scroll for full extraction.
Mappings are write-once. You can’t change a field’s type after data is indexed. Pivot via alias + reindex — never drop-and-recreate on a live index.

Go deeperSee also

Elasticsearch FAQ

What is Elasticsearch used for?

Elasticsearch is a distributed search and analytics engine built on Apache Lucene. It is used for full-text search, log aggregation (the ELK stack), application performance monitoring, and increasingly for vector similarity search in RAG pipelines and AI applications.

What is the difference between a query and a filter in Elasticsearch?

Queries compute a relevance score (how well a document matches). Filters are binary yes/no checks with no scoring — they are cached and faster. Use filters for structured data like status, date ranges, and tags; use queries for full-text search where ranking matters.

How does kNN vector search work in Elasticsearch?

Map a field as dense_vector with a fixed dims count and index: true. Index documents with the embedding array in that field. At query time, send a knn clause with the query vector and k (number of neighbours). Elasticsearch uses HNSW (Hierarchical Navigable Small World) for approximate nearest-neighbour retrieval.

What is an Elasticsearch index mapping?

A mapping defines the schema for an index — field names, data types (keyword, text, date, dense_vector), and analysis settings. Use explicit mappings in production to avoid dynamic mapping surprises. You cannot change field types after indexing; you must reindex to a new mapping.

How do I do hybrid search in Elasticsearch?

Use the RRF (Reciprocal Rank Fusion) retriever to combine a kNN vector search with a BM25 full-text query. Set up a retriever tree with rrf at the root and knn and standard retrievers as children. RRF normalises scores from both result sets and merges the ranked lists without needing manual score blending.

How does Elasticsearch handle index lifecycle management?

Index Lifecycle Management (ILM) automates the transition of indices through hot, warm, cold, frozen, and delete phases based on age or size. Data streams pair ILM with time-series data to automatically roll over to a new backing index when the current one is too large or too old.