Elasticsearch – Edge nGram

Elasticsearch gerçek zamanlı olarak çalışabilen bir arama motoru. Index’lediğiniz veri üzerinden çok hızlı bir şekilde arama ve filtreleme yapma imkanı sunuyor. İstenilen arama ve filtreleme sonuçlarına ulaşabilmek için öncelikle verinin doğru bir şekilde Elasticsearch’e tanıtılması gerekiyor. Bunu da index oluştururken belirttiğimiz mapping ayarıyla yapıyoruz. Mapping bize; verinin yapısı, türü, index’lenirken ve aranırken hangi analyzer’ların kullanılacağı gibi bir çok tanım yapma imkanı sunuyor.

Bir örnek üzerinden inceleyelim. Veritabanında articles isimli bir tabloda blog yazılarının olduğunu varsayalım:

CREATE TABLE `articles` (
  `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
  `user_id` int(10) unsigned NOT NULL,
  `title` varchar(100) NOT NULL,
  `body` text NOT NULL,
  `status` tinyint(1) NOT NULL DEFAULT '0',
  `created_at` datetime NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

İki adet yazı ekleyelim:

INSERT INTO `articles` (`id`, `user_id`, `title`, `body`, `status`, `created_at`)
VALUES
	(1,1,'Rosetta\'dan kötü haber','ESA\'nın büyük umutlarla uzaya fırlattığı uzay aracı Rosetta, yaşanan teknik bir arıza nedeniyle kumanda merkeziyle olan bağlantısını kaybetti.',1,'2015-04-05 13:35:00'),
	(2,2,'Gizemli mesajın kaynağı uzaylılar mı?','Bilim insanları, 14 yılda 10 defa saptanan enerji kaynağının dünya dışı bir medeniyetten gelmiş olabileceği ihtimali üzerinde duruyor.',0,'2015-04-05 15:35:00');

Aşağıdaki YAML uzantılı dosyada (elastic.yml) articles tablosuna göre bir mapping yer alıyor:

index: blog

body:
    settings:
        number_of_shards:   1
        number_of_replicas: 0

    mappings:
        _default_:
            properties:
                id:
                    type: integer

                user_id:
                    type: integer

                title:
                    type: string

                body:
                    type: string

                status:
                    type: integer

                created_at:
                    type: date
                    format: yyyy-MM-dd HH:mm:ss

Şimdi Elasticsearch’ün PHP kütüphanesini kullanarak tüm blog yazılarını index’leyelim:

/**
 * Get all articles.
 *
 * @return array
 */
function getAllArticles()
{
    $db = new PDO('mysql:host=127.0.0.1;dbname=test', 'root', '');
    $db->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);

    return $db->query('SELECT * FROM articles')->fetchAll(PDO::FETCH_ASSOC);
}

/**
 * Reindex all articles.
 */
function indexAllArticles()
{
    $indexName = 'blog';
    $typeName  = 'article';
    $client    = new Elasticsearch\Client();
    $yaml      = new Symfony\Component\Yaml\Parser();
    $elastic   = $yaml->parse(file_get_contents('elastic.yml'));

    // delete index
    $client->indices()->delete(['index' => $indexName, 'ignore' => 404]);

    // create index with mapping
    $client->indices()->create($elastic);

    // index all articles
    $allArtices      = getAllArticles();
    $params          = [];
    $params['index'] = $indexName;
    $params['type']  = $typeName;

    foreach ($allArtices as $article) {
        $params['body'][] = array(
            'index' => array(
                '_id' => $article['id']
            )
        );

        $params['body'][] = $article;
    }

    // bulk index
    $client->bulk($params);
}

indexAllArticles();

elastic.yml dosyasında yer alan mapping ile Elasticsearch’ün varsayılan analyzer’ını kullanmış olduk. Yani string olarak verilen title ve body alanları standart analyzer ile indexlendi.

2 nolu yazının başlığının Elasticsearch tarafından nasıl index’lendiğini inceleyelim:

http://localhost:9200/blog/_analyze?text=Gizemli%20mesajin%20kaynagi%20uzaylilar%20mi?

Başlıktaki her kelime tespit edilip ayrı ayrı index’e yazılmış. Eğer aranan kelime bu kelimelerden biriyle eşleşirse 2 nolu yazı arama sonucunda dönecektir.

Bir arama yapalım:

/**
 * Make search.
 * 
 * @param string $keyword
 * @return array
 */
function search($keyword)
{
    $client = new Elasticsearch\Client();

    // filtered query body
    $json   = '{
  "query": {
    "filtered": {
      "query": {
        "multi_match" : {
            "query" : "'.$keyword.'",
            "fields" : [ "title", "body" ]
        }
      },
      "filter": {}
    }
  }
}';

    $params['index'] = 'blog';
    $params['type']  = 'article';
    $params['body']  = $json;

    return $client->search($params);
}

$response = search('uzay');
print_r($response);

uzay kelimesini aradığımızda 1 nolu blog yazısı geliyor. 1 nolu yazının body alanında geçen “..fırlattığı uzay aracı..” cümlesinde yer alan uzay kelimesi index’e eklendiği için arama sonucunda geldi. Aslında 2 nolu yazının başlığında uzaylılar kelimesi yer almakta ve biz full-text araması yapmak istediğimizden doğal olarak bu yazının da sonuçlarda gelmesini bekliyoruz. Bu amaçla index’in mapping kısmını değiştirmemiz gerekiyor.

Öncelikle yapmamız gereken custom analyzer’lar oluşturmak. Custom analyzer’lar ile bir alanın nasıl index’leneceğini belirleyebiliyoruz. Örneğin custom analyzer’ımıza edge_ngram filtresi ekleyerek her kelimenin ilk 3 ile 20 hane arasında tüm varyasyonlarını index’e eklenmesini sağlayabiliriz. uzaylılar kelimesini ele alırsak, edge_ngram filtresi sayesinde aşağıdaki kısımlar index’e eklenecektir:

uza
uzay
uzayl
uzayli
uzaylil
uzaylila
uzaylilar

Böylece uzay ve uzaylı aramaları yapıldığında 2 nolu yazı için eşleşme sağlanacaktır.

elastic.yml dosyasını güncelleyelim:

index: blog

body:
    settings:
        number_of_shards:   1
        number_of_replicas: 0

        analysis:

            filter:
                my_ngram:
                    type:     edge_ngram
                    min_gram: 3
                    max_gram: 20

                turkish_lowercase:
                    type:     lowercase
                    language: turkish

            analyzer:
                my_index_analyzer:
                    type: custom
                    tokenizer: standard
                    filter:
                      - apostrophe
                      - turkish_lowercase
                      - asciifolding
                      - my_ngram

                my_search_analyzer:
                    type: custom
                    tokenizer: standard
                    filter:
                      - apostrophe
                      - turkish_lowercase
                      - asciifolding

    mappings:
        _default_:
            properties:
                id:
                    type: integer

                user_id:
                    type: integer

                title:
                    type: string
                    index_analyzer:  my_index_analyzer
                    search_analyzer: my_search_analyzer

                body:
                    type: string
                    index_analyzer:  my_index_analyzer
                    search_analyzer: my_search_analyzer

                status:
                    type: integer

                created_at:
                	type: date
                	format: yyyy-MM-dd HH:mm:ss

NOT: Index analyzer ile search analyzer’ı ayırmamızın sebebi, arama sırasında aranan kelimenin (edge_gram filtresinden dolayı) parçalara bölünmemesi gerektiğindendir. Eğer analyzer’ları ayırmazsak bilim aramasında, içinde bilye geçen bir yazı da karşımıza gelecektir ki bu beklenmeyen bir sonuç olacaktır.

Index’i tekrar oluşturalım:

indexAllArticles();

Şimdi 2 nolu yazının başlığını -analyzer belirterek- tekrar analiz edelim:

http://localhost:9200/blog/_analyze?analyzer=my_index_analyzer&text=Gizemli%20mesajin%20kaynagi%20uzaylilar%20mi?

Beklenildiği üzere başlıktaki her kelimenin, my_index_analyzer‘daki edge_ngram filtresinden dolayı 3-20 varyasyonlarını üretilip index’e ekleniyor. Artık uzay aramasında 2 nolu yazı da sonuçlar arasında gelecektir.