facebook

Blog

Resta aggiornato

Vediamo come sfruttare le funzionalità avanzate di ElasticSearch per la nostra applicazione
ElasticSearch: funzionalità avanzate
mercoledì 01 Aprile 2020

Nel mio precedente articolo, abbiamo discusso dell’utilizzo di ElasticSearch come semplice motore di ricerca full-text, di come installarlo e configurarlo in maniera veloce per integrarlo nella nostra applicazione .NET tramite il plugin NEST. 

Oggi mostreremo, sempre nell’ambito di un sito e-commerce, come abbiamo utilizzato le funzionalità offerte dal motore per migliorare l’accuratezza delle nostre ricerche.

Avevamo utilizzato una classe flat Product senza classi innestate per gestire la ricerca con semplicità ma, naturalmente, è un approccio che ha delle limitazioni. Abbiamo, quindi, introdotto un nuovo modello dei dati, in cui ogni nuovo oggetto è un’entità da modellare.

Un documento può contenere un numero indefinito di campi e valori (array, tipi semplici e complessi) associati a esso e, ricordiamo, è indicizzato in formato JSON.

La nostra classe di modello Product è quindi diventata:

public class Product
{
    public int Id { get; set; }
    public string Ean { get; set; }
    public string Name { get; set; }
    public string Description { get; set; }
    public Brand Brand { get; set; }
    public Category Category { get; set; }
    public Store Store { get; set; }
    public decimal Price { get; set; }
    public string Currency { get; set; }
    public int Quantity { get; set; }
    public float Rating { get; set; }
    public DateTime ReleaseDate { get; set; }
    public string Image { get; set; }
    public List<Review> Reviews { get; set; }
}

dove le classi Brand, Category, Store, Review e User sono rispettivamente:

public class Brand
{
    public int Id { get; set; }
    public string Name { get; set; }
    public string Description { get; set; }
}
 
public class Category
{
    public int Id { get; set; }
    public string Name { get; set; }
    public string Description { get; set; }
}
 
public class Store
{
    public int Id { get; set; }
    public string Name { get; set; }
    public string Description { get; set; }
}
 
public class Review
{
    public int Id { get; set; }
    public short Rating { get; set; }
    public string Description { get; set; }
    public User User { get; set; }
}
 
public class User
{
    public int Id { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public string IPAddress { get; set; }
    public GeoIp GeoIp { get; set; }
}

in cui GeoIp è una classe della libreria NEST utilizzata per i riferimenti geografici.

L’indice per i prodotti è stato chiamato products. L’abbiamo creato e configurato così:

client.Indices.Create(“products”, index => index
    .Map<Product>(x => x.AutoMap())
    .Map<Brand>(x => x.AutoMap())
    .Map<Category>(x => x.AutoMap())
    .Map<Store>(x => x.AutoMap())
    .Map<Review>(x => x.AutoMap())
    .Map<User>(x =>
        x.AutoMap()
        .Properties(props => props
            .Keyword(t => t.Name("fullname"))
            .Ip(t => t.Name(dv => dv.IPAddress))
            .Object<GeoIp>(t => t.Name(dv => dv.GeoIp))
        )
    )
)

Abbiamo creato, appositamente per l’indice di ElasticSearch, una nuova proprietà fullname per la classe User, e definito quali sono le informazioni geografiche che verranno poi elaborate.

Per far fronte alla necessità di elaborare i nostri prodotti prima dell’indicizzazione, uno strumento utilissimo è l’ingest node, che è un nodo su cui avviene il pre-processing dei documenti. L’ingest node intercetta tutte le richieste di indicizzazione, comprese le bulk, applica le trasformazioni sul contenuto, e restituisce il documento all’API di indicizzazione.

L’ingest va abilitato nel file di configurazione elasticsearch.yml, tramite il parametro:

node.ingest: true

Nel nostro caso, in cui il nodo è unico sia per la ricerca che per l’ingest, non abbiamo bisogno di impostazioni specifiche nel codice, ma nel caso di ingest nodes dedicati, il client di ElasticSearch andrebbe configurato così:

var pool = new StaticConnectionPool(new [] 
{
    new Uri("http://ingestnode1:9200"),
    new Uri("http://ingestnode2:9200"),
    new Uri("http://ingestnode3:9200")
});
var settings = new ConnectionSettings(pool);
var client = new ElasticClient(settings);

Per pre-processare il documento prima dell’indicizzazione è necessario definire una pipeline che specifichi una serie di processi che trasformano il documento. Esistono diversi processi predefiniti, già pronti da utilizzare, tra cui GeoIP (per ottenere indicazioni geografiche a partire da un IP), JSON (converte una stringa in un oggetto JSON), Lowercase e Uppercase, Drop (elimina i documenti con caratteristiche specificate) ed è inoltre possibile creare processi custom.

La pipeline che abbiamo utilizzato nel nostro caso di esempio è la seguente:

client.Ingest.PutPipeline("product-pipeline", p => p
                .Processors(ps => ps
                    .Uppercase<Brand>(s => s
                        .Field(t => t.Name)
                    )
                    .Uppercase<Category>(s => s
                        .Field(t => t.Name)
                    )
                    .Set<User>(s => s.Field("fullname")
                        .Value(s.Field(f => f.FirstName) + " " + 
                            s.Field(f => f.LastName)))
                    .GeoIp<User>(s => s
                        .Field(i => i.IPAddress)
                        .TargetField(i => i.GeoIp)
                    )
                )
            );

Questa pipeline elabora i documenti in modo che:

