Sitecore Commerce: Elasticsearch driven Faceted Search/Navigation for eCommerce – Part 1

This will be a series of posts on how to make Elasticsearch work with Sitecore in an eCommerce scenario. Sitecore has native Lucene and Solr support and extensions from commercial vendors such as Coveo but such luck if you plan on using elasticsearch. Some attempts have been made to build a elasticsearch content search provider but that was for version 7 of sitecore.

Elasticsearch is a great search engine but quite a bit of work needs to be done to configure it to the specific use case of an eCommerce Faceted Search/Navigation scenario regardless of the CMS/Commerce Platform. In this first post we’ll be focusing on installing the Elasticsearch instance. In the following posts we’ll be defining the data mapping, inserting some test data and building a query suitable for Faceted Search/Guided Navigation and finally making Sitecore talk to Elasticsearch.

Introduction to Faceted Search/Navigation
Faceted Search/Navigation, also known as Guided Navigation is a technique which extends the traditional full-text search with a faceted navigation system. It allows the users to refine or narrow down the product search result set using filters based on faceted classification of the product records. The search and navigation user interface is built such that each incremental filtration restricts the filters further and narrows down the results quickly. The user is also shown the count of products in each of the available facet constraints. For example, if the user is searching for a T-Shirts and the T-shirts come in Red, Green, and Blue color. Here Color would be the dimension and the values (Red, Green and Blue) would be facet constraints and the user would be shown how many of the Red (20), Green (12), Blue (10) products are available. Any constraints with 0 items available would be excluded from the UI as it would lead to a zero results page.

The products have dimensions or facets defined on them such as Brand, Color, Size, Categories, Physical Dimensions, Price, etc. A faceted search system classifies each Product along multiple explicit dimensions/facets which enables the search results to be accessed and ordered in multiple ways rather than in a single, pre-determined, taxonomic order. Increasing number of modern eCommerce sites now use the faceted search/navigation pattern and it has become a well understood eCommerce user experience but proper care must be taken during information architecture design of the site to support Faceted Search.

Here is a screenshot from northface.com as an example that uses faceted navigation. The products on the northface site have category, color, size, price, and activity as facets. See the annotations on the screenshots for the facets and the constraints UI as well as the commonly used item count feature. The UI also allows sorting by Relevance, Price, Rating, etc. Also note that the display images for the search results reflect the color facet constraint (Red and Orange). We are going to need to index the product data in a certain way to be able to support this.


Installing Elasticsearch 6.4.2 (windows command line way)

Install Java
The specific version of elasticsearch we will be installing need version 9. Download and install java from http://download.oracle.com/otn/java/jdk/9.0.4+11/c2514751926b4512b076cc82f959763f/jre-9.0.4_windows-x64_bin.exe. Oracle will annoyingly make you signup and login to be able to download this.

Install Elasticsearch

  • Download the latest elasticsearch version (6.4.2 as of this writing) from https://www.elastic.co/downloads/elasticsearch
  • Unblock, Extract and Copy the downloaded files to c:\elasticsearch-6.4.2
  • Run the elasticsearch batch file from C:\elasticsearch-6.4.2\bin>elasticsearch.bat
  • Check if elasticsearch responds on port 9200 http://localhost:9200/

Install Kibana
We’ll be using the DevTools from Kibana to interact with the elasticsearch instance.

  • Download from https://www.elastic.co/downloads/kibana
  • Unblock, Extract and Copy the downloaded files to C:\kibana-6.4.2-windows-x86_64
  • This will take longer to download and extract kibana as it’s a larger download and has lot more files.
  • C:\kibana-6.4.2-windows-x86_64\bin>kibana.bat
  • Check if Kibana Responds at http://localhost:5601

Testing the Installtion

  • Open kibana from http://localhost:5601 and go to the DevTools sections
  • In the left hand pane of the Console area Type “GET /” and hit the green play button to run the query.
  • You should see the elasticsearch cluster information returned in the right hand pane.

In the next post we’ll create the index, define the Data Mapping, load some test data and construct a query suitable for our needs.

Sitecore Custom Media Request Handler to perform image name translation

