05c — Highlighting and autocomplete

Two features that transform search results from data dumps into usable UIs: highlighting shows why a document matched, while autocomplete makes search feel instantaneous. This part covers both — including the three highlighter types and the three autocomplete strategies so you can pick the right tool.


Goals for this part


5.4 Highlighting matches

Highlighting returns the fragment of text that caused the document to match, with match terms wrapped in markup — essential for any user-facing search UI.

5.4.1 Basic highlight

GET /shop-orders-demo/_search
{
  "query": {
    "match": { "notes": "payment failure" }
  },
  "highlight": {
    "fields": {
      "notes": {},
      "customer.name": {}
    }
  }
}

By default, matched terms are wrapped in <em> tags. The response includes a highlight object alongside each hit’s _source.

5.4.2 Custom tags and fragment control

{
  "highlight": {
    "pre_tags":  ["<b>"],
    "post_tags": ["</b>"],
    "fields": {
      "notes": {
        "fragment_size": 150,
        "number_of_fragments": 3,
        "no_match_size": 100
      }
    }
  }
}
Parameter Effect
fragment_size Target character length of each snippet (default: 100)
number_of_fragments How many snippets to return (default: 5). Set to 0 to return the whole field.
no_match_size Characters to return from the start of the field when no fragment matches (for context)
order "score" (most relevant fragments first) or "none" (document order)

5.4.3 Highlighting nested fields via inner_hits

Highlighting works inside inner_hits — you declare the highlight inside the nested query block:

{
  "query": {
    "nested": {
      "path": "items",
      "query": {
        "match": { "items.name": "kabel" }
      },
      "inner_hits": {
        "highlight": {
          "fields": {
            "items.name": {
              "fragment_size": 80,
              "number_of_fragments": 1
            }
          }
        }
      }
    }
  }
}

5.4.4 Highlighter types

Type Mechanism Best for
unified (default) Uses the Lucene Unified Highlighter; works with most query types General purpose; handles bool, match_phrase, span
plain Re-analyzes the field text; accurate but slow on large fields Exact accuracy when unified gives wrong fragments
fvh (Fast Vector Highlighter) Requires term_vector: with_positions_offsets on the field; very fast High-traffic highlight on large text fields

Switch per field:

"notes": {
  "type": "fvh"
}

Pitfall: Highlighting requires the field’s text to be available — either from _source (default) or from stored fields. If you set "_source": false globally, highlighting fails unless fields are explicitly stored. Also: highlighting keyword fields is a no-op — only analyzed text fields produce sensible fragments.


5.5 Suggest / completion — autocomplete and typo tolerance

5.5.1 Suggesters overview

Elasticsearch has three suggester types with very different use cases:

Suggester Use case Query cost
term Spell-check: suggest corrections per token Low-moderate
phrase Spell-check: suggest corrections for the whole phrase Moderate
completion Autocomplete: prefix-match as-you-type Very low (FST in memory)

5.5.2 Term and phrase suggesters (spell-check style)

Use when a search returns zero results and you want to suggest “Did you mean…?”:

GET /shop-orders-demo/_search
{
  "query": { "match_none": {} },
  "suggest": {
    "notes_suggestion": {
      "text": "payemnt failurr",
      "phrase": {
        "field": "notes",
        "size": 3,
        "gram_size": 2,
        "highlight": {
          "pre_tag": "<em>",
          "post_tag": "</em>"
        },
        "collate": {
          "query": {
            "source": {
              "match": { "notes": "{{suggestion}}" }
            }
          },
          "prune": true
        }
      }
    }
  }
}

collate verifies each suggestion actually returns results before offering it — avoids “Did you mean X?” when X also returns nothing.

5.5.3 Completion suggester — prefix autocomplete

The completion suggester uses an in-memory Finite State Transducer (FST) for O(1) prefix lookup. It requires a dedicated field type in the mapping.

Mapping:

PUT /product-suggestions
{
  "mappings": {
    "properties": {
      "suggest": {
        "type": "completion",
        "analyzer": "simple"
      },
      "product_id": { "type": "keyword" },
      "name":       { "type": "text" }
    }
  }
}