  • i campi name di Brand e Category vengano indicizzati in maiuscolo tramite l’ingest Uppercase;
  • il campo fullname di User contenga firstName e lastName tramite l’ingest Set;
  • il campo IPAddress di User diventi un indirizzo geolocalizzato tramite l’ingest GeoIp.

Le pipeline vengono salvate nello stato del cluster di ElasticSearch, e, per utilizzarle, basta specificare il parametro della pipeline nella richiesta di indicizzazione cosicché l’ingest node sappia quale pipeline utilizzare:

client.Bulk(b => b
    .Index("products")
    .Pipeline("product-pipeline")
    .Timeout("5m") 
    .Index<Person>(/*snip*/)
    .Index<Person>(/*snip*/)
    .Index<Person>(/*snip*/)
    .RequestConfiguration(rc => rc
        .RequestTimeout(TimeSpan.FromMinutes(5)) 
    )
);

Con queste operazioni abbiamo definito il processo di indicizzazione al fine di ottenere una lista di documenti indicizzati congrui alle nostre esigenze.

Dopo aver indicizzato i documenti con le pipeline definite, possiamo controllare subito la corretta indicizzazione andando col browser alla pagina http://localhost:9200/products/_search, in cui otterremo un risultato simile a questo:

Il processo di ricerca, come specificato nel precedente articolo, si basa sull’analisi dei documenti, ovvero il processo di tokenizzazione (divisione di un testo in piccole parti, chiamate token) e normalizzazione (consente di trovare corrispondenze ai token che non sono esattamente uguali alle parole cercate, ma simili abbastanza da essere rilevanti) del testo indicizzato per la ricerca, che avviene tramite un analyzer. 

Un analyzer è composto da tre componenti:

