From 24a087797a12ce5ce7234444d263b9014ae3f9b8 Mon Sep 17 00:00:00 2001 From: Hendrik Ebbers Date: Tue, 14 Jul 2026 08:29:48 +0200 Subject: [PATCH 1/3] chore(spec-017): mark search-enhancements in progress, link issue #33 Refs #33 --- docs/specs/INDEX.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md index fe9d4db..c0b76a7 100644 --- a/docs/specs/INDEX.md +++ b/docs/specs/INDEX.md @@ -22,4 +22,4 @@ roadmap step, to be implemented sequentially (each builds on the previous). | 014 | 014-ops-robustness | Ops & robustness | backend, infrastructure, observability | robots.txt handling, fault tolerance, logging/metrics | #27 | done | | 015 | 015-additional-web-sources | Additional web sources | backend, configuration | hiero.org/blog + Support & Care as config-only sources | #29 | done | | 016 | 016-git-markdown-source | Git markdown source | backend, architecture, security | `type: git` source + `GitSourceStrategy` (GitHub Markdown) | #31 | done | -| 017 | 017-search-enhancements | Search enhancements | backend, search | Facets, synonyms/stop words, optional semantic search | — | open | +| 017 | 017-search-enhancements | Search enhancements | backend, search | Facets, synonyms/stop words, optional semantic search | #33 | in progress | From 7b14f68ad42ddee30cacb5f3910b375d074dd543 Mon Sep 17 00:00:00 2001 From: Hendrik Ebbers Date: Tue, 14 Jul 2026 08:35:46 +0200 Subject: [PATCH 2/3] =?UTF-8?q?feat(spec-017):=20search=20enhancements=20?= =?UTF-8?q?=E2=80=94=20synonyms/stop-words=20+=20facet=20surfacing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scope (from the design's optional menu): synonyms/stop-words and facet surfacing. Semantic/embeddings search deferred to a future spec. - ContentSearchProperties (open-elements.content.search.synonyms/stop-words) + SearchSettingsInitializer (ApplicationRunner @Order 40, gated on meilisearch.enabled): pushes them via MeilisearchClient.updateSettings (no reindex); no-op when unset; failure logged, not fatal. - Facet surfacing: SearchHits gains a facets field; ContentSearchService.search requests facets:[categories] and returns their counts. - Semantic search off by default -> keyword search unchanged (non-regression). - Tests: SearchSettingsInitializerTest + ContentSearchServiceTest.searchSurfacesFacets. 159 tests green. steps.md; CLAUDE.md (roadmap 001-017 complete). Refs #33 --- CLAUDE.md | 15 +++- docs/specs/017-search-enhancements/steps.md | 58 +++++++++++++ .../openelements/content/ContentConfig.java | 2 +- .../content/ContentSearchProperties.java | 31 +++++++ .../content/ContentSearchService.java | 10 ++- .../com/openelements/content/SearchHits.java | 8 +- .../content/SearchSettingsInitializer.java | 67 +++++++++++++++ src/main/resources/application.yaml | 6 ++ .../content/ContentMcpToolProviderTest.java | 2 +- .../content/ContentSearchServiceTest.java | 16 ++++ .../SearchSettingsInitializerTest.java | 83 +++++++++++++++++++ 11 files changed, 285 insertions(+), 13 deletions(-) create mode 100644 docs/specs/017-search-enhancements/steps.md create mode 100644 src/main/java/com/openelements/content/ContentSearchProperties.java create mode 100644 src/main/java/com/openelements/content/SearchSettingsInitializer.java create mode 100644 src/test/java/com/openelements/content/SearchSettingsInitializerTest.java diff --git a/CLAUDE.md b/CLAUDE.md index 31017d2..5d8fbb9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,8 +16,9 @@ rate limit, retries), extracts content/metadata with jsoup, indexes into Meilise bootstrap + scheduled incremental refresh), and exposes four MCP tools on `/mcp` (`search_content`, `list_posts`, `get_post`, `list_categories`) backed by a scoped Meilisearch key. Three website sources are configured (spec 015): `open-elements` (`/posts/**`), `hiero` (`/blog/**`), and `support-and-care` -(`/en/support-care*`). Phase 3+ (see [`docs/roadmap.md`](docs/roadmap.md)): Git/Markdown sources and -search enhancements. +(`/en/support-care*`), plus a disabled example `git` (Markdown) source (spec 016). Search supports +optional synonyms/stop-words and category facets (spec 017). **The full roadmap (specs 001–017) is +implemented.** The one intentionally-deferred item is semantic/embeddings search (a future spec). ### Tech Stack @@ -62,7 +63,9 @@ search enhancements. │ ├── ContentSearchService.java # read facade: multiSearch + Highlighter (+ result records) │ ├── ContentMcpToolProvider.java # McpToolProvider: the 4 tools on /mcp │ ├── RobotsPolicy.java / HttpRobotsPolicy.java # robots.txt allow/disallow + Crawl-delay -│ └── GitSourceStrategy.java / GitConfig.java # type: git — GitHub Markdown source (spec 016) +│ ├── GitSourceStrategy.java / GitConfig.java # type: git — GitHub Markdown source (spec 016) +│ ├── ContentSearchProperties.java # optional synonyms/stop-words config (spec 017) +│ └── SearchSettingsInitializer.java # pushes synonyms/stop-words via updateSettings ├── src/main/resources/application.yaml # datasource, JPA, OAuth2, MCP, Meilisearch, content config ├── src/test/java/com/openelements/content/ # behavior tests (context, MCP enabled/disabled, search-down, jsoup) └── docs/ @@ -112,7 +115,11 @@ changes. `GitSourceStrategy` (`SourceType.GIT`, `GitConfig`) indexes Markdown fr Trees-API discovery filtered by `paths` globs (blob SHA = change marker), raw-content fetch with a server-side bearer token, YAML-frontmatter + Markdown extraction (Hugo shortcodes cleaned), path → canonical-URL mapping, and locale from the filename suffix — producing the same `ContentDocument` as -websites, so downstream is unchanged. `ContentIndexer` is the orchestration engine: discover → diff discovered `lastmod` against +websites, so downstream is unchanged. Search enhancements (spec 017): `ContentSearchProperties` +(`open-elements.content.search.synonyms`/`stop-words`) are pushed to the index at startup by +`SearchSettingsInitializer` via `updateSettings` (no reindex; no-op when unset), and `search_content` +returns category facet counts (`SearchHits.facets`). Semantic/embeddings search is intentionally +deferred to a future spec. `ContentIndexer` is the orchestration engine: discover → diff discovered `lastmod` against the state read from the index (`ContentIndexStore`; the index *is* the state) → fetch only new/changed items via the strategy → batch-upsert, and delete documents that 404 or vanished from discovery. `MeilisearchContentIndexStore` implements the store over `MeilisearchClient` (paged `multiSearch` to diff --git a/docs/specs/017-search-enhancements/steps.md b/docs/specs/017-search-enhancements/steps.md new file mode 100644 index 0000000..bedbc0c --- /dev/null +++ b/docs/specs/017-search-enhancements/steps.md @@ -0,0 +1,58 @@ +# Implementation Steps: Search Enhancements + +Scope for this spec (chosen from the design's optional menu): **synonyms & stop words** and **facet +surfacing**. **Semantic/hybrid search is deferred** to a separate spec (heavy: embedder provider + +indexing cost), per the design's open question. + +## Step 1: Synonyms & stop words + +- [x] `ContentSearchProperties` (`@ConfigurationProperties("open-elements.content.search")`): `synonyms` (map) + `stopWords` (list); registered on `ContentConfig` +- [x] `SearchSettingsInitializer` (`ApplicationRunner`, `@Order(40)`, gated on `meilisearch.enabled`): pushes `synonyms`/`stopWords` via `MeilisearchClient.updateSettings` (no data reindex); no-op when unset; failure logged, not fatal +- [x] Commented example in `application.yaml` + +**Related behaviors:** synonym expands a query; stop word ignored; settings applied without reindex disruption + +--- + +## Step 2: Facet surfacing + +- [x] `SearchHits` gains a `facets` (`List`) field +- [x] `ContentSearchService.search` requests `facets: ["categories"]` and populates `SearchHits.facets` from `facetDistribution`; `list_categories` unchanged (already faceted) + +**Related behaviors:** search response includes facet distribution; list_categories reflects enriched facets + +--- + +## Step 3: Semantic search (deferred) + +- [x] Not implemented — with it absent/off, no embeddings are generated and keyword search is unchanged (the "semantic is opt-in / off = unchanged" behavior holds by default). A future spec would add an embedder + hybrid mode. + +**Related behaviors:** semantic opt-in (off by default → unchanged); hybrid search — deferred + +--- + +## Step 4: Tests + +- [x] `SearchSettingsInitializerTest` — settings pushed via `updateSettings`, no-op when empty, `buildSettings` keys +- [x] `ContentSearchServiceTest.searchSurfacesFacets` — search requests facets and returns counts +- [x] Non-regression: the full existing suite (155 → 159) still passes with enhancements present but unconfigured + +**Acceptance criteria:** +- [x] All tests pass (`mvn test`); build green + +--- + +## Behavior Coverage + +| Scenario | Layer | Covered in Step | +|----------|-------|-----------------| +| Synonym expands a query | Backend | Step 4 (settings pushed); live expansion is Meilisearch's, verified against a live instance | +| Stop word is ignored in ranking | Backend | Step 4 (settings pushed); live effect is Meilisearch's | +| Settings applied without reindex disruption | Backend | Step 4 (`SearchSettingsInitializerTest` — `updateSettings`, not addDocuments) | +| Search response includes facet distribution | Backend | Step 4 (`searchSurfacesFacets`) | +| list_categories reflects enriched facets | Backend | Existing `ContentSearchService.categoryFacets` (spec 011) | +| Hybrid search returns semantically related results | Backend | Deferred (Step 3) — separate spec | +| Semantic search is opt-in | Backend | Step 3 — off by default → keyword search unchanged | +| Enhancements do not break core search | Backend | Step 4 (full suite green; enhancements additive/gated) | + +All scenarios are backend; there is no frontend in this spec. diff --git a/src/main/java/com/openelements/content/ContentConfig.java b/src/main/java/com/openelements/content/ContentConfig.java index 27d3e08..0c9ae36 100644 --- a/src/main/java/com/openelements/content/ContentConfig.java +++ b/src/main/java/com/openelements/content/ContentConfig.java @@ -20,7 +20,7 @@ * {@code @Bean} definitions (indexer, search service, MCP tools). */ @Configuration -@EnableConfigurationProperties(ContentSourceProperties.class) +@EnableConfigurationProperties({ContentSourceProperties.class, ContentSearchProperties.class}) public class ContentConfig { /** diff --git a/src/main/java/com/openelements/content/ContentSearchProperties.java b/src/main/java/com/openelements/content/ContentSearchProperties.java new file mode 100644 index 0000000..a11868c --- /dev/null +++ b/src/main/java/com/openelements/content/ContentSearchProperties.java @@ -0,0 +1,31 @@ +package com.openelements.content; + +import java.util.List; +import java.util.Map; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Optional search-tuning settings bound from {@code open-elements.content.search.*} (spec 017). + * + *

