Advanced product prioritization in Search & Navigation

Sven-Erik Jonsson 2024-01-18 11:54:12

During development of a product prioritization feature I encountered some limitations in what can be done with the built in provided score boosting. Given a requirement that the customer wants to set a level of prioritization as a value on the product itself, the only viable option is using boosting by filters. This leaves using the .BoostMatching() extension that is using the underlying CustomFiltersScoreQuery as the only viable option, leading to an implementation with some method chaining.

search.BoostMatching(x => x.ReviewRatingValue.InRange(3.0, 3.9999), 3)
      .BoostMatching(x => x.ReviewRatingValue.InRange(4.0, 4.4999), 4)
      .BoostMatching(x => x.ReviewRatingValue.InRange(4.5, 5.0), 5);

This implementation serves no other purpose than to illustrate that the resulting scoring becomes rough, as you explicitly have to state every filter that you want to have influence the resulting document scores. This lead me to explore if there where other ways to use ScoreQueries to achieve more control.

Function score query

As Search & Navigation uses ElasticSearch for querying I had a look at what other functionality is available in the ElasticSearch documentation and found a feature not available in EPiServer.Find, namely the Field Value factor function.

The field_value_factor function allows you to use a field from a document to influence the score. It’s similar to using the script_score function, however, it avoids the overhead of scripting. If used on a multi-valued field, only the first value of the field is used in calculations.

This seems to fit our customers needs perfectly, as we can let the value of ReviewRatingValue drive the score, and even precalculate a scaled value based on additional factors such as votes.

Implementation

Create FieldValueFactorScoreFunction.cs

using EPiServer.Find.Api.Querying.Queries;
using Newtonsoft.Json;

namespace Customer.Web.Infrastructure.Find.Queries;

[JsonConverter(typeof(FieldValueFactorScoreFunctionConverter))]
public class FieldValueFactorScoreFunction : ScoreFunction
{
    public string Field { get; }
    public double? Factor { get; set; }
    public double Missing { get; set; } = 1;

    public FieldValueModifier Modifier { get; set; } = FieldValueModifier.None;

    public FieldValueFactorScoreFunction(string field)
    {
        Field = field;
    }
}

Create FieldValueModifier.cs

using EPiServer.Find.Api.Querying.Queries;
using Newtonsoft.Json;

namespace Customer.Web.Infrastructure.Find.Queries;

[JsonConverter(typeof(EnumLowercaseConverter))]
public enum FieldValueModifier
{
    None,
    Log,
    Log1p,
    Log2p,
    Ln,
    Ln1p,
    Ln2p,
    Square,
    Sqrt,
    Reciprocal
}

Create FieldValueFactorScoreFunctionConverter.cs

using EPiServer.Find.Api.Querying.Queries;
using EPiServer.Find.Helpers;
using Newtonsoft.Json;
using System;

namespace Customer.Web.Infrastructure.Find.Queries;

public class FieldValueFactorScoreFunctionConverter : JsonConverter
{
    private readonly EnumLowercaseConverter _enumConverter;

    public FieldValueFactorScoreFunctionConverter()
    {
        _enumConverter = new EnumLowercaseConverter();
    }

    public override bool CanConvert(Type objectType)
    {
        return objectType.IsAssignableFrom(typeof(FieldValueFactorScoreFunction));
    }

    public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
    {
        return null;
    }

    public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
    {
        if (value.IsNot<FieldValueFactorScoreFunction>())
        {
            writer.WriteNull();
        }
        else
        {
            var query = (FieldValueFactorScoreFunction)value;

            writer.WriteStartObject();
            writer.WritePropertyName("field_value_factor");
            writer.WriteStartObject();

            writer.WritePropertyName("field");
            writer.WriteValue(query.Field);

            writer.WritePropertyName("modifier");
            _enumConverter.WriteJson(writer, query.Modifier, serializer);

            writer.WritePropertyName("missing");
            writer.WriteValue(query.Missing);

            if (query.Factor.HasValue)
            {
                writer.WritePropertyName("factor");
                writer.WriteValue(query.Factor);
            }

            writer.WriteEndObject();
            writer.WriteEndObject();
        }
    }
}

Create FindExtensions.cs

using EPiServer.Find.Api.Querying.Queries;
using EPiServer.Find;
using Skeidar.Web.Infrastructure.Find.Queries;
using System.Linq.Expressions;
using System;

namespace Customer.Web.Infrastructure.Find;

public static class FindExtensions
{
    private static ITypeSearch<TSource> BoostByValue<TSource>(
            this ITypeSearch<TSource> search,
            Expression<Func<TSource, object>> expression,
            double? factor = 1.0,
            double missing = 1.0,
            FieldValueModifier modifier = FieldValueModifier.None)
    {
        var conventions = search.Client.Conventions;

        var fieldName = conventions.FieldNameConvention.GetFieldName(expression);
        var scoreFunction = new FieldValueFactorScoreFunction(fieldName)
        {
            Factor = factor,
            Missing = missing,
            Modifier = modifier
        };

        return new Search<TSource, FunctionScoreQuery>(search, (context) =>
        {
            var query = new FunctionScoreQuery(context.RequestBody.Query ?? new MatchAllQuery());

            query.Functions.Add(scoreFunction);

            context.RequestBody.Query = query;
        });
    }
}

 

Using the extension looks like this:

search.BoostByValue(x => x.ReviewRatingValue);

Final thoughs

Going beyond, scaling could be applied based on the number of votes the product has applied so that products with only a few positive votes don't float to the top immediately, the extension method could be extended so that multiple fields can be boosted with a ScoreMode applied. There are powerful possibilities. Try it out.

 

Want to find out how this can be applied in your solution? 

Boka in ett möte i kalendern