Skip to content

Commit 26b9e0b

Browse files
Merge pull request #34 from OpenElementsLabs/feat/017-search-enhancements
Search enhancements — synonyms/stop-words + facet surfacing
2 parents 38dbf15 + 4c70b53 commit 26b9e0b

12 files changed

Lines changed: 286 additions & 14 deletions

File tree

CLAUDE.md

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,9 @@ rate limit, retries), extracts content/metadata with jsoup, indexes into Meilise
1616
bootstrap + scheduled incremental refresh), and exposes four MCP tools on `/mcp` (`search_content`,
1717
`list_posts`, `get_post`, `list_categories`) backed by a scoped Meilisearch key. Three website sources are
1818
configured (spec 015): `open-elements` (`/posts/**`), `hiero` (`/blog/**`), and `support-and-care`
19-
(`/en/support-care*`). Phase 3+ (see [`docs/roadmap.md`](docs/roadmap.md)): Git/Markdown sources and
20-
search enhancements.
19+
(`/en/support-care*`), plus a disabled example `git` (Markdown) source (spec 016). Search supports
20+
optional synonyms/stop-words and category facets (spec 017). **The full roadmap (specs 001–017) is
21+
implemented.** The one intentionally-deferred item is semantic/embeddings search (a future spec).
2122

2223
### Tech Stack
2324