Background: We have a client that uses inRiver as Product Information Management system. inRiver has a adapter for Sitecore that connects Sitecore to inRiver. The adapter works by creating and updating Sitecore items for Products, Categories, Images, etc. from the PIM. All Sitecore items created by the adapter have a name which has Ids from inRriver. For example a category image would have a name of 139442-720 and display name of 23475_CAT_0.jpg.

Problem: When referring to these images from the website we were in a way forced to use the name instead of the display name because Sitecore media request handler cannot lookup images by display name. We needed to be able to refer to the images using /-/media/inriver/23475_CAT_0.jpg instead of /-/media/inriver/139442-720

Solution: What we needed in essence is a custom Media Request Handler that would translate from the Display Name to the Item Name, retrieve the media stream and send the response to the browser. We decided to use the Solr index to do the lookup instead of using the Sitecore API for speed. We have an index that indexes the inriver media sitecore items. Now we can query this index using the DisplayName and get the Id based Item name back. Once we have the item name we can get the media using Sitecore MediaManager. After that its just a matter of copying the stream to the Response stream.

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
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
public class CustomMediaRequestHandler : MediaRequestHandler
    {
        public override void ProcessRequest(HttpContext context)
        {
            if (context.Request.Path.ToLower().Contains("/media/inriver/"))
            {
                var requestedFilename = Path.GetFileName(context.Request.Url.LocalPath);

                //we want to fallback to regular sitecore media handler if we don't need to do the translation
                if (!Regex.Match(requestedFilename, @"^\d{3,8}-\d{3,8}\.([a-z]|[A-Z]){2,4}$", RegexOptions.Compiled | RegexOptions.IgnoreCase).Success)
                {
                    var mediaItem = SearchHelper.GetMediaItemByResourceDisplayName(Path.GetFileNameWithoutExtension(requestedFilename), requestedFilename);

                    if (mediaItem == null)
                    {
                        Log.Warn("Can't find media item for url: " + context.Request.Url.LocalPath, this);
                    }

                    if (mediaItem != null)
                    {
                        Media media = MediaManager.GetMedia(mediaItem);
                        var fileExtention = Path.GetExtension(requestedFilename).ToLower();

                        context.Response.ContentType = ReturnMimeType(fileExtention);

                        if (fileExtention == ".png" || fileExtention == ".jpg" || fileExtention == ".jpeg")
                        {
                            var mediaOptions = GetMediaOptions(context);

                            using (var mediaStream = media.GetStream(mediaOptions))
                            {
                                mediaStream.CopyTo(context.Response.OutputStream);
                            }
                        }
                        else
                        {
                            using (var mediaStream = media.GetStream())
                            {
                                mediaStream.CopyTo(context.Response.OutputStream);
                            }
                        }
                        context.Response.Cache.SetCacheability(Settings.MediaResponse.Cacheability);
                        context.Response.Cache.SetMaxAge(Settings.MediaResponse.MaxAge);
                        context.Response.Flush();
                        context.Response.End();
                    }
                }
            }
            base.ProcessRequest(context);
        }

        private static MediaOptions GetMediaOptions(HttpContext context)
        {
            var mh = context.Request.QueryString.Get("mh");
            if(string.IsNullOrWhiteSpace(mh))
            {
                mh = context.Request.QueryString.Get("h");
            }

            var mw = context.Request.QueryString.Get("mw");
            if (string.IsNullOrWhiteSpace(mw))
            {
                mw = context.Request.QueryString.Get("w");
            }

            MediaOptions mo = new MediaOptions { BackgroundColor = Color.Transparent };

            if (!string.IsNullOrWhiteSpace(mh))
            {
                mo.MaxHeight = int.Parse(mh);
            }

            if (!string.IsNullOrWhiteSpace(mw))
            {
                mo.MaxWidth = int.Parse(mw);
            }
            return mo;
        }

        private string ReturnMimeType(string fileExtension)
        {
            fileExtension = fileExtension ?? string.Empty;
            switch (fileExtension.ToLower())
            {
                case ".gif":
                    return "image/gif";
                case ".png":
                    return "image/png";
                case ".tiff":
                case ".tif":
                    return "image/tiff";
                case ".jpg":
                case ".jpeg":
                    return "image/jpeg";
                case ".pdf":
                    return "application/pdf";
                case ".ai":
                case ".eps":
                    return "application/postscript";
                case ".zip":
                    return "application/zip";
                default:
                    return string.Empty;
            }
        }

    }