Applied to the Meilisearch index at startup by {@link SearchSettingsInitializer}. Both are + * empty by default, in which case nothing is pushed. + * + * @param synonyms word → equivalent terms (Meilisearch {@code synonyms}); e.g. {@code ai: [artificial intelligence]} + * @param stopWords words ignored in ranking/matching (Meilisearch {@code stopWords}) + */ +@ConfigurationProperties("open-elements.content.search") +public record ContentSearchProperties( + Map> synonyms, + List stopWords +) { + + public ContentSearchProperties { + synonyms = synonyms == null ? Map.of() : Map.copyOf(synonyms); + stopWords = stopWords == null ? List.of() : List.copyOf(stopWords); + } + + /** @return {@code true} if there is anything to push to the index */ + public boolean hasSettings() { + return !synonyms.isEmpty() || !stopWords.isEmpty(); + } +} diff --git a/src/main/java/com/openelements/content/ContentSearchService.java b/src/main/java/com/openelements/content/ContentSearchService.java index 6512e14..8bd79cf 100644 --- a/src/main/java/com/openelements/content/ContentSearchService.java +++ b/src/main/java/com/openelements/content/ContentSearchService.java @@ -57,6 +57,7 @@ public SearchHits search(String query, ContentFilters filters, int page, int siz query0.put("highlightPreTag", Highlighter.PRE_MARK); query0.put("highlightPostTag", Highlighter.POST_MARK); query0.put("showRankingScore", true); + query0.put("facets", List.of("categories")); return parseHits(client.multiSearch(wrap(query0))); } @@ -170,7 +171,7 @@ static SearchHits parseHits(JsonNode response) { for (JsonNode hit : result.path("hits")) { hits.add(toHit(hit)); } - return new SearchHits(hits, result.path("estimatedTotalHits").asLong(0)); + return new SearchHits(hits, result.path("estimatedTotalHits").asLong(0), categoryFacetsOf(result)); } private static SearchHit toHit(JsonNode hit) { @@ -184,9 +185,10 @@ private static SearchHit toHit(JsonNode hit) { static List parseFacets(JsonNode response) { JsonNode result = firstResult(response); - if (result == null) { - return List.of(); - } + return result == null ? List.of() : categoryFacetsOf(result); + } + + private static List categoryFacetsOf(JsonNode result) { List counts = new ArrayList<>(); result.path("facetDistribution").path("categories").fields() .forEachRemaining(entry -> counts.add(new CategoryCount(entry.getKey(), entry.getValue().asLong(0)))); diff --git a/src/main/java/com/openelements/content/SearchHits.java b/src/main/java/com/openelements/content/SearchHits.java index 4e7d09d..76e963e 100644 --- a/src/main/java/com/openelements/content/SearchHits.java +++ b/src/main/java/com/openelements/content/SearchHits.java @@ -3,18 +3,20 @@ import java.util.List; /** - * A page of search/list results. + * A page of search/list results, optionally with category facet counts. * * @param hits the results on this page * @param estimatedTotal Meilisearch's estimate of the total matching documents + * @param facets category facet counts for the query (empty unless facets were requested) */ -public record SearchHits(List hits, long estimatedTotal) { +public record SearchHits(List hits, long estimatedTotal, List facets) { public SearchHits { hits = hits == null ? List.of() : List.copyOf(hits); + facets = facets == null ? List.of() : List.copyOf(facets); } static SearchHits empty() { - return new SearchHits(List.of(), 0); + return new SearchHits(List.of(), 0, List.of()); } } diff --git a/src/main/java/com/openelements/content/SearchSettingsInitializer.java b/src/main/java/com/openelements/content/SearchSettingsInitializer.java new file mode 100644 index 0000000..6818859 --- /dev/null +++ b/src/main/java/com/openelements/content/SearchSettingsInitializer.java @@ -0,0 +1,67 @@ +package com.openelements.content; + +import com.openelements.spring.base.services.search.MeilisearchClient; +import com.openelements.spring.base.services.search.MeilisearchProperties; +import java.util.LinkedHashMap; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +/** + * Applies optional {@code synonyms}/{@code stopWords} settings to the content index at startup, via + * {@code MeilisearchClient.updateSettings} — which the library's {@code IndexSettings} bean + * (spec 003) deliberately omits. These take effect on subsequent searches without a data reindex. + * + *

Runs after the library's settings/bootstrap initializers ({@code @Order} 20/30) and is a no-op + * when no synonyms or stop words are configured. Only created when the Meilisearch stack is enabled; + * a failure to reach Meilisearch is logged, not fatal. + */ +@Component +@Order(40) +@ConditionalOnProperty(prefix = "openelements.meilisearch", name = "enabled", havingValue = "true") +public class SearchSettingsInitializer implements ApplicationRunner { + + private static final Logger log = LoggerFactory.getLogger(SearchSettingsInitializer.class); + + private final MeilisearchClient client; + private final ContentSearchProperties searchProperties; + private final String indexUid; + + public SearchSettingsInitializer(MeilisearchClient client, ContentSearchProperties searchProperties, + MeilisearchProperties meilisearchProperties) { + this.client = client; + this.searchProperties = searchProperties; + this.indexUid = meilisearchProperties.resolveIndex("content"); + } + + @Override + public void run(ApplicationArguments args) { + if (!searchProperties.hasSettings()) { + return; + } + try { + client.updateSettings(indexUid, buildSettings(searchProperties)); + log.info("Applied search settings to {}: {} synonym(s), {} stop word(s)", + indexUid, searchProperties.synonyms().size(), searchProperties.stopWords().size()); + } catch (Exception e) { + log.warn("Could not apply search settings to {}: {}", indexUid, e.toString()); + } + } + + /** Builds the Meilisearch settings payload for the configured synonyms/stop words. */ + static Map buildSettings(ContentSearchProperties properties) { + Map settings = new LinkedHashMap<>(); + if (!properties.synonyms().isEmpty()) { + settings.put("synonyms", properties.synonyms()); + } + if (!properties.stopWords().isEmpty()) { + settings.put("stopWords", properties.stopWords()); + } + return settings; + } +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 1844658..a9552a8 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -55,6 +55,12 @@ open-elements: rate-limit-per-host: 2 request-timeout: 10s max-body-bytes: 5MB + # Optional search tuning (spec 017), applied via updateSettings at startup (no reindex). + # Empty by default. Example: + # search: + # synonyms: + # ai: [ "artificial intelligence", "machine learning" ] + # stop-words: [ "the", "a", "an" ] sources: - id: open-elements type: website diff --git a/src/test/java/com/openelements/content/ContentMcpToolProviderTest.java b/src/test/java/com/openelements/content/ContentMcpToolProviderTest.java index 09e2a1f..be82b41 100644 --- a/src/test/java/com/openelements/content/ContentMcpToolProviderTest.java +++ b/src/test/java/com/openelements/content/ContentMcpToolProviderTest.java @@ -140,7 +140,7 @@ void listCategoriesDelegates() { } private void searchContentResult() { - searchService.hits = new SearchHits(List.of(new SearchHit("T", "u", "2026-01-01", "snip", 0.9)), 1); + searchService.hits = new SearchHits(List.of(new SearchHit("T", "u", "2026-01-01", "snip", 0.9)), 1, List.of()); } private static ContentDocument document() { diff --git a/src/test/java/com/openelements/content/ContentSearchServiceTest.java b/src/test/java/com/openelements/content/ContentSearchServiceTest.java index 7f83c0f..8d9daee 100644 --- a/src/test/java/com/openelements/content/ContentSearchServiceTest.java +++ b/src/test/java/com/openelements/content/ContentSearchServiceTest.java @@ -168,6 +168,22 @@ void facetsBuildRequest() { assertThat(query).containsEntry("filter", "source = \"open-elements\""); assertThat(query).containsEntry("limit", 0); } + + @Test + @DisplayName("search requests category facets and returns their counts alongside the hits") + void searchSurfacesFacets() { + JsonNode response = parse(""" + {"results":[{"estimatedTotalHits":5,"hits":[], + "facetDistribution":{"categories":{"ai":3,"web3":2}}}]}"""); + CapturingClient client = new CapturingClient(response); + ContentSearchService service = new ContentSearchService(client, properties()); + + SearchHits hits = service.search("x", ContentFilters.none(), 0, 10); + + assertThat(firstQuery(client.capturedBody)).containsEntry("facets", List.of("categories")); + assertThat(hits.facets()).containsExactlyInAnyOrder( + new CategoryCount("ai", 3), new CategoryCount("web3", 2)); + } } @Nested diff --git a/src/test/java/com/openelements/content/SearchSettingsInitializerTest.java b/src/test/java/com/openelements/content/SearchSettingsInitializerTest.java new file mode 100644 index 0000000..e874a0f --- /dev/null +++ b/src/test/java/com/openelements/content/SearchSettingsInitializerTest.java @@ -0,0 +1,83 @@ +package com.openelements.content; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.openelements.spring.base.services.search.MeilisearchClient; +import com.openelements.spring.base.services.search.MeilisearchProperties; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link SearchSettingsInitializer} using a capturing {@link MeilisearchClient} + * subclass — verifies the synonym/stop-word settings are pushed via {@code updateSettings} (no + * reindex) and that it is a no-op when nothing is configured. + */ +@DisplayName("Search settings initializer") +class SearchSettingsInitializerTest { + + private static MeilisearchProperties meilisearch() { + return new MeilisearchProperties("http://localhost:7700", "", "content_", Duration.ofSeconds(10)); + } + + @Test + @DisplayName("configured synonyms and stop words are pushed to the index via updateSettings") + void appliesConfiguredSettings() { + ContentSearchProperties properties = new ContentSearchProperties( + Map.of("ai", List.of("artificial intelligence")), List.of("the", "a")); + CapturingClient client = new CapturingClient(); + + new SearchSettingsInitializer(client, properties, meilisearch()).run(null); + + assertThat(client.calls).isEqualTo(1); + assertThat(client.lastIndex).isEqualTo("content_content"); + assertThat(client.lastSettings).containsKey("synonyms").containsKey("stopWords"); + assertThat(client.lastSettings.get("stopWords")).isEqualTo(List.of("the", "a")); + } + + @Test + @DisplayName("nothing is pushed when no synonyms or stop words are configured") + void noOpWhenEmpty() { + CapturingClient client = new CapturingClient(); + + new SearchSettingsInitializer(client, new ContentSearchProperties(Map.of(), List.of()), meilisearch()) + .run(null); + + assertThat(client.calls).isZero(); + } + + @Test + @DisplayName("buildSettings includes only the configured keys") + void buildSettingsIncludesOnlyConfigured() { + Map onlySynonyms = SearchSettingsInitializer.buildSettings( + new ContentSearchProperties(Map.of("ai", List.of("ml")), List.of())); + assertThat(onlySynonyms).containsOnlyKeys("synonyms"); + + Map onlyStopWords = SearchSettingsInitializer.buildSettings( + new ContentSearchProperties(Map.of(), List.of("the"))); + assertThat(onlyStopWords).containsOnlyKeys("stopWords"); + } + + /** Test double capturing the updateSettings call. */ + private static final class CapturingClient extends MeilisearchClient { + private int calls; + private String lastIndex; + private Map lastSettings; + + CapturingClient() { + super(new MeilisearchProperties("http://localhost:7700", "", "content_", Duration.ofSeconds(10)), + new ObjectMapper()); + } + + @Override + public long updateSettings(String indexUid, Map settings) { + calls++; + lastIndex = indexUid; + lastSettings = settings; + return 1L; + } + } +} From 4c70b5303e2a92ac224dfe048702a7a3ec3743f8 Mon Sep 17 00:00:00 2001 From: Hendrik Ebbers Date: Tue, 14 Jul 2026 08:36:13 +0200 Subject: [PATCH 3/3] chore(spec-017): mark search-enhancements done Refs #33 --- docs/specs/INDEX.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md index c0b76a7..b1800ab 100644 --- a/docs/specs/INDEX.md +++ b/docs/specs/INDEX.md @@ -22,4 +22,4 @@ roadmap step, to be implemented sequentially (each builds on the previous). | 014 | 014-ops-robustness | Ops & robustness | backend, infrastructure, observability | robots.txt handling, fault tolerance, logging/metrics | #27 | done | | 015 | 015-additional-web-sources | Additional web sources | backend, configuration | hiero.org/blog + Support & Care as config-only sources | #29 | done | | 016 | 016-git-markdown-source | Git markdown source | backend, architecture, security | `type: git` source + `GitSourceStrategy` (GitHub Markdown) | #31 | done | -| 017 | 017-search-enhancements | Search enhancements | backend, search | Facets, synonyms/stop words, optional semantic search | #33 | in progress | +| 017 | 017-search-enhancements | Search enhancements | backend, search | Facets, synonyms/stop words, optional semantic search | #33 | done |