[DajSięPoznać#2] Elasticsearch – budowa inteligentnej wyszukiwarki

Wstęp

Elasticsearch to baza NoSql zorientowana na przetwarzanie ogromnych ilości danych, zarówno tekstowych, jak i numerycznych. Można dzięki niej zbudować wyszukiwarkę pełnotekstową a’la google. W tym projekcie będzie jeszcze wielokrotnie używany.

Jego zaletą jest dokumentowy, JSON-owy model danych, wadą dość wysoka bariera wejścia dla nowych programistów związana z mocno skomplikowanym językiem budowania zapytań (które też zresztą są JSON-ami). Dodatkowo najpopularniejszy klient .NET korzysta mocno z generyków i expression trees. Ale dla mnie ES nie jest technologią nową, choć nie łączyłem się jeszcze nigdy do niego z F#.  Zaczynamy od Nugeta.

nest

Indeks ulic

Przyda się w aplikacji wyszukiwarka ulic poprzydzielanych do dzielnic Krakowa. Listę ulic znalazłem na stronach urzędu miasta, wrzuciłem je do pliku tekstowego i napisałem prosty parser ładujący dane tekstowe do ustrukturyzowanej postaci. Sama funkcja parsująca usuwa regexami niektóre niepotrzebne dopiski:

let ignoredUnits = "^(PARK|MOST|KOPIEC|RONDO|\
                          PLANTY|BULWAR|ESTAKADA|OGRÓD|WĘZEŁ|ZAMEK|\
                          ŹRÓDŁO|SKWER|ZJAZD|Strona|Rodzaj)(.*)$"

let matchPattern = "(ULICA|ALEJA|OSIEDLE|PLAC)\s(.*)([A-ZŚ]{2})\s(.*)"
let flawedName = "(.*)(Nr.*)"
let nameWithBrackets = "(.*)(\(.*)";

member x.ParseAdministrationUnit (str: String) =
    let matchResult = Regex.Match(str, matchPattern)
    let mutable name = matchResult.Groups.[1].Value + " " + matchResult.Groups.[2].Value
    
    let flawedNameMatch = Regex.Match(name, flawedName)
    if flawedNameMatch.Groups.Count = 3 then
        name <- flawedNameMatch.Groups.[1].Value

    let nameWithBracketsMatch = Regex.Match(name, nameWithBrackets)
    if nameWithBracketsMatch.Groups.Count = 3 then
        name <- nameWithBracketsMatch.Groups.[1].Value

    {Name = name.TrimEnd();  AllocationCode = matchResult.Groups.[3].Value; District = matchResult.Groups.[4].Value}

 

Zwracany rekord będzie jednocześnie modelem danych indeksowanym w Elasticsearchu.

Sama indeksacja wygląda tak:

namespace Byteville.Core
open System
open System.IO
open System.Text.RegularExpressions
open System.Web
open Nest
type DataLoader() =

    member x.LoadAndFilterStreetsFile filePath = 
        let path = match filePath with
                    | "" -> System.Web.Hosting.HostingEnvironment.MapPath("~/Items/streets.txt")
                    | x -> x
        let matchingLines = 
            File.ReadAllLines(path)
            |> Array.filter(fun line -> not(Regex.IsMatch(line, ignoredUnits)))            
        matchingLines

    member x.CreateStreetsIndex() =
        let client = new WebClient()
        let mapping = """{"mappings":{"administrationunit":{"properties":{"name":{"type":"string","store":"yes","index":"analyzed"},"allocationCode":{"type":"string","store":"yes","index":"not_analyzed"},"district":{"type":"string","store":"yes","index":"not_analyzed"}}}}}"""
        client.UploadString("http://localhost:9200/streets/", "PUT", mapping)

    member x.IndexStreets(path: String) =        
        let node = new Uri("http://localhost:9200")
        let settings = new ConnectionSettings(node)        
        let client = new ElasticClient(settings.DefaultIndex("streets"))
        x.CreateStreetsIndex() |> ignore

        x.LoadAndFilterStreetsFile(path) 
            |> Array.map(fun line -> x.ParseAdministrationUnit(line)) 
            |> client.IndexMany 
            |> ignore

 

Przed wysłaniem samych danych zakładamy mapping, czyli definiujemy, które pola będą przeszukiwane pełnotekstowo. Pozostałych nie ma sensu przetwarzać. Widać po tym kodzie trzy fajne rzeczy z F#: pattern matching użyty zamiast if-else , potrójny quote, który pozwala umieścić string bez potrzeby escape;owania go, oraz mój ulubiony pipe-forward operator (|>), który pozwala przekierowywać wyjście do następnej operacji.

Wyszukiwarka

API Elasticsearcha umożliwia bardzo wiele różnych rodzajów zapytań, które można zresztą łączyć ze sobą. W tym przypadku użyłem jako podstawowej wersji zapytania typu PrefixQuery, a więc takiego, przy którym nie musimy kończyć wyrazu kiedy go uzupełniamy. Dodatkowo wyszukiwarka jest odporna na literówki użytkowników. W przypadku, gdy zapytanie prefixowe zwróci pustą tablicę, próbujemy drugi raz odpytać ES, tym razem przy użyciu FuzzyQuery. Zapytania takie są znacznie bardziej wymagające jeśli chodzi o zasoby, głównie CPU, ale powinny przeważnie zwrócić wyniki gdy user pomyli jedną literę.

Mój Controller WebAPI do przeszukiwania ulic wygląda tak:

type SearchController() =
    inherit ApiController()

    let BuildPrefixQuery(phrase: String) = 
        let prefixQuery = new PrefixQuery()
        prefixQuery.Value <- phrase
        prefixQuery

    let BuildFuzzyQuery(phrase: String) = 
        let fuzzyQuery = new FuzzyQuery()
        fuzzyQuery.Value <- phrase
        fuzzyQuery

    let BuildQuery(name: String, phrase: String, fuzzy: bool) = 
        let req = new SearchRequest()
        let queryBase = match fuzzy with
                        | true -> BuildFuzzyQuery(phrase) :> FieldNameQueryBase
                        | false -> BuildPrefixQuery(phrase) :> FieldNameQueryBase
        
        let field = new Field()
        field.Name <- name
        queryBase.Field <- field
        req.Query <- new QueryContainer(queryBase)
        req

    let GetElasticClient() = 
        let node = new Uri("http://localhost:9200")
        let settings = new ConnectionSettings(node)        
        new ElasticClient(settings.DefaultIndex("streets"))

    member self.Get([<FromUri>]q :String) =
        let client = GetElasticClient()
        let prefixQuery = BuildQuery("name", q, false)
        let search = fun (client: ElasticClient, query: SearchRequest) -> 
            client.Search<AdministrationUnit>(query).Documents.ToArray()
       
        let result = match search(client, prefixQuery) with
                        | [||] -> search(client, BuildQuery("name", q, true))
                        | documents -> documents

        result

 

Jak to będzie działało ?

Jeżeli wpiszemy frazę “Mick”, to zwrócona nam zostanie “Aleja Adama Mickiewicza”

Jeżeli wpiszemy frazę “Myckiewicza”, to zwrócone zostaną poprzez fuzzy search “Aleja Adama Mickiewicza” oraz “Ulica Józefa Mackiewicza”. Na obu jutro rano będą korki 🙂

Advertisements

One thought on “[DajSięPoznać#2] Elasticsearch – budowa inteligentnej wyszukiwarki

  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