[DajSięPoznać#11] Elasticsearch scoring, system rekomendacji

Wstęp

Przeszukiwanie dużych zbiorów danych w oparciu o filtry boolowskie ma swoje wady. Może się na przykład tak zdarzyć, że zaznaczymy iż interesują nas oferty do 350 000, do 50 m^2 i blisko centrum. Tymczasem niefortunnie okaże się, że najlepiej pasująca nam oferta ma wartość 350 001 i nasz filtr ją pominie. Dlatego warto rozważyć alternatywny sposób selekcji wyników: w oparciu o scoring.

Scoring w Elasticsearch

Zadaniem mechanizmów FTS jest nie tylko filtrowanie wyników względem podanych kryteriów. Bardzo często oczekujemy od nich również, by sortowały wyniki pod względem “odpowiedniości” do zapytania. Dlatego właśnie każdy zwrócony przez Elasticsearch-a wynik zawiera także parametr score.

Podczas wyszukiwania pełnotekstowego na score ma wpływ kilka czynników, takich jak:

  • liczba wystąpień frazy w tekście
  • rzadkość pojawiania się frazy we wszystkich dokumentach
  • wielkość pół w których wystąpiła fraza (im mniejsze tym lepiej)
  • boost, a więc parametr który możemy ustawić przy danym polu lub frazie

Formuła TF/IDF jest całkiem fajnie opisana na stronach z oficjalną dokumentacją.

Wartości numeryczne i funkcje rozkładu

Wymienione we wstępie parametry (odległość od zadanego punktu, cena, powierzchnia) są przykładami wartości numerycznych, dla których najlepiej byłoby móc zdefiniować własny score. Mamy możliwość oskryptowania funkcji scoringowej w groovy, pythonie czy javascripcie, ale jest to niewygodne, a także niezgodne z filozofią Elasticsearch, według której zapytania tworzymy poprzez specjalny DSL oparty o format JSON.

Inną, bardziej wygodną opcją jest użycie zapytania typu function_score.  W zapytaniu takim możemy dla różnych pól (wymiarów wyszukiwania) wskazać wartości oczekiwane, wagi reprezentujące ważność tych pól a także funkcje rozkładu.

Mamy do dyspozycji rozkład Gaussa, eksponencjalny oraz liniowy. Poniższy rysunek (ze strony elasticsearch) dobrze obrazuje znaczenie poszczególnych parametrów:

elas_1705

Origin oznacza wartość oczekiwaną, natomiast jeżeli nie chcemy przekreślać okolicznych wartości, możemy odpowiednio dobrać offset (np. jako 10% ceny akceptowane przez użytkownika w obie strony). Zmiennymi scale i  decay można zdefiniować zakres tolerancji, jaki nas interesuje.

Oczywiście w tym przypadku im wyższy score (będący sumą składowych), tym bliżej jesteśmy oczekiwanego przez nas wyniku.

 Implementacja

Endpoint do wyliczania rekomendacji wygląda następująco:

member x.Get([<FromUri>]query:RecommendationsQueryModel) = 
    let client = GetElasticClient("adverts")
    let req = new SearchRequest()

    let score = new Nest.FunctionScoreQuery()
    let functions = new List<IScoreFunction>()

    GeoDistanceQuery(query.lat, query.lon) |> functions.Add
    PriceGaussQuery(query.price) |> functions.Add
    AreaLinearQuery(query.area) |> functions.Add
    score.Functions <- functions
   
    req.Query <- new QueryContainer(score)

    let result = client.Search<AdvertMetadata>(req).Hits.ToArray() 
                    |> Array.map(fun hit -> {AdvertMetadata = hit.Source; 
                                                Score = hit.Score})

    x.Ok(result) :> IHttpActionResult

 

Poza oryginalnymi dokumentami zwracany jest także do klienta Score, dlatego korzystamy z property Hits, zamiast Documents jak w poprzednich wypadkach.

Budowa funkcji rozkładu Gaussa dla ceny wygląda następująco:

let PriceGaussQuery(price) =         
    let priceGauss = new Nest.GaussDecayFunction()

    priceGauss.Field <- CreateNameField("TotalPrice")
    priceGauss.Offset <- new Nullable<float>(0.1 * price)
    priceGauss.Scale <- new Nullable<float>(0.25 * price)
    priceGauss.Origin <- new Nullable<float>(0.95 * price)
    priceGauss.Weight <- new Nullable<float>(2.0) 

    priceGauss

 

Wartość oczekiwana celowo jest przesunięta o 5% w stronę niższych wartości tak, by promować oferty tańsze. Waga ustawiona na wartość 2 ma znaczenie tylko względem pozostałych funkcji rozkładu, gdyż porównywanie score’ów ma sens tylko w obrębie pojedynczego zapytania.

Odległości geograficzne

Kolejną świetną cechą Elasticsearcha jest wbudowane wsparcie dla typu geo_point. Oczywiście standard JSON nie definiuje takiego typu, dlatego podczas zakładania indeksu (budowy mappingu) musimy wskazać, które pole ma być w ten sposób interpretowane. Dodatkowo w polu tym musi znajdować się określona struktura danych (np. obiekt z propercjami lat i lon reprezentującymi współrzędne geograficzne).

Pamiętając, że ziemia jest elipsoidą obrotową, dokładną odległość pomiędzy parą współrzędnych wyliczamy często za pomocą formuły “Haversine”.  Wzór nie jest może jakoś bardzo skomplikowany, ale Elasticsearch daje wbudowane wsparcie dla tego typu operacji. Przy definicji funkcji rozkładu wystarczy podać promienie offsetu i skali wraz z jednostkami.

let GeoDistanceQuery(lat, lon) =         
    let distGauss = new Nest.GaussGeoDecayFunction()

    let offset = new Nest.Distance("2km")
    let scale = new Nest.Distance("5km")
    let loc = new Nest.GeoLocation(lat, lon)

    distGauss.Field <- CreateNameField("Location")
    distGauss.Offset <- offset
    distGauss.Scale <- scale
    distGauss.Origin <- loc 
    distGauss.Weight <- new Nullable<float>(10.0)    

    distGauss
Advertisements

One thought on “[DajSięPoznać#11] Elasticsearch scoring, system rekomendacji

  1. Pingback: [DajSięPoznać] Podsumowanie | When the smoke is going down

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s