Indexing with weights (for popularity-based ranking):

POST /product-suggestions/_doc
{
  "product_id": "EL-001",
  "name": "USB-C Charging Cable",
  "suggest": {
    "input": ["USB-C Charging Cable", "USB Cable", "Charging Cable"],
    "weight": 85
  }
}

Querying:

GET /product-suggestions/_search
{
  "suggest": {
    "product_autocomplete": {
      "prefix": "usb",
      "completion": {
        "field": "suggest",
        "size": 5,
        "skip_duplicates": true,
        "fuzzy": {
          "fuzziness": "AUTO",
          "min_length": 4,
          "prefix_length": 2
        }
      }
    }
  }
}

fuzzy on completion allows one edit for typos while still using the FST — at the cost of needing to walk more branches.

5.5.4 search_as_you_type and edge-ngram alternatives

Approach Pros Cons
completion field Fastest (FST in memory); supports weights/contexts Separate field; no relevance scoring from other fields; static suggestions
search_as_you_type field type Standard query syntax; relevance scoring; phrase matching Larger index (stores ._2gram, ._3gram sub-fields)
edge_ngram tokenizer on text field Full scoring; no separate field; works with match Larger index (all prefix tokens stored); slower than FST

search_as_you_type example:

{
  "mappings": {
    "properties": {
      "name": {
        "type": "search_as_you_type"
      }
    }
  }
}

Query it with multi_match across the auto-generated sub-fields:

{
  "query": {
    "multi_match": {
      "query": "usb cab",
      "type": "bool_prefix",
      "fields": ["name", "name._2gram", "name._3gram"]
    }
  }
}

Decision guide: - High-volume autocomplete with popularity ranking → completion - Autocomplete that must incorporate relevance signals from other fields → search_as_you_type or edge_ngram - Spell correction for failed queries → phrase suggester with collate


Lab — Highlighting and autocomplete

Part A: Highlighting

Goal: Add useful highlight fragments to search results.

Step 1 — Run a basic search with default highlighting:

GET /shop-orders-demo/_search
{
  "query": {
    "match": { "notes": "payment" }
  },
  "highlight": {
    "fields": {
      "notes": {}
    }
  },
  "size": 3
}

Observe: What tags wrap the matched term? How many fragments are returned?

Step 2 — Customize: use <mark> tags, limit to 1 fragment of 120 characters, and show 80 chars even when no fragment matches:

GET /shop-orders-demo/_search
{
  "query": {
    "match": { "notes": "payment" }
  },
  "highlight": {
    "pre_tags": ["<mark>"],
    "post_tags": ["</mark>"],
    "fields": {
      "notes": {
        "fragment_size": 120,
        "number_of_fragments": 1,
        "no_match_size": 80
      }
    }
  },
  "size": 3
}

Step 3 — Switch to the fvh highlighter on the notes field. You will first need to update the mapping to store term vectors:

PUT /shop-orders-demo-fvh
{
  "mappings": {
    "properties": {
      "notes": {
        "type": "text",
        "term_vector": "with_positions_offsets"
      }
    }
  }
}

Then run a search with "type": "fvh" in the highlight block and compare the response time vs the default unified highlighter. (On small datasets the difference is subtle; the benefit shows at scale.)

Step 4 — Highlight inside inner_hits for a nested query:

GET /shop-orders-demo/_search
{
  "query": {
    "nested": {
      "path": "items",
      "query": {
        "match": { "items.name": "kabel" }
      },
      "inner_hits": {
        "highlight": {
          "fields": {
            "items.name": {
              "fragment_size": 80,
              "number_of_fragments": 1
            }
          }
        }
      }
    }
  },
  "_source": ["order_id"],
  "size": 3
}

Takeaway: Highlighting fragments appear per-field in the highlight key alongside each hit. The fvh type is the fastest option for large text fields but requires the field to store term vectors at index time.


Part B: Completion suggester

Goal: Build a working autocomplete index with popularity-based ordering.

Step 1 — Create the index:

