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

  1. Elasticsearch 7.x or later, for synonym_graph — the older synonym filter cannot represent multi-token expansion correctly.
  2. Query-time synonym application, since synonym_graph is not supported at index time for exactly the reasons this page describes.
  3. Access to the _analyze API, which is the only practical way to see what a synonym rule actually produced.
  4. 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.

Token positions with a plain synonym filter and with synonym_graph The plain filter shifts subsequent tokens when expanding one token into two, while the graph filter keeps the expansion within one position slot. plain filter cheap (1) television (2) set (3) stand (4) "tv stand" is no longer a phrase in this stream synonym_graph cheap (1) television set (2) tv (2) stand (3) both alternatives occupy position 2, so "tv stand" still matches
The graph filter keeps alternatives inside one position slot. That single property is what makes multi-word synonyms compatible with phrase matching.

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.

Problems that look like missing synonyms Tokenisation failures need analyzer work, morphology needs stemming, hierarchy needs a taxonomy, and arbitrary vocabulary gaps suit vector retrieval. hyphens, units → analyzer plurals, tenses → stemmer general → specific → taxonomy arbitrary vocabulary → vector search a synonym file past a few hundred entries is usually one of these four in disguise and each has a fix that scales, which a growing list does not
Four problems that generate synonym requests and none of which synonyms solve well. Each has a fix that handles the whole class rather than one instance.

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.

Checks for a multi-word synonym rule Verify token positions, verify a phrase query, and verify the reload reached every shard. 1. token positions _analyze 2. phrase query match_phrase across the rule 3. reload count successful == total Three commands, one minute, and they catch every failure mode described on this page.
The middle check is the one teams omit, and phrase matching is precisely where multi-word rules fail.

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.