Search with Elasticsearch
Querying Content
To see the types of content queries you can make in CrafterCMS, please see Basic Query Mechanics
Implementing a Faceted Search
Using Elasticsearch it is possible to use aggregations to provide a faceted search to allow users to refine the search results based on one or more fields.
Note
Elasticsearch offers a variety of aggregations that can be used depending on the type of the fields in your model or the requirements in the UI to display the data, for detailed information visit the official documentation
In this section, we will be using the most basic aggregation terms
to provide a faceted search based on the
category of blog articles.
First we must define the fields that will be used for the aggregation, in this case the page model for Article
has
a categories
field that uses a datasource to get values from a taxonomy in the site. For this case the name of the
field in the Elasticsearch index is categories.item.value_smv
.
To build the faceted search we must:
Include the appropriate aggregations in the Elasticsearch search request
Process the aggregations from the Elasticsearch search response
Display the facets in the search result page
Sending aggregations in the search request
In Elasticsearch aggregations are added in the request using the aggs
key, each aggregation must have a unique name
as key and the configuration depending on the type.
1def result = elasticsearch.search([
2 query: [
3 query_string: [
4 query: q as String
5 ]
6 ],
7 from: start,
8 size: rows,
9 aggs: [
10 "categories": [
11 terms: [
12 field: "categories.item.value_smv",
13 min_doc_count: 1
14 ]
15 ]
16 ]
17])
In the previous example we include a terms
agregation called categories
that will return all found values for
the field categories.item.value_smv
that have at least 1 article assigned.
Processing aggregations in the search response
Elasticsearch will return the aggregations in the response under the aggregations
field, the contents of each
aggregation will be different depending of the type.
1def facets = [:]
2if(result.aggregations) {
3 result.aggregations.getAsMap().each { name, agg ->
4 facets[name] = agg.buckets.collect{ [ value: it.key, count: it.docCount ] }
5 }
6}
In the previous example we extract the aggregations from the response object to a simple map, this example assumes
that all aggregation will be of type terms
so it gets the key
and docCount
for each value found
(Elasticsearch calls them buckets).
The result from a query of all existing articles could return something similar to this:
1"facets":{
2 "categories":[
3 { "value":"Entertainment", "count":3 },
4 { "value":"Health", "count":3 },
5 { "value":"Style", "count":1 },
6 { "value":"Technology", "count":1 }
7 ]
8}
According to the given example, if we run our query again including a filter for category with value Entertainment
it will return exactly 3 articles, and in the next query we will get a new set of facets based on those articles.
This is how users can quickly reduce the number of result and find more useful data with less effort.
Displaying facets in the search result pages
This step will change depending on the technology being used to display all information, it can be done in Freemarker or a SPA using Angular, React or Vue. As an example we will use Handlebars templates that will be rendered using jQuery.
1<script id="search-facets-template" type="text/x-handlebars-template">
2 {{#if facets}}
3 <div class="row uniform">
4 {{#each facets}}
5 <div class="3u 6u(medium) 12u$(small)">
6 <input type="checkbox" id="{{value}}" name="{{value}}" value="{{value}}">
7 <label for="{{value}}">{{value}} ({{count}})</label>
8 </div>
9 {{/each}}
10 </div>
11 {{/if}}
12</script>
13
14<script id="search-results-template" type="text/x-handlebars-template">
15 {{#each articles}}
16 <div>
17 <h4><a href="{{url}}">{{title}}</a></h4>
18 {{#if highlight}}
19 <p>{{{highlight}}}</p>
20 {{/if}}
21 </div>
22 {{else}}
23 <p>No results found</p>
24 {{/each}}
25</script>
We use the templates to render the results after executing the search
1$.get("/api/search.json", params).done(function(data) {
2 if (data == null) {
3 data = {};
4 }
5 $('#search-facets').html(facetsTemplate({ facets: data.facets.categories }));
6 $('#search-results').html(articlesTemplate(data));
7});
The final step is to trigger a new search when the user selects one of the values in the facets
1$('#search-facets').on('click', 'input', function() {
2 var categories = [];
3 $('#search-facets input:checked').each(function() {
4 categories.push($(this).val());
5 });
6
7 doSearch(queryParam, categories);
8});
Multi-index Query
CrafterCMS supports querying more than one Elasticsearch index in a single query.
To search your site and other indexes, simply send a search query with a comma separated list of indexes/aliases (ES pointer to an index). It will then search your site and the other indexes
Remember that all other Elasticsearch indexes/aliases to be searched need to be prefixed with the site name like this: SITENAME_{external-index-name}
. When sending the query, remove the prefix SITENAME_
from the other indexes/aliases.
Here’s how the query will look like for the above image of a multi-index query for the site acme
(the SITENAME), and the CD database index acme_cd-database
:
1def result = elasticsearch.search(new SearchRequest('cd-database').source(builder))
1curl -s -X POST "localhost:8080/api/1/site/elasticsearch/search?index=cd-database" -d '
2{
3 "query" : {
4 "match_all" : {}
5 }
6}
7'
See here for more information on the Crafter Engine API search
.
CrafterCMS supports the following search query parameters:
indices_boost
search_type
allow_no_indices
expand_wildcards
ignore_throttled
ignore_unavailable
See the official docs for more information on the above parameters.
For more information on indices_boost
, see here
Implementing a Type-ahead Service
In this section, we will be looking at how to use a query to provide suggestions as the user types.
Build the Service
Create a REST service that returns suggestions based on the content in your site.
Requirements
The service will take the user’s current search term and find similar content.
The service will return the results as a list of strings
To create the REST endpoint, place the following Groovy file in your scripts folder
1 import org.craftercms.sites.editorial.SuggestionHelper
2
3 // Obtain the text from the request parameters
4 def term = params.term
5
6 def helper = new SuggestionHelper(elasticsearch)
7
8 // Execute the query and process the results
9 return helper.getSuggestions(term)
You will also need to create the helper class in the scripts folder
1 package org.craftercms.sites.editorial
2
3 class SuggestionHelper {
4
5 static final String DEFAULT_CONTENT_TYPE_QUERY = "content-type:\"/page/article\""
6 static final String DEFAULT_SEARCH_FIELD = "subject_t"
7
8 def elasticsearch
9
10 String contentTypeQuery = DEFAULT_CONTENT_TYPE_QUERY
11 String searchField = DEFAULT_SEARCH_FIELD
12
13 SuggestionHelper(elasticsearch) {
14 this.elasticsearch = elasticsearch
15 }
16
17 def getSuggestions(String term) {
18 def queryStr = "${contentTypeQuery} AND ${searchField}:*${term}*"
19
20 def result = elasticsearch.search([
21 query: [
22 query_string: [
23 query: queryStr as String
24 ]
25 ]
26 ])
27
28 return process(result)
29 }
30
31 def process(result) {
32 def processed = result.hits.hits*.getSourceAsMap().collect { doc ->
33 doc[searchField]
34 }
35 return processed
36 }
37
38 }
Once those files are created and the site context is reloaded you should be able to test the REST endpoint from a browser and get a result similar to this:
http://localhost:8080/api/1/services/suggestions.json?term=men
1[
2 "Men Styles For Winter",
3 "Women Styles for Winter",
4 "Top Books For Young Women",
5 "5 Popular Diets for Women"
6]
Build the UI
The front end experience is built with HTML, Javascript and specifically AJAX.
Requirements
When the user types a value send a request to the server to get instant results
Display the results and show suggestions about what the user might be looking for
Do not fire a query for every keystroke. This can lead to more load than necessary, instead, batch user keystrokes and send when batch size is hit or when the user stops typing.
You can also integrate any existing library or framework that provides a type-ahead component, for example to use the jQuery UI Autocomplete component you only need to provide the REST endpoint in the configuration:
1$('#search').autocomplete({
2 // Wait for at least this many characters to send the request
3 minLength: 2,
4 source: '/api/1/services/suggestions.json',
5 // Once the user selects a suggestion from the list, redirect to the results page
6 select: function(evt, ui) {
7 window.location.replace("/search-results?q=" + ui.item.value);
8 }
9});