Skip to content

Commit ffa26a9

Browse files
committed
Use suggestion as a search query if the initial one produced no results
1 parent 7b56927 commit ffa26a9

4 files changed

Lines changed: 151 additions & 118 deletions

File tree

src/main/java/io/quarkus/search/app/SearchService.java

Lines changed: 114 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -21,18 +21,23 @@
2121
import io.quarkus.search.app.quarkiverseio.QuarkiverseIO;
2222
import io.quarkus.search.app.quarkusio.QuarkusIO;
2323

24+
import io.quarkus.logging.Log;
25+
2426
import org.hibernate.search.backend.elasticsearch.ElasticsearchExtension;
27+
import org.hibernate.search.backend.elasticsearch.search.query.ElasticsearchSearchResult;
2528
import org.hibernate.search.engine.search.common.BooleanOperator;
2629
import org.hibernate.search.engine.search.common.ValueModel;
2730
import org.hibernate.search.engine.search.predicate.dsl.MatchPredicateOptionsStep;
2831
import org.hibernate.search.engine.search.predicate.dsl.PredicateFinalStep;
2932
import org.hibernate.search.engine.search.predicate.dsl.SearchPredicateFactory;
3033
import org.hibernate.search.engine.search.predicate.dsl.SimpleQueryFlag;
3134
import org.hibernate.search.mapper.pojo.standalone.mapping.SearchMapping;
35+
import org.hibernate.search.mapper.pojo.standalone.session.SearchSession;
3236

3337
import org.eclipse.microprofile.openapi.annotations.Operation;
3438
import org.jboss.resteasy.reactive.RestQuery;
3539

40+
import com.google.gson.JsonArray;
3641
import com.google.gson.JsonObject;
3742

