Sunday, December 16, 2018

Elasticsearch Part 2: Contoh Sederhana Elasticsearch Search Query

Tulisan ini merupakan lanjutan dari tulisan sebelumnya. Pada tulisan ini akan dijelaskan cara membuat sebuah service pencarian data menggunakan elasticsearch node menggunakan elasticsearch template dan search query.
Sebenarnya seperti JpaRepository untuk mengakses data pada elasticsearch dapat digunakan pula ElasticsearchRepository. Pada interface ElasticsearchRepository juga sudah terdapat beberapa built in method untuk melakukan pencarian, seperti findAll(), findById(), dll. Namun kemampuan querynya sangat terbatas.
Kemudian sebenarnya kita juga dapat mengakses data elasticsearch seperti kita menggunakan JPQL atau native query, yaitu dengan membuat sebuah method di interface repositorynya lalu memberi annotation Query dan mendefinisikan querynya. Namun hal ini berbeda untuk elasticsearch yang menurut saya querynya cukup rumit, silahkan baca tentang elasticsearch Query DSL
Sehingga saya menyarankan untuk menggunakan elasticsearch template dan search query, yang menurut saya cukup mirip dengan JpaSpecification. Dengan search query ini kita dapat mengoperasikan query dsl tanpa "string declaration".

Tanpa memperpanjang penjelasan metode query, selanjutnya saya akan menjelaskan praktikalnya.
Misalnya kita akan membuat sebuah service untuk mencari Driver (supir). data ini akan disimpan ke MySQL dan elasticsearch.
  • Data Driver dapat dicari berdasarkan beberapa id yang kita inginkan dan didefinisikan dalam sebuah collection of integer, 
  • Dapat dicari berdasarkan string full name, nick name, gender, dan vehicle. Namun string yang digunakan untuk pencarian harus toleran terhadap fuzziness dan sloppiness (secara sederhana dapat kita sebut dengan typo).
  • Data dicari berdasarkan rentang price-nya, yang didefinisikan dengan batas minimum price dan batas maximum price.
  • Dapat dicari dari jaraknya terhadap suatu lokasi yang didefinisikan dengan nilai latitude dan longitude tertentu.
  • Dan dapat dicari berdasarkan letaknya dalam suatu daerah (misal dalam satu kota), yang didefinisikan dengan nilai south west latitude, south west longitude, north east latitude, dan north east longitude.
Driver Class

@Entity@Table(name = "driver")
@Data@Document(indexName = "driver", type = "driver")
public class Driver implements Serializable {

    @Id    @GeneratedValue    private Integer id;
    @Field(type = FieldType.Keyword)
    private String fullName;
    @Field(type = FieldType.Keyword)
    private String nickName;
    @Field(type = FieldType.Keyword)
    private Gender gender;
    @Field(type = FieldType.Keyword)
    private String vehicle;
    private Integer price;
    @Transient    @GeoPointField    private GeoPoint location;}
Annotation yang berkaitan langsung dengan elasticsearch adalah annotation Document, sedangkan Entity, dan Table berkaitan dengan MySQL database dan annotation Data merupakan plugin lombok. Pada Document terdapat parameter indexName yang secara sederhana dapat dikatakan sebagai database name di elasticsearch. Sedangkan parameter type secara sederhana dapat dikatan sebagai table name di elasticsearch.
Selanjutanya annotation Id perlu didefinisikan pula untuk elasticsearch. Meskipun annotation GeneratedValue tidak bekerja pada elasticsearch, tapi Id tetap diperlukan untuk menjelaskan ElasticsearchRepository bahwa data Driver dapat dikenali dengan field Id "private Integer id".
Lalu terdapat annotation Field pada sebagian besar String data type. Pada spring data elasticsearch data type String akan di mapping ke type text secara default. Namun type data ini memiliki banyak batasan, misalnya field terkait jadi sulit untuk digunakan sebagai parameter sorting. Sedangkan keyword lebih flexible dan dapat digunakan untuk sorting.
Kemudian terpadat annotation Transient yang meng-ignore field location untuk dipetakan ke table di MySQL, karena jika tidak akan menyebabkan error yang disebabkan type data GeoPoint yang tidak dapat dipetakan dalam table MySQL. Sedangkan annotation GeoPointField mirip dengan annotation field, tetapi digunakan secara khusus untuk type data yang berhubungan dengan geospatial (geografis).
catatan:
Type data GeoPoint yang digunakan berasal dari org.springframework.data.elasticsearch.core.geo. yang saya ketahui terdapat 2 GeoPoint data type, yang satu lagi berasalah dari org.elasticsearch.common.geo yang dapat digunakan untuk sorting data berdasarkan jarak.
Driver Repositories
Dikarenakan data Driver disimpan pada elasticsearch dan MySQL, maka terdapat 2 interface repository untuk class Driver.

