Search extras: highlighting, suggesters, collapse, percolation & more like this

This section documents the current Elasticsearch 9.x line (with 8.19 as the final 8.x release) as published at the Elasticsearch documentation, which is the reference these pages are written and verified against. No specific patch version is pinned. Some capabilities (Kibana-only UIs, the ML/NLP model-management workflow, cross-cluster replication, and parts of the paid / serverless-only surface) are linked, not documented in depth.

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production, as Elasticsearch iterates quickly.

This section’s bibliography lists the reference material consulted while preparing these pages.

Once a query returns the right hits in the right order, a second set of features shapes what the user actually sees: snippets with the query terms marked, autocomplete and spelling suggestions, one row per group instead of many near-duplicates, and parameterised or stored query bodies. A final feature turns search around entirely — storing queries and matching an incoming document against all of them.

Highlighting

A highlight section next to query asks Elasticsearch to return, per hit, short fragments of the requested fields with the matched terms wrapped in tags. It re-runs the query against the field’s content, so highlights stay consistent with what matched. See Highlighting.

GET /articles/_search
{
  "query": { "match": { "body": "distributed search" } },
  "highlight": {
    "fields": {
      "body": {
        "fragment_size": 150,
        "number_of_fragments": 3,
        "pre_tags": ["<mark>"],
        "post_tags": ["</mark>"]
      }
    }
  }
}

Each hit gains a highlight object: "body": ["…​a <mark>distributed</mark> <mark>search</mark> engine…​"]. fragment_size is the target fragment length in characters; number_of_fragments caps how many are returned (set it to 0 to highlight and return the whole field, unfragmented). pre_tags / post_tags default to <em> / </em>.

Choosing a highlighter

Three implementations exist; pick per field with "type".

Type When to use

unified

The default. Breaks the field into sentences with a BreakIterator and scores them with the BM25-like passage ranker. Handles all query types. Start here.

plain

Re-analyses the field in memory and re-runs a Lucene query against it. Accurate for complex queries on small fields; slow on large fields because it has no term positions to work from.

fvh

The Fast Vector Highlighter. Needs "term_vector": "with_positions_offsets" in the mapping, which enlarges the index, but is the fastest option for large fields and supports per-term boosting of fragments.

GET /articles/_search
{
  "query": { "match_phrase": { "body": "fault tolerant" } },
  "highlight": {
    "type": "fvh",
    "fields": { "body": {} }
  }
}

Other useful knobs: require_field_match: false highlights a field even when the match came from a different field (common with copy_to or multi_match); order: "score" returns the best-scoring fragments first; highlight_query highlights against a different query than the one that selected the hit (for example, mark synonyms the main query expanded but the user did not type). All options are listed on the highlighting reference.

Suggesters: autocomplete and did-you-mean

A suggest section (with or without a query) asks for alternative text. The three suggesters solve different problems.

term and phrase suggesters (did-you-mean)

The term suggester returns per-token spelling corrections drawn from a field’s actual terms, ranked by edit distance and frequency. It does not consider whether the corrected tokens make sense together.

POST /articles/_search
{
  "suggest": {
    "text": "elasticsach clstr",
    "spelling": {
      "term": { "field": "body" }
    }
  }
}

The phrase suggester builds on it: it generates whole candidate phrases, scores them with an n-gram language model over the field, and returns the most likely correction as a unit — the right tool for a "Did you mean: elasticsearch cluster?" line.

POST /articles/_search
{
  "suggest": {
    "text": "elasticsach clstr",
    "did_you_mean": {
      "phrase": {
        "field": "body.trigram",
        "size": 1,
        "gram_size": 3,
        "direct_generator": [
          { "field": "body.trigram", "suggest_mode": "always" }
        ],
        "highlight": { "pre_tag": "<em>", "post_tag": "</em>" }
      }
    }
  }
}

The phrase suggester needs a shingled sub-field (here body.trigram, an analyzer with a shingle token filter). See Suggesters for the term, phrase, and completion parameters.

completion suggester (prefix autocomplete)

The completion suggester is a separate, in-memory FST structure built from a completion field. It matches by prefix only, returns in a few milliseconds, and is meant to drive a search box’s dropdown as the user types. Define the field, index inputs (optionally weighted), then query with _search + suggest.

PUT /places
{
  "mappings": {
    "properties": {
      "name":       { "type": "text" },
      "suggest":    { "type": "completion" }
    }
  }
}

