Faceted search

Faceted search has been possible to do in Relatude since Lucene shipped with Relatude, but it involved a lot of low level lucene code, so it was rarely used. With release 4.8 of Relatude, we have a new faceted search API that is much easier to use.

Faceted search

"Faceted search, also called faceted navigation or faceted browsing, is a technique for accessing information organized according to a faceted classification system, allowing users to explore a collection of information by applying multiple filters." 

Faceted search is a very user friendly way of exploring and finding exactly the content you need. When you do a free-text search, you can specify a number of facets that you want users to be able to filter the search results on. For each of the facets, the search returns a one or more facet values, each with a facet count for each of the facets. An example facet can be content class, and a facet hit can be Article. Example:

 facets

 

The API builds on the existing search API. With the introduction of faceted search and synonym/dictionary support a new AdvancedIndexQuery object was introduced. The constructor takes the existing IndexQuery object as a parameter. So you define most of the search code in the same way as before, but with a few additions.

Set the properties to facet

This works in the same way as how you limit a search to certain content classes in a regular search query. You create a list of property ids of the properties you want to facet on. 

AdvancedIndexQuery <HierarchicalContent> advancedQuery = new AdvancedIndexQuery<HierarchicalContent >(indexQuery);
List<int > propertiesToFacetOn = new List< int>();
propertiesToFacetOn.Add( ProductBase.PropertyIdProductCategory);
propertiesToFacetOn.Add( ProductBase.PropertyIdManufacturer);
advancedQuery.PropertiesToFacetOn = propertiesToFacetOn;

The properties specified here must have been added to the search index as a separate column. This can be enabled in the settings of the property in the ontology module. For more information see this article.

Enable faceted search

The AdvancedIndexQuery have a property called, wait for it, "EnableFacetedSearch".

Decide how to choose which filters to show

There are several different ways on how you can calculate, show facets and facet values. Relatude supports the 3 most common methods of doing this: 

Show all facet values for active facets
If the users selects one of the facet hits to filter the results, this option means that the selected filter will be shown as highlighted, but all other filters for that facet will still be shown. Example: If a user selects SystemUser as filter, all other content classes will be shown, so you can filter on more than one content class. 

Show only selected facet value for active facet
If the users selects one of the facet hits to filter the results, this option means that the selected filter is the only filter shown for that facet. Example: If a user selects SystemUser as filter, other content classes will not be shown. This means that you can only have one filter per facet. 

Show all facet values for all facets
If the users selects one of the facet hits to filter the results, this option means that both the selected filter(s) and all the unselected filters are shown for that facet. Example: If a user selects SystemUser as filter, other content classes will also be shown. All the other facet values for the other facet values will also be shown.

Set the facet filters

When the user selects a facet value to filter on, you need to add that filter to the search query. And in many situations, there are more than one selected facet value. Add all the filters to a Dictionary<string,string> , and set the FacetFilters property on the AdvancedIndexQuery.

Execute search query

When using the AdvancedIndexQuery object, you must use the WAFContext.Session.SearchAdvanced method. This method returns a an object of type SearchResultSet<T>.

SearchResultSet<HierarchicalContent> results = WAFContext.Session.SearchAdvanced<HierarchicalContent >(advancedQuery);

Render Facet Search Results

The SearchResultSet<T> object has a property called "FacetResults", which contains the facets and facet values for the new search query. These elements must be rendered in the page to provide users with the updated filters to  further refine the search results. This involves writing a header with the facet name, and then a list of the facet values, with the facet count for each value. The hard part of this is getting the links correct, depending on how you want it to work. 

Render regular search results

The regular search results are handled as before. No need to think about facets at all.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.UI;
using System.Web.UI.WebControls;
using WAF.Engine;
using System.Text;
using WAF.Presentation.Web;
using WAF.Engine.Content.Native;
using WAF.Engine.Content.MySite;
using WAF.Engine.Search;
using WAF.Engine.Search.Facets;

public partial class Templates_SearchPage : System.Web.UI.Page {
    public string SearchTerm {
        get;
        set;
    }
    protected void Page_Load(object sender, EventArgs e) {
        StringBuilder sb = new StringBuilder();
        Site site = WAFContext.Engine.SystemSession.GetSite();       
        
        List<DefCulture> cultures = site.Cultures.Get();
        foreach (DefCulture culture in cultures) {
            sb.AppendLine(culture.Name + " - " + culture.LocaleID);
            sb.AppendLine("<br>");
        }
        litTest.Text = sb.ToString();

        int currentPage = 0;
        if (!string.IsNullOrEmpty(Request["q"])) {
            SearchTerm = Request["q"];
        }
        if (Request["page"] != null) {
            int.TryParse(Request["page"].ToString(), out currentPage);
        }
        if (!IsPostBack && !string.IsNullOrEmpty(SearchTerm)) {
            DoSearch(SearchTerm, currentPage);
        }
    }