@@ -62,7 +63,9 @@ search enhancements.
6263
│ ├── ContentSearchService.java # read facade: multiSearch + Highlighter (+ result records)
6364
│ ├── ContentMcpToolProvider.java # McpToolProvider: the 4 tools on /mcp
6465
│ ├── RobotsPolicy.java / HttpRobotsPolicy.java # robots.txt allow/disallow + Crawl-delay
65-
│ └── GitSourceStrategy.java / GitConfig.java # type: git — GitHub Markdown source (spec 016)
66+
│ ├── GitSourceStrategy.java / GitConfig.java # type: git — GitHub Markdown source (spec 016)
67+
│ ├── ContentSearchProperties.java # optional synonyms/stop-words config (spec 017)
68+
│ └── SearchSettingsInitializer.java # pushes synonyms/stop-words via updateSettings
6669
├── src/main/resources/application.yaml # datasource, JPA, OAuth2, MCP, Meilisearch, content config
6770
├── src/test/java/com/openelements/content/ # behavior tests (context, MCP enabled/disabled, search-down, jsoup)
6871
└── docs/
@@ -112,7 +115,11 @@ changes. `GitSourceStrategy` (`SourceType.GIT`, `GitConfig`) indexes Markdown fr
112115
Trees-API discovery filtered by `paths` globs (blob SHA = change marker), raw-content fetch with a
113116
server-side bearer token, YAML-frontmatter + Markdown extraction (Hugo shortcodes cleaned), path →
114117
canonical-URL mapping, and locale from the filename suffix — producing the same `ContentDocument` as
115-
websites, so downstream is unchanged. `ContentIndexer` is the orchestration engine: discover → diff discovered `lastmod` against
118+
websites, so downstream is unchanged. Search enhancements (spec 017): `ContentSearchProperties`
119+
(`open-elements.content.search.synonyms`/`stop-words`) are pushed to the index at startup by
120+
`SearchSettingsInitializer` via `updateSettings` (no reindex; no-op when unset), and `search_content`
121+
returns category facet counts (`SearchHits.facets`). Semantic/embeddings search is intentionally
122+
deferred to a future spec. `ContentIndexer` is the orchestration engine: discover → diff discovered `lastmod` against
116123
the state read from the index (`ContentIndexStore`; the index *is* the state) → fetch only new/changed
117124
items via the strategy → batch-upsert, and delete documents that 404 or vanished from discovery.
118125
`MeilisearchContentIndexStore` implements the store over `MeilisearchClient` (paged `multiSearch` to
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# Implementation Steps: Search Enhancements
2+
3+
Scope for this spec (chosen from the design's optional menu): **synonyms & stop words** and **facet
4+
surfacing**. **Semantic/hybrid search is deferred** to a separate spec (heavy: embedder provider +
5+
indexing cost), per the design's open question.
6+
7+
## Step 1: Synonyms & stop words
8+
9+
- [x] `ContentSearchProperties` (`@ConfigurationProperties("open-elements.content.search")`): `synonyms` (map) + `stopWords` (list); registered on `ContentConfig`
10+
- [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
11+
- [x] Commented example in `application.yaml`
12+
13+
**Related behaviors:** synonym expands a query; stop word ignored; settings applied without reindex disruption
14+
15+
---
16+
17+
## Step 2: Facet surfacing
18+
19+
- [x] `SearchHits` gains a `facets` (`List<CategoryCount>`) field
20+
- [x] `ContentSearchService.search` requests `facets: ["categories"]` and populates `SearchHits.facets` from `facetDistribution`; `list_categories` unchanged (already faceted)
21+
22+
**Related behaviors:** search response includes facet distribution; list_categories reflects enriched facets
23+
24+
---
25+
26+
## Step 3: Semantic search (deferred)
27+
28+
- [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.
29+
30+
**Related behaviors:** semantic opt-in (off by default → unchanged); hybrid search — deferred
31+
32+
---
33+
34+
## Step 4: Tests
35+
36+
- [x] `SearchSettingsInitializerTest` — settings pushed via `updateSettings`, no-op when empty, `buildSettings` keys
37+
- [x] `ContentSearchServiceTest.searchSurfacesFacets` — search requests facets and returns counts
38+
- [x] Non-regression: the full existing suite (155 → 159) still passes with enhancements present but unconfigured
39+
40+
**Acceptance criteria:**
41+
- [x] All tests pass (`mvn test`); build green
42+
43+
---
44+
45+
## Behavior Coverage
46+
47+
| Scenario | Layer | Covered in Step |
48+
|----------|-------|-----------------|
49+
| Synonym expands a query | Backend | Step 4 (settings pushed); live expansion is Meilisearch's, verified against a live instance |
50+
| Stop word is ignored in ranking | Backend | Step 4 (settings pushed); live effect is Meilisearch's |
51+
| Settings applied without reindex disruption | Backend | Step 4 (`SearchSettingsInitializerTest``updateSettings`, not addDocuments) |
52+
| Search response includes facet distribution | Backend | Step 4 (`searchSurfacesFacets`) |
53+
| list_categories reflects enriched facets | Backend | Existing `ContentSearchService.categoryFacets` (spec 011) |
54+
| Hybrid search returns semantically related results | Backend | Deferred (Step 3) — separate spec |
55+
| Semantic search is opt-in | Backend | Step 3 — off by default → keyword search unchanged |
56+
| Enhancements do not break core search | Backend | Step 4 (full suite green; enhancements additive/gated) |
57+
58+
All scenarios are backend; there is no frontend in this spec.

docs/specs/INDEX.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,4 @@ roadmap step, to be implemented sequentially (each builds on the previous).
2222
| 014 | 014-ops-robustness | Ops & robustness | backend, infrastructure, observability | robots.txt handling, fault tolerance, logging/metrics | #27 | done |
2323
| 015 | 015-additional-web-sources | Additional web sources | backend, configuration | hiero.org/blog + Support & Care as config-only sources | #29 | done |
2424
| 016 | 016-git-markdown-source | Git markdown source | backend, architecture, security | `type: git` source + `GitSourceStrategy` (GitHub Markdown) | #31 | done |
25-
| 017 | 017-search-enhancements | Search enhancements | backend, search | Facets, synonyms/stop words, optional semantic search | | open |
25+
| 017 | 017-search-enhancements | Search enhancements | backend, search | Facets, synonyms/stop words, optional semantic search | #33 | done |

src/main/java/com/openelements/content/ContentConfig.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
* {@code @Bean} definitions (indexer, search service, MCP tools).
2121
*/
2222
@Configuration
23-
@EnableConfigurationProperties(ContentSourceProperties.class)
23+
@EnableConfigurationProperties({ContentSourceProperties.class, ContentSearchProperties.class})
2424
public class ContentConfig {
2525

2626
/**
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package com.openelements.content;
2+
3+
import java.util.List;
4+
import java.util.Map;
5+
import org.springframework.boot.context.properties.ConfigurationProperties;
6+
7+
/**
8+
* Optional search-tuning settings bound from {@code open-elements.content.search.*} (spec 017).
9+
*
10+
* <p>Applied to the Meilisearch index at startup by {@link SearchSettingsInitializer}. Both are
11+
* empty by default, in which case nothing is pushed.
12+
*
13+
* @param synonyms word → equivalent terms (Meilisearch {@code synonyms}); e.g. {@code ai: [artificial intelligence]}
14+
* @param stopWords words ignored in ranking/matching (Meilisearch {@code stopWords})
15+
*/
16+
@ConfigurationProperties("open-elements.content.search")
17+
public record ContentSearchProperties(
18+
Map<String, List<String>> synonyms,
19+
List<String> stopWords
20+
) {
21+
22+
public ContentSearchProperties {
23+
synonyms = synonyms == null ? Map.of() : Map.copyOf(synonyms);
24+
stopWords = stopWords == null ? List.of() : List.copyOf(stopWords);
25+
}
26+
27+
/** @return {@code true} if there is anything to push to the index */
28+
public boolean hasSettings() {
29+
return !synonyms.isEmpty() || !stopWords.isEmpty();
30+
}
31+
}

src/main/java/com/openelements/content/ContentSearchService.java

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ public SearchHits search(String query, ContentFilters filters, int page, int siz
5757
query0.put("highlightPreTag", Highlighter.PRE_MARK);
5858
query0.put("highlightPostTag", Highlighter.POST_MARK);
5959
query0.put("showRankingScore", true);
60+
query0.put("facets", List.of("categories"));
6061
return parseHits(client.multiSearch(wrap(query0)));
6162
}
6263

@@ -170,7 +171,7 @@ static SearchHits parseHits(JsonNode response) {
170171
for (JsonNode hit : result.path("hits")) {
171172
hits.add(toHit(hit));
172173
}
173-
return new SearchHits(hits, result.path("estimatedTotalHits").asLong(0));
174+
return new SearchHits(hits, result.path("estimatedTotalHits").asLong(0), categoryFacetsOf(result));
174175
}
175176

176177
private static SearchHit toHit(JsonNode hit) {
@@ -184,9 +185,10 @@ private static SearchHit toHit(JsonNode hit) {
184185

185186
static List<CategoryCount> parseFacets(JsonNode response) {
186187
JsonNode result = firstResult(response);
187-
if (result == null) {
188-
return List.of();
189-
}
188+
return result == null ? List.of() : categoryFacetsOf(result);
189+
}
190+
191+
private static List<CategoryCount> categoryFacetsOf(JsonNode result) {
190192
List<CategoryCount> counts = new ArrayList<>();
191193
result.path("facetDistribution").path("categories").fields()
192194
.forEachRemaining(entry -> counts.add(new CategoryCount(entry.getKey(), entry.getValue().asLong(0))));

src/main/java/com/openelements/content/SearchHits.java

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,20 @@
33
import java.util.List;
44

55
/**
6-
* A page of search/list results.
6+
* A page of search/list results, optionally with category facet counts.
77
*
88
* @param hits the results on this page
99
* @param estimatedTotal Meilisearch's estimate of the total matching documents
10+
* @param facets category facet counts for the query (empty unless facets were requested)
1011
*/
11-
public record SearchHits(List<SearchHit> hits, long estimatedTotal) {
12+
public record SearchHits(List<SearchHit> hits, long estimatedTotal, List<CategoryCount> facets) {
1213

1314
public SearchHits {
1415
hits = hits == null ? List.of() : List.copyOf(hits);
16+
facets = facets == null ? List.of() : List.copyOf(facets);
1517
}
1618

1719
static SearchHits empty() {
18-
return new SearchHits(List.of(), 0);
20+
return new SearchHits(List.of(), 0, List.of());
1921
}
2022
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package com.openelements.content;
2+
3+
import com.openelements.spring.base.services.search.MeilisearchClient;
4+
import com.openelements.spring.base.services.search.MeilisearchProperties;
5+
import java.util.LinkedHashMap;
6+
import java.util.Map;
7+
import org.slf4j.Logger;
8+
import org.slf4j.LoggerFactory;
9+
import org.springframework.boot.ApplicationArguments;
10+
import org.springframework.boot.ApplicationRunner;
11+
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
12+
import org.springframework.core.annotation.Order;
13+
import org.springframework.stereotype.Component;
14+
15+
/**
16+
* Applies optional {@code synonyms}/{@code stopWords} settings to the content index at startup, via
17+
* {@code MeilisearchClient.updateSettings} — which the library's {@code IndexSettings} bean
18+
* (spec 003) deliberately omits. These take effect on subsequent searches without a data reindex.
19+
*
20+
* <p>Runs after the library's settings/bootstrap initializers ({@code @Order} 20/30) and is a no-op
21+
* when no synonyms or stop words are configured. Only created when the Meilisearch stack is enabled;
22+
* a failure to reach Meilisearch is logged, not fatal.
23+
*/
24+
@Component
25+
@Order(40)
26+
@ConditionalOnProperty(prefix = "openelements.meilisearch", name = "enabled", havingValue = "true")
27+
public class SearchSettingsInitializer implements ApplicationRunner {
28+
29+
private static final Logger log = LoggerFactory.getLogger(SearchSettingsInitializer.class);
30+
31+
private final MeilisearchClient client;
32+
private final ContentSearchProperties searchProperties;
33+
private final String indexUid;
34+
35+
public SearchSettingsInitializer(MeilisearchClient client, ContentSearchProperties searchProperties,
36+
MeilisearchProperties meilisearchProperties) {
37+
this.client = client;
38+
this.searchProperties = searchProperties;
39+
this.indexUid = meilisearchProperties.resolveIndex("content");
40+
}
41+
42+
@Override
43+
public void run(ApplicationArguments args) {
44+
if (!searchProperties.hasSettings()) {
45+
return;
46+
}
47+
try {
48+
client.updateSettings(indexUid, buildSettings(searchProperties));
49+
log.info("Applied search settings to {}: {} synonym(s), {} stop word(s)",
50+
indexUid, searchProperties.synonyms().size(), searchProperties.stopWords().size());
51+
} catch (Exception e) {
52+
log.warn("Could not apply search settings to {}: {}", indexUid, e.toString());
53+
}
54+
}
55+
56+
/** Builds the Meilisearch settings payload for the configured synonyms/stop words. */
57+
static Map<String, Object> buildSettings(ContentSearchProperties properties) {
58+
Map<String, Object> settings = new LinkedHashMap<>();
59+
if (!properties.synonyms().isEmpty()) {
60+
settings.put("synonyms", properties.synonyms());
61+
}
62+
if (!properties.stopWords().isEmpty()) {
63+
settings.put("stopWords", properties.stopWords());
64+
}
65+
return settings;
66+
}
67+
}

src/main/resources/application.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,12 @@ open-elements:
5555
rate-limit-per-host: 2
5656
request-timeout: 10s
5757
max-body-bytes: 5MB
58+
# Optional search tuning (spec 017), applied via updateSettings at startup (no reindex).
59+
# Empty by default. Example:
60+
# search:
61+
# synonyms:
62+
# ai: [ "artificial intelligence", "machine learning" ]
63+
# stop-words: [ "the", "a", "an" ]
5864
sources:
5965
- id: open-elements
6066
type: website

src/test/java/com/openelements/content/ContentMcpToolProviderTest.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ void listCategoriesDelegates() {
140140
}
141141

142142
private void searchContentResult() {
143-
searchService.hits = new SearchHits(List.of(new SearchHit("T", "u", "2026-01-01", "snip", 0.9)), 1);
143+
searchService.hits = new SearchHits(List.of(new SearchHit("T", "u", "2026-01-01", "snip", 0.9)), 1, List.of());
144144
}
145145

146146
private static ContentDocument document() {

0 commit comments

Comments
 (0)