PUT /product-suggestions
{
  "settings": { "number_of_replicas": 0 },
  "mappings": {
    "properties": {
      "suggest": { "type": "completion", "analyzer": "simple" },
      "product_id": { "type": "keyword" },
      "name":       { "type": "text" }
    }
  }
}

Step 2 — Index 8 products with varying weights (higher weight = more popular):

POST /product-suggestions/_bulk
{ "index": {} }
{ "product_id": "EL-001", "name": "USB-C Charging Cable", "suggest": { "input": ["USB-C Charging Cable", "USB Cable", "Charging Cable"], "weight": 95 } }
{ "index": {} }
{ "product_id": "EL-002", "name": "USB-A to USB-C Adapter", "suggest": { "input": ["USB-A to USB-C Adapter", "USB Adapter"], "weight": 60 } }
{ "index": {} }
{ "product_id": "EL-003", "name": "USB Hub 4-port", "suggest": { "input": ["USB Hub", "4-port Hub"], "weight": 40 } }
{ "index": {} }
{ "product_id": "EL-004", "name": "Wireless Mouse", "suggest": { "input": ["Wireless Mouse", "Mouse"], "weight": 80 } }
{ "index": {} }
{ "product_id": "EL-005", "name": "Mechanical Keyboard", "suggest": { "input": ["Mechanical Keyboard", "Keyboard"], "weight": 70 } }
{ "index": {} }
{ "product_id": "EL-006", "name": "USB Microphone", "suggest": { "input": ["USB Microphone", "Microphone"], "weight": 50 } }
{ "index": {} }
{ "product_id": "EL-007", "name": "Monitor HDMI Cable", "suggest": { "input": ["Monitor HDMI Cable", "HDMI Cable", "Cable"], "weight": 55 } }
{ "index": {} }
{ "product_id": "EL-008", "name": "USB-C Docking Station", "suggest": { "input": ["USB-C Docking Station", "Docking Station", "USB Dock"], "weight": 30 } }

Step 3 — Query for prefix “usb” and observe the ordering by weight:

GET /product-suggestions/_search
{
  "suggest": {
    "product_autocomplete": {
      "prefix": "usb",
      "completion": {
        "field": "suggest",
        "size": 5,
        "skip_duplicates": true
      }
    }
  }
}

Questions: 1. Which products appear? Are they ordered by weight (descending)? 2. Does the product with the highest weight appear first?

Step 4 — Enable fuzzy matching and test with a one-character typo (“uab” for “usb”):

GET /product-suggestions/_search
{
  "suggest": {
    "product_autocomplete": {
      "prefix": "uab",
      "completion": {
        "field": "suggest",
        "size": 5,
        "skip_duplicates": true,
        "fuzzy": {
          "fuzziness": "AUTO",
          "min_length": 4,
          "prefix_length": 1
        }
      }
    }
  }
}

Does fuzzy matching return USB products despite the typo?

Step 5 — Compare: create a second index using search_as_you_type instead:

PUT /product-sat
{
  "settings": { "number_of_replicas": 0 },
  "mappings": {
    "properties": {
      "name": { "type": "search_as_you_type" }
    }
  }
}

Index the same products (name only), then query with bool_prefix:

POST /product-sat/_bulk
{ "index": {} }
{ "name": "USB-C Charging Cable" }
{ "index": {} }
{ "name": "USB-A to USB-C Adapter" }
{ "index": {} }
{ "name": "USB Hub 4-port" }
{ "index": {} }
{ "name": "Wireless Mouse" }
{ "index": {} }
{ "name": "Mechanical Keyboard" }
GET /product-sat/_search
{
  "query": {
    "multi_match": {
      "query": "usb cab",
      "type": "bool_prefix",
      "fields": ["name", "name._2gram", "name._3gram"]
    }
  }
}

Compare the index size of product-suggestions vs product-sat:

GET /_cat/indices/product-*?v&h=index,store.size,docs.count

Step 6 — Clean up:

DELETE /product-suggestions
DELETE /product-sat

Takeaway: completion is fastest for pure autocomplete with popularity ranking. search_as_you_type is more flexible (works with scoring and multi-word queries) but carries a larger index footprint and no built-in popularity weighting.