Handling Multi-Word Synonyms in Elasticsearch
Single-word synonyms are easy and multi-word ones break in ways that look like unrelated bugs: phrase queries stop matching, results appear for terms nobody searched, and the failures are intermittent because they depend on which synonym applied. The cause is that expanding one token into several disturbs token positions, and position is what phrase and proximity queries depend on. This page fixes that properly. It sits under synonym and stopword management in ranking algorithms and relevance tuning.
Prerequisites
- Elasticsearch 7.x or later, for
synonym_graph— the oldersynonymfilter cannot represent multi-token expansion correctly. - Query-time synonym application, since
synonym_graphis not supported at index time for exactly the reasons this page describes. - Access to the
_analyzeAPI, which is the only practical way to see what a synonym rule actually produced. - A handful of failing queries to test against, ideally drawn from your zero-result queue.
Diagnosis: positions are the whole problem
A tokenizer assigns each token a position. A phrase query matches when the query’s tokens appear at consecutive positions in a document. When a synonym expands one token into two — “tv” to “television set” — those two tokens have to occupy positions, and the naive filter places them consecutively, shifting everything after them.
The result is a token stream that no longer describes the original text:
input: "cheap tv stand"
tokens: cheap(1) tv(2) stand(3)
with the plain synonym filter and rule "tv => television set":
cheap(1) television(2) set(3) stand(4)
# The phrase "tv stand" no longer exists; "set stand" does. Phrase queries fail.
with synonym_graph:
cheap(1) [tv | television set](2) stand(3)
# The alternative occupies one position slot, so "tv stand" still matches.
synonym_graph represents the expansion as a graph with alternative paths through the same position range, which is what preserves the positional relationship between the surrounding tokens. Every multi-word synonym problem traces back to whether that graph exists.
Solution Steps
1. Use synonym_graph at query time only
{
"settings": { "analysis": {
"filter": {
"product_synonyms": {
"type": "synonym_graph",
"synonyms_path": "analysis/synonyms.txt",
"updateable": true
}
},
"analyzer": {
"product_index": { "tokenizer": "standard", "filter": ["lowercase"] },
"product_search": { "tokenizer": "standard", "filter": ["lowercase", "product_synonyms"] }
}
}}
}
{ "title": { "type": "text", "analyzer": "product_index", "search_analyzer": "product_search" } }
updateable: true is what allows the synonym file to be reloaded without a reindex, and it is only permitted on a search analyzer — which is another reason index-time expansion is the wrong choice here.
2. Write the rules in the form that matches your intent
The two syntaxes mean different things and mixing them up is a common source of surprise.
# Equivalent form: every term maps to every other term, both directions.
tv, television, telly
# Explicit form: left side is replaced by right side, one direction only.
tv => television set
sneaker, trainer => running shoe
Equivalent form is right for genuine synonyms. Explicit form is right when one term should be rewritten into another — a brand alias, a legacy name — and using it accidentally for a symmetric relationship silently makes the relationship one-way.
3. Put the filter after lowercase and before nothing else surprising
Filter order matters because each filter sees the output of the previous one. A synonym rule written in lowercase will not match tokens that have not been lowercased yet, and a stemmer placed before the synonym filter will have already altered the tokens the rules expect.
"filter": ["lowercase", "product_synonyms", "asciifolding"]
The general rule is: normalise case first, apply synonyms second, and apply anything that alters word forms afterwards.
4. Reload without a reindex when the rules change
# Edit analysis/synonyms.txt on every node, then:
curl -s -X POST 'localhost:9200/products/_reload_search_analyzers' | jq '._shards'
# => { "total": 8, "successful": 8, "failed": 0 }
The successful count must equal total. A partial reload leaves some shards with the old rules, producing results that differ depending on which shard answered — an intermittent failure that is genuinely hard to diagnose from the outside.
5. Verify with the analyze API before trusting anything
curl -s 'localhost:9200/products/_analyze' -H 'Content-Type: application/json' -d '{
"analyzer": "product_search", "text": "cheap tv stand", "explain": false
}' | jq -r '.tokens[] | "\(.position)\t\(.token)"'
# 0 cheap
# 1 tv
# 1 television
# 2 set <- if "set" appears at position 2 rather than sharing with "television",
# 2 stand the graph is not being built and phrase queries will break
Verification
Confirm a phrase query that spans the synonym still matches:
curl -s 'localhost:9200/products/_search' -H 'Content-Type: application/json' -d '{
"query": {"match_phrase": {"title": {"query": "tv stand", "analyzer": "product_search"}}}
}' | jq '.hits.total.value'
# => 34 (zero here means the graph is not in effect)
Confirm the synonym works in both directions when the rule is symmetric:
for q in "tv stand" "television stand"; do
echo -n "$q: "
curl -s "localhost:9200/products/_search?q=$(printf %s "$q" | jq -sRr @uri)" \
| jq '.hits.total.value'
done
# tv stand: 34
# television stand: 34
Confirm every node reloaded after a rules change:
curl -s -X POST 'localhost:9200/products/_reload_search_analyzers' \
| jq '{total: ._shards.total, successful: ._shards.successful}'
# => { "total": 8, "successful": 8 }
When a synonym is the wrong tool
Several problems present as missing synonyms and are better solved elsewhere, and recognising them prevents a synonym file that grows without improving anything.
A term that fails because of tokenisation — a hyphenated model number split into pieces, a unit stripped by the analyzer — needs an analyzer fix, not a synonym. Adding a rule for gore-tex, goretex papers over a tokenizer that should be handling hyphens, and the next hyphenated term will fail identically.
A term that fails because of morphology — plural, tense, inflected form — needs a stemmer or a language analyzer. Adding individual rules for each form works for the terms you happen to notice and fails for everything else in the same language.
A relationship that is hierarchical rather than equivalent — “footwear” should match “running shoe” but not the reverse — is a taxonomy, not a synonym set. Expressing it symmetrically makes every specific query match every general document, which is a precision loss users notice immediately.
And a query where the user’s word and the document’s word differ arbitrarily, with no pattern to capture, is the case vector retrieval handles without any list at all. A synonym file that has grown past a few hundred entries in an attempt to cover general vocabulary is usually a sign that hybrid retrieval would do the job better.
Common Pitfalls
Multi-word synonyms at index time
Index-time expansion bakes the alternatives into the postings, which inflates term frequencies for the expanded terms and distorts scoring for every document containing them. It also makes every rule change a reindex. Elasticsearch declines synonym_graph at index time deliberately; treating that restriction as an obstacle to work around rather than a warning is how teams end up with a corpus whose statistics no longer reflect its content.
Rules that overlap ambiguously
Two rules whose left sides share terms — tv, television and smart tv => smart television — produce a graph whose paths depend on match order, and the behaviour is difficult to predict. Keep rules non-overlapping where possible, and when they must overlap, test the specific interaction with _analyze rather than assuming.
Editing the file on one node
The synonym file lives on disk on each node, so an edit applied by hand to one host and a reload issued cluster-wide produces a search cluster with two different rule sets. Distribute the file with configuration management, and treat the successful count from the reload response as the confirmation that it actually landed everywhere.
Operational notes
Keep multi-word rules to a minimum and prefer the shortest expansion that works. Every additional token in an expansion widens the query, and a rule that turns one token into four produces queries with many more clauses — which can approach the boolean clause limit on a query that also has filters and boosts. Rules expanding beyond two or three tokens are usually better expressed as a curated redirect than as a synonym.
Test multi-word rules against phrase queries specifically, because that is where they break and it is not where anyone thinks to look. A synonym set that works perfectly for match queries and silently breaks match_phrase is the normal failure mode, and a single phrase-query assertion per multi-word rule in the test suite catches it permanently.
The general principle worth carrying away is that synonyms are a precision instrument for known, specific vocabulary gaps, and a poor general-purpose tool. Used that way — a few dozen carefully chosen rules with a documented reason each — they are among the highest-return relevance work available.
Related
- Synonym and stopword management — the parent guide covering curation and the index-time versus query-time decision.
- Managing synonyms without reindexing — the reload mechanism these rules depend on.
- Reducing zero-result searches — where the queries that justify a new synonym rule come from.