public interface DriverElasticRepository extends ElasticsearchRepository<Driver, Integer> {
}
 dan
public interface DriverRepository extends JpaRepository<Driver, Integer>{
}
Driver Mapping
Dikarenakan kita menggunakan custome mapping (annotation field) pada Driver class, maka kita perlu memberi tahu elasticsearch template untuk menggunakan mapping yang telah kita tentukan. Proses ini disebut put mapping dan proses ini sebaiknya cukup dilakukan sekali, sehingga saya melakukannya pada post construct.
@Componentpublic class Init {

    @Autowired    ElasticsearchTemplate elasticsearchTemplate;
    @PostConstruct    public void init(){
        elasticsearchTemplate.putMapping(Driver.class);        elasticsearchTemplate.putMapping(Regency.class);    }
}
Driver Service
Rancangan Query dengan BoolQueryBuilder
Misal secara default service pencarian melakukan pencarian berdasarkan price, dan batas penentunya (minimum price dan maximum price) tidak pernah null, karena telah diinisialisasi.

BoolQueryBuilder boolQueryBuilder = new BoolQueryBuilder()
        .filter(new RangeQueryBuilder("price")
                .gte(minimumPrice)
                .lte(maximumPrice));
Dengan begitu kita membuat sebuah "container" yang akan menampung Query kita, dan fitur yang mempu menjadi penampung adalah BoolQueryBuilder. berdasarkan source code di atas juga cukup jelas, bahwa proses yang dilakukan adalah melakukan pencarian "price" yang lebih dari sama dengan (greater than equal) minimumPrice dan kurang dari sama dengan (less than equal) maximumPrice.

Kemudian untuk memenuhi pencarian berdasarkan id yang kita tentukan pada collection of integers dan melakukan sorting berdasarkan urutannya pada collection of integers.
if (ids != null && ids.size() > 0){
    float score = ids.size()*scoreThreshold;    for (Integer id : ids){
        boolQueryBuilder = boolQueryBuilder
                .should(new TermQueryBuilder("id", String.valueOf(id)).boost(score));        score = score - scoreThreshold;    }
}
potongan source code di atas menjelaskan bahwa setiap data yang nilai id-nya sama dengan yang ada dalam ids diberi score tertentu. Scoring ini menyebabkan data ter-sortir secara otomatis, karena sistem pencarian dan pengurutan data dalam elasticsearch bekaitan dengan scorenya. Score sendiri merupakan nilai yang ditambahkan untuk setiap data yang telah memenuhi query yang telah ditentukan. sehingga berdasarkan source code di atas, data pertama yang ada dalam collection ids akan ditempatkan pada posisi pertama.

Lalu untuk memenuhi pencarian menggunakan String untuk full name, nick name, gender, dan vehicle, serta toleransinya terhadap fuzziness dan sloppiness digunakan source code berikut.
if (!StringUtils.isEmpty(words)){
    boolQueryBuilder = boolQueryBuilder
            .filter(new MultiMatchQueryBuilder(words)
                    .field("fullName")
                    .field("nickName")
                    .field("gender")
                    .field("vehicle")
                    .type(MultiMatchQueryBuilder.Type.BEST_FIELDS)
                    .fuzziness(Fuzziness.TWO));}
Source code di atas menjelaskan bahwa query "container" ditambahkan dengan query pencarian String pada field fullName, nickName, gender, dan vehicle dan dipilih yang nilainya paling sesuai dengan nilai words. Pada query ini digunakan sloppiness default, dan fuzziness edit distance 2. Saya menyarankan untuk membaca istilah fuzziness edit distance, slop, prefix_length, max_expansions, dan tranpositions, karena istilah ini cukup penting untuk membuat string query yang baik.