    protected void btnSearch_Click(object sender, EventArgs e) {
        Response.Redirect(WAFContext.GetUrl(WAFContext.Request.NodeId, "q=" + HttpUtility.UrlEncode(txtSearchTerm.Text)),false);
        Context.ApplicationInstance.CompleteRequest();
    }

    private void DoSearch(string searchTerm, int currentPage) {
        int totalCount = 0;
        if (currentPage > 0) currentPage--;
        int pageCount = 0;
        IndexQuery<HierarchicalContent> indexQuery = new IndexQuery<HierarchicalContent>();
        indexQuery.IncludeDeletedVersions = false;
        indexQuery.IncludeUnPublishedNodes = false;
        indexQuery.PageSize = 5;
        indexQuery.AllowLeadingWildcard = false;
        indexQuery.AutoWildcardsLeading = false;
        indexQuery.PageIndex = currentPage;
        indexQuery.ProtectedContent = false;
        List<int> classes = new List<int>();
        classes.Add(ShoeProduct.ContentClassId);
        classes.Add(ProductBase.ContentClassId);


        indexQuery.BodySearch = searchTerm;
        indexQuery.AutoWildcards = true;
        indexQuery.ClassIds = classes;


        AdvancedIndexQuery<HierarchicalContent> advancedQuery = new AdvancedIndexQuery<HierarchicalContent>(indexQuery);
        List<int> propertiesToFacetOn = new List<int>();
        propertiesToFacetOn.Add(ProductBase.PropertyIdProductCategory);
        propertiesToFacetOn.Add(ProductBase.PropertyIdManufacturer);
        advancedQuery.PropertiesToFacetOn = propertiesToFacetOn;
        advancedQuery.EnableFacetedSearch = true;
        advancedQuery.FacetFilterOption = FacetFilterOptions.ShowAllFacetValuesForActiveFacets;
        advancedQuery.CustomLuceneFieldsToFacetOn = new List<string>() { "Class" };

        Dictionary<string, string> facetFilters = new Dictionary<string, string>();
        if (Request.QueryString["ProductCategory"] != null) {
            string catFilter = Request.QueryString["ProductCategory"];
            facetFilters.Add("ProductCategory", catFilter);
        }
        if (Request.QueryString["Manufacturer"] != null) {
            string manufacturerFilter = Request.QueryString["Manufacturer"];
            facetFilters.Add("Manufacturer", manufacturerFilter);
        }
        if (Request.QueryString["Class"] != null) {
            string catFilter = Request.QueryString["Class"];
            facetFilters.Add("Class", catFilter);
        }
        advancedQuery.FacetFilters = facetFilters;

        SearchResultSet<HierarchicalContent> results = WAFContext.Session.SearchAdvanced<HierarchicalContent>(advancedQuery); //WAFContext.Session.Search(indexQuery, out pageCount, out totalCount);

        FacetSearchResults facetResults = results.FacetResults;
        StringBuilder sbFacets = new StringBuilder();
        if (facetResults.FacetResults.Count > 0) {
            sbFacets.AppendLine("<ul class=\"facet-filter-container\">");
            foreach (FacetResult res in facetResults.FacetResults) {
                sbFacets.AppendLine("<li class=\"facet-filter\">" + res.FacetPropertyFriendlyName + " " + GetFacetHits(res, searchTerm, facetFilters) + "</li>");
            }
            sbFacets.AppendLine("</ul>");
        } else {
            sbFacets.AppendLine("<p style=\"color:Red;\">No facet results!!!</p><br>");
        }
        litFacets.Text = sbFacets.ToString();
        totalCount = results.TotalCount;

        StringBuilder sb = new StringBuilder();
        foreach (SearchResult<HierarchicalContent> result in results.Hits) {
            sb.AppendLine("<div style=\"margin-bottom:20px;\">");
            sb.AppendLine("<h3><a href=\"" + HttpUtility.HtmlAttributeEncode(WAFContext.GetUrl(result.NodeId)) + "\">" + HttpUtility.HtmlEncode(result.Name) + "</a></h3>");
            sb.AppendLine("<p>");
            sb.AppendLine(result.FormatSample(result.Body, 255, "<b>", "</b>"));
            sb.AppendLine("<div class=\"item-list-website-link\">Relevance: " + result.ScorePercentage + "%</div></p>");
            sb.AppendLine("</div>");
        }
        litResults.Text = sb.ToString();
        StringBuilder sbPaging = new StringBuilder();
        if (pageCount > 0) {
            sbPaging.Append("<b>Page " + (currentPage + 1) + " of " + pageCount + "</b><br />");
            if (currentPage > 0) {
                //show previous page link
                sbPaging.Append("<a href=\"" + WAFContext.GetUrl(WAFContext.Request.NodeId, "page=" + currentPage + "&q=" + searchTerm) + "\">Previous</a>");
            }
            if (pageCount > (currentPage + 1)) {
                //show next page link
                sbPaging.Append("&nbsp;&nbsp;<a href=\"" + WAFContext.GetUrl(WAFContext.Request.NodeId, "page=" + (currentPage + 2) + "&q=" + searchTerm) + "\">Next</a>");
            }
        }
        if (results.Hits.Count == 0) {
            litResults.Text = "No results!";
        }
        litPaging.Text = sbPaging.ToString();
        txtSearchTerm.Text = searchTerm;
    }
    private string GetFacetHits(FacetResult facetResult, string searchTerm, Dictionary<string, string> facetFilters) {
        StringBuilder sb = new StringBuilder();
        sb.AppendLine("<ul>");
        foreach (FacetHit hit in facetResult.FacetHits) {
            List<string> valuesOfSelectedFacetProp = new List<string>();
            if (facetFilters.Keys.Contains(facetResult.FacetPropertyCodeName)) {
                valuesOfSelectedFacetProp = facetFilters[facetResult.FacetPropertyCodeName].Split(',').ToList();
            }
            string facetName = hit.FacetName;
            if (facetResult.FacetPropertyCodeName == "Class") facetName = hit.FacetName.Substring(hit.FacetName.LastIndexOf(".")+1);
            if (facetFilters.Keys.Contains(facetResult.FacetPropertyCodeName) && valuesOfSelectedFacetProp.Contains(hit.FacetName)) {
                string valuesExceptCurrent = string.Join(",",valuesOfSelectedFacetProp.Select(s => s).Except(new[] { hit.FacetName }).ToArray());
                if (string.IsNullOrEmpty(valuesExceptCurrent)) {
                    sb.AppendLine("<li><a href=\"" + WAFContext.GetUrl(WAFContext.Request.NodeId, GetExistingFacetFiltersQueryString(facetFilters, facetResult.FacetPropertyCodeName) + "&q=" + searchTerm) + "\"><b>" + facetName + " (" + hit.HitCount + ")</b></a></li>");
                } else { 
                    sb.AppendLine("<li><a href=\"" + WAFContext.GetUrl(WAFContext.Request.NodeId, GetExistingFacetFiltersQueryString(facetFilters, "", facetResult.FacetPropertyCodeName, valuesExceptCurrent) + "&q=" + searchTerm) + "\"><b>" + facetName + " (" + hit.HitCount + ")</b></a></li>");
                }
            } else {
                if (facetFilters.Keys.Contains(facetResult.FacetPropertyCodeName)) {
                    //filter is already used. Add facet to filter.     
                    string oldValue = facetFilters[facetResult.FacetPropertyCodeName];
                    string newValue = oldValue + "," + hit.FacetName;
                    sb.AppendLine("<li><a href=\"" + WAFContext.GetUrl(WAFContext.Request.NodeId, GetExistingFacetFiltersQueryString(facetFilters, "", facetResult.FacetPropertyCodeName, newValue) + "&q=" + searchTerm) + "\">" + facetName + " (" + hit.HitCount + ")</a></li>");
                } else {
                    sb.AppendLine("<li><a href=\"" + WAFContext.GetUrl(WAFContext.Request.NodeId, facetResult.FacetPropertyCodeName + "=" + hit.FacetName + GetExistingFacetFiltersQueryString(facetFilters, "") + "&q=" + searchTerm) + "\">" + facetName + " (" + hit.HitCount + ")</a></li>");
                }
            }
        }
        sb.AppendLine("</ul>");
        return sb.ToString();
    }

