From 5a4161d3f51bc1bc1980724c29418e22faf43081 Mon Sep 17 00:00:00 2001 From: marko-bekhta Date: Mon, 13 Jan 2025 10:07:19 +0100 Subject: [PATCH] Add possible suggestion to the empty results response --- .../io/quarkus/search/app/SearchService.java | 25 ++++++++++++ .../quarkus/search/app/dto/SearchResult.java | 36 ++++++++++++++++-- .../io/quarkus/search/app/entity/Guide.java | 1 + .../app/hibernate/AnalysisConfigurer.java | 35 +++++++++++++++++ src/main/resources/web/app/qs-form.ts | 18 +++++++++ src/main/resources/web/app/qs-target.ts | 26 ++++++++++++- .../quarkus/search/app/SearchServiceTest.java | 38 +++++++++++++++++++ 7 files changed, 174 insertions(+), 5 deletions(-) diff --git a/src/main/java/io/quarkus/search/app/SearchService.java b/src/main/java/io/quarkus/search/app/SearchService.java index 1c70084c..59cb967f 100644 --- a/src/main/java/io/quarkus/search/app/SearchService.java +++ b/src/main/java/io/quarkus/search/app/SearchService.java @@ -22,6 +22,7 @@ import io.quarkus.runtime.LaunchMode; +import org.hibernate.search.backend.elasticsearch.ElasticsearchExtension; import org.hibernate.search.engine.search.common.BooleanOperator; import org.hibernate.search.engine.search.common.ValueModel; import org.hibernate.search.engine.search.predicate.dsl.SimpleQueryFlag; @@ -30,6 +31,8 @@ import org.eclipse.microprofile.openapi.annotations.Operation; import org.jboss.resteasy.reactive.RestQuery; +import com.google.gson.JsonObject; + import io.vertx.ext.web.Router; @ApplicationScoped @@ -69,6 +72,7 @@ public SearchResult search(@RestQuery @DefaultValue(QuarkusVersi @RestQuery @DefaultValue("100") @Min(0) @Max(value = 200, message = MAX_FOR_PERF_MESSAGE) int contentSnippetsLength) { try (var session = searchMapping.createSession()) { var result = session.search(Guide.class) + .extension(ElasticsearchExtension.get()) .select(f -> f.composite().from( f.id(), f.field("type"), @@ -131,9 +135,30 @@ public SearchResult search(@RestQuery @DefaultValue(QuarkusVersi .sort(f -> f.score().then().field(language.addSuffix("title_sort"))) .routing(QuarkusVersionAndLanguageRoutingBinder.searchKeys(version, language)) .totalHitCountThreshold(TOTAL_HIT_COUNT_THRESHOLD + (page + 1) * PAGE_SIZE) + .requestTransformer(context -> requestSuggestion(context.body(), q, language, highlightCssClass)) .fetch(page * PAGE_SIZE, PAGE_SIZE); return new SearchResult<>(result); } } + private void requestSuggestion(JsonObject payload, String q, Language language, String highlightCssClass) { + if (q == null || q.isBlank()) { + return; + } + JsonObject suggest = new JsonObject(); + payload.add("suggest", suggest); + suggest.addProperty("text", q); + JsonObject suggestion = new JsonObject(); + suggest.add("didYouMean", suggestion); + JsonObject phrase = new JsonObject(); + suggestion.add("phrase", phrase); + phrase.addProperty("field", language.addSuffix("fullContent_suggestion")); + phrase.addProperty("size", 1); + phrase.addProperty("gram_size", 1); + JsonObject highlight = new JsonObject(); + phrase.add("highlight", highlight); + highlight.addProperty("pre_tag", ""); + highlight.addProperty("post_tag", ""); + } + } diff --git a/src/main/java/io/quarkus/search/app/dto/SearchResult.java b/src/main/java/io/quarkus/search/app/dto/SearchResult.java index 11f0be13..e19594da 100644 --- a/src/main/java/io/quarkus/search/app/dto/SearchResult.java +++ b/src/main/java/io/quarkus/search/app/dto/SearchResult.java @@ -2,14 +2,44 @@ import java.util.List; -public record SearchResult(Total total, List hits) { +import io.quarkus.logging.Log; - public SearchResult(org.hibernate.search.engine.search.query.SearchResult result) { +import org.hibernate.search.backend.elasticsearch.search.query.ElasticsearchSearchResult; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; + +public record SearchResult(Total total, List hits, Suggestion suggestion) { + + public SearchResult(ElasticsearchSearchResult result) { this(new Total(result.total().isHitCountExact() ? result.total().hitCount() : null, result.total().hitCountLowerBound()), - result.hits()); + result.hits(), extractSuggestion(result)); } public record Total(Long exact, Long lowerBound) { } + + public record Suggestion(String query, String highlighted) { + } + + private static Suggestion extractSuggestion(ElasticsearchSearchResult result) { + try { + JsonObject suggest = result.responseBody().getAsJsonObject("suggest"); + if (suggest != null) { + JsonArray options = suggest + .getAsJsonArray("didYouMean") + .get(0).getAsJsonObject() + .getAsJsonArray("options"); + if (options != null && !options.isEmpty()) { + JsonObject suggestion = options.get(0).getAsJsonObject(); + return new Suggestion(suggestion.get("text").getAsString(), suggestion.get("highlighted").getAsString()); + } + } + } catch (RuntimeException e) { + // Though it shouldn't happen, just in case we will catch any exceptions and return no suggestions: + Log.warnf(e, "Failed to extract suggestion: %s" + e.getMessage()); + } + return null; + } } diff --git a/src/main/java/io/quarkus/search/app/entity/Guide.java b/src/main/java/io/quarkus/search/app/entity/Guide.java index 4206777b..37a6341a 100644 --- a/src/main/java/io/quarkus/search/app/entity/Guide.java +++ b/src/main/java/io/quarkus/search/app/entity/Guide.java @@ -61,6 +61,7 @@ public class Guide { @I18nFullTextField(name = "fullContent", valueBridge = @ValueBridgeRef(type = InputProviderHtmlBodyTextBridge.class), highlightable = Highlightable.FAST_VECTOR, termVector = TermVector.WITH_POSITIONS_OFFSETS, analyzerPrefix = AnalysisConfigurer.DEFAULT, searchAnalyzerPrefix = AnalysisConfigurer.DEFAULT_SEARCH) @I18nFullTextField(name = "fullContent_autocomplete", valueBridge = @ValueBridgeRef(type = InputProviderHtmlBodyTextBridge.class), analyzerPrefix = AnalysisConfigurer.AUTOCOMPLETE, searchAnalyzerPrefix = AnalysisConfigurer.DEFAULT_SEARCH) + @I18nFullTextField(name = "fullContent_suggestion", valueBridge = @ValueBridgeRef(type = InputProviderHtmlBodyTextBridge.class), analyzerPrefix = AnalysisConfigurer.SUGGESTION, searchAnalyzerPrefix = AnalysisConfigurer.SUGGESTION) @IndexingDependency(reindexOnUpdate = ReindexOnUpdate.NO) public I18nData htmlFullContentProvider = new I18nData<>(); diff --git a/src/main/java/io/quarkus/search/app/hibernate/AnalysisConfigurer.java b/src/main/java/io/quarkus/search/app/hibernate/AnalysisConfigurer.java index 896d8142..d00e7872 100644 --- a/src/main/java/io/quarkus/search/app/hibernate/AnalysisConfigurer.java +++ b/src/main/java/io/quarkus/search/app/hibernate/AnalysisConfigurer.java @@ -25,6 +25,7 @@ public class AnalysisConfigurer implements ElasticsearchAnalysisConfigurer { public static final String DEFAULT = "basic_analyzer"; public static final String DEFAULT_SEARCH = DEFAULT + "_search"; + public static final String SUGGESTION = "suggestion"; public static final String AUTOCOMPLETE = "autocomplete"; public static final String SORT = "sort"; // This is simplified by assuming no default package, lowercase package names and capitalized class name, @@ -36,6 +37,10 @@ public static String defaultAnalyzer(Language language) { return language.addSuffix(DEFAULT); } + public static String suggestionAnalyzer(Language language) { + return language.addSuffix(SUGGESTION); + } + public static String defaultSearchAnalyzer(Language language) { return language.addSuffix(DEFAULT_SEARCH); } @@ -101,6 +106,16 @@ void configureEnglishLikeLanguage(ElasticsearchAnalysisConfigurationContext cont "asciifolding") .charFilters("html_strip"); + context.analyzer(suggestionAnalyzer(language)).custom() + .tokenizer("standard") + .tokenFilters( + // To make all words in lowercase. + "lowercase", + // To convert characters into ascii ones, e.g. à to a or ę to e etc. + "asciifolding", + "shingle") + .charFilters("html_strip"); + // The analyzer to be applied to the user-input text. context.analyzer(defaultSearchAnalyzer(language)).custom() .tokenizer("standard") @@ -158,6 +173,16 @@ void configureJapanese(ElasticsearchAnalysisConfigurationContext context) { "icu_normalizer", "html_strip"); + context.analyzer(suggestionAnalyzer(language)).custom() + .tokenizer("kuromoji_tokenizer") + .tokenFilters( + // To make all words in lowercase. + "lowercase", + // To convert characters into ascii ones, e.g. à to a or ę to e etc. + "asciifolding", + "shingle") + .charFilters("html_strip"); + context.analyzer(defaultSearchAnalyzer(language)).custom() .tokenizer("kuromoji_tokenizer") .tokenFilters( @@ -211,6 +236,16 @@ void configureChinese(ElasticsearchAnalysisConfigurationContext context) { "asciifolding") .charFilters("html_strip"); + context.analyzer(suggestionAnalyzer(language)).custom() + .tokenizer("smartcn_tokenizer") + .tokenFilters( + // To make all words in lowercase. + "lowercase", + // To convert characters into ascii ones, e.g. à to a or ę to e etc. + "asciifolding", + "shingle") + .charFilters("html_strip"); + // The analyzer to be applied to the user-input text. context.analyzer(defaultSearchAnalyzer(language)).custom() .tokenizer("smartcn_tokenizer") diff --git a/src/main/resources/web/app/qs-form.ts b/src/main/resources/web/app/qs-form.ts index e7f301e6..d93f71a7 100644 --- a/src/main/resources/web/app/qs-form.ts +++ b/src/main/resources/web/app/qs-form.ts @@ -6,10 +6,12 @@ import {LocalSearch} from "./local-search"; export const QS_START_EVENT = 'qs-start'; export const QS_RESULT_EVENT = 'qs-result'; export const QS_NEXT_PAGE_EVENT = 'qs-next-page'; +export const QS_QUERY_SUGGESTION_EVENT = 'qs-query-suggestion'; export interface QsResult { hits: QsHit[]; hasMoreHits: boolean; + suggestion: QsSuggestion; } export interface QsHit { @@ -21,6 +23,11 @@ export interface QsHit { type: string | undefined; } +export interface QsSuggestion { + query: string; + highlighted: string; +} + /** * This component is the form that triggers the search */ @@ -99,6 +106,7 @@ export class QsForm extends LitElement { LocalSearch.enableLocalSearch(); const formElements = this._getFormElements(); this.addEventListener(QS_NEXT_PAGE_EVENT, this._handleNextPage); + this.addEventListener(QS_QUERY_SUGGESTION_EVENT, this._handleQuerySuggestion) formElements.forEach((el) => { const eventName = this._isInput(el) ? 'input' : 'change'; el.addEventListener(eventName, this._handleInputChange); @@ -111,6 +119,7 @@ export class QsForm extends LitElement { disconnectedCallback() { super.disconnectedCallback(); this.removeEventListener(QS_NEXT_PAGE_EVENT, this._handleNextPage); + this.removeEventListener(QS_QUERY_SUGGESTION_EVENT, this._handleQuerySuggestion) const formElements = this._getFormElements(); formElements.forEach(el => { const eventName = this._isInput(el) ? 'input' : 'change'; @@ -210,6 +219,15 @@ export class QsForm extends LitElement { this._search(); } + private _handleQuerySuggestion = (e: CustomEvent) => { + this._getFormElements().forEach((el) => { + if (this._isInput(el) && el.name === 'q') { + el.value = e.detail.suggestion.query; + } + }); + this._handleInputChange(e); + } + private _isInput(el: HTMLFormElement) { return el.tagName.toLowerCase() === 'input' } diff --git a/src/main/resources/web/app/qs-target.ts b/src/main/resources/web/app/qs-target.ts index da752ed0..3a5ec3c1 100644 --- a/src/main/resources/web/app/qs-target.ts +++ b/src/main/resources/web/app/qs-target.ts @@ -1,9 +1,10 @@ import {LitElement, html, css, unsafeCSS} from 'lit'; import {customElement, property, state, queryAll} from 'lit/decorators.js'; import './qs-guide' -import {QS_NEXT_PAGE_EVENT, QS_RESULT_EVENT, QS_START_EVENT, QsResult} from "./qs-form"; +import {QS_NEXT_PAGE_EVENT, QS_QUERY_SUGGESTION_EVENT, QS_RESULT_EVENT, QS_START_EVENT, QsResult} from "./qs-form"; import debounce from 'lodash/debounce'; import icons from "./assets/icons"; +import { unsafeHTML } from 'lit/directives/unsafe-html.js'; /** @@ -41,6 +42,14 @@ export class QsTarget extends LitElement { font-style: italic; text-align: center; background: var(--empty-background-color, #F0CA4D); + + .suggestion { + text-decoration: underline; + cursor: pointer; + .highlighted { + font-weight: bold; + } + } } @@ -90,11 +99,20 @@ export class QsTarget extends LitElement { render() { if (this._result?.hits) { if (this._result.hits.length === 0) { - return html` + if (this._result.suggestion) { + return html` +
+

Sorry, no ${this.type}s matched your search. + Did you mean ${unsafeHTML(this._result.suggestion.highlighted)}?

+
+ `; + } else { + return html`

Sorry, no ${this.type}s matched your search. Please try again.

`; + } } const result = this._result.hits.map(i => this._renderHit(i)); return html` @@ -188,4 +206,8 @@ export class QsTarget extends LitElement { private _loadingEnd = () => { this._loading = false; } + + private _querySuggestion() { + this._form.dispatchEvent(new CustomEvent(QS_QUERY_SUGGESTION_EVENT, {detail: {suggestion: this._result.suggestion}})); + } } diff --git a/src/test/java/io/quarkus/search/app/SearchServiceTest.java b/src/test/java/io/quarkus/search/app/SearchServiceTest.java index 0ccaaec2..1fcb6b5e 100644 --- a/src/test/java/io/quarkus/search/app/SearchServiceTest.java +++ b/src/test/java/io/quarkus/search/app/SearchServiceTest.java @@ -519,6 +519,44 @@ void findAllUppercase() { "Duplicated context, context locals, asynchronous processing and propagation"); } + /** + * Since there are some typos, the search results should include a suggestion with the text that would produce some results. + */ + @Test + void suggestion() { + var result = given() + .queryParam("q", "hiberante search") + .when().get(GUIDES_SEARCH) + .then() + .statusCode(200) + .extract().body().as(SEARCH_RESULT_SEARCH_HITS); + assertThat(result.suggestion().query()) + .isEqualTo("hibernate search"); + + result = given() + .queryParam("q", "aplication") + .when().get(GUIDES_SEARCH) + .then() + .statusCode(200) + .extract().body().as(SEARCH_RESULT_SEARCH_HITS); + assertThat(result.suggestion().query()) + .isEqualTo("application"); + } + + /** + * As the query text is already fine, and matches the existing tokens, no suggestion is expected. + */ + @Test + void noSuggestion() { + var result = given() + .queryParam("q", "hibernate search") + .when().get(GUIDES_SEARCH) + .then() + .statusCode(200) + .extract().body().as(SEARCH_RESULT_SEARCH_HITS); + assertThat(result.suggestion()).isNull(); + } + private static ThrowingConsumer hitsHaveCorrectWordHighlighted(AtomicInteger matches, String word, String cssClass) { return sentence -> {