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ğıda, MySQL’deki articles tablosuna göre article index’ine ait bir mapping yer alıyor ($indexV1):

{
  "settings": {
    "number_of_shards": 1,
    "number_of_replicas": 1
  },
  "mappings": {
    "properties": {
      "id": {
        "type": "integer"
      },
      "user_id": {
        "type": "integer"
      },
      "title": {
        "type": "text"
      },
      "body": {
        "type": "text"
      },
      "status": {
        "type": "short"
      },
      "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:

composer.json

{
  "require": {
    "elasticsearch/elasticsearch": "^7.12",
    "ext-pdo": "*"
  }
}
require 'vendor/autoload.php';

/**
 * 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.
 *
 * @param string $indexJson
 */
function indexAllArticles($indexJson)
{
    $indexName = 'article';
    $client    = Elasticsearch\ClientBuilder::create()->build();

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

    // create index with mapping
    $client->indices()->create([
        'index' => $indexName,
        'body'  => $indexJson
    ]);

    // index all articles
    $allArticles     = getAllArticles();
    $params          = [];
    $params['index'] = $indexName;

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

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

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

indexAllArticles($indexV1);

$indexV1 degiskeninde 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:

curl -X GET "localhost:9200/article/_analyze?pretty" -H 'Content-Type: application/json' -d'
{
  "analyzer" : "standard",
  "text" : "Gizemli mesajın kaynağı uzaylılar mı?"
}
'

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 = Elasticsearch\ClientBuilder::create()->build();

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

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

    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 tokenizer’i 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 tokenizer’i 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.

Index’imizi asagidaki $indexV2 ile güncelleyelim:

{
  "settings": {
    "number_of_shards": 1,
    "number_of_replicas": 1,
    "index": {
      "max_ngram_diff": 17
    },
    "analysis": {
      "filter": {
        "turkish_stop": {
          "type":       "stop",
          "stopwords":  "_turkish_" 
        },
        "turkish_lowercase": {
          "type":       "lowercase",
          "language":   "turkish"
        }
      },
      "analyzer": {
        "my_index_analyzer": {
          "filter": [
            "apostrophe",
            "turkish_lowercase",
            "turkish_stop",
            "asciifolding"
          ],
          "type": "custom",
          "tokenizer": "my_tokenizer"
        },
        "my_search_analyzer": {
          "filter": [
            "apostrophe",
            "turkish_lowercase",
            "turkish_stop",
            "asciifolding"
          ],
          "type": "custom",
          "tokenizer": "standard"
        }
      },
      "tokenizer": {
        "my_tokenizer": {
          "type": "edge_ngram",
          "min_gram": "3",
          "max_gram": "20",
          "token_chars": [
            "letter",
            "digit"
          ]
        }
      }
    }
  },
  "mappings": {
    "properties": {
      "id": {
        "type": "integer"
      },
      "user_id": {
        "type": "integer"
      },
      "title": {
        "type": "text",
        "analyzer": "my_index_analyzer",
        "search_analyzer": "my_search_analyzer"
      },
      "body": {
        "type": "text",
        "analyzer": "my_index_analyzer",
        "search_analyzer": "my_search_analyzer"
      },
      "status": {
        "type": "short"
      },
      "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 tokenizer’indan 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($indexV2);

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

curl -X GET "localhost:9200/article/_analyze?pretty" -H 'Content-Type: application/json' -d'
{
  "analyzer" : "my_index_analyzer",
  "text" : "Gizemli mesajın kaynağı uzaylılar mı?"
}
'

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.