[DajSięPoznać#9] Elasticsearch: wyszukiwarka jak google, trendy jak na twitterze

Wstęp

W jednym z pierwszych postów opisywałem, jak zbudować inteligentną wyszukiwarkę, odporną na literówki użytkowników. Tym razem również zbudowane zostanie API do przeszukiwania pełnotekstowego, ale znacznie bogatsze z dużym naciskiem na wydajność. Dodatkowo opisywany również wcześniej bucketing można wykorzystać do zbudowania analogicznej funkcjonalności jak trendy na twitterze, a więc monitorowanie tego, które słowa często pojawiają się w treści w ostatnim czasie.

Filtry w Elasticsearch

Filtry pozwalają na wielowymiarowe zawężanie zbioru zwracanych danych. Zazwyczaj są to operacje bardzo szybkie w porównaniu do wyszukiwania pełnotekstowego, dlatego warto je zamieścić przed tą operacją tak, by maksymalnie przefiltrować zbiór danych do kolejnych operacji. Elasticsearch bardzo dobrze cache’uje wyniki poszczególnych filtrów, dlatego jeśli mamy złączone kilka takich filtrów, to warto na początku stosować te często się powtarzające (np. filtrowanie po dzielnicy). Parametry zapytania FTS wyglądają następująco:

[<AllowNullLiteral>]
type FtsQueryModel() =
    member val q:string = null with get, set
    member val dateFrom:string = null with get, set
    member val priceFrom = defaultof<float> with get, set
    member val priceTo = defaultof<float> with get, set
    member val pricePerMeterFrom = defaultof<float> with get, set
    member val pricePerMeterTo = defaultof<float> with get, set
    member val district:string = null with get,set
    member val street:string = null with get,set
    member val positiveFields:string = null with get,set
    member val negativeFields:string = null with get,set

Oprócz pierwszego property, które jest frazą wpisaną w wyszukiwarkę, pozostałe parametry (każdy opcjonalny) zostaną przetransformowane w różne typy filtrów, odpowiednio będą to:

  • DateRangeQuery (np. przeszukuj tylko dane z ostatniego miesiąca)
  • NumericRangeQuery (np. ceny tylko poniżej 300 000 PLN)
  • TermQuery (filtrowanie porównujące, np. po nazwie dzielnicy)

Wszystkie filtry są łączone w BoolQuery:

let BuildQuery(model:FtsQueryModel) = 
    let req = new SearchRequest()
    let boolQuery = new Nest.BoolQuery()
    let filters = new List<QueryContainer>()

    if not(model.district = null) then 
        CreateTermsQuery(model.district, "District") |> filters.Add

    if not(model.street = null) then
        CreateTermsQuery(model.street, "Street") |> filters.Add

    CreateRangeQueries(model.priceFrom, model.priceTo, "TotalPrice") |> filters.AddRange
    CreateRangeQueries(model.pricePerMeterFrom, model.pricePerMeterTo, "PricePerMeter") |> filters.AddRange

    if not(model.dateFrom = null) then
        CreateDatesRangeFilter(model.dateFrom, "CreationDate") |> filters.Add

    if not(model.positiveFields = null) then
        CreateBooleanQueries(model.positiveFields, true) |> filters.AddRange

    if not(model.negativeFields = null) then
        CreateBooleanQueries(model.negativeFields, false) |> filters.AddRange

    if not(model.q = null) then
        CreateMultiMatchQuery(model.q) |> filters.AddRange

    boolQuery.Must <- filters        
    req.Query <- new QueryContainer(boolQuery)
    req

Przykładowo filtr numeryczny budowany jest z użyciem połączonych konstrukcji F#: Pattern Matching i Discriminated Union (będącej rozbudowanym, parametryzowalnym enumem z C#).

type FilterInequality =
   | LTE of value: float
   | GTE of value: float

let CreateRangeQuery(inequality: FilterInequality, name) = 
    let range = new Nest.NumericRangeQuery()
    match inequality with
            | GTE(value) ->  range.GreaterThanOrEqualTo <- new Nullable<float>(value) 
            | LTE(value) ->  range.LessThanOrEqualTo <- new Nullable<float>(value)  

    range.Field <- CreateNameField(name)
    new QueryContainer(range)

 

Full Text Search po wielu polach

Ostatnia sekwencja filtrów to zapytanie prefixowe po wielu polach. Ponieważ fraz wpisanych przez użytkownika może być wiele, budujemy kolekcję filtrów typu MultiMatch połączonych operatorem And.

let CreateQueryForToken(token:string) = 
    let multiMatch = new Nest.MultiMatchQuery()
    multiMatch.Operator <- new Nullable<Nest.Operator>(Operator.And)
    multiMatch.Type <- new Nullable<Nest.TextQueryType>(TextQueryType.PhrasePrefix)    
    let fields = [|"Title"; "Description"|]
              
    multiMatch.Fields <- Nest.Fields.op_Implicit(fields)
    multiMatch.Query <- token
    new QueryContainer(multiMatch)

 

Trendy

Pola tekstowe przy budowie indeksu domyślnie poddawane są analizie. W trakcje analizy następuje przetworzenie tekstu na tokeny (np. rozdzielenie tekstu po białych znakach), transformacja tokenów (np. lowercase), a także ich filtracja. Za ten proces odpowiada analyzer i podczas budowy indeksu możemy przypisać któryś z wbudowanych analyzerów do danego pola z modelu danych lub zbudować swój.

W takim razie trendy można uzyskać bardzo prosto poprzez przefiltrowanie zbioru dokumentów (np. po dacie czy lokalizacji jak to robi twitter) i wykonanie operacji bucketingu na takim polu tekstowym. Otrzymamy wtedy listę najczęściej występujących tokenów. Problem polega na tym, że taki indeks będzie zawierał sporo danych o niskiej wartości semantycznej, np. wyrazy dwuznakowe czy też oczywiste w tym kontekście słowa typu Kraków czy mieszkanie, dlatego przy budowie indeksu można założyć własny analyzer filtujący tokeny poniżej 3 znaków i kilka innych predefiniowanych.

{
  "settings": {
      "analysis": {
      "tokenizer": "standard",
      "filter": {
        "custom-stopwords": {
          "type": "stop",
          "stopwords": [
            "Kraków",
            "mieszkanie"
          ]
        },
        "min-length-3": {
          "type": "length",
          "min": 3
        }
      },
      "analyzer": {
        "trends-analyzer": {
          "type": "custom",
          "tokenizer": "standard",
          "filter": [
            "lowercase",
            "min-length-3",
            "custom-stopwords"
          ]
        }
      }
    }
  }
}
Advertisements

One thought on “[DajSięPoznać#9] Elasticsearch: wyszukiwarka jak google, trendy jak na twitterze

  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