PUT /places/_doc/1?refresh
{
  "name": "Madrid Barajas Airport",
  "suggest": { "input": ["Madrid Barajas", "Barajas Airport", "MAD"], "weight": 10 }
}

POST /places/_search
{
  "suggest": {
    "place-autocomplete": {
      "prefix": "bara",
      "completion": { "field": "suggest", "size": 5, "skip_duplicates": true }
    }
  }
}

A completion field can carry contexts — a category or geo filter stored with each entry so the same index serves, say, only airports near the user:

PUT /places
{
  "mappings": {
    "properties": {
      "suggest": {
        "type": "completion",
        "contexts": [
          { "name": "kind", "type": "category" },
          { "name": "location", "type": "geo", "precision": 4 }
        ]
      }
    }
  }
}

Then each suggest request must supply the context values it wants. fuzzy on the completion query allows a typo or two in the prefix.

search_as_you_type (infix, ranked results)

completion matches prefixes of whole inputs only and returns suggestion strings, not scored documents. When you want ordinary ranked hits that also match a substring in the middle of a field as the user types, map the field as search_as_you_type. It transparently creates shingle and prefix sub-fields and is queried with a normal multi_match of type bool_prefix.

PUT /articles
{ "mappings": { "properties": { "title": { "type": "search_as_you_type" } } } }

GET /articles/_search
{
  "query": {
    "multi_match": {
      "query": "distr sea",
      "type": "bool_prefix",
      "fields": [ "title", "title._2gram", "title._3gram" ]
    }
  }
}

Both completion and search_as_you_type are pre-built, purpose-specific index structures. The manual alternative — an analyzer with an edge_ngram (or ngram) token filter feeding a plain text field — is covered in Text analysis. That approach is more flexible (any query works against the resulting terms) but enlarges the main index and needs care to avoid also n-gramming the query side; the dedicated field types trade that flexibility for speed and a smaller footprint.

Shaping the result set

collapse and inner_hits

collapse returns only the top hit for each distinct value of a keyword or numeric field — one product per model_id, one message per thread_id — without a terms aggregation. inner_hits then fetches a few of the collapsed-away documents per group. Sorting and search_after still work; only from/size count the collapsed rows. See Collapse search results.

GET /listings/_search
{
  "query": { "match": { "description": "road bike" } },
  "collapse": {
    "field": "model_id",
    "inner_hits": {
      "name": "cheapest_variants",
      "size": 3,
      "sort": [ { "price": "asc" } ]
    }
  },
  "sort": [ { "_score": "desc" } ]
}

Second-level collapse is allowed inside inner_hits but not at the top level. Unlike an aggregation, collapse does not give an exact group count; add a cardinality aggregation on the same field if you need one.

_msearch: many searches, one round trip

_msearch sends several independent searches in a single request using newline-delimited JSON (NDJSON): a header line (index and options) then a body line, repeated. Each search is executed independently and the responses array preserves order. See Multi search API.

GET /_msearch
{ "index": "articles" }
{ "query": { "match": { "body": "kibana" } }, "size": 1 }
{ "index": "listings" }
{ "query": { "match_all": {} }, "size": 0, "aggs": { "avg_price": { "avg": { "field": "price" } } } }

The trailing newline after the last body line is required. _msearch shares one thread-pool slot and one network round trip, so it is cheaper than firing the same searches concurrently; max_concurrent_searches caps how many run in parallel on the cluster.

Search templates

A search template is a search body written as a Mustache document with \{{placeholders}}, rendered with per-request parameters. It keeps the query shape on the server: the application sends only values. Register it under _scripts, then call _search/template. See Search templates.

PUT /_scripts/articles-by-tag
{
  "script": {
    "lang": "mustache",
    "source": {
      "query": {
        "bool": {
          "must":   [ { "match": { "body": "{{q}}" } } ],
          "filter": [ { "term": { "tags": "{{tag}}" } } ]
        }
      },
      "size": "{{size}}{{^size}}10{{/size}}"
    }
  }
}

GET /articles/_search/template
{
  "id": "articles-by-tag",
  "params": { "q": "replication", "tag": "internals", "size": 5 }
}

