Elasticsearch

Elastcsearch je server koji služi za pretragu i analizu podataka. Napisan je u Java programskom jeziku što omogućava pokretanje na svim platformama. Baziran je na Lucene indeksima o kojima smo govorili u prethodnom članku (link) i omogućava korisnicima da pretraže veliku količinu podataka vrlo brzo. Može se koristiti i za čuvanje podataka ali je njegova glavna uloga indeksiranje i pretraga podataka u realnom vremenu. Alat je besplatan i može mu se pristupiti preko RESTful API-ja (HTTP zahtevi GET, POST, PUT, DELETE). Za skladištenje podataka koristi JSON format.

Neke od osnovnih karakteristika Elasticsearch servera su:

  • može da radi sa količinom podataka koja se meri petabajtima,
  • koristi se kao zamena za MongoDB i RavenDB,
  • koristi denormalizaciju kako bi ubrzao pretragu,
  • koriste ga mnoge velike organizacije kao što su Wikipedia, StackOverflow, GitHub,
  • radi u realnom vremenu i čim se novi podatak doda, dostupan je za pretragu,
  • može da se distribuira,
  • predstavljanje podataka pomoću JSON objekata mu omogućava da se koristi u većini programskih jezicima,
  • besplatan je i dostupan pod Apache licencom 2.0.

Pre nego što pređemo na instalaciju i upotrebu, upoznaćemo se sa osnovnim konceptima i terminologijom Elasticsearch-a.

Osnovni koncepti

Najpre ćemo objasniti strukturu podataka sa kojima radi Elastisearch.

Osnovna jedinica od koje polazimo je polje. Polje zapravo predtavlja jedan podatak. Kolekcija polja predstavlja jedan JSON objekat, što je analogno n-torki kod RDBMS. Skup JSON objekata se naziva dokument. Kolekcija dokumenata koji imaju zajednička polja naziva se tip. Tipovi se dalje organizuju u strukturu koja se naziva indeks. Na primer, indeks može da sadrži podatke o društvenoj mreži, gde su tipovi profili, komentari, poruke i sl. U poređenju sa RDBMS, indeks je kolekcija tipova kao što je baza podataka kolekcija tabela. Svaka tabela predstavlja kolekciju n-torki, a analogno tome svaki tip predstavlja kolekciju JSON objekata.

Ono što dodatno ubrzava pretragu je mogućnost distribucije. Čvor predstavlja jednu pokrenutu instancu Elastcsearch-a. Dakle, moguće je pokrenuti više instanci Elastisearch-a i rasporediti ih na različite servere nekog distribuiranog sistema. Na taj način pretraga može da se paralelizuje. Indeksi se horizontalno mogu podeliti u strukturu koja se naziva shard. To omogućava da se jedan indeks rasporedi na više različitih servera. Osim toga, kreiraju se i duplikati indeksa i njegovih delova. Duplikati takođe mogu da se rasporede na više servera i pružaju veću sigurnost podataka kao i bržu pretragu. Nije obavezno da instance budu na različitim serverima, moguće je da se sve pokrenu na jednom serveru. Skup jednog ili više čvorova se naziva klaster. Klaster omogućava da čitav sistem vidimo kao celinu.

Instalacija

Java

Kako je Elasticsearch napisan u Java programskom jeziku, za funckionisanje samog servera potrebno je imati instaliranu Javu i to minimum verzija 7.

Elasticsearch

Potrebno je preuzeti Elasticsearch sa linka: Elasticsearch download. Za korisnike Windows operativnog sistema preuzima se ZIP fajl, dok korisnici Linux operativnog sistema treba da preuzmu TAR fajl. Arhivu je potrebno raspakovati na željenu lokaciju i time je instalacija završena.

NodeJS

Za potrebe primera treba instalirati NodeJS i npm čije instalacije možete preuzeti ovde.

Pokretanje

Potrebno je preko konzole doći do direktorijuma elasticsearch-2.1.0/bin, i potom izvršiti komandu:

Linux:

./elasticsearch

Windows:

elasticsearch