Kemudian berkaitan dengan pencarian Driver di suatu daerah yang ditentukan dengan south west latitude, south west longitude, north east latitude, dan north east longitude (keempatnya dapat disebut dengan bounding box) digunakan object Regency (data Kota) yang bounding boxnya dapat diambil dari nominatin.
if (regencyId != null){
    Regency regency = regencyElasticRepository.findById(regencyId).get();    boolQueryBuilder = boolQueryBuilder
            .filter(new GeoBoundingBoxQueryBuilder("location")
                    .setCorners(regency.getNorthEastLat(), regency.getSouthWestLng(),                             regency.getSouthWestLat(), regency.getNorthEastLng()));}
Meskipun proses aslinya cukup rumit, deklarasi pada source code cukup sederhana. Kita cukup memberikan parameter nama field "location" dengan nilai bounding box. Kemudian query akan mencari data yang location-nya ada di dalam daerah yang dibatasi bounding box.
Terakhir untuk memenuhi pencarian Driver yang berada pada jarak tertentu dapat digunakan source code berikut.
if (currentLatitude != null && currentLongitude != null && radius != null){
    boolQueryBuilder = boolQueryBuilder
            .filter(QueryBuilders.geoDistanceQuery("location")
                    .point(currentLatitude, currentLongitude)
                    .distance(radius, DistanceUnit.KILOMETERS));}
Mirip dengan GeoBoundingQueryBuilder, proses geoDistanceQuery ini juga sebenarnya cukup rumit, tetapi dikarenakan kita menggunakan search query kita cukup fokus pada deklarasinya saja, dan deklarasinya juga sangat sederhana. Kita cukup memberikan parameter nama field "location" beserta dengan point komparasi jaraknya (point) dan batas jarak yang diinginkan (distance).

Realisasi Rancangan Query dengan SearchQuery
Dengan begitu semua kondisi pencarian telah terpenuhi. Namun yang telah kita lakukan barulah perancangan query. Rancangan query ini perlu dijalankan (direalisasikan). Caranya adalah dengan menggunakan Search Query dan elasticsearch template. Berikut deklarasinya dalam source code yang telah saya buat.
SearchQuery searchQuery;if (ids != null && ids.size() > 0){
    searchQuery = new NativeSearchQueryBuilder()
            .withMinScore(scoreThreshold)
            .withQuery(boolQueryBuilder)
            .build();} else if (currentLatitude != null && currentLongitude != null && radius != null){
    searchQuery = new NativeSearchQueryBuilder()
            .withQuery(boolQueryBuilder)
            .withPageable(new PageRequest(page, limit))
            .withSort(SortBuilders
                    .geoDistanceSort("location", new org.elasticsearch.common.geo
                            .GeoPoint(currentLatitude, currentLongitude))
                    .order(SortOrder.ASC))
            .build();} else {
    searchQuery = new NativeSearchQueryBuilder()
            .withQuery(boolQueryBuilder)
            .withPageable(new PageRequest(page, limit, new Sort(sortDirection, sortBy)))
            .build();}
Berdasarkan source code di atas terdapat 3 deklarasi nilai Search Query. Kondisi pertama membuat rancangan query direaslisasikan dengan melakukan sorting berdasarkan score minimum yang telah ditentukan. kondisi kedua melakukan sorting berdasarkan jarak terdekat ke terjauh terhadap point yang telah di definisikan currentLatitude dan currentLongitude. Kondisi ketiga melakukan sorting berdasarkan field yang ditentukan String sortBy dan directionnya ASC atau DESC, tergantung nilai sortDirection.

Fetch Data Hasil SearchQuery
Proses terakhir adalah mengambil data yang telah ditentukan oleh search query. Pada source code berikut data diambil kedalam Page, karena kita telah mengimplementasikan pagination sejauh ini.
catatan:
Jika kita mengambil data kedalam List atau Collection lain dari elasticsearch, maka elasticsearch hanya akan memberikan 10 data. Oleh karena itu kita sebaiknya mendefinisikan nilai limit, dan nilai maximum limit secara default adalah 10000, lebih dari itu kita perlu melakukan konfigurasi khusus pada elasticsearch.

elasticsearchTemplate.createIndex(Driver.class);Page<Driver> resultPage = elasticsearchTemplate.queryForPage(searchQuery, Driver.class);List<Driver> resultList = resultPage.getContent();
Perlu diketahui createIndex sebenarnya cukup dilakukan sekali saja, dapat saja dideklarasikan pada post construct.

Repository project dapat dilihat di sini.

No comments:

Post a Comment