Concepts / Building Search UI / Query suggestions
Sep. 26, 2019

Query Suggestions

Overview

In order to help users with their search, Algolia provides a feature called query suggestions. This feature creates an index with the best queries done by the users. You can then use this index to propose suggestions to your users as they are typing into the searchBox. The great thing with this feature is that once you’ve configured the generation of the index, it’s just about querying another index and you can easily use multi-index search to for that.

In this guide we will cover the use case where a searchBox displays a list of suggestions along with the associated categories. Once the user select a suggestion both the query and the category will be applied.

To build our list of suggestions we leverage the connector autocomplete. We won’t cover in too much details how to integrate an autocomplete with InstantSearch.js. The autocomplete guide has already a dedicated section on that topic.

Refine your results with the suggestions

The first step of this guide is to setup our custom autocomplete component. To implement it we use the library Selectize that provides an API to create an autocomplete menu. Once we have this component, we need to wrap it with the autocomplete connector. You can find more information in the guide on autocomplete.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
const autocomplete = instantsearch.connectors.connectAutocomplete(
  ({ indices, refine, widgetParams }, isFirstRendering) => {
    const { container, onSelectChange } = widgetParams;

    if (isFirstRendering) {
      container.html('<select id="ais-autocomplete"></select>');

      container.find('select').selectize({
        options: [],
        valueField: 'query',
        labelField: 'query',
        highlight: false,
        onType: refine,
        onBlur() {
          refine(this.getValue());
        },
        onChange(value) {
          refine(value);
          onSelectChange({
            query: value,
          });
        },
        score() {
          return () => 1;
        },
        render: {
          option({ query }) {
            return `
              <div class="option">
                ${query}
              </div>
            `;
          },
        },
      });

      return;
    }

    const [select] = container.find('select');

    select.selectize.clearOptions();
    indices.forEach(({ results }) => {
      results.hits.forEach(hit => select.selectize.addOption(hit));
    });
    select.selectize.refreshOptions(select.selectize.isOpen);
  }
);

Now that we have our autocomplete component we can create our multi-index search experience. The autocomplete targets the index that contains the suggestions. The rest of the widgets target the main index that holds our data. You can find more information about that in the guide about multi-index search.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// ...

const searchClient = algoliasearch(
  'YourApplicationID',
  'YourAdminAPIKey'
);

const suggestions = instantsearch({
  indexName: 'instant_search_demo_query_suggestions',
  searchClient,
});

// ...

suggestions.addWidgets([
  autocomplete({
    container: $('#autocomplete'),
    onSelectChange({ query }) {
      search.helper.setQuery(query).search();
    },
  })
]);

const search = instantsearch({
  indexName: 'instant_search',
  searchClient,
});

search.addWidgets([
  instantsearch.widgets.hits({
    container: '#hits',
    templates: {
      // ...
    },
  })
]);

suggestions.start();
search.start();

That’s it! We have setup our autocomplete multi-index search experience. You should be able to select a suggestion from the autocomplete and use this suggestion to search into the main index.

A common pattern with an autocomplete of suggestions is to display the relevant categories along with the suggestions. Then when a user select a suggestion both the suggestion and the associated category are used to refine the search. For this example the relevant categories are stored on the suggestions records. We have to update our render function to display the categories with the suggestions. For simplicity and brevity of the code we assume that all suggestions have categories, but this is not the case in the actual dataset. Take a look at the complete example to see the actual implementation.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
container.find('select').selectize({
  // ...
  render: {
    option(item) {
      const [category] = item.instant_search.facets.exact_matches.categories;

      return `
        <div class="option">
          ${item.query} in <i>${category.value}</i>
        </div>
      `;
    },
  },
});

Now that we are able to display the categories we have to be able to find them when a suggestion is selected. To do so we are gonna use the data-* attributes API available the DOM elements. We’ll store the category on the suggestion element to be able to find it back when the suggestion is selected.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
container.find('select').selectize({
  // ...
  render: {
    option(item) {
      const [category] = item.instant_search.facets.exact_matches.categories;

      return `
        <div class="option" data-category="${category.value}">
          ${item.query} in <i>${category.value}</i>
        </div>
      `;
    },
  },
});

The last step is to retrieve the value stored on the element once the suggestion is selected. We can do that inside the onChange callback. We use the same strategy than the query to provide the category back to the main instance.

1
2
3
4
5
6
7
8
9
10
container.find('select').selectize({
  // ...
  onChange(value) {
    refine(value);
    onSelectChange({
      category: this.getOption(value).data('category'),
      query: value,
    });
  },
});

The last section of the guide explain how to use the related catogories on the main search. The first step is to create a virtual widget. This widget is only used for filtering and it doesn’t render anything on the page (hence the name “virtual”). It’s handy when you have a situation where you have to manualy manipulate the search state. In that case this is excactly what we want: refine a category without using a built-in widgets like menu or refinementList.

1
2
3
4
5
6
7
8
9
10
11
const virtualRefinementList = instantsearch.connectors.connectRefinementList(
  () => null
);

// ...

search.addWidgets([
  virtualRefinementList({
    attribute: 'categories',
  })
]);

Now we can manually update the search state without worrying about the setup for the facets - the virtual widget handle this logic for us. The final step is to apply the selected category to the search. We can do that inside the onSelectChange callback provided to the autocomplete component. We leverage the helper to update the search parameters.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
suggestions.addWidgets([
  autocomplete({
    container: $('#autocomplete'),
    onSelectChange({ query, category }) {
      search.helper
        .setQuery(query)
        .removeDisjunctiveFacetRefinement('categories');

      if (category) {
        search.helper.addDisjunctiveFacetRefinement('categories', category);
      }

      search.helper.search();
    },
  })
]);

That’s it! Now when a suggestion is selected both the query and the category are applied to the main search. You can find the complete source code of the example on GitHub.

Did you find this page helpful?