Start hereQuick start · 6 you’ll reach for daily
Target versions · paceVersions
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
# 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?pretty | Status. green/yellow/red; yellow = unassigned replicas. |
| GET _cat/nodes?v | Node list with roles, heap, load. |
| GET _cat/indices?v&s=index | All indices, sorted alphabetically. |
| GET _cat/shards/blogs?v | Shard placement for a specific index. |
| GET _cat/aliases?v | Aliases → index mappings. |
| GET blogs/_count | Doc count (cheap). |
| GET blogs/_stats · GET blogs/_settings · GET blogs/_mapping | Per-index introspection. |
| HEAD blogs/_doc/123 | Existence check — returns 200 / 404. |
| DELETE blogs | Drop 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. |
# 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. |
# 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 clause | Per-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_vector | Store only; query via brute-force at read time. |
# 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 _bulk | NDJSON: 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_for | Block until visible. Use in tests, not in prod. |
| ?refresh=true | Force immediate refresh. Expensive — avoid in hot paths. |
| "_op_type": "index" | "create" | "update" | "delete" | Per-row action. create errors if id exists. |
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/_rollover | Manual 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=auto | Parallelise 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=1 | Compact 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 Elasticsearch | Sync 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_bulk | Bulk 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.
# 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
filter, not must.
filter clauses don’t score and are cached at the segment level. Reserve
must for the part that should affect ranking.
match + knn.
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
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.
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.
Go deeperSee also
dense_vector.