Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Experiments: Add possible suggestion to the empty results response #391

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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",
Comment on lines +181 to +182
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure converting Japanese to Ascii will go well...

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that's true 🙈 😃. I suspect that it does not affect characters it doesn't know .... since we have this filter just above in the default analyzer (it is the last one in the list)

"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",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here, converting Chinese to ascii seems bold.

"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}}));
}
}
38 changes: 38 additions & 0 deletions src/test/java/io/quarkus/search/app/SearchServiceTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -519,6 +519,44 @@ void findAllUppercase() {
"<span class=\"highlighted\">Duplicated</span> <span class=\"highlighted\">context</span>, <span class=\"highlighted\">context</span> <span class=\"highlighted\">locals</span>, <span class=\"highlighted\">asynchronous</span> <span class=\"highlighted\">processing</span> <span class=\"highlighted\">and</span> <span class=\"highlighted\">propagation</span>");
}

/**
* 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<String> hitsHaveCorrectWordHighlighted(AtomicInteger matches, String word,
String cssClass) {
return sentence -> {
Expand Down
Loading