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
- Add highlight fragments to search results with custom tags and fragment control
- Understand the three highlighter types and when to use each
- Build a
completionfield for prefix autocomplete with popularity ranking and fuzzy matching - Choose between
completion,search_as_you_type, andedge_ngramapproaches
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": falseglobally, highlighting fails unless fields are explicitly stored. Also: highlightingkeywordfields is a no-op — only analyzedtextfields 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.