Parse query-time WordDelimiterGraph into phrase alternatives in Lucene Linguistics
Is your feature request related to a problem? Please describe.
We index classifieds. Sellers write product tokens in mixed spellings: T-Shirt / T Shirt / Tshirt, PS5 / PS 5, 32GB / 32 GB / iPhone32GB.
At index time we split on delimiters and on letter/digit boundaries (wordDelimiterGraph with generateNumberParts and catenateWords). That is required for recall. A search for 32GB must find both "iPhone 32 GB" (space) and "iPhone32GB" (one word).
At query time the same split, without graph awareness, becomes independent terms. Then:
T-Shirtbecomest AND shirtand also matches "Shirt rot mit T Aufdruck" (tandshirtoccur, but not next to each other)PS5becomesps AND 5and also matches "Ford Focus 125 PS Euro 5" (psand5occur, but not next to each other)
We need: accept either of N alternative spellings / word groupings for one query term, and still run the field's normal linguistics (German stemming, germanNormalization) on each alternative.
Elasticsearch does this without extra query logic. Search analyzer:
word_delimiter_graph
generate_number_parts: true
catenate_words: true
preserve_original: trueThat filter emits a token graph: split parts as sequential tokens (t, then shirt), a catenated token that spans those positions (tshirt, positionLength = 2), optionally the original token. We query with query_string and that analyzer. Lucene QueryBuilder walks the graph. With auto_generate_synonyms_phrase_query it turns a multi-position path into a phrase, and stacked paths at the same position into alternatives. Same shape as a multi-word synonym ny, new york becoming (ny OR "new york"). Indexing ignores positionLength; the index analyzer is word_delimiter with generate_number_parts + catenate_words and no preserve_original.
On Vespa we configure the same Lucene filters on Lucene Linguistics. Query construction uses userInput (grammar: all, grammar.profile for the German search analyzer). Query-side linguistics stems and normalizes. It does not consume positionLength / alternative paths as phrases. So T-Shirt stays AND(t, shirt), not EQUIV(tshirt, phrase(t, shirt)). text() / grammar.tokenization: linguistics delegates tokenization to linguistics, but that still does not build phrases from the graph.
Describe the solution you'd like
A supported way in Lucene Linguistics / query parsing to:
- Keep
wordDelimiterGraphon the search analyzer (withpreserveOriginal/catenateWordsas needed). - Walk the resulting token graph at query time.
- Emit phrase items for sequential split parts, and
equiv/ alternatives for stacked paths (catenated / original vs parts). - Still run the rest of the field linguistics (stemming, normalization) on each emitted term.
Minimum documents (keyword field, index-time word delimiter as above):
| id | title / keywords |
|---|---|
| 1 | Tshirt Baumwolle |
| 2 | T Shirt Baumwolle |
| 3 | Shirt rot mit T Aufdruck |
| 4 | PS5 Slim mit Controller |
| 5 | PS 5 Spiel |
| 6 | Ford Focus 125 PS Euro 5 |
| 7 | iPhone 32 GB |
| 8 | iPhone32GB |
Expected matches:
| Query | Match | Do not match | Why |
|---|---|---|---|
T-Shirt |
1, 2 | 3 | joined form or parts next to each other |
Tshirt |
1 | 2, 3 | already one word, stay one term |
PS5 |
4, 5 | 6 | ps and 5 must be adjacent |
32GB |
7, 8 | letter/digit split, parts must be adjacent | |
Sofa |
(unchanged) | term does not split |
German stemming / language profile must still run on each alternative.
Expected query tree (after linguistics, approximate Vespa items):
| Query | Query tree |
|---|---|
T-Shirt |
EQUIV(tshirt, phrase(t, shirt)) |
Tshirt |
tshirt |
PS5 |
phrase(ps, 5) (and/or EQUIV(ps5, phrase(ps, 5)) if the original token is kept) |
32GB |
phrase(32, gb) |
shirt rot |
AND(shirt, rot) (each word handled on its own; no false phrase across user spaces) |
T-Shirt is letter + delimiter + letter, so catenateWords produces a joined alternative. PS5 / 32GB are letter/digit splits, so catenateWords does not join them. Adjacent parts are enough, because index-time splitting already stored ps + 5 next to each other for a document written PS5.
Describe alternatives you've considered
Custom container Searcher (what we run today). After YQL parsing it splits the raw
querystring on letter/digit/delimiter boundaries and replaces each split keyword item withEQUIV(catenated, phrase(parts))or onlyphrase(parts). Filters,nearestNeighbor, and negations stay untouched.WordItems are created as from-query soStemmingSearcherstill stems them. It works, and there is no inherent Searcher cost. The cost is that we reimplement WordDelimiter rules instead of reading Lucene's graph, and we have to special-case query-tree parents (NotItem.removeItem(0)puts aTrueItemback, so a rewrite that removes children does not terminate).Application-level query rewrite before YQL. Same split/phrase logic, but duplicated per client and outside linguistics. Worse than a Searcher.
Port the ES analyzers via Lucene Linguistics and stop there. That covers joined-form recall for some cases. It does not give adjacency. That needs graph-to-phrase expansion, which is the
auto_generate_synonyms_phrase_querypiece Vespa does not have.
Additional context
Related: query-side analysis currently stems and normalizes, it does not fully tokenize via linguistics unless you use text() / grammar.tokenization: linguistics (#33540). Even with that, the missing piece is turning a WordDelimiterGraph (or any graph filter with positionLength > 1) into phrase alternatives.
Happy to share a reduced application package or the Searcher if that helps.
CC: @radu-gheorghe
Source: vespa-engine/vespa