  1. 0 o più filtri sui caratteri
  2. 1 tokenizzatore
  3. 0 o più filtri sui token

Esistono degli analyzer predefiniti e pronti da utilizzare, ma, per migliorare l’accuratezza delle ricerche, anche a seconda delle nostre esigenze, abbiamo creato un custom analyzer.
Un custom analyzer consente di avere il controllo, delle modifiche al testo prima della tokenizzazione durante l’analisi, di come il testo venga convertito in token e delle modalità di normalizzazione.
Il nostro custom analyzer si presenta così:

var an = new CustomAnalyzer();
an.CharFilter = new List<string>();
an.CharFilter.Add("html_strip");
an.Tokenizer = "edgeNGram";
an.Filter = new List<string>();
an.Filter.Add("standard");
an.Filter.Add("lowercase");
an.Filter.Add("stop");
 
settings.Analysis.Tokenizers.Add("edgeNGram", new Nest.EdgeNGramTokenizer
{
    MaxGram = 15,
    MinGram = 3
});
 
settings.Analysis.Analyzers.Add("product-analyzer", an);

Il nostro analyzer crea token in lowercase, utilizzando la tokenizzazione standard, da 3 a 15 caratteri. Possiamo aggiungere il nostro analyzer all’indice creato per il prodotti, o in corrispondenza di uno o più campi o come analyzer standard.

client.CreateIndex("products", c => c
    // Analyzer aggiunto solo per la proprietà Description di Product
    .AddMapping<Product>(e => e
        .MapFromAttributes()
        .Properties(p => p.String(s => s.Name(f => f.Description)
        .Analyzer("product-analyzer")))
    )
    //Analyzer aggiunto come default
        .Analysis(analysis => analysis
            .Analyzers(a => a
            .Add("default", an)
        )
    )
)

Quando creiamo un custom analyzer possiamo anche testarlo tramite le testing API. I test naturalmente possono essere effettuati anche sugli analyzer predefiniti.

var analyzeResponse = client.Indices.Analyze(a => a
    .Tokenizer("standard")
    .Filter("lowercase", "stop")
    .Text("Lorem ipsum dolor sit amet, consectetur...")
);

Un altro strumento che ci risulta utile è l’aggregazione dei dati che ci fornisce dati aggregati a partire dalla query di ricerca, e si basa su blocchi semplici che possono essere via via composti per creare aggregazioni più complesse di dati. 

Ci sono differenti tipi di aggregazione, ognuna con uno scopo e un output definiti. Esse si possono suddividere in: 