    private string GetExistingFacetFiltersQueryString(Dictionary<string, string> facetFilters, string excludedFilterKey, string keyToUpdateValue = "", string updatedValue = "") {
        StringBuilder sb = new StringBuilder();
        if (excludedFilterKey == null) excludedFilterKey = "";
        foreach (KeyValuePair<string, string> filter in facetFilters) {
            if (filter.Key != excludedFilterKey) {
                sb.Append("&");
                if (filter.Key == keyToUpdateValue) {
                    sb.AppendLine(filter.Key + "=" + updatedValue);
                } else {
                    sb.AppendLine(filter.Key + "=" + filter.Value);
                }
            }
        }
        return sb.ToString();
    }

}
<h1>
            Search page</h1>
            <asp:Panel runat="server" ID="pnlSearchForm" DefaultButton="btnSearch">
                <asp:Literal runat="server" ID="litTest" EnableViewState="false"></asp:Literal>
        <p>
            <asp:TextBox runat="server" ID="txtSearchTerm" EnableViewState="false"></asp:TextBox><asp:Button runat="server" ID="btnSearch" Text="Search" OnClick="btnSearch_Click" />
        </p>
        </asp:Panel>
        <br /><br />
        <asp:Literal runat="server" ID="litFacets" EnableViewState="false"></asp:Literal>
        <p>
            <asp:Literal runat="server" ID="litResults" EnableViewState="false"></asp:Literal></p>
        <p>
            <asp:Literal runat="server" ID="litPaging" EnableViewState="false"></asp:Literal></p>

Note that the example above is meant to demonstrate the API, and must not be seen a an example of best-practice regarding how to structure code etc.