Mastico, la nuova gemma di Cantiere Creativo!
Creare query, anche semplici, utilizzando Elasticsearch è spesso tedioso e complesso, mentre con Chewy migliora ma il nostro codice non rimarrà molto DRY. Vi presentiamo una nostra soluzione per semplificare le query con Chewy ed Elasticsearch.
Quando si tratta di implementare un sistema di ricerca nei nostri progetti, quasi sempre abbiamo a che fare con situazioni complesse: potremmo aver bisogno di ricerche incrociate su più campi, in più lingue e magari con l’esigenza di fare delle full-text search.
In questo caso, la soluzione che adottiamo più spesso si chiama Elasticsearch.
Elasticsearch is a highly scalable open-source full-text search and analytics engine. It allows you to store, search, and analyze big volumes of data quickly and in near real time. It is generally used as the underlying engine/technology that powers applications that have complex search features and requirements.
ES è un sistema potente e complesso che riesce a maneggiare grandi quantità di dati. Ovviamente non è una soluzione plug-and-play quindi raramente lo utilizziamo da sé, infatti ci sono delle gemme che ci aiutano a bootstrapare le funzionalità di cui abbiamo bisogno.
Una di queste gemme si chiama Chewy.
Cos'è Chewy?
Chewy è un framework ad alto livello basato sul client elasticsearch-ruby.
Chewy semplifica molto il modo di creare e gestire un indice, ovvero una collezione di modelli che condividono caratteristiche simili, il quale poi possiamo interrogare ed avere i risultati della nostra ricerca.
Prendiamo come esempio una parte del sistema di ricerca che abbiamo implementato nel sito degli Uffizi:
class UffiziIndex < Chewy::Index class << self def index_name(_suggest = nil) "#{Rails.env}_uffizi_#{I18n.locale}" end end define_type( Artwork.includes(:museum), delete_if: -> { translation_for(I18n.locale).nil? } ) do field :title, value: -> { title } field :author, value: -> { author } field :formatted_text, value: -> { formatted_text } field :abstract_text, value: -> { abstract_text } field :formatted_renovation, value: -> { formatted_renovation } field :location, value: -> { location } field :technique, value: -> { technique } end end
Se volessimo cercare la Venere di Botticelli potremmo tranquillamente cercare “venere”:
UffiziIndex::Artwork.all.query(word: {title: "venere"}).load.to_a => [#<Artwork:0x007fa66ec00a28 id: 3, museum_id: 15, title: "Nascita di Venere", author: "Sandro Botticelli (Firenze 1445-1510) ", position: 109>]
Come vediamo, riceviamo un solo risultato, quello esatto, ma cosa succederebbe se ci fossero più quadri con “venere” nel titolo e volessimo solo il dipinto del Botticelli?
In questo caso possiamo concatenare più query:
UffiziIndex::Artwork.all.query(word: {title: "venere"}).query(word: {author: "botticelli"}).load.to_a => [#<Artwork:0x007fa66ec00a28 id: 3, museum_id: 15, title: "Nascita di Venere", author: "Sandro Botticelli (Firenze 1445-1510) ", position: 109>]
Il risultato rimane lo stesso, ma ci stiamo già accorgendo che se volessimo fare delle query molto più specifiche per filtrare maggiormente i risultati arriveremo a scrivere diverse righe di codice.
E non c’è un modo più intelligente per farlo? Sì. ES fornisce multi_match
il quale permette di fare più ricerche su più campi, ma vediamo come si comporta:
UffiziIndex::Artwork.all.query(multi_match: {fields: [:title, :author], query: "venere botticelli"}).load.to_a => [#<Artwork:0x007fa66eada0b8 id: 3, museum_id: 15, title: "Nascita di Venere", author: "Sandro Botticelli (Firenze 1445-1510) ", position: 109>, #<Artwork:0x007fa66eada360 id: 30, museum_id: 6, title: "Adorazione dei Magi", author: "Sandro Botticelli (Firenze 1445-1510)", position: 2>, #<Artwork:0x007fa66eada1f8 id: 20, museum_id: 11, title: "Fortezza", author: "Sandro Botticelli (Firenze 1445 -1510)", position: 2015>]
Mentre prima volevamo trovare la Venere, e soltanto la Venere di Botticelli, adesso abbiamo tutti i dipinti che includono “venere” nel titolo e tutti gli autori che contengono “Botticelli” nel nome. È successo il contrario: abbiamo un OR invece di un AND.
Sicuramente ci saranno decine di modi per raggiungere il nostro obiettivo con Chewy, e per questo abbiamo deciso di creare un helper che ci possa rendere la vita più facile tutte le volte che utilizzeremo la gemma di Toptal.
Ecco a voi Mastico!
Mastico ci aiuta a semplificare l’interfaccia per la costruzione di queries e ci fornisce una configurazione base di Chewy di modo che, una volta installato, possiamo iniziare subito a fare le nostre ricerche!
chewy_query = UffiziIndex::Artwork.all Mastico::Query.new(fields: [:title], query: "Venere").apply(chewy_query).load.to_a => [#<Artwork:0x007fa6668fc820 id: 3, museum_id: 15, title: "Nascita di Venere", author: "Sandro Botticelli (Firenze 1445-1510) ", position: 109>]
Questa prima query è sì più lunga di quella di Chewy, ma il nostro scopo è incrociare più attributi, e quindi proviamo a vedere come dovrebbe essere la query che ci ritorna solo la Venere del Botticelli:
Mastico::Query.new(fields: [:title, :author], query: "Venere Botticelli").apply(chewy_query).load.to_a => [#<Artwork:0x007fa66a4281b0 id: 3, museum_id: 15, title: "Nascita di Venere", author: "Sandro Botticelli (Firenze 1445-1510) ", position: 109>]
Abbiamo ottenuto ciò che volevamo e la query è appena più lunga di quella precedente, ma la cosa ancora migliore è che basterà aggiungere un altro campo nell’array di fields
per ricercare dentro altri attributi.
Vediamo nel dettaglio cosa viene eseguito quando lanciamo questo comando, ovvero quello che avremmo dovuto fare a mano con ES: https://gist.github.com/mttmanzo/a7ffb82c312a3b3f4d027fb681e49ef6.
Una volta passati fields
e query
, Mastico inizia a concatenare la ricerca con altri valori come il tipo di ricerca (:word
, :prefix
, :infix
e :fuzzy
) e il boost, ovvero quanto vogliamo mettere in risalto quella parola.
Questo è sufficiente per farci iniziare ad implementare ricerche anche complesse in maniera semplice e veloce. Ma cosa succederebbe se sbagliassi a scrivere la parola?
Mastico gestisce questa eventualità in automatico con il tipo fuzzy
, quindi se cercassimo “Botticello” troveremmo comunque le opere di Botticelli.
Mastico::Query.new(fields: [:author], query: "Botticello").apply(chewy_query).load.to_a => [#<Artwork:0x007fa66e9a78f8 id: 30, museum_id: 6, title: "Adorazione dei Magi" author: "Sandro Botticelli (Firenze 1445-1510)", position: 2>, #<Artwork:0x007fa66e9a7678 id: 3, museum_id: 15, title: "Nascita di Venere", author: "Sandro Botticelli (Firenze 1445-1510) ", position: 109>, #<Artwork:0x007fa66e9a77b8 id: 20, museum_id: 11, title: "Fortezza", author: "Sandro Botticelli (Firenze 1445 -1510)", position: 2015>]
Bello, ma se voglio filtrare le “stop-word"? C’è una soluzione anche a questo, infatti basta passare l’attributo word_weight
alla query:
def word_weight(word) case word when "botteghe" 0.0 when /\Ab[ao]tte\z/ 0.0 else 1.0 end end Mastico::Query.new(fields: [:author], query: "Botticello", word_weight: method(:word_weight)).apply(chewy_query).load.to_a
I valori restituiti rappresentano il boost, il quale può essere utilizzato anche per enfatizzare la ricerca su altre parole chiave.
Tutte queste opzioni posso sembrare complesse da abbinare, ma in realtà e possibile concatenarle in un semplice hash:
QUERY_FIELDS = { title: { types: [:fuzzy] }, formatted_text: { types: [:word] }, author: { boost: 3.0, types: [:word] }, # qua definiamo sia il tipo che il boost, solo per questa parola. abstract_text: { types: [:fuzzy] }, location: { types: [:infix] }, technique: { types: [:prefix] }, }.freeze def matching_text_scope(text) Mastico::Query.new(query: text, fields: QUERY_FIELDS).apply(UffiziIndex::Artwork.all) end
Le feature che abbiamo appena visto ci sono di enorme aiuto e ci aiutano giornalmente in molti dei nostri progetti, ma non aspettiamo altro che il feedback da utilizzatori esterni e sopratutto idee per migliorare Mastico! Chi volesse contribuire, ovviamente, può farlo qua https://github.com/cantierecreativo/mastico.