Repository navigation
Implement synonym map support (closes #69) - #79
Merged
Merged
Conversation
Synonym maps are service-level resources with their own /synonymmaps
routes, stored alongside the index definitions as {name}.synonymmap.json.
A field opts in through the synonymMaps property it already carried but
which was, until now, accepted and ignored.
Expansion happens at query time only, as it does in Azure: the maps are
resolved per search and layered onto the per-field search analyzer, so a
field's own analyzer still decides how text is split before any synonym
is considered. Expanding while indexing would bake the current rules into
the stored terms and leave documents indexed before an edit disagreeing
with those after it, repairable only by a full reindex. Doing it at query
time is what makes a map safe to edit.
Rules are parsed with Lucene's SolrSynonymParser, which is the format —
and the only format — Azure supports. Both rule forms behave as the
service documents them: an equivalency rule keeps the term that was typed
and adds the alternatives, while an arrow rule replaces it.
A field naming a map that does not exist, or one no query could reach, is
rejected when the index is created rather than left to search unexpanded.
A map that goes missing later is skipped instead, since the emulator's
indexes and maps are separate files a user may restore independently, and
failing every search would be a harsh answer to a dangling name that only
widens results.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
paulirwin
marked this pull request as ready for review
August 20, 2026 21:05
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #69.
SearchField.SynonymMapswas modelled and parsed but never used, and there were no/synonymmapsroutes at all — so an index could name a synonym map, be accepted, and then search exactly as though it had not./servicestatsreported the map count as a hardcoded0.Approach
Synonym maps are service-level resources, not index sub-objects — unlike analyzers and normalizers, which live inside the index definition. So this follows
ISearchIndexRepositoryrather than the normalizer template: maps get their own routes, their own{name}.synonymmap.jsonfiles beside the index definitions, and their own lifetime. A field opts in by naming one. That indirection is the point of the feature — one map is edited once and takes effect across every field of every index that names it.Expansion happens at query time only, as it does in Azure. The maps are resolved per search and layered onto the per-field search analyzer, so a field's own analyzer still decides how text is split before any synonym is considered; there is deliberately no index-time equivalent. Expanding while indexing would bake the current rules into the stored terms, leaving documents indexed before an edit disagreeing with those after it — repairable only by a full reindex. Doing it at query time is what makes a map safe to edit, and
EditedSynonymMap_TakesEffectWithoutReindexingcovers exactly that.Rules are parsed with Lucene's
SolrSynonymParser— the format, and the only format, Azure supports. Both rule forms behave as the service documents:usa, united states) keeps the term that was typed and adds the alternatives.dog => canine) replaces it, sodogno longer matches documents holding onlydog.Lucene.Net.Analysis.Commonwas already referenced, so no new package.Judgement calls worth a look
A missing map is skipped, not thrown on. Azure refuses to delete a map while an index still names it. The emulator's indexes and maps are separate files a user may edit or restore independently, so failing every search against the index seemed a harsh answer to a dangling name whose only effect is to widen results. A map that is missing at index-creation time is still rejected outright — the mistake is reported where it can be acted on.
SynonymFilteris constructed withignoreCase: false. This looks wrong at first glance and I verified it empirically before settling on it: the rules are lower-cased when parsed, and any lower-casing field analyzer — the default, and near-universal — has already folded the query by the time the filter sees it, soUSAandusaboth expand correctly. Passingtruewould instead make synonym matching quietly case-insensitive for a field whose analyzer deliberately is not.Matching_IgnoresCasepins the behaviour.ISynonymMapRepositoryonLuceneNetIndexSearcheris optional. Roughly a dozen existing unit tests construct the searcher directly over a bare Lucene directory; defaulting it tonull(no maps, which is the correct behaviour) avoided churning those call sites for a feature they do not exercise./synonymmapsresponses are hand-serialized with System.Text.Json, not OData — the same reasoning asIndexesController.IndexJson(#41).SynonymMapcarries a[JsonExtensionData]bag so unmodelled properties such asencryptionKeysurvive a round-trip, and OData would have emitted them as{}or dropped them. The listing'svaluewrapper is written by hand as a result.Tests
36 unit + 10 integration, all green.
SynonymMapTests) — asserts on the token stream rather than on search results, because that is what distinguishes an equivalency rule from a mapping rule; a query that merely matched could have matched on the original term.SynonymMapValidationTests)SynonymMapEndToEndTests) — through the real indexing and search path. Documents are indexed with no synonym applied, so a match can come from nothing but the query having been widened.WithoutTheMap_TheSameQueryMatchesNothingsits beside the positive case deliberately: together they show the match comes from expansion rather than from some looser matching.SynonymMapIntegrationTests) — through the real Azure SDK, which decides the routes, thevaluewrapper and the wire property names. This caught thatSynonymMap.Formatis not public in SDK 11.7.0.Full unit suite green at 973 (937 pre-existing + 36), zero warnings.
Notes for review
ImageBuildFailedExceptionfailures from ~20 Testcontainers fixtures building images concurrently. It is pre-existing Docker contention rather than a regression: unrelated suites fail identically in bulk and pass in isolation. 46 related tests — including the schema-change and round-trip suites most likely to catch a fault here — pass together. Worth confirming CI is happy.IndexSchemaChangeValidator— Azure allowssynonymMapsto change on an existing field, since it is query-time only, andIndexSchemaChangeTestsalready asserted that.README documents the feature under a new Synonym maps section.
🤖 Generated with Claude Code