\{{^size}}…​\{{/size}} is a Mustache "inverted section" supplying a default when size is absent. \{{#toJson}}myParam\{{/toJson}} injects an array or object parameter, and \{{#join}}myArray\{{/join}} comma-joins a list. Use _render/template to see the query a set of params produces without running it:

POST /_render/template
{
  "id": "articles-by-tag",
  "params": { "q": "replication", "tag": "internals" }
}

An inline (non-stored) template is passed as "source" in the same call instead of "id". Stored templates are cluster state, so every node has them and they need no redeploy.

Percolation: matching documents against stored queries

The percolate query inverts search. Instead of running one query over many documents, you store many queries as documents and, given one incoming document, find every stored query that document would match. This is how you build "email me when a listing matches my saved search" alerting or content classification/tagging. See Percolate query.

The index needs a percolator field plus the same mappings the stored queries will run against:

PUT /saved-searches
{
  "mappings": {
    "properties": {
      "query":  { "type": "percolator" },
      "title":  { "type": "text" },
      "price":  { "type": "double" },
      "tags":   { "type": "keyword" }
    }
  }
}

PUT /saved-searches/_doc/alert-1?refresh
{
  "query": {
    "bool": {
      "must":   [ { "match": { "title": "road bike" } } ],
      "filter": [ { "range": { "price": { "lte": 800 } } } ]
    }
  }
}

Now percolate a candidate document — it is not indexed, just matched:

GET /saved-searches/_search
{
  "query": {
    "percolate": {
      "field": "query",
      "document": {
        "title": "Carbon road bike, barely used",
        "price": 650,
        "tags": ["cycling"]
      }
    }
  }
}

Each returned hit is a stored query that matched; add highlight to see which terms of the candidate triggered each one. percolate also accepts documents (an array, matched in one call) or index + id to pull an already-indexed document. The percolator field parses and stores each query at index time, so mapping changes to the queried fields after queries are stored can require reindexing the percolator index.

More Like This: finding similar documents

The more_like_this query ("MLT") extracts representative terms from one or more input texts/documents using a TF-IDF-style selection, forms a disjunction (should) query from those terms, and runs it — a lexical, term-based similarity search. See More like this query.

Fields referenced via like/unlike must be text or keyword. There are two ways to supply the "like" input:

  • inline free text — like: "text to match", or a per-field { "text": "…​", "fields": […​] } entry — which needs no special indexing beyond the field being analyzed;

  • a reference to an existing document — like: [{ "_index": "…​", "_id": "…​" }] — which needs either _source enabled (the default mapping, works out of the box) or the field mapped "store": true / "term_vector": "yes" (or with_positions/with_positions_offsets) so Elasticsearch reuses stored term statistics instead of re-analyzing _source at query time on every request — the same trade-off as the fvh highlighter’s term_vector note in Choosing a highlighter above.

Key tuning parameters, with their current defaults:

Parameter Default

fields

index.query.default_field, i.e. *

like / unlike

(required / none) — free text, per-field text, or document references

min_term_freq

2

max_query_terms

25

min_doc_freq / max_doc_freq

5 / 2147483647

min_word_length / max_word_length

0 / 0 (unbounded)

stop_words

none

analyzer

the analyzer of the first field in fields

minimum_should_match

"30%"

boost_terms

0 (deactivated)

include

false

Best uses: "related articles/products" recommendations, near-duplicate/duplicate-content detection, lightweight content-based recommendations, and moderation (grouping similar spam/abuse reports) — a cheap alternative to embeddings when semantic nuance isn’t required.

MLT matches shared vocabulary, not shared meaning: it is purely lexical, needs no embedding model or inference cost, and works immediately on existing text mappings. When queries and documents may share meaning without sharing words, see Vector & semantic search for the embedding-based alternative.

PUT /articles
{
  "mappings": {
    "properties": {
      "title": { "type": "text" },
      "body":  { "type": "text", "term_vector": "yes" },
      "tags":  { "type": "keyword" }
    }
  }
}

GET /articles/_search
{
  "query": {
    "more_like_this": {
      "fields": ["title", "body"],
      "like": "vector databases and approximate nearest neighbour search",
      "min_term_freq": 1,
      "max_query_terms": 12
    }
  }
}

GET /articles/_search
{
  "query": {
    "more_like_this": {
      "fields": ["title", "body"],
      "like": [
        { "_index": "articles", "_id": "42" }
      ],
      "unlike": [
        { "_index": "articles", "_id": "7" }
      ],
      "min_term_freq": 2,
      "min_doc_freq": 3
    }
  }
}

The first query matches on free text; the second finds articles similar to document 42 while steering away from document 7, both re-using body’s stored term vectors. `include: true returns the input documents themselves alongside the results (default false excludes them), useful when testing relevance tuning.