  • Bucketing: contenitori a cui sono associati una chiave e un criterio;
  • Metric: metriche calcolate su un insieme di documenti;
  • Matrix: una serie di operazioni effettuate su più campi del documento che producono dati in forma matriciale;
  • Pipeline: aggregazione di più aggregazioni.

Nel nostro caso, abbiamo utilizzato le aggregazioni per ottenere il numero di prodotti per brand, categorie, fasce di prezzo. Qui, ad esempio, un aggregato del prezzo dei prodotti per i parametri di ricerca:

s => s
    .Query(...)
    .Aggregations(aggs => aggs
        .Average("average_price", avg => avg.Field(p => p.Price))
        .Max("max_price", avg => avg.Field(p => p.Price))
        .Min("min_price", avg => avg.Field(p => p.Price))
    )

Un’altra aggregazione utile è il raggruppamento per brand, store o categoria:

s => s
     .Query(...)
     .Aggregations(aggs => aggs
         .ValueCount("products_for_category", avg => avg.Field(p => p.Category.Name))
         .ValueCount("products_for_brand", avg => avg.Field(p => p.Brand.Name))
         .ValueCount("products_for_store", avg => avg.Field(p => p.Store.Name))
     )

In questo modo, possiamo avere, in tempo reale, quanti prodotti della ricerca effettuata sono presenti per categoria, brand e store. I dati aggregati sono molto utili anche per creare delle dashboard o organizzare delle ricerche con filtri dinamici, oltre che naturalmente ai fini statistici.

Migliorare le ricerche

Come abbiamo precedentemente visto, a ogni risultato della ricerca è associato uno score, ovvero un numero che determina quanto vicino sia il parametro di ricerca fornito a quel particolare risultato. Lo score dipende fondamentalmente da 3 parametri: la frequenza del termine ricercato, frequenza del documento invertito, lunghezza del campo su cui si cerca.

Per escludere dai risultati della ricerca quelli che hanno uno score troppo basso, possiamo utilizzare il MinScore:?

s => s
     .MinScore(0.5)
     .Query(...)

In questo modo escludiamo tutti i risultati che hanno uno score inferiore allo 0.5.

I suggesters sono dei metodi che consentono la ricerca su ElasticSearch utilizzando termini simili a quelli del testo di ricerca. Il completion suggester, ad esempio, è molto utile per l’autocomplete, che consente di guidare l’utente verso i risultati più rilevanti mentre sta digitando del testo. Il completion suggester è naturalmente ottimizzato per restituire i risultati più velocemente possibile, per cui utilizza strutture che abilitano il fast lookup ma che, naturalmente, richiedono risorse.

Nel nostro caso, abbiamo realizzato un metodo di autocomplete sul nome del prodotto, che viene invocato ogni volta che si digita nella casella di ricerca:

s => s
    .Query(...)
    .Suggest(su => su
        .Completion("name", cs => cs
            .Field(f => f.Name)
            .Fuzzy(f => f
                .Fuzziness(Fuzziness.Auto)
            )
            .Size(5)
        )
    )

Un altro metodo di ricerca che ci è risultato utile è l’indices boost, ovvero, la possibilità di assegnare dei moltiplicatori agli indici in modo da favorire i risultati di uno degli indici a discapito degli altri, quando si ricerca su più indici. Può essere molto utile in caso di particolari accordi commerciali con i fornitori dei prodotti dell’e-commerce o, banalmente, per dare risalto ai prodotti del proprio store.
Un esempio di indices boost è:

s => s
    .Query(...)
    .IndicesBoost(b => b
        .Add("products-1", 1.5)
        .Add("products-2", 1)
    )

Nell’esempio abbiamo assegnato un coefficiente di 1.5 ai risultati dell’indice products-1 e 1 ai risultati dell’indice products-2, per cui i risultati di products-1 saranno favoriti.

Un’altra modalità per migliorare le ricerche è sicuramente l’ordinamento secondo parametri prestabiliti, nel nostro caso abbiamo:

s => s
    .Query()
    .Sort(ss => ss
        .Descending(SortSpecialField.Score)
        .Descending(p => p.Price)
        .Descending(p => p.ReleaseDate)
        .Ascending(SortSpecialField.DocumentIndexOrder)
    )

In questo caso, abbiamo dato priorità allo score, poi al prezzo, alla data di introduzione in commercio e infine all’ordine sull’indice.

Esecuzione del progetto

Il nostro progetto d’esempio è un’applicazione .NET Core MVC e WebApi che prevede una dashboard con una casella di ricerca e dei dati automaticamente aggiornati a seconda della stringa inserita. Alla prima esecuzione, possiamo caricare una lista di n oggetti Product, creati randomicamente dal plugin Bogus. A monte ci sono altre classi faker per la costruzione di oggetti random di tipo Brand, Category, Store, Review e User. Questo ci consente di avere una base di dati su cui effettuare le nostre ricerche.

var productFaker = new Faker<Product>()
    .CustomInstantiator(f => new Product())
        .RuleFor(p => p.Id, f => f.IndexFaker)
        .RuleFor(p => p.Ean, f => f.Commerce.Ean13())
        .RuleFor(p => p.Name, f => f.Commerce.ProductName())
        .RuleFor(p => p.Description, f => f.Lorem.Sentence(f.Random.Int(5, 20)))
        .RuleFor(p => p.Brand, f => f.PickRandom(brands))
        .RuleFor(p => p.Category, f => f.PickRandom(categories))
        .RuleFor(p => p.Store, f => f.PickRandom(stores))
        .RuleFor(p => p.Price, f => f.Finance.Amount(1, 1000, 2))
        .RuleFor(p => p.Currency, "€")
        .RuleFor(p => p.Quantity, f => f.Random.Int(0, 1000))
        .RuleFor(p => p.Rating, f => f.Random.Float(0, 1))
        .RuleFor(p => p.ReleaseDate, f => f.Date.Past(2))
        .RuleFor(p => p.Image, f => f.Image.PicsumUrl())
        .RuleFor(p => p.Reviews, f => reviewFaker.Generate(f.Random.Int(0, 1000))
    )

Al centro della pagina, è presente la dashboard in cui vengono utilizzati i filtri, gli analyzer e i metodi descritti in questo articolo. Digitando del testo nella casella di ricerca in alto, ci vengono suggeriti i prodotti che corrispondono al testo cercato e, contestualmente, viene aggiornato il contenuto della dashboard.

Conclusioni

In questo articolo, abbiamo visto come utilizzare Elasticsearch per elaborare, analizzare e cercare dati in maniera più efficace per scenari reali complessi. Mi auguro di aver suscitato il vostro interesse per l’argomento.

Il progetto di esempio col codice utilizzato è disponibile qui.

Al prossimo articolo!