Tuesday, March 15, 2016

Sitecore faceting search results based on top level site architecture

Many web sites will have a structure/site architecture which contains a number of top level categories of which each contains the relevant pages/content. When a user is searching that same web site, it can be a useful feature to have a facet which allows the user to narrow down the results by category (aka the top level architecture).

An example of this would be the following diagram:


It's a simple example, yet illustrates a site with three categories at the top level (about, products and services). The goal here would then to have a feature on the search results page that allows faceting on these categories (dynamically). So if a given search does not have results available under about, that category won't show and you also see a results count for available categories.


 This is actually quite simple to implement in Sitecore using Lucene and improves the user experience.

Indexing the category

To allow us to facet on this category, we will need to create a computed index field for Lucene. This field will store the top level category as text (rather than an item GUID).
namespace MyProject.ComputedIndexFields
{
    /// <summary>
    /// Index the main level section the item falls under
    /// </summary>
    public class IndexCategory : IComputedIndexField
    {
        /// <inheritdoc />
        public string FieldName { get; set; }
        /// <inheritdoc />
        public string ReturnType { get; set; }

        /// <inheritdoc />
        public object ComputeFieldValue(IIndexable indexable)
        {
            Item item = indexable as SitecoreIndexableItem;

            if (item != null && !item.Paths.IsMediaItem)
            {
                try
                {
                    if (item.ParentID == SearchConstants.rootItem)
                    {
                        if (item.TemplateID == SearchConstants.landingTemplate)
                        {
                            // landing page item - return itself
                            return item.DisplayName;
                        }
                        else
                        {
                            // Root level item which is not landing page
                            return null;
                        }
                    }

                    return RecursiveParent(item);
                }
                catch (Exception)
                {
                    return null;
                }
            }
           
            return null; // Return null if nothing to index
        }

        private static string RecursiveParent(Item currentItem)
        {
            if (currentItem.ParentID == SearchConstants.sitecoreItem)
            {
                // went too far
                return null;
            }

            if (currentItem.ParentID == SearchConstants.rootItem)
            {
                // parent item is the root, return current item
                return currentItem.DisplayName;
            }

            return RecursiveParent(currentItem.Parent);
        }
    }
}
public static class SearchConstants
{
  public static ID rootItem = new ID("{A7CDAD31-BE99-4E67-A86C-2D1792F5564A}"); // Home item
  public static ID sitecoreItem = new ID("{11111111-1111-1111-1111-111111111111}"); // Top level item in tree (Sitecore)
  public static ID landingTemplate = new ID("{A81FB275-022F-430C-8C02-A4B526F8981A}"); // landing page template ID
}
<fieldMap type="Sitecore.ContentSearch.FieldMap, Sitecore.ContentSearch">
  <fieldNames hint="raw:AddFieldByFieldName">
    !-- Category (root parent item) for faceting-->
    <field fieldName="categoryfacet" storageType="YES" indexType="TOKENIZED" vectorType="NO" boost="1f" type="System.String" settingType="Sitecore.ContentSearch.LuceneProvider.LuceneSearchFieldConfiguration, Sitecore.ContentSearch.LuceneProvider">
      <Analyzer type="Sitecore.ContentSearch.LuceneProvider.Analyzers.LowerCaseKeywordAnalyzer, Sitecore.ContentSearch.LuceneProvider" />
    </field>
  </fieldNames>
</fieldMap>

<fields hint="raw:AddComputedIndexField">
  <!-- Category (root parent item) for faceting -->
  <field fieldName="categoryfacet" storageType="yes" indexType="untokenized" 
    patch:after="field[last()]">WCC.Internet.UI.ComputedIndexFields.IndexCategory, WCC.Internet.UI</field>
  </fields>
In the computed index field code, we first check to see if the current item is a child of the Sitecore home node, if so we check if the template is a landing page (this is the template for top level items). If it is a landing page we return it's display name, as this is the category it falls under, if not we return null as the page is a system page and won't appear in search.

For all other items, we use a recursive function that navigates to the parent item until we hit a landing page and then return this. There is also a check here to ensure we don't go past the top level Sitecore item in the tree (if so a null is returned).

The end result is that each item in the index (non media items of course) will have a field called categoryfacet which contains the display name for the top level item the given page falls under.

You may notice that the computed index field is actually defined twice in the search index XML, this is because we need to use the LowerCaseKeywordAnalyzer to ensure the facet name is not stored untokenized (two words for the word New Zealand for example).

Getting the facets for a given search

Now that we have indexed the content for this facet, we need to get the available facets (with counts) for a given search.
var searchIndex = ContentSearchManager.GetIndex("MySearchIndex"); // Get the search index
var searchPredicate = BuildSearchPredicate(searchRequest); // Build the search predicate 

using (var searchContext = searchIndex.CreateSearchContext()) // Get a context of the search index
{
    var searchResults = searchContext.GetQueryable<SearchModel>().Where(searchPredicate); // Search the index for items which match the predicate

    var searchFacets = searchContext.GetQueryable<SearchModel>().Where(searchPredicate).FacetOn(x => x.Category).GetFacets();

    // Category facets
    var categoryFacets = searchFacets.Categories.Where(x => x.Name == "categoryfacet").FirstOrDefault(); // Get the custom categoryfacet facet list

    if (categoryFacets != null)
    {
        foreach (var facet in categoryFacets.Values.Where(x => x.Name != null))
        {
            results.CategoryFacets.Add(new SearchFacet
            {
                Count = facet.AggregateCount,
                Value = facet.Name
            });
        }
    }

    var pagedResults = searchResults.Page((searchRequest.Page - 1), searchRequest.PerPage).GetResults();
}

public class SearchFacet
{
    public string Value { get; set; }
    public int Count { get; set; }
}

public class SearchModel
{
    [IndexField("__smallupdateddate")]
    public DateTime LastUpdated { get; set; }

    [IndexField("_name")]
    public string ItemName { get; set; }

    [IndexField("_displayname")]
    public string DisplayName { get; set; }

    [IndexField("_templatename")]
    public string TemplateName { get; set; }

    [IndexField("introduction")]
    public string Introduction { get; set; }

    [IndexField("text")]
    public string Maintext { get; set; }

    [IndexField("exclude_from_search")]
    public bool ExcludeFromSearch { get; set; }

    [IndexField("page_description")]
    public string PageDescription { get; set; }

    [IndexField("categoryfacet")]
    public string Category { get; set; }

    [IndexField("customtitle")]
    public string Title { get; set; }
}
In the code example we are using predicate logic to query our search index. We use the Page function on the search results to return the first X results but it's the following line which gets us the facets for all search results:
var searchFacets = searchContext.GetQueryable<SearchModel>().Where(searchPredicate).FacetOn(x => x.Type).FacetOn(x => x.Category).GetFacets();
We then get the facet named categoryfacet (as this is our index fields name) and loop through the results (which are not null), to get each facet and a count.

Faceting the search

Once we display the category facets on the front end to the user, they can then narrow the search results by selecting which category/categories their results should appear under. The search code can then include additional logic, to only show results which fall under the selected categories:
private static Expression<Func<SearchModel, bool>> GetCategoryFacetPredicate(List<string> categories)
{
    var predicate = PredicateBuilder.True<SearchModel>(); // Items which meet the predicate

    foreach (var category in categories)
    {
        predicate = predicate.Or(x => x.Category == category);
    }

    return predicate;
}
Now the users have an additional facet to further narrow their results, which empowers them to find the information they require faster and with a better experience.

No comments:

Post a Comment