Skip to content

Commit

Permalink
Add possible suggestion to the empty results response
Browse files Browse the repository at this point in the history
  • Loading branch information
marko-bekhta committed Jan 13, 2025
1 parent fdb5be7 commit c62ebbc
Show file tree
Hide file tree
Showing 6 changed files with 136 additions and 5 deletions.
25 changes: 25 additions & 0 deletions src/main/java/io/quarkus/search/app/SearchService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -69,6 +72,7 @@ public SearchResult<GuideSearchHit> 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"),
Expand Down Expand Up @@ -131,9 +135,30 @@ public SearchResult<GuideSearchHit> 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", "<span class=\"" + highlightCssClass + "\">");
highlight.addProperty("post_tag", "</span>");
}

}
36 changes: 33 additions & 3 deletions src/main/java/io/quarkus/search/app/dto/SearchResult.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,44 @@

import java.util.List;

public record SearchResult<T>(Total total, List<T> hits) {
import io.quarkus.logging.Log;

public SearchResult(org.hibernate.search.engine.search.query.SearchResult<T> result) {
import org.hibernate.search.backend.elasticsearch.search.query.ElasticsearchSearchResult;

import com.google.gson.JsonArray;
import com.google.gson.JsonObject;

public record SearchResult<T>(Total total, List<T> hits, Suggestion suggestion) {

public SearchResult(ElasticsearchSearchResult<T> 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;
}
}
1 change: 1 addition & 0 deletions src/main/java/io/quarkus/search/app/entity/Guide.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<InputProvider> htmlFullContentProvider = new I18nData<>();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);
}
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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")
Expand Down
18 changes: 18 additions & 0 deletions src/main/resources/web/app/qs-form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
*/
Expand Down Expand Up @@ -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);
Expand All @@ -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';
Expand Down Expand Up @@ -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'
}
Expand Down
26 changes: 24 additions & 2 deletions src/main/resources/web/app/qs-target.ts
Original file line number Diff line number Diff line change
@@ -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';


/**
Expand Down Expand Up @@ -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;
}
}
}
Expand Down Expand Up @@ -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`
<div id="qs-target" class="no-hits">
<p>Sorry, no ${this.type}s matched your search.
Did you mean <span class="suggestion" @click=${this._querySuggestion}>${unsafeHTML(this._result.suggestion.highlighted)}</span>?</p>
</div>
`;
} else {
return html`
<div id="qs-target" class="no-hits">
<p>Sorry, no ${this.type}s matched your search. Please try again.</p>
</div>
`;
}
}
const result = this._result.hits.map(i => this._renderHit(i));
return html`
Expand Down Expand Up @@ -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}}));
}
}

0 comments on commit c62ebbc

Please sign in to comment.