Time je elasticsearch pokrenut na portu 9200. Ukoliko ukucate u pretraživaču adresu http://localhost:9200, možete proveriti da li je server pokrenut. Ako jeste, pojaviće se sličan izlaz:

{ "name" : "Brain-Child", "cluster_name" : "elasticsearch", "version" : { "number" : "2.1.0", "build_hash" : "72cd1f1a3eee09505e036106146dc1949dc5dc87", "build_timestamp" : "2015-11-18T22:40:03Z", "build_snapshot" : false, "lucene_version" : "5.3.1" }, "tagline" : "You Know, for Search" } 

Dalja komunikacija sa serverom se obavlja putem RESTful API-ja.

Korišćenje Elasticsearch servera

Kako se Elasticsearch koristi najbolje ćemo pokazati na primerima. Kreiraćemo web aplikaciju za pretragu dokumenata u bilbioteci (to mogu biti knjige, naučni radovi i slično). Za serverski deo web aplikacije ćemo koristiti NodeJS i time pokazati kako se Elasticsearch koristi u okviru ovog programskog jezika. Aplikaciju možete preuzeti ovde: ElasticsearchDemo. Pre nego što pređemo na primere, kreiraćemo projekat i pokazati kako se Elasticsearch uključuje.

Najpre ćemo kreirati package.json fajl pomoću komande:

npm init

Projekat ćemo nazvati elasticsearch-demo. Nakon toga treba pokrenuti komandu:

npm install

Elasticsearch ima modul za NodeJS, tako da je potrebno instalirati ga sledećom komandom:

npm install elasticsearch --save

U okviru modula se nalaze sve funckije za komunikaciju sa Elasticsearch serverom putem RESTful API-ja. Modul se uključuje u script fajl na sledeći način:

var elasticsearch = require('elasticsearch');

Nakon toga, treba podesti klijenta koji obavlja komunikaciju sa Elasticsearch-om. To se vrši na sledeći način:

const client = new elasticsearch.Client({ host: '127.0.0.1:9200', log: 'error' //log: 'trace' }); 

Log opcija može da uzme vrednost error (štampamo samo greške) ili trace (štampamo sva dešavanja vezana za rad sa bazom).

Da bi se koristio, Elasticsearch mora da bude pokrenut u konzoli (objašnjeno u delu za instalaciju).

Prvo ćemo pokazati kako se indeksi kreiraju i brišu, a nakon toga i kako se vrše upiti.

Dodavanje indeksa

Podaci se u indeks unose u obliku JSON objekata. Struktura JSON objekta se definiše mapiranjem. Pre nego što unesemo neki JSON objekat pozivamo funkciju za mapiranje i definišemo strukturu podataka u indeksu. Primer poziva ove funckije je dat u nastavku.

function initMapping() { return elasticClient.indices.putMapping({ index: indexName, type: "document", body: { properties: { title: { type: "string" }, content: { type: "string" } } } }); } exports.initMapping = initMapping; 

Inicijalno mapiranje nije obavezno. Moguće je odmah dodati JSON objekat u indeks a Elasticsearch će na osnovu tog objekta izvršiti mapiranje i odrediti strukturu podataka. Za svako od unetih polja se definiše tip podatka i nakon toga u svakom novom JSON objektu koji sadrži to polje, ono mora biti tipa koji je prethodno definisan. Na primer, ako smo indeksirali {„key1“: 12}, Elasticsearch polje key1 mapira kao tip long. Ako probamo da indeksiramo {„key1“: „value1“, „key2“: „value2“}, doći će do greške jer se očekuje da polje key1 bude tipa long. Objekat {„key1“: 13, „key2“: „value2“} će biti prihvaćen pri čemu će se key2 mapirati kao string.

U našem primeru, nećemo koristiti inicijalno mapiranje već ćemo ih odmah uneti u indeks. Kreiraćemo indeks library. Pre nego što ga kreiramo, proverićemo da li već postoji na sledeći način:

function indexExists(indexName) { return client.indices.exists({ index: indexName }); } 

Funckija vraća true ukoliko indeks postoji, a false ukoliko ne postoji. Ako postoji, možemo pozvati funkciju za brisanje indeksa:

deleteIndex = function(indexName) { return client.indices.delete({ index:indexName //index:'_all' - delete all indexes }, function(err, res) { if(err) { console.error(err.message); } else { console.log('Indexes have been deleted.'); } }); } 

Opcija index služi da se izabere indeks koji se briše. Potrebno je da se navedu nazivi odgovarajućih indeksa. Ukoliko želimo da obrišemo sve indekse, opciju postavljamo na _all. Sada možemo da kreiramo svoj indeks na sledeći način:

function initIndex(indexName) { return client.indices.create({ index: indexName }); } 

Kada smo kreirali indeks, potrebno je da dodamo dokumente. Struktura dokumenata u našem primeru je sledeća:

document = { "id": "599daa4153652e26cca60232", "title": "Dolor commodo ullamco officia ad Lorem excepteur anim consequat cillum Lorem.", "year": 2011, "authors": [ { "firstname": "Zelma", "lastname": "May", "institution": "Tellifly", "email": "zelmamay@tellifly.com" }, { "firstname": "Woods", "lastname": "Wall", "institution": "Fanfare", "email": "woodswall@fanfare.com" }, { "firstname": "Maritza", "lastname": "Fitzpatrick", "institution": "Voratak", "email": "maritzafitzpatrick@voratak.com" } ], "abstract": "In aute ipsum esse consequat labore ullamco cupidatat enim minim cupidatat est ut consequat adipisicing. Culpa ad ipsum pariatur esse nisi anim eiusmod. Exercitation fugiat laborum aliquip ut laborum eiusmod eiusmod esse Lorem cillum cillum. Ad proident minim aliqua velit magna eu sit enim eiusmod do aute proident. Do velit sint fugiat ex incididunt labore ea sit duis est. Laborum anim id non nostrud excepteur deserunt ut nostrud fugiat ex.\r\n", "link": "docs/deserunt.pdf", "keywords": [ "commodo", "velit", "culpa", "nisi", "Lorem", "irure", "do" ], "content": "some text" } 

Dakle, za svaki članak pamtimo id, naziv, godinu izdanja, autore, apstrakt, ključne reči i sadržaj. Za unošenje jednog dokumenta poziva se sledeća funkcija:

function addDocument(indexName, document) { return client.index({ index: indexName, type: "document", body: { id: document.id, title: document.title, year: document.year, authors: document.authors, abstract: document.type, link: document.link, keywords: document.keywords, content: document.content } }); } exports.addDocument = addDocument; 

Za veliki broj dokumenata najbolje je koristiti funckiju bulk koja je napravljena da veliku kolićinu podataka unosi mnogo efikasnije (koristeći paralelizam). Za potrebe primera koristićemo JSON generator koji za zadat format JSON-a generiše niz slučajnih JSON objekata. Format JSON-a korišćenog u ovom primeru je:

[ '{{repeat(500)}}', { id: '{{objectId()}}', title: '{{lorem()}}', year: '{{integer(2000,2017)}}', authors: [ '{{repeat(3)}}', { firstname: '{{firstName()}}', lastname: '{{surname()}}', institution: '{{company()}}', email: '{{email()}}' } ], abstract: '{{lorem(1, "paragraphs")}}', link: 'docs/{{lorem(1,"words")}}.pdf', keywords: [ '{{repeat(7)}}', '{{lorem(1, "words")}}' ], content: '{{lorem(10, "paragraphs")}}' } ] 

Sada imamo kreiran niz JSON objekata sačuvan u fajlu generatedData.json. Ono što ćemo prvo uraditi je čitanje tog fajla a nakon toga ćemo iskoristiti funckiju bulk za indesiranje podataka.

importData = function(filename, index, type) { const dataRaw = fs.readFileSync(filename); const data = JSON.parse(dataRaw); console.log(`${data.length} items parsed from data file`); bulkIndex(index, type, data); } exports.importData = importData; bulkIndex = function bulkIndex(indexName, type, data) { let bulkBody = []; data.forEach(item => { bulkBody.push({ index: { _index: indexName, _type: type, _id: item.id } }); bulkBody.push(item); }); client.bulk({body: bulkBody}).then( response => { let errorCount = 0; response.items.forEach(item => { if (item.index && item.index.error) { console.log(++errorCount, item.index.error); } }); return console.log(`Successfully indexed ${data.length - errorCount} out of ${data.length} items`); }).catch(console.err); } exports.bulkIndex = bulkIndex; 

Najpre smo pročitali json fajl, a nakon toga pozvali funkciju koja je te objekte prvo spakovala u niz (za svaki objekat se prvo unosi zaglavlje koje sadrži naziv indeksa, tipa i ID a nakon toga i sam objekat) a potom pozvala funckiju bulk kojoj je prosledila kreiran niz. Funckija bulk vraća niz objekata koje smo indeksirali i za svaki se može proveriti da li je uspešno indeksiran. Sve ove funckije se nalaze u fajlu createIndex.js, pa ako ste preuzeli arhivu na početku možete ga pokrenuti komandom:

node createIndex.js

Nakon toga možemo proveriti da li je indeks kreiran i da li su podaci uneti, kao i informacije o ostalim indeksima koji postoje. To se postiže pozivom sledeće funckije:

indices = function indices() { return client.cat.indices({v: true}) .then(console.log) .catch(err => console.error(`Error connecting to the es client: ${err}`)); }; 

Opcija v: true znači da želimo da se ispiše i zaglavlje. Funckija se nalazi u fajlu indexes.js. Kada ga pokrenemo, izlaz bi trebalo da bude sličan sledećem:

health status index   uuid                   pri rep docs.count docs.deleted store.size pri.store.size yellow open   library yYIRvkS4RPS8hPkl-adxcw   5   1        500            0        1mb            1mb 

O svakom indeksu vidimo osnovne podatke. Health može da uzme vrednost green (ako je sve u redu), yellow(klaster je pokrenut ali ima problema, kod nas je problem to što je pokrenuta samo jedna instanca na lokalnom računaru a podrazumevan broj instanci je 5) i red (postoji problem i nije pokrenuta nijedna instanca). Osim toga, možemo videti i naziv indeksa, ID, iz koliko delova se sastoji (shard), koliko duplikata ima (podrazumevan broj duplikata je 1, a kako smo pokrenuli samo jednu instancu taj duplikat ne može da se alocira), broj dokumenata unutar indeksa, koliko je memorije zauzeto.

Sada kada smo kreirali indekse, možemo krenuti sa pretragom.

Pretraga

Za pretragu dokumenata nekog indeksa pozivamo sledeću funckiju:

search = function search(index, body) { return client.search({index: index, body: body}); } 

Parametar index predstavlja naziv indeksa u okviru koga vršimo pretragu. Parametar body je upit koji postavljamo. Upit je u JSON formatu a u nastavku ćemo videti kako se postavljaju parametri za razne vrste upita.

Pretraga svih dokumenata

let body = { size: 20, from: 0, query: { match_all: {} } }; 

Možemo videti da na početku imamo opcije size i from. One govore o tome koliko rezultata pretrage želimo. Opcija from označava od kog dokumenta želimo da počnemo, što može biti korisno za podelu rezultata po stranicama. Ukoliko se opcija size ne postavi, podrazumevana vrednost je 10.

Kada pokrenemo neki upit, rezultat dobijamo u sledećem formatu:

{ took: 6, timed_out: false, _shards: { total: 5, successful: 5, failed: 0 }, hits: { total: 1000, max_score: 1, hits: [ [Object], [Object], ... [Object] ] } } 

gde su:

  • took – broj milisekundi koji je protekao za vreme pretrage
  • timed_out – uzima vrednost *true* ako nista nije pronađeno do maksimalno dozvoljenog vremena
  • _shards – informacije o statusu na svakom čvoru (ukupan broj, broj čvorova sa uspešnom pretragom, broj čvorova na kojima je došlo do greške)
  • hits – rezultati pretrage
    • total – ukupan broj rezultata pretrage
    • max_score – maksialna ocena rezultata (svaki rezultat ima svoju ocenu poklapanja, na osnovu koje se rangira)
    • hits – niz rezultata pretrage

Svaki od rezultata pretrage se sastoji od sledećih polja:

"hits" : [ { "_index" : "bank", "_type" : "account", "_id" : "0", "sort": [0], "_score" : null, "_source" : { object_fields } }, { "_index" : "bank", "_type" : "account", "_id" : "1", "sort": [1], "_score" : null, "_source" : { object_fields } }, ... ]
  • _index – indeks kome pripada
  • _type – tip kome pripada
  • _id – ID dokumenta
  • _score – ocena poklapanja
  • _source – niz objekata koji predstavljaju rezultate (JSON bjekti koje smo na početku indeksirali).

Pretraga po kriterijumima

Kada hoćemo da dodamo određene kriterijume pretrage, menjamo samo polje query, tako da ćemo u nastavku samo razmatrati taj deo upita.

Ako hoćemo da neko polje ima određenu vrednost, koristimo match upite.

{ query: { match: { title: { query: 'search terms go here' } } } } 

U ovom primeru, u rezultatu će se pojaviti dokumenti čije polje title sadrži neku od reči ‘search terms go here’. U zavisnosti od poklapanja dobiće određene ocene, na osnovu kojih će se rangirati. Sada ćemo dodati još neke parametre u ovaj upit.

{ query: { match: { title: { query: 'search tems go here', minimum_should_match: 3, fuzziness: 2 } } } } 

Rezultat ovakvog upita su dokumenti koji moraju da sadrže u naslovu najmanje 3 reči, dok fuzziness označava stepen sličnosti reči koje se pojavljuju u naslovu i zadatih (sa tems će se poklapati i terms sa nešto manjom ocenom).

Pretraga se ne mora vršiti samo u okviru jednog polja. Sledeći primer pokazuje pretragu po poljima title, authors.firstname i authors.lastname. U ovom slučaju, umesto match pišemo multi_match.

{ query: { multi_match: { query: 'search terms go here', fields: ['title', 'authors.firstname', 'authors.lastname'] } } } 

I ovde je moguće dodati opcije minimum_should_match i fuzziness. Osim toga, kod pretrage više polja moguće je koristiti i džoker karaktere. Na primer, niz polja u ovom upitu se može napisati ovako:

['title', 'authors.*name'] 

Ukoliko ne želimo da vršimo pretragu po pojedinačnim rečima, možemo koristiti opciju type: ‘phrase’.

{ query: { match: { title: { query: 'search tems go here', type: 'phrase' } } } } 

Rezultat ove pretrage će biti svi naslovi koji u sebi sadrže čitav string.

Još jedna od mogućnosti koju nudi Elasticsearch jeste kombinovanje više upita. Ovaj tip upita se naziva bool. Sadrži opcije: must (upiti unutar ove opcije moraju biti ispunjeni), should (upiti unutar ove opcije ne moraju biti ispunjeni ali je poželjno, dobija se veća ocena), must_not (upiti unutar ove opcije ne smeju biti ispunjeni). Do sada smo videli kako funkcionišu match i multi_match. U ovom delu ćemo uvesti query_string koji nam omogućava da pišemo složenije upite koji koriste logičke operatore AND i OR, i range koji omogućava pretragu u nekom opsegu. Sada ćemo dati konkretan primer.

{ query: { bool: { must: [ { query_string: { query: '(authors.firstname:term1 OR authors.lastname:term2) AND (title:term3)' } } ], should: [ { match: { content: { query: 'search phrase goes here', type: 'phrase' } } } ], must_not: [ { range: { year: { gte: 2011, lte: 2013 } } } ] } } } 

Rezultat ovog upita će biti dokumenti kod kojih naslov sadrži term3 a mora biti ispunjeno i da ime autora sadrži term1 ili prezime sadrži term2. Poželjno je da se u sadržaju dokumenta nalazi fraza ‘search phrase goes here’, a ne sme biti objavljen 2011., 2012. ili 2013. godine.

Filteri

Filter je još jedna od opcija upita tipa bool. Sama reč govori o tome da se vrši filtriranje dokumenata na osnovu nekog kriterijuma. Ali, razlika u odnosu na opciju must je u tome što filtriranje ne ulazi u ocenu, tj. ne utiče na redosled rezultata, već samo uklanja rezultate koji ne prolaze taj kriterijum.

{ query: { { bool: { must: [ { match: { title: 'search terms go here' } } ], filter: [ { range: { year: { gte: 2011, lte: 2015 } } } ] } } } } 

U ovom upitu ćemo prvo eliminisati sve publikacije koje nisu objavljene između 2011. i 2015.

Agregacije

Agregacije omogućavaju da se izvrši neka funckija nad nekim poljem (suma, minimum, maksimum, prosečna vrednost, broj rezultata). Druga vrsta agregacije je grupisanje (bucket). U sledećem primeru se računa minimalna godina a grupisanje se vrši po ključnim rečima.

{ query: { aggregations: { min_year: { min: {field: 'year'} }, keywords: { terms: {field: 'keywords'} } } } } 

Rezultat ovog upita će biti:

{ ... "aggregations": { "keywords": { "doc_count_error_upper_bound": 0, "sum_other_doc_count": 2452, "buckets": [ { "key": "pariatur", "doc_count": 88 }, { "key": "adipisicing", "doc_count": 75 }, ... ] }, "min_year": { "value": 1970 } } } 

Predlozi

Još jedna vrsta pretrage su predlozi. Oni zapravo predlažu slične pojmove ili fraze onima koje smo naveli.

client.suggest({ index: 'articles', body: { text: 'text goes here', titleSuggester: { term: { field: 'title', size: 5 } } } }).then(...) 

U body delu navodimo tekst za koji želimo predlog i predloge koje želimo da dobijemo. Moguće je da postoji više različitih vrsta predloga (za svaki posebno navodimo karakteristike). U našem primeru, kreirali smo predlog titleSuggester koji će imati predloge reči za polje title, pri čemu je maksimalan broj predloga 5. Izlaz koji dobijamo je sledeći:

... "titleSuggester": [ { "text": "term", "offset": 0, "length": 4, "options": [ { "text": "terms", "score": 0.75, "freq": 120 }, { "text": "team", "score": 0.5, "freq": 151 } ] }, ... ] ... 

Dobijamo niz objekata čija je veličina jednaka broju reči koje smo naveli u pozivu funckije u polju text. Dakle, za svaku reč se pravi predlog. Predlozi se nalaze u polju options sa određenom ocenom.

Primer

U našem primeru smo vršili pretragu dokumenata po naslovu, sadržaju, autorima i godini izdavanja. Koristili smo sledeći upit:

body = { size: 20, from: 0, query: { bool: { must: [ { multi_match: { query: text, fuzziness: 2, fields: ['title^6', 'abstract^5', 'content^5', 'authors.firstname^2',  'authors.lastname^2'] } } ], filter: [ { range: { year: { gte: from, lte: to } } } ], should: [{ match: { year: { query: to, boost: 1 } } }] } } } 

Deo upita:

fields: ['title^6', 'abstract^5', 'content^5', 'authors.firstname^2',  'authors.lastname^2'] 

znači da ćemo ocenu poklapanja reči u naslovu pomnožiti sa 6, u apstraktu i sadržaju sa 5, a u imenu i prezimenu autora sa 2. Izvršićemo filtriranje po godini izdavanja (neće ulaziti u ocenjivanje), i preferiraćemo ćlanke čija je godina izdanja zadata na ulazu.

Kako smo već pokrenuli Elasticsearch i kreirali indekse, možemo pokrenuti i deo aplikacije za pretragu. To se postiže sledećom komandom:

node app.js 

U pretraživaču otvorite link http://localhost:8080/index.html.

U bazi postoji 200 članaka, a biće izlistano 20 rezultata koji najviše odgovaraju kriterijumima pretrage.

Elasticsearch predstavlja mnogo moćan alat za pretragu a videli smo da se i vrlo lako integriše sa ostalim programskim jezicima. Dosta je korišćen od strane velikih organizacija kao što su Wikipedia, StackOverflow. Nudi mnogo mogućnosti a mi smo ovim člankom pokrili samo mali broj. Ukoliko želite da znate više, možete posetiti stranicu Elasticsearch.

Literatura