3843
@ApplicationScoped
@@ -62,79 +67,97 @@ public SearchResult<GuideSearchHit> search(@RestQuery @DefaultValue(QuarkusVersi
6267
@RestQuery @DefaultValue("1") @Min(0) @Max(value = 10, message = MAX_FOR_PERF_MESSAGE) int contentSnippets,
6368
@RestQuery @DefaultValue("100") @Min(0) @Max(value = 200, message = MAX_FOR_PERF_MESSAGE) int contentSnippetsLength) {
6469
try (var session = searchMapping.createSession()) {
65-
var result = session.search(Guide.class)
66-
.extension(ElasticsearchExtension.get())
67-
.select(f -> f.composite().from(
68-
f.id(),
69-
f.field("type"),
70-
f.field("origin"),
71-
f.highlight(language.addSuffix("title")).highlighter("highlighter_title_or_summary").single(),
72-
f.highlight(language.addSuffix("summary")).highlighter("highlighter_title_or_summary").single(),
73-
f.highlight(language.addSuffix("fullContent")).highlighter("highlighter_content"))
74-
.asList(GuideSearchHit::new))
75-
.where((f, root) -> {
76-
// Match all documents by default
77-
root.add(f.matchAll());
78-
79-
if (categories != null && !categories.isEmpty()) {
80-
root.add(f.terms().field("categories").matchingAny(categories));
81-
}
82-
83-
if (origin != null && !origin.isEmpty()) {
84-
root.add(f.match().field("origin").matching(origin));
85-
}
86-
87-
if (q != null && !q.isBlank()) {
88-
root.add(f.or(
89-
// Duplicate the query so that we apply a multiplicative boost to quarkus.io guides.
90-
// The end result is that a low-relevance match on quarkus.io _can_ be scored
91-
// lower than a high-relevance match on quarkiverse.io,
92-
// if it's significantly more relevant.
93-
// Note that we could, alternatively,
94-
// do something like bool().must(textMatch()).should(origin(quarkusio).boost(2f))),
95-
// but then the boost would be additive, so we would ignore relative relevance
96-
// of quarkus.io/quarkiverse.io results.
97-
f.bool().must(textMatch(f, q, language))
98-
.filter(originMatch(f, QuarkusIO.QUARKUS_ORIGIN))
99-
// Always score lower for compatibility (legacy) guides.
100-
// TODO: Maybe we should use a duplicate query with multiplicative boost for this too?
101-
.should(f.not(f.match().field(language.addSuffix("topics"))
102-
.matching("compatibility", ValueModel.INDEX))
103-
.boost(50.0f))
104-
.boost(2.0f),
105-
f.bool().must(textMatch(f, q, language))
106-
.filter(originMatch(f, QuarkiverseIO.QUARKIVERSE_ORIGIN))));
107-
}
108-
})
109-
.highlighter(f -> f.fastVector()
110-
// Highlighters are going to use spans-with-classes so that we will have more control over styling the visual on the search results screen.
111-
.tag("<span class=\"" + highlightCssClass + "\">", "</span>"))
112-
.highlighter("highlighter_title_or_summary", f -> f.fastVector()
113-
// We want the whole text of the field, regardless of whether it has a match or not.
114-
.noMatchSize(TITLE_OR_SUMMARY_MAX_SIZE)
115-
.fragmentSize(TITLE_OR_SUMMARY_MAX_SIZE)
116-
// We want the whole text as a single fragment
117-
.numberOfFragments(1))
118-
.highlighter("highlighter_content", f -> f.fastVector()
119-
// If there's no match in the full content we don't want to return anything.
120-
.noMatchSize(0)
121-
// Content is really huge, so we want to only get small parts of the sentences.
122-
// We give control to the caller on the content snippet length and the number of these fragments
123-
.numberOfFragments(contentSnippets)
124-
.fragmentSize(contentSnippetsLength)
125-
// The rest of fragment configuration is static
126-
.orderByScore(true)
127-
// We don't use sentence boundaries because those can result in huge fragments
128-
.boundaryScanner().chars().boundaryMaxScan(10).end())
129-
.sort(f -> f.score().then().field(language.addSuffix("title_sort")))
130-
.routing(QuarkusVersionAndLanguageRoutingBinder.searchKeys(version, language))
131-
.totalHitCountThreshold(TOTAL_HIT_COUNT_THRESHOLD + (page + 1) * PAGE_SIZE)
132-
.requestTransformer(context -> requestSuggestion(context.body(), q, language, highlightCssClass))
133-
.fetch(page * PAGE_SIZE, PAGE_SIZE);
134-
return new SearchResult<>(result);
70+
var result = performSearch(version, categories, q, origin, language, highlightCssClass, page, contentSnippets,
71+
contentSnippetsLength, session);
72+
if (result.total().hitCount() > 0) {
73+
return new SearchResult<>(result);
74+
} else {
75+
SearchResult.Suggestion suggestion = extractSuggestion(result);
76+
if (suggestion != null) {
77+
result = performSearch(version, categories, suggestion.query(), origin, language, highlightCssClass, page,
78+
contentSnippets, contentSnippetsLength, session);
79+
}
80+
return new SearchResult<>(result, suggestion);
81+
}
13582
}
13683
}
13784

85+
private ElasticsearchSearchResult<GuideSearchHit> performSearch(String version, List<String> categories, String q,
86+
String origin, Language language, String highlightCssClass, int page, int contentSnippets,
87+
int contentSnippetsLength, SearchSession session) {
88+
return session.search(Guide.class)
89+
.extension(ElasticsearchExtension.get())
90+
.select(f -> f.composite().from(
91+
f.id(),
92+
f.field("type"),
93+
f.field("origin"),
94+
f.highlight(language.addSuffix("title")).highlighter("highlighter_title_or_summary").single(),
95+
f.highlight(language.addSuffix("summary")).highlighter("highlighter_title_or_summary").single(),
96+
f.highlight(language.addSuffix("fullContent")).highlighter("highlighter_content"))
97+
.asList(GuideSearchHit::new))
98+
.where((f, root) -> {
99+
// Match all documents by default
100+
root.add(f.matchAll());
101+
102+
if (categories != null && !categories.isEmpty()) {
103+
root.add(f.terms().field("categories").matchingAny(categories));
104+
}
105+
106+
if (origin != null && !origin.isEmpty()) {
107+
root.add(f.match().field("origin").matching(origin));
108+
}
109+
110+
if (q != null && !q.isBlank()) {
111+
root.add(f.or(
112+
// Duplicate the query so that we apply a multiplicative boost to quarkus.io guides.
113+
// The end result is that a low-relevance match on quarkus.io _can_ be scored
114+
// lower than a high-relevance match on quarkiverse.io,
115+
// if it's significantly more relevant.
116+
// Note that we could, alternatively,
117+
// do something like bool().must(textMatch()).should(origin(quarkusio).boost(2f))),
118+
// but then the boost would be additive, so we would ignore relative relevance
119+
// of quarkus.io/quarkiverse.io results.
120+
f.bool().must(textMatch(f, q, language))
121+
.filter(originMatch(f, QuarkusIO.QUARKUS_ORIGIN))
122+
// Always score lower for compatibility (legacy) guides.
123+
// TODO: Maybe we should use a duplicate query with multiplicative boost for this too?
124+
.should(f.not(f.match().field(language.addSuffix("topics"))
125+
.matching("compatibility", ValueModel.INDEX))
126+
.boost(50.0f))
127+
.boost(2.0f),
128+
f.bool().must(textMatch(f, q, language))
129+
.filter(originMatch(f, QuarkiverseIO.QUARKIVERSE_ORIGIN))));
130+
}
131+
})
132+
.highlighter(f -> f.fastVector()
133+
// Highlighters are going to use spans-with-classes so that we will have more control over styling the visual on the search results screen.
134+
.tag("<span class=\"" + highlightCssClass + "\">", "</span>"))
135+
.highlighter(
136+
"highlighter_title_or_summary", f -> f.fastVector()
137+
// We want the whole text of the field, regardless of whether it has a match or not.
138+
.noMatchSize(TITLE_OR_SUMMARY_MAX_SIZE)
139+
.fragmentSize(TITLE_OR_SUMMARY_MAX_SIZE)
140+
// We want the whole text as a single fragment
141+
.numberOfFragments(1))
142+
.highlighter(
143+
"highlighter_content", f -> f.fastVector()
144+
// If there's no match in the full content we don't want to return anything.
145+
.noMatchSize(0)
146+
// Content is really huge, so we want to only get small parts of the sentences.
147+
// We give control to the caller on the content snippet length and the number of these fragments
148+
.numberOfFragments(contentSnippets)
149+
.fragmentSize(contentSnippetsLength)
150+
// The rest of fragment configuration is static
151+
.orderByScore(true)
152+
// We don't use sentence boundaries because those can result in huge fragments
153+
.boundaryScanner().chars().boundaryMaxScan(10).end())
154+
.sort(f -> f.score().then().field(language.addSuffix("title_sort")))
155+
.routing(QuarkusVersionAndLanguageRoutingBinder.searchKeys(version, language))
156+
.totalHitCountThreshold(TOTAL_HIT_COUNT_THRESHOLD + (page + 1) * PAGE_SIZE)
157+
.requestTransformer(context -> requestSuggestion(context.body(), q, language, highlightCssClass))
158+
.fetch(page * PAGE_SIZE, PAGE_SIZE);
159+
}
160+
138161
private PredicateFinalStep textMatch(SearchPredicateFactory f, String q, Language language) {
139162
return f.simpleQueryString()
140163
.field(language.addSuffix("title")).boost(10.0f)
@@ -178,4 +201,25 @@ private void requestSuggestion(JsonObject payload, String q, Language language,
178201
highlight.addProperty("post_tag", "</span>");
179202
}
180203

204+
private static SearchResult.Suggestion extractSuggestion(ElasticsearchSearchResult<?> result) {
205+
try {
206+
JsonObject suggest = result.responseBody().getAsJsonObject("suggest");
207+
if (suggest != null) {
208+
JsonArray options = suggest
209+
.getAsJsonArray("didYouMean")
210+
.get(0).getAsJsonObject()
211+
.getAsJsonArray("options");
212+
if (options != null && !options.isEmpty()) {
213+
JsonObject suggestion = options.get(0).getAsJsonObject();
214+
return new SearchResult.Suggestion(suggestion.get("text").getAsString(),
215+
suggestion.get("highlighted").getAsString());
216+
}
217+
}
218+
} catch (RuntimeException e) {
219+
// Though it shouldn't happen, just in case we will catch any exceptions and return no suggestions:
220+
Log.warnf(e, "Failed to extract suggestion: %s" + e.getMessage());
221+
}
222+
return null;
223+
}
224+
181225
}

src/main/java/io/quarkus/search/app/dto/SearchResult.java

Lines changed: 7 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,20 @@
22

33
import java.util.List;
44

5-
import io.quarkus.logging.Log;
6-
75
import org.hibernate.search.backend.elasticsearch.search.query.ElasticsearchSearchResult;
86

9-
import com.google.gson.JsonArray;
10-
import com.google.gson.JsonObject;
11-
127
public record SearchResult<T>(Total total, List<T> hits, Suggestion suggestion) {
138

149
public SearchResult(ElasticsearchSearchResult<T> result) {
1510
this(new Total(result.total().isHitCountExact() ? result.total().hitCount() : null,
1611
result.total().hitCountLowerBound()),
17-
result.hits(), extractSuggestion(result));
12+
result.hits(), null);
13+
}
14+
15+
public SearchResult(ElasticsearchSearchResult<T> result, Suggestion suggestion) {
16+
this(new Total(result.total().isHitCountExact() ? result.total().hitCount() : null,
17+
result.total().hitCountLowerBound()),
18+
result.hits(), suggestion);
1819
}
1920

2021
public record Total(Long exact, Long lowerBound) {
@@ -23,23 +24,4 @@ public record Total(Long exact, Long lowerBound) {
2324
public record Suggestion(String query, String highlighted) {
2425
}
2526

26-
private static Suggestion extractSuggestion(ElasticsearchSearchResult<?> result) {
27-
try {
28-
JsonObject suggest = result.responseBody().getAsJsonObject("suggest");
29-
if (suggest != null) {
30-
JsonArray options = suggest
31-
.getAsJsonArray("didYouMean")
32-
.get(0).getAsJsonObject()
33-
.getAsJsonArray("options");
34-
if (options != null && !options.isEmpty()) {
35-
JsonObject suggestion = options.get(0).getAsJsonObject();
36-
return new Suggestion(suggestion.get("text").getAsString(), suggestion.get("highlighted").getAsString());
37-
}
38-
}
39-
} catch (RuntimeException e) {
40-
// Though it shouldn't happen, just in case we will catch any exceptions and return no suggestions:
41-
Log.warnf(e, "Failed to extract suggestion: %s" + e.getMessage());
42-
}
43-
return null;
44-
}
4527
}

src/main/resources/web/app/qs-target.ts

Lines changed: 29 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,15 @@ export class QsTarget extends LitElement {
4242
font-style: italic;
4343
text-align: center;
4444
background: var(--empty-background-color, #F0CA4D);
45+
}
46+
47+
.search-result-title {
48+
margin-top: 2.5rem;
49+
font-weight: var(--heading-font-weight);
50+
}
51+
52+
.result-message {
53+
margin-bottom: 0px;
4554
4655
.suggestion {
4756
text-decoration: underline;
@@ -52,11 +61,6 @@ export class QsTarget extends LitElement {
5261
}
5362
}
5463
55-
.search-result-title {
56-
margin-top: 2.5rem;
57-
font-weight: var(--heading-font-weight);
58-
}
59-
6064
qs-guide {
6165
grid-column: span 4;
6266
margin: 1rem 0rem 1rem 0rem;
@@ -104,29 +108,32 @@ export class QsTarget extends LitElement {
104108
render() {
105109
if (this._result?.hits) {
106110
if (this._result.hits.length === 0) {
107-
if (this._result.suggestion) {
108-
return html`
109-
<div id="qs-target" class="no-hits">
110-
<p>Sorry, no ${this.type}s matched your search.
111-
Did you mean <span class="suggestion" @click=${this._querySuggestion}>${unsafeHTML(this._result.suggestion.highlighted)}</span>?</p>
112-
</div>
113-
`;
114-
} else {
115-
return html`
111+
return html`
116112
<div id="qs-target" class="no-hits">
117113
<p>Sorry, no ${this.type}s matched your search. Please try again.</p>
118114
</div>
119115
`;
120-
}
121116
}
122117
const result = this._result.hits.map(i => this._renderHit(i));
123-
return html`
124-
${this.searchResultsTitle === '' ? '' : html`<h1 class="search-result-title">${this.searchResultsTitle}</h1>`}
125-
<div id="qs-target" class="qs-hits" aria-label="Search Hits">
126-
${result}
127-
</div>
128-
${this._loading ? this._renderLoading() : ''}
129-
`;
118+
if (this._result.suggestion) {
119+
return html`
120+
${this.searchResultsTitle === '' ? '' : html`<h1 class="search-result-title">${this.searchResultsTitle}</h1>`}
121+
<p class="result-message">Sorry, no ${this.type}s matched your original search query.
122+
Showing results for <span class="suggestion" @click=${this._querySuggestion}>${unsafeHTML(this._result.suggestion.highlighted)}</span> instead.</p>
123+
<div id="qs-target" class="qs-hits" aria-label="Search Hits">
124+
${result}
125+
</div>
126+
${this._loading ? this._renderLoading() : ''}
127+
`;
128+
} else {
129+
return html`
130+
${this.searchResultsTitle === '' ? '' : html`<h1 class="search-result-title">${this.searchResultsTitle}</h1>`}
131+
<div id="qs-target" class="qs-hits" aria-label="Search Hits">
132+
${result}
133+
</div>
134+
${this._loading ? this._renderLoading() : ''}
135+
`;
136+
}
130137
}
131138
if (this._loading) {
132139
return html`

src/test/java/io/quarkus/search/app/SearchServiceTest.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -518,7 +518,7 @@ void suggestion() {
518518
.isEqualTo("configuring your application");
519519

520520
result = given()
521-
.queryParam("q", "vertex")
521+
.queryParam("q", "vert.ex")
522522
.when().get(GUIDES_SEARCH)
523523
.then()
524524
.statusCode(200)

0 commit comments

Comments
 (0)