Taste of Tech Topics

Acroquest Technology株式会社のエンジニアが書く技術ブログ

GiNZAの固有表現抽出とElasticsearchを使って自動でタグ検索

こんにちは。@Ssk1029Takashiです。
最近は家でもどうにかラーメンを食べられないかと試行錯誤しています。

タグ検索とは、キーワード検索とは違い、検索する前からユーザーが選択肢からキーワードをセレクトボックスなどで選んで、検索できる検索方法です。
通常のキーワード検索と違って、ユーザーが0からキーワードを考える必要がないため、効率的に情報を絞り込めます。
もしくは、キーワード検索と併用して使用することも可能です。

ただ、コンテンツごとにタグを設定するのはとても手間がかかります。
コンテンツ作成者も必ずしもタグを設定してくれるとは限りません。
このような時に、自動でタグ付けをしてくれる仕組みがあると楽にタグ検索を実現できます。

ただ、単純な形態素解析で名詞をタグとすると、ゴミが多くなってしまいます。
そこで、今回は、日本語処理ライブラリであるGiNZAの固有表現抽出機能とElasticsearchを使って、簡易的にタグ検索を実現してみます。

今回のゴール

タグ検索というと、以下の画面のようにタグをチェックボックスで選択して、選択したタグによって検索結果が変わる画面が一般的です。
f:id:acro-engineer:20200426155821p:plain

今回目指すのは上記の検索のデータ投入・検索クエリを作成していきます。

今回書かないこと

本記事は以下のことは対象外としています。
1. 検索画面の実装
2. Elasticsearchの基本的なクエリの使い方

GiNZAとは

GiNZAについては、過去にブログで紹介しているので、詳細はそちらを参照してください。
acro-engineer.hatenablog.com

今回はこのGiNZAの固有表現抽出をタグ付けに使用していきます。

固有表現抽出とは

固有表現抽出とは、自然言語処理技術のうちの一つで、文章中から人名・組織名・地名などの固有名詞や、「100%」などの数値表現を抽出する技術です。
一般的には、以下の8つのクラスが固有表現のクラスとして抽出されます。
ART 固有物名、LOC(地名)、ORG(組織)、PSN(人名)、DAT(日付)TIM(時間)、MNY(金額)、PNT(割合)

どういうときに使われるかというと、例えば、未知語を辞書登録するために固有名詞を抽出したり、人名・企業名を抽出してプライバシー保護に使用したりなどが考えられます。

GiNZAの場合、より細かいクラスを定義しています。
拡張固有表現階層 定義
GiNZAはGSK2014-A (2019) BCCWJ版という、上記の固有表現クラスを定義したコーパスで学習されたモデルをもとに固有表現抽出を行います。

非常に細かく固有表現を分類しているので、より詳細な分析ができるようになっています。

今回は、文章中の固有表現はその文章の中心的な話題を示すことが多いのではないか仮定して、タグ検索に応用してみます。

どうやって解決するか

以下に概略図を書きました。
f:id:acro-engineer:20200425084125p:plain

つまり、本文とは別にGiNZAで固有表現として抽出したキーワードをタグとしてElasticsearchに投入します。
検索時はタグとして投入したキーワードの一覧を取得します。

GiNZAでの固有表現抽出

まず、GiNZAで固有表現抽出を試してみましょう。

実際に動かすコードは以下のようになります。

import spacy

nlp = spacy.load('ja_ginza')
doc = nlp("Acroquestは新横浜にある会社です。")

for ent in doc.ents:
    print(ent.text, ent.start_char, ent.end_char, ent.label_)

出力結果

Acroquest 0 9 Company
新横浜 10 13 City

ちゃんと「Acroquest」を会社、「新横浜」を地名と認識しています。

また、Spacyには固有表現抽出された結果をより分かりやすく可視化する機能もあります。

from spacy import displacy

displacy.render(doc, style="ent", jupyter=True)

結果
f:id:acro-engineer:20200419171932p:plain

このように、文章のどの単語が固有表現として抽出されたのかを一目で確認できます。

検索に応用する

それではElasticsearchと固有表現抽出を使ったタグ検索を実現していきます。

検索データを作る

コードにすると以下のようになります。

nlp = spacy.load('ja_ginza')
es_client = Elasticsearch()

for title, sentence in zip(titles, sentences):
  doc = nlp(sentence)
  tags = [ent.text.lower() for ent in doc.ents]
  tags = list(set(tags)) #重複するタグを削除
  document = {
      "title": title,
      "sentence": sentence,
      "tag": tags
  }
  es_client.index(index="content", doc_type="_doc", body=document)

各フィールドの値は以下のようにします。

フィールド 説明
title 記事のタイトル(画面表示用に投入)
sentence 記事本文
tag 記事から固有表現抽出で作成したタグ

試しに、このブログのここ最近の冒頭文とタイトルを入れてみました。
Elasticsearchに投入後は、以下のようにデータが登録されます。
(抜粋)

{
    "title": "LINE BotとAmazon Rekognitionでワーク&ライフハック",
    "sentence": """
こんにちは、DevOpsエンジニアの横山です。
今回は、LINE BotAWSの画像分析サービスを使って社員のワーク&ライフハックを行った内容を紹介したいと思います。
             """,
          "tag": [
        "line bot",
        "社員",
        "aws",
        "横山",
        "devopsエンジニア"
    ]
},
{
    "title" : "ANGEL Dojo最終発表で「アライアンス賞」を受賞しました!",
    "sentence" : """
こんにちは!
2年目エンジニアの古賀です。
先日ブログで紹介したANGEL Dojoですが、 2020年3月6日、最終発表があり、AWSの審査員の方々が選ぶ「アライアンス賞」を受賞しました!
上位3チームに選ばれると貰える賞の1つで、 今回がんばってきたことが1つの形になって、とても嬉しいです。
アライアンス賞の詳細は後ほど^^
※ANGEL Dojo とは、AWS様主催の疑似プロジェクトを通して、 クラウド開発力とAmazonの文化を学び、ビジネスで日本を元気にしよう! という企画です。
       """,
    "tag" : [
      "2020年3月6日",
      "上位3チーム",
      "angel dojo",
      "aws",
      "2年",
      "古賀",
      "日本",
      "アライアンス賞",
      "amazon",
      "審査員",
      "1つ",
      "目エンジニア"
    ]
  }
},

入ったタグをKibanaのタグクラウドで見てみると以下のようになります。
f:id:acro-engineer:20200425154433p:plain
最近記事が多かったANGEL DojoやAutoGluonなどの単語が拾えていますね。

数値表現や日付が混じってしまっていますが、GiNZAは固有表現で識別したクラスも取得できるので、数値や日付はフィルタするようにすればより精度が高く抽出できます。

検索する

データが入れば、次は検索してみましょう。
タグ検索には以下の2ステップが必要です。
1. タグの一覧取得
2. 選択したタグでの検索

1のタグ一覧の取得はtagフィールドに対して、Terms Aggregationを実行することで取得できます。

GET content/_search
{
  "size": 0,
  "aggs": {
    "terms": {
      "terms": {
        "field": "tag",
        "size": 10
      }
    }
  }
}

上のクエリで、tagをリストを出現数順に取得することができます。

2の選択したタグでの検索は、1で取得したタグから選択した値をtagフィールドにTerms Queryで検索することで取得できます。

GET content/_search
{
  "size": 0,
  "aggs": {
    "terms": {
      "terms": {
        "field": "tag",
        "size": 10
      }
    }
  }
}

以上で、GiNZAを使ったタグ検索は実現できます。

画面にすると以下のようになります。
f:id:acro-engineer:20200426155821p:plain

0からキーワードを考えるよりも、なんとなくでも文書の内容がわかったほうが探しやすいですね。

まとめ

GiNZAの固有表現抽出とElasticsearchを使って自動でタグ検索を実現してみました。
改善点としては、数詞や日付が入ってしまっているので、抽出されたタグから固有表現のクラスでフィルタリングするとより精度の高いタグ検索が可能になります。
GiNZAは3.0から固有表現のクラスがより細かくなっているため、扱いやすくなっています。

それでは、皆さんもよいNLPライフを。

Acroquest Technologyでは、キャリア採用を行っています。


  • ディープラーニング等を使った自然言語/画像/音声/動画解析の研究開発
  • Elasticsearch等を使ったデータ収集/分析/可視化
  • マイクロサービス、DevOps、最新のOSSを利用する開発プロジェクト
  • 書籍・雑誌等の執筆や、社内外での技術の発信・共有によるエンジニアとしての成長

 
少しでも上記に興味を持たれた方は、是非以下のページをご覧ください。
Kaggle Masterと働きたい尖ったエンジニアWanted! - Acroquest Technology株式会社のデータサイエンティストの求人 - Wantedlywww.wantedly.com

Elastic{ON} Americas East 最速レポート!

f:id:acro-engineer:20200424012245p:plain:w600
こんにちは、Elastic Certified Engineerの@shin0higuchiです😊

日本時間4/23(木)22:00から、Elastic{ON} Americas Eastが、Webinar形式で開催されました。
例年、Elatic{ON} Tourという形で開催されていましたが、今年はCOVID-19の影響でオンライン開催に変更されています。
Elastic COVID-19 update | Elastic Blog を読むと経緯がわかります。)

COVID-19は早く収束してほしいですが、海外のイベントに自宅から参加できるのは嬉しいですね。
今回は、若手数名で参加したので、オムニバス形式で内容のまとめ・感想を書いて行きたいと思います!
※スクリーンキャプチャを利用していますが、ブログでの利用許可を主催者側に得ています。

Keynote

Elastic Certified Engineerのノムラです。
Keynoteは主にElastic社の3つのソリューションについての紹介の話でした。
f:id:acro-engineer:20200424012726p:plain:w400
シャイの眼鏡姿が新鮮ですね(笑)

Elastic社の3つのソリューションについての紹介

3つのソリューションとは

  • Search
  • Observability
  • Security

のことです。
この3つのソリューションに紐づくサービスについて、デモも交えながらの紹介でした。

各サービスの中でも個人的にはSearchのサービスの1つであるWorkplace Searchが印象的でした。
www.elastic.co
エンタープライズサーチにおいて、かなり強力なソリューションになりそうだと感じました。
GUIから簡単にリソースを連携/登録できるのが利用する際に便利そうです。

また、GUIマーケティング、開発、ファイナンスのようなグループを作成することができ、各グループにユーザを登録できます。
そのグループ毎に検索可能なリソースを割り当てることで「他グループには見せたくない」情報やドキュメントを管理できるのも魅力的だと感じました。

Combining Logs, Metrics, and Traces for Unified Observability

f:id:acro-engineer:20200424024957p:plain:w400
4年目エンジニアの緑川です。
このセッションでは、Logs, Metric, APM, Uptimeなど、複数のデータを1つのダッシュボードに統合し可視化していました。
Elastic Common Schemaにより、ソースが異なるデータもフォーマットが統一されるため、
分析が簡単に出来るようになっているので便利ですね。

Search for All with Elastic Workplace Search

f:id:acro-engineer:20200424023531p:plain:w400
Workplace searchは、Google DriveDropboxSharepointなどの異なるデータソースに保存されているデータを横断して検索可能です。
普段自分も、SharepointやOneDriveなど、複数のサービスを利用していますが、
自分が欲しい情報がどこにあるのかを探すのに時間がかかるため、非効率だと感じていました。
横断的に検索して見つけられる、かつ、UIから用意されたコネクタを設定するだけなので、お手軽で便利そうです。

Bandwidth: Use Cases for Elastic Cloud on Kubernetes

Bandwidth社における、Elastic Cloud on Kubernetes(ECK)の事例紹介。
f:id:acro-engineer:20200424013249p:plain:w400

Bandwidth社は、いわゆるCPaaS(communications platform as a service)の企業です。
www.bandwidth.com
クラスタの規模が大きいことや、PaaSという特性上、複数クラスタを運用するコストが課題となっていたようです。

Openshift/k8sの導入、Elastic Cloudの利用開始などの経緯について紹介していました。Ansibleによって複雑なデプロイメントを管理していたのをECKにしたことで運用コストが大幅に下がったということでした。
(学習・導入コストは大きかったが、それを大きく上回る益があったそうです)

Elastic Security: Enterprise Protection Built on the Elastic Stack

f:id:acro-engineer:20200424013508p:plain:w400
Elastic Stackが提供するセキュリティ機能についてのセッション。
分析者のスキルによらず、統一的なセキュリティ分析プラットフォームを提供できることだと思います。
セッションの中では次の3点を挙げていました。

  • eliminate blind spots
  • stop threats at scale
  • arm every analyst

デモの印象として、ひとたびElasticsearchにログを集約する仕組みさえ整えてしまえば、分析者のスキルがなくとも脅威を検出できそうだと感じました。 ちょっと前まで、ElasticsearchのMLでセキュリティ脅威を検出するために、データ加工や検出するためのロジックに頭を悩ませていただけに、感動も一入です。

最近Elasticが特に力を入れている部分だと思うので、今後にさらに期待したいですね😊

Elastic Stack Roadmap Deep Dive

再びノムラです。
本セクションではこれまでのロードマップの中から

  • Data Management
  • Data Analysis
  • Actions & Alerting

についてプレゼンとデモがありました。
その中でも特に印象的だったData Managementについて記載します。

Data Management

昨年新しく追加されたFrozen Indexの機能を踏まえたインデックスライフサイクルとデータストレージの運用についての話でした。
インデックスのライフサイクルに合わせて、

[↑検索頻度高↑]

  • Hot
  • Warn
  • Cold
  • Frozen

[↓検索頻度低↓]

と状態を変えていくことでヒープ(メモリ)とディスクをより効率的に利用することができます。

プレゼンではFrozen Idnexの内部的な仕組みについて、解説がありました。
詳細は割愛しますが、検索を高速化するために内部的に保持しているデータをFrozenでは保持しないようにすることでメモリの使用量を節約しているそうです。

上手く活用して大規模データでもコストを抑えられるようにしたいですね。

Elasticsearch on Azure

最後のセッションもノムラがお届けします。
このセッションでは、去年の12月にGAとなったAzureでのElasticsearch Serviceの紹介でした。以下のブログにも詳しく書かれています。
www.elastic.co

セッションの中では、今後のロードマップが発表され、

  • Azure Marketplaceの支払いと統合する
  • Elasticsearch ServiceでAzure Private Linkを利用可能とする

等によりAzureの他サービスとの統合/連携が簡易になっていくようです。
当社ではAzure上でElasticStackを利用するシーンもあるため、はやく実現して欲しいですね。

最後に

全体を通して、「Elastic Stackだけで全部できる」という点が強調されていたように思います。あらゆるデータを集約し、様々なユースケースにシームレスに対応できる。 改めてElastic Stackのパワーを感じました。

個人的にはWorkplace SearchのGAが近いことや、Endpoint Securityが今後どのようにStackに統合されていくのかは要注目だと感じました。


それでは、今回のレポートはこのあたりで。
お読みいただきありがとうございました😊


Acroquest Technologyでは、キャリア採用を行っています。

  • ディープラーニング等を使った自然言語/画像/音声/動画解析の研究開発
  • Elasticsearch等を使ったデータ収集/分析/可視化
  • マイクロサービス、DevOps、最新のOSSを利用する開発プロジェクト
  • 書籍・雑誌等の執筆や、社内外での技術の発信・共有によるエンジニアとしての成長

 
少しでも上記に興味を持たれた方は、是非以下のページをご覧ください。

世界初のElastic認定エンジニアと一緒に働きたい人Wanted! - Acroquest Technology株式会社のデータサイエンティストの求人 - Wantedlywww.wantedly.com

Elasticsearchのクエリ変更による影響度をオフライン評価する

概要

こんにちは、shin0higuchiです😊
この記事はElastic Stack (Elasticsearch) Advent Calendar 2019 - Qiita20日目です。

皆さんはElasticsearchの検索クエリのランキングをチューニングする際、どのようなプロセスで実施していますか?
変更したクエリを最初からABテストにかけるのではなく、オフライン評価をおこなうことが多いのではないでしょうか?
今回はElasticsearchクエリを変更した時の、検索結果(ランキング)への影響度をオフライン評価する方法についてです。

f:id:acro-engineer:20191220013401p:plain:w600

バージョン情報など

  • Elasticsearch : 7.5.0
  • Python:3.8.0

下準備

クエリ変更云々の前に、まずベースとなるデータを用意します。
利用するのはwikipediaの日本語データです。

indexのmappingはシンプルに次のようにしました。
Wikipedia記事の内容はkuromoji analyerを適用したtextフィールドにindexします。
また、textのサブフィールドとして、bigramでtokenizeするtext.ngramフィールドも作成します。

{
  "settings": {
    "analysis": {
      "analyzer": {
        "bi-gram":{
          "tokenizer":"bi-gram"  
        }
      },
      "tokenizer": {
        "bi-gram":{
          "type":"ngram",
          "min_gram":2,
          "max_gram":2
        }
      }
    }
  },
  "mappings": {
    "properties": { 
    "text":{
      "type":"text",
      "analyzer":"kuromoji",
      "fields": {
        "ngram":{
          "type":"text",
          "analyzer":"bi-gram"
        }
      }
    }
    }
  }
}

また、今回はクエリ変更による影響度がテーマなので、変更前のクエリを事前に用意しておきます。

{
  "size":20,
  "query": {
     "match": {
        "text": "日本の電車"
     }
  }
}

影響度の測定

今回は影響度の測定のために、Ranking Evaluation APIを利用します。
Ranking Evaluation APIは、検索クエリのランキング結果に基づき、品質を測るためのものです。
概要については、昨年のアドベントカレンダーで記事にしていますのでご覧ください。
acro-engineer.hatenablog.com

本来、Ranking Evaluation APIは、正解となるランキングデータを事前に用意し、それに基づいてクエリを評価します。
本記事では、便宜上「変更前クエリの検索結果」を正解データと位置付け、クエリの変更がどの程度影響を及ぼすかを確認していきます。

ここからは、ElasticsearchへのリクエストはPythonを用いる想定で記述します。
※ 個人的にはKibanaのConsoleも好きですが、レスポンスを次のクエリに渡して実行する場合は辛いものがあります。

さて、変更前のクエリを実行してみます。
ここではIDの並び順のみ取得します。

# クエリを発行し、レスポンスから検索結果のIDリストのみを取得する(bodyは上述の変更前クエリ)
res = requests.post(ES_HOST + '/wikidata/_search', data=json.dumps(body), headers=headers).json()
org_result_set = [k['_id'] for k in res['hits']['hits']]
org_result_set

レスポンスは次の通り(size=20に設定しています)

['GMVlHm8BSg9IjoHR5Dek',
 '3tRtHm8BSg9IjoHROBB0',
 'SMpoHm8BSg9IjoHRXBsO',
 '9shnHm8BSg9IjoHRj4XA',
 '_MRlHm8BSg9IjoHRVgLP',
 'z8ZmHm8BSg9IjoHRrsBq',
 'BsVlHm8BSg9IjoHR-2i-',
 'GtBrHm8BSg9IjoHRhpPG',
 'D8hnHm8BSg9IjoHRuc3Z',
 'O9VtHm8BSg9IjoHRwSNt',
 'l8hnHm8BSg9IjoHRuctX',
 'HdBrHm8BSg9IjoHRe30U',
 '7tBrHm8BSg9IjoHRjpin',
 '4cpoHm8BSg9IjoHRpsTn',
 'esVmHm8BSg9IjoHRO8xl',
 '0MZmHm8BSg9IjoHRw_PP',
 'e9VtHm8BSg9IjoHRzTZ2',
 'JsdnHm8BSg9IjoHRAFPl',
 'HcdnHm8BSg9IjoHRAFGB',
 'kddvHm8BSg9IjoHRKJCY']


この結果を使って、Ranking Evaluationのリクエストを作成します。
まずはqueryを変更せずに実行してみます。

body = {
  "requests": [
    {
      "id": "1",
      "request": {
        "query": {
          "match": {
            "text": "日本の電車"
          }
        }
      },
      "ratings": []
    }
  ],
  "metric": {
    "dcg": {
      "k" : 20,
      "normalize": True
    }
  }
}

# 変更前クエリのレスポンスを元に、rating(正解データ)のリストを作成
for id in org_result_set:
  body['requests'][0]['ratings'].append({
      '_index':'wikidata',
      '_id':id,
      'rating':1
  })

requests.post(ES_HOST + '/wikidata/_rank_eval', data=json.dumps(body), headers=headers).json()

今回は比較のためのmetricとして、nDCGを利用することにします。
検索結果のランキングに全く差分がなければ、nDCGの値は1になります。
逆に言えば、 元の結果から大きく外れるほど0に近づくことになります。

それでは、クエリを変更してみましょう。
textフィールドへの検索をおこなうだけでなく、text.ngramも検索対象に入れてみます。

body = {
  "requests": [
    {
      "id": "1",
      "request": {
        "query": {
          "bool": {
            "should": [
              {
                "match": {
                  "text": "日本の電車"
                }
              },
              {
                "match": {
                  "text.ngram": "日本の電車"
                }
              }
            ]
          }
        }
      },
      "ratings": []
    }
  ],
  "metric": {
    "dcg": {
      "k" : 20,
      "normalize": True
    }
  }
}

# 変更前クエリのレスポンスを元に、rating(正解データ)のリストを作成
for id in org_result_set:
  body['requests'][0]['ratings'].append({
      '_index':'wikidata',
      '_id':id,
      'rating':1
  })

requests.post(ES_HOST + '/wikidata/_rank_eval', data=json.dumps(body), headers=headers).json()

レスポンス

{'metric_score': 0.1856246546622184,
 'details': {'1': {'metric_score': 0.1856246546622184,
   'unrated_docs': [{'_index': 'wikidata', '_id': 'n8tpHm8BSg9IjoHRJMSw'},
    {'_index': 'wikidata', '_id': '7dNtHm8BSg9IjoHRB67v'},
    {'_index': 'wikidata', '_id': 'ttNtHm8BSg9IjoHRB62I'},
    {'_index': 'wikidata', '_id': 'otJsHm8BSg9IjoHRUjhV'},
    {'_index': 'wikidata', '_id': 'kdNsHm8BSg9IjoHR-pG5'},
    {'_index': 'wikidata', '_id': '-s5qHm8BSg9IjoHRpc46'},
    {'_index': 'wikidata', '_id': 'yM5qHm8BSg9IjoHRrNzE'},
    {'_index': 'wikidata', '_id': 'KcpoHm8BSg9IjoHRSQgb'},
    {'_index': 'wikidata', '_id': 'pNZuHm8BSg9IjoHRlo2o'},
    {'_index': 'wikidata', '_id': 's9ZuHm8BSg9IjoHRl46t'},
    {'_index': 'wikidata', '_id': 'QMloHm8BSg9IjoHRFpFY'},
    {'_index': 'wikidata', '_id': '1s1pHm8BSg9IjoHRyyBV'},
    {'_index': 'wikidata', '_id': 'eM1qHm8BSg9IjoHRAIyh'},
    {'_index': 'wikidata', '_id': '5stpHm8BSg9IjoHRHraG'},
    {'_index': 'wikidata', '_id': 'FdBrHm8BSg9IjoHRhpPG'},
    {'_index': 'wikidata', '_id': 'adFrHm8BSg9IjoHR6VuY'},
    {'_index': 'wikidata', '_id': '9dFsHm8BSg9IjoHRAZiH'}],
   'hits': [{'hit': {'_index': 'wikidata',
      '_type': '_doc',
      '_id': 'n8tpHm8BSg9IjoHRJMSw',
      '_score': 29.578623},
     'rating': None},
    {'hit': {'_index': 'wikidata',
      '_type': '_doc',
      '_id': 'GtBrHm8BSg9IjoHRhpPG',
      '_score': 29.015862},
     'rating': 1},
    {'hit': {'_index': 'wikidata',
      '_type': '_doc',
      '_id': '7dNtHm8BSg9IjoHRB67v',
      '_score': 28.828218},
     'rating': None},
    {'hit': {'_index': 'wikidata',
      '_type': '_doc',
      '_id': 'ttNtHm8BSg9IjoHRB62I',
      '_score': 28.828218},
     'rating': None},
    {'hit': {'_index': 'wikidata',
      '_type': '_doc',
      '_id': '7tBrHm8BSg9IjoHRjpin',
      '_score': 28.740217},
     'rating': 1},
    {'hit': {'_index': 'wikidata',
      '_type': '_doc',
      '_id': 'otJsHm8BSg9IjoHRUjhV',
      '_score': 28.483252},
     'rating': None},
    {'hit': {'_index': 'wikidata',
      '_type': '_doc',
      '_id': 'kdNsHm8BSg9IjoHR-pG5',
      '_score': 28.478014},
     'rating': None},
    {'hit': {'_index': 'wikidata',
      '_type': '_doc',
      '_id': '-s5qHm8BSg9IjoHRpc46',
      '_score': 28.416624},
     'rating': None},
    {'hit': {'_index': 'wikidata',
      '_type': '_doc',
      '_id': 'yM5qHm8BSg9IjoHRrNzE',
      '_score': 28.312298},
     'rating': None},
    {'hit': {'_index': 'wikidata',
      '_type': '_doc',
      '_id': '9shnHm8BSg9IjoHRj4XA',
      '_score': 28.297285},
     'rating': 1},
    {'hit': {'_index': 'wikidata',
      '_type': '_doc',
      '_id': 'KcpoHm8BSg9IjoHRSQgb',
      '_score': 28.2964},
     'rating': None},
    {'hit': {'_index': 'wikidata',
      '_type': '_doc',
      '_id': 'pNZuHm8BSg9IjoHRlo2o',
      '_score': 28.217278},
     'rating': None},
    {'hit': {'_index': 'wikidata',
      '_type': '_doc',
      '_id': 's9ZuHm8BSg9IjoHRl46t',
      '_score': 28.217278},
     'rating': None},
    {'hit': {'_index': 'wikidata',
      '_type': '_doc',
      '_id': 'QMloHm8BSg9IjoHRFpFY',
      '_score': 28.217278},
     'rating': None},
    {'hit': {'_index': 'wikidata',
      '_type': '_doc',
      '_id': '1s1pHm8BSg9IjoHRyyBV',
      '_score': 28.157154},
     'rating': None},
    {'hit': {'_index': 'wikidata',
      '_type': '_doc',
      '_id': 'eM1qHm8BSg9IjoHRAIyh',
      '_score': 28.04702},
     'rating': None},
    {'hit': {'_index': 'wikidata',
      '_type': '_doc',
      '_id': '5stpHm8BSg9IjoHRHraG',
      '_score': 27.978518},
     'rating': None},
    {'hit': {'_index': 'wikidata',
      '_type': '_doc',
      '_id': 'FdBrHm8BSg9IjoHRhpPG',
      '_score': 27.97674},
     'rating': None},
    {'hit': {'_index': 'wikidata',
      '_type': '_doc',
      '_id': 'adFrHm8BSg9IjoHR6VuY',
      '_score': 27.943295},
     'rating': None},
    {'hit': {'_index': 'wikidata',
      '_type': '_doc',
      '_id': '9dFsHm8BSg9IjoHRAZiH',
      '_score': 27.888338},
     'rating': None}],
   'metric_details': {'dcg': {'dcg': 1.3068473871238868,
     'ideal_dcg': 7.040268381923511,
     'normalized_dcg': 0.1856246546622184,
     'unrated_docs': 17}}}},
 'failures': {}}

nDCGの値が「0.1856246546622184」と、変更前の検索結果とかなりの差分があることがわかります。
nDCGの値が低くなったのは、検索ワード固有のものかもしれないので、リクエストを複数の検索ワードで発行し、平均を取るなどすると良いでしょう。

これによって、クエリ変更した際、検索結果にどの程度の影響が出るかを大まかに知ることができます。
「影響度がある程度大きい」かつ「オフラインで検索結果の改善が見られた」クエリを選んでABテストにかけるという判断基準のひとつとして有用かと思います。

まとめ

  • Ranking Evaluation APIを用いることで、クエリ変更時の影響度を予測することができた
  • 影響度を予測することで、ABテストにかけるかどうかの判断材料のひとつとして利用することができる


明日は @yukata_uno さんによる記事になります。お楽しみに!



Acroquest Technologyでは、キャリア採用を行っています。

  • ディープラーニング等を使った自然言語/画像/音声/動画解析の研究開発
  • Elasticsearch等を使ったデータ収集/分析/可視化
  • マイクロサービス、DevOps、最新のOSSを利用する開発プロジェクト
  • 書籍・雑誌等の執筆や、社内外での技術の発信・共有によるエンジニアとしての成長

少しでも上記に興味を持たれた方は、是非以下のページをご覧ください。

ユーザに最高の検索体験を提供したいエンジニアWanted! - Acroquest Technology株式会社のエンジニアの求人 - Wantedlywww.wantedly.com

Elastic Stackを導入することでRDB検索を高速化する

こんにちは、Elastic Certified EngineerHiroshi Yoshioka です。この記事は Elastic stack (Elasticsearch) Advent Calendar 2019 の16日目の記事になります。

はじめに

本日のテーマは「Elastic Stackを活用したRDB検索の高速化」です。RDBを用いたシステムにおいて、データサイズが肥大化すると一般に検索性能が劣化します。特にLike演算子を用いたテキスト検索は、劣化の度合いが顕著です。

システム構成を変更せずに性能改善を行う場合、データスキーマSQLを見直すことである程度の性能改善が可能ですが、スキーマ変更に伴うアプリケーション改修量が多く、またそれに見合う性能改善を得られない場合もあります。一方で、Elastic Stackを導入し、検索処理部分のみをElasticsearchに移行するアプローチは、アプリケーション改修量は小さく、また飛躍的な性能改善を実現することが可能です。

検索性能改善:Before/After

以下は、過去Elastic Stackで性能改善した事例の性能数値です。

事例(1)

  • RDBMySQL
  • 蓄積データ:3200万件
検索時間(Before) 検索時間(After) 検索速度
30~60秒 50~200ミリ秒 300~600倍に向上

事例(2)

検索時間(Before) 検索時間(After) 検索速度
60~220秒 100~200ミリ秒 600~1000倍に向上

コメント

既存システムが遅すぎでは?という指摘があるかもしれませんが、データ設計/データサイズ/SQLによっては検索に数十秒かかるシステムをよく見かけます。これをElastic Stackを導入することで、検索時間を100ミリ秒前後に抑えることができました。

システム構成

Before/Afterのシステム構成は以下のようになります。

既存システム構成
既存システム構成
Elastic Stack導入後システム構成
Elastic Stack導入後システム構成

「Elastic Stack導入によるRDB検索高速化」のポイント

  • データは非正規化してElasticsearchに持たせる(Nested、Parent/Childは使わない)
  • 非正規化によりインデックスサイズがRDBのデータサイズよりも大きくなるので、適宜チューニングをしてサイズのダイエットをする
  • Elasticsearchへのデータ同期はLogstashを利用する
  • Elasticsearchには、検索機能として最低限必要な情報のみをデータ移行しインデックスサイズを抑える
  • Elasticsearchは検索結果のIDのみを返却し、画面に必要なデータはRDBから取得する。
  • 画面に必要なすべてのデータをElasticsearchに持たせるアプローチの方がシンプルですが、インデックスサイズが膨らみ、それに伴って必要なデータノード数も増えることに注意

最後に

Elastic Stackを導入する上で、インデックス/クエリ設計、データ同期(初期移行、定期同期、障害復旧)、運用設計など、検討事項は多々ありますが、書くと長くなりすぎるので、今回はこの辺で終ります。 RDB検索の高速化に興味がある方は、以下ページにてぜひお問い合わせください。 www.endosnipe.com


Acroquest Technologyでは、キャリア採用を行っています。

  • ディープラーニング等を使った自然言語/画像/音声/動画解析の研究開発
  • Elasticsearch等を使ったデータ収集/分析/可視化
  • マイクロサービス、DevOps、最新のOSSを利用する開発プロジェクト
  • 書籍・雑誌等の執筆や、社内外での技術の発信・共有によるエンジニアとしての成長

少しでも上記に興味を持たれた方は、是非以下のページをご覧ください。

ユーザに最高の検索体験を提供したいエンジニアWanted! - Acroquest Technology株式会社のエンジニアの求人 - Wantedlywww.wantedly.com

Logstash の conf ファイルをリグレッションテストする

こんにちは。アキバです。

この記事は、 Elastic Stack (Elasticsearch) Advent Calendar 2019 の14日目です。
qiita.com

今年の8月にElastic Certified Engineerになることができました。
ということで、今年の Advent Calendar は Elastic Stack のカレンダーにお邪魔します。

構築したシステムの「正しさ」

突然ですが、Elastic Stackを使って構築する/したシステムの「正しさ」を検証していますか?
きちんと検証できていないと、インフラ/ミドルウェアの障害は影響も大きく、問題になりがちです。

そもそも、「正しさ」を検証するべき箇所は何があるでしょうか?


一つは、「設定の正しさ」があるでしょう。

  • OS環境
  • Elasticsearchのパラメータ(elasticsearch.ymlの記載や _settings API など)
  • Index Mapping

など。

これらについては、静的なファイルチェックに加えて、構築後の環境に対する Serverspecや、Karateを使ったAPIテストなどが有効そうです。

※Karateを使ったAPIテストについては、以前にshin0higuchiが書いた以下の記事も見てください
acro-engineer.hatenablog.com


もう一つは、「ロジックの正しさ」がありますね。

JavaPythonなど、Elasticsearchを使うアプリケーションのロジックはユニットテストをすることを考えますが、Logstashのconfはどうでしょうか?

書いてみたことがある方は分かると思いますが、Logstashのconf(つまりパイプライン)は、れっきとしたロジックであると言えます。
すなわち、入力(input)があり、処理(filter)があり、出力(output)があり、filter部分にロジックがあるわけです。

今回は、この「Logstash conf (の filter) をテストする」ことを考えてみたいと思います。

logstash-test-runner

Logstash の conf それ自体は、定義ファイルのような形で作成・デプロイするので、このファイルを単体で試験しようとする人は多くないようです。

しかしながら、少し検索してみたら、以下のような、node.js ベースのTest Runnerを作っている方がいました。

github.com

原理はシンプルで、以下の3種類

  1. 入力となるログデータ
  2. テストしたいconfファイル
  3. 出力として期待する正解データ

を1セットとして用意すると、実際に 2. の conf を実行して得られたデータと 3. の正解データを比較して合否を判定してくれるというものです。

Logstashの実行は、Elastic社の純正Dockerイメージを取得して行うため、実行環境にあらかじめLogstashをインストールしておく必要はありませんし、バージョンの切り替えも簡単に行えます。

テストの仕組みとしては手軽で面白そうです。これを実際に使ってみましょう。

環境の準備

実行する環境は、Linuxbash シェルスクリプトが動作する環境)にします。

今回は、Azure上にVM(OSはUbuntu 18.04.3 LTS)を立てましたが、以下の要件を満たせるLinux環境を作れるなら、オンプレでも問題ないと思います。

※logstash-test-runner のページから抜粋

  • NodeJS > v8
  • Docker
  • Bash > v4

なお、前述の通り、ツール動作時にElastic社のDockerイメージを取得するため、インターネットに接続できる環境にしてください。

今回、私が構築した環境は以下の通りです。
本当は、node.jsはv11とかを入れておくべきなのでしょうけれども、手抜きで済ませております。

$ node -v
v8.10.0

$ docker -v
Docker version 19.03.5, build 633a0ea838

$ bash --version
GNU bash, version 4.4.20(1)-release (x86_64-pc-linux-gnu)

早速動かしてみる

まずは、git を使って、テストツールをクローンします。

$ git clone https://github.com/agolo/logstash-test-runner.git
$ cd logstash-test-runner

デフォルトで用意されているテスト(__tests__)を実行してみます。

テストは、test.sh というスクリプトから実行します。(お試しで作ったスクリプトではありません)
test.shの第1引数にディレクトリを指定すると、その配下のディレクトリをテストケースとして認識するようです。

今回の環境では、dockerコマンドはrootで実行する必要があるため、test.shもsudoをつけて実行します。

また、実行の際には、明示的にbashで実行する必要がありました。(この点はディストリビューションにもよるでしょう)

$ sudo bash test.sh __tests__

f:id:acro-engineer:20191215070512p:plain

通りました。簡単ですね!
初回はDockerイメージを取得するのに少々時間がかかりますが、2回目以降は各テスト30~60秒で通ります。
(テストにかかる時間の半分ぐらいは、Logstashの起動時間ですが)

Logstashのバージョン指定

デフォルトではlogstashのバージョンが5.5.1なのですが、第2引数を指定することで変えることができます。

せっかくなので、最新バージョンである7.5で試してみましょう。

$ sudo bash test.sh __tests__ docker.elastic.co/logstash/logstash:7.5.0

f:id:acro-engineer:20191215070515p:plain

おや、mongoのテストが失敗しました。
差分が出ると、上記のような diff結果が表示されます。

どこから失敗するようになるのでしょうか?

ということで、それぞれのバージョンで試してみました。
(これがコマンドラインオプションだけで試せるのはとても便利ですね)

バージョン 結果
5.5.1 成功
6.0.0 成功
6.5.0 成功
6.8.0 成功
7.0.0 失敗
7.5.0 失敗

上の結果から、6.x → 7.0 にバージョンアップするときに Logstash の動作仕様が変わったということがわかります。

差分を修正する

前述の差分は、よく見ると mongo テストの出力データの先頭2行が入れ替わっていることがわかります。

本来は、「なぜ入れ替わったのか」の原因を特定し、Logstash conf を修正するなどして解決するべきですが、
今回の入れ替わりがなぜ発生するのかは、少し調べてみただけではわかりませんでした。

ということで、(本来は良くない対処ですが)正解データを修正して、テストが通るようになるか試してみましょう。

mongo テストの正解データは、 __tests__/mongo/output.log です
これを開いて編集します。

{"conn_type":"COMMAND","querytime_ms":109,"@message":"command production.feeds command: find { find: \"feeds\", filter: { _id: \"abcdefg\" }, limit: 1, shardVersion: [ Timestamp 0|0, ObjectId('000000000000000000000000') ] } planSummary: IDHACK keysExamined:1 docsExamined:1 cursorExhausted:1 numYields:0 nreturned:1 reslen:2031 locks:{ Global: { acquireCount: { r: 2 }, acquireWaitCount: { r: 1 }, timeAcquiringMicros: { r: 108897 } }, Database: { acquireCount: { r: 1 } }, Collection: { acquireCount: { r: 1 } } } protocol:op_command","@timestamp":"2017-08-24T18:16:02.110Z","mongo_message":"command production.feeds command: find { find: \"feeds\", filter: { _id: \"abcdefg\" }, limit: 1, shardVersion: [ Timestamp 0|0, ObjectId('000000000000000000000000') ] } planSummary: IDHACK keysExamined:1 docsExamined:1 cursorExhausted:1 numYields:0 nreturned:1 reslen:2031 locks:{ Global: { acquireCount: { r: 2 }, acquireWaitCount: { r: 1 }, timeAcquiringMicros: { r: 108897 } }, Database: { acquireCount: { r: 1 } }, Collection: { acquireCount: { r: 1 } } } protocol:op_command","loglevel":"I","@version":"1","host":"testing_host","context":"conn88987","message":"2017-08-24T18:16:02.110+0000 I COMMAND  [conn88987] command production.feeds command: find { find: \"feeds\", filter: { _id: \"abcdefg\" }, limit: 1, shardVersion: [ Timestamp 0|0, ObjectId('000000000000000000000000') ] } planSummary: IDHACK keysExamined:1 docsExamined:1 cursorExhausted:1 numYields:0 nreturned:1 reslen:2031 locks:{ Global: { acquireCount: { r: 2 }, acquireWaitCount: { r: 1 }, timeAcquiringMicros: { r: 108897 } }, Database: { acquireCount: { r: 1 } }, Collection: { acquireCount: { r: 1 } } } protocol:op_command 109ms","tags":["_jsonparsefailure"],"timestamp":"2017-08-24T18:16:02.110+0000"}
{"conn_type":"ASIO","message":"2017-08-24T21:55:01.204+0000 I ASIO     [NetworkInterfaceASIO-ShardRegistry-0] Successfully connected to mydatabase.com:27019, took 66ms (1 connections now open to mydatabase.com:27019)","tags":["_jsonparsefailure"],"querytime_ms":66,"@message":"Successfully connected to mydatabase.com:27019, took (1 connections now open to mydatabase.com:27019)","@timestamp":"2017-08-24T21:55:01.204Z","mongo_message":"Successfully connected to mydatabase.com:27019, took (1 connections now open to mydatabase.com:27019)","loglevel":"I","@version":"1","host":"testing_host","context":"NetworkInterfaceASIO-ShardRegistry-0","timestamp":"2017-08-24T21:55:01.204+0000","mongo_message2":" (1 connections now open to mydatabase.com:27019)"}
{"conn_type":"NETWORK","@message":"received client metadata from 40.70.67.250:43270 conn99615: { driver: { name: \"NetworkInterfaceASIO-TaskExecutorPool-1\", version: \"3.4.7\" }, os: { type: \"Linux\", name: \"Ubuntu\", architecture: \"x86_64\", version: \"16.04\" } }","@timestamp":"2017-08-24T22:01:36.933Z","mongo_message":"received client metadata from 40.70.67.250:43270 conn99615: { driver: { name: \"NetworkInterfaceASIO-TaskExecutorPool-1\", version: \"3.4.7\" }, os: { type: \"Linux\", name: \"Ubuntu\", architecture: \"x86_64\", version: \"16.04\" } }","loglevel":"I","@version":"1","host":"testing_host","context":"conn99615","message":"2017-08-24T22:01:36.933+0000 I NETWORK  [conn99615] received client metadata from 40.70.67.250:43270 conn99615: { driver: { name: \"NetworkInterfaceASIO-TaskExecutorPool-1\", version: \"3.4.7\" }, os: { type: \"Linux\", name: \"Ubuntu\", architecture: \"x86_64\", version: \"16.04\" } }","tags":["_jsonparsefailure"],"timestamp":"2017-08-24T22:01:36.933+0000"}

これを、以下のように変えます。(つまり、COMMAND と ASIO の2行を入れ替えただけです)

{"conn_type":"ASIO","message":"2017-08-24T21:55:01.204+0000 I ASIO     [NetworkInterfaceASIO-ShardRegistry-0] Successfully connected to mydatabase.com:27019, took 66ms (1 connections now open to mydatabase.com:27019)","tags":["_jsonparsefailure"],"querytime_ms":66,"@message":"Successfully connected to mydatabase.com:27019, took (1 connections now open to mydatabase.com:27019)","@timestamp":"2017-08-24T21:55:01.204Z","mongo_message":"Successfully connected to mydatabase.com:27019, took (1 connections now open to mydatabase.com:27019)","loglevel":"I","@version":"1","host":"testing_host","context":"NetworkInterfaceASIO-ShardRegistry-0","timestamp":"2017-08-24T21:55:01.204+0000","mongo_message2":" (1 connections now open to mydatabase.com:27019)"}
{"conn_type":"COMMAND","querytime_ms":109,"@message":"command production.feeds command: find { find: \"feeds\", filter: { _id: \"abcdefg\" }, limit: 1, shardVersion: [ Timestamp 0|0, ObjectId('000000000000000000000000') ] } planSummary: IDHACK keysExamined:1 docsExamined:1 cursorExhausted:1 numYields:0 nreturned:1 reslen:2031 locks:{ Global: { acquireCount: { r: 2 }, acquireWaitCount: { r: 1 }, timeAcquiringMicros: { r: 108897 } }, Database: { acquireCount: { r: 1 } }, Collection: { acquireCount: { r: 1 } } } protocol:op_command","@timestamp":"2017-08-24T18:16:02.110Z","mongo_message":"command production.feeds command: find { find: \"feeds\", filter: { _id: \"abcdefg\" }, limit: 1, shardVersion: [ Timestamp 0|0, ObjectId('000000000000000000000000') ] } planSummary: IDHACK keysExamined:1 docsExamined:1 cursorExhausted:1 numYields:0 nreturned:1 reslen:2031 locks:{ Global: { acquireCount: { r: 2 }, acquireWaitCount: { r: 1 }, timeAcquiringMicros: { r: 108897 } }, Database: { acquireCount: { r: 1 } }, Collection: { acquireCount: { r: 1 } } } protocol:op_command","loglevel":"I","@version":"1","host":"testing_host","context":"conn88987","message":"2017-08-24T18:16:02.110+0000 I COMMAND  [conn88987] command production.feeds command: find { find: \"feeds\", filter: { _id: \"abcdefg\" }, limit: 1, shardVersion: [ Timestamp 0|0, ObjectId('000000000000000000000000') ] } planSummary: IDHACK keysExamined:1 docsExamined:1 cursorExhausted:1 numYields:0 nreturned:1 reslen:2031 locks:{ Global: { acquireCount: { r: 2 }, acquireWaitCount: { r: 1 }, timeAcquiringMicros: { r: 108897 } }, Database: { acquireCount: { r: 1 } }, Collection: { acquireCount: { r: 1 } } } protocol:op_command 109ms","tags":["_jsonparsefailure"],"timestamp":"2017-08-24T18:16:02.110+0000"}
{"conn_type":"NETWORK","@message":"received client metadata from 40.70.67.250:43270 conn99615: { driver: { name: \"NetworkInterfaceASIO-TaskExecutorPool-1\", version: \"3.4.7\" }, os: { type: \"Linux\", name: \"Ubuntu\", architecture: \"x86_64\", version: \"16.04\" } }","@timestamp":"2017-08-24T22:01:36.933Z","mongo_message":"received client metadata from 40.70.67.250:43270 conn99615: { driver: { name: \"NetworkInterfaceASIO-TaskExecutorPool-1\", version: \"3.4.7\" }, os: { type: \"Linux\", name: \"Ubuntu\", architecture: \"x86_64\", version: \"16.04\" } }","loglevel":"I","@version":"1","host":"testing_host","context":"conn99615","message":"2017-08-24T22:01:36.933+0000 I NETWORK  [conn99615] received client metadata from 40.70.67.250:43270 conn99615: { driver: { name: \"NetworkInterfaceASIO-TaskExecutorPool-1\", version: \"3.4.7\" }, os: { type: \"Linux\", name: \"Ubuntu\", architecture: \"x86_64\", version: \"16.04\" } }","tags":["_jsonparsefailure"],"timestamp":"2017-08-24T22:01:36.933+0000"}

これで、もう一度実行してみましょう。

$ sudo bash test.sh __tests__ docker.elastic.co/logstash/logstash:7.5.0

f:id:acro-engineer:20191215070519p:plain

成功するようになりました。

まとめ

logstash-test-runner を用いることで、conf ファイルをリグレッションテストする仕組みが比較的簡単に作れることがわかりました。

このツールは、ツール出力を正解データと差分比較することで合否判定をするため、正解データを予め用意しておくことが必要になります。
通常、Logstashの出力で正解データを手動で作成して用意することは現実的ではありませんので、「一回実行してうまくいったログ」を正解データとして使用することになるでしょう。

そういった意味で、このツールは「リグレッションテスト」用途に向いていると言えるでしょう。
それでも、Logstash の conf に対して、変更による破壊が起きていないかを早い段階で見つけられるようになるので、安心感が得られると思いました。


さて、そうすると、CIを回したくなりますね。
このツールの結果をJenkinsなどでレポート表示するには、多少解析する必要がありそうです。

この辺りの仕組みが作れないか、いずれ検討してみたいと思います。


では。

Acroquest Technologyでは、キャリア採用を行っています。


  • ディープラーニング等を使った自然言語/画像/音声/動画解析の研究開発
  • Elasticsearch等を使ったデータ収集/分析/可視化
  • マイクロサービス、DevOps、最新のOSSを利用する開発プロジェクト
  • 書籍・雑誌等の執筆や、社内外での技術の発信・共有によるエンジニアとしての成長

 
少しでも上記に興味を持たれた方は、是非以下のページをご覧ください。

世界初のElastic認定エンジニアと一緒に働きたい人Wanted! - Acroquest Technology株式会社のエンジニアの求人 - Wantedlywww.wantedly.com

全文検索で文書の新しさを考慮したスコアリング

皆さんこんにちは。@Ssk1029Takashiです。
この記事はElastic Stack (Elasticsearch) Advent Calendar 2019の13日目になります。
qiita.com

何か調べ物をしているとき、見つけた記事が古く使えないということが良くあります。
例えばElasticsearchのクエリを調べていたら、ver5時代の記事ですでに仕様が変わっていたりなど。。。
検索結果としては基本的には時系列が新しいものを優先して出してほしいことが多いです。

このように、検索システムでは基本的には新しい記事、なおかつ検索キーワードと関連度が高い記事を優先して出してほしいということがあります。
この時、単純に時系列でソートすると関連度を考慮できないため、検索スコアにいい感じに時系列情報を組み込む必要があります。
Elasticsearchでは、この問題をscript score queryを使って解決できます。

script score queryとは

Elasticsearch ver7から追加されたクエリで、scoreの値をpainless scriptで書くことができます。
www.elastic.co

例えば、以下のクエリで検索するとします。

GET /_search
{
    "query" : {
        "script_score" : {
            "query" : {
                "match": { "message": "elasticsearch" }
            },
            "script" : {
                "source" : "doc['likes'].value / 10 "
            }
        }
     }
}

このクエリでは、各検索結果のscoreはlikeフィールドの値を10で割った値になります。
このように検索クエリの中で、scoreの値をカスタマイズできるのがscript score queryです。

script score queryで時系列を考慮するには

script score queryでは、いくつかデフォルトで使用できる関数があるのですが、今回はその中のdecay関数を使用します。

decay関数の簡単な説明

decay関数とは名前の通り、特定の値に基づいて検索スコアを減衰させる関数です。
decay関数は適用できるデータ型によって3種類に分けられます。
1. 数値に基づきスコアを減衰させる関数
2. 地理的な距離に基づきスコアを減衰させる関数
3. 時系列に基づきスコアを減衰させる関数

また、それぞれのdecay関数には減衰値の計算方法が3種類(線形、ガウス分布正規分布)用意されています。
例えば、時系列に基づくdecay関数は以下の3つです。
1. decayDateLinear
2. decayDateExp
3. decayDateGauss

それぞれどのように値が減衰していくかのイメージが公式ドキュメントにあります。
https://www.elastic.co/guide/en/elasticsearch/reference/7.5/images/decay_2d.png
Function score query | Elasticsearch Reference [7.5] | Elastic
この中から、システム要件に合ったものを選びます。

実際のクエリは以下のような形になります。

{
  "query": {
    "script_score": {
      "query": {
        "match_all": {}
      },
      "script": {
        "source": "decayDateGauss(params.origin, params.scale, params.offset, params.decay, doc['timestamp'].value)",
        "params": {
          "origin": "2019-12-13T00:00:00Z",
          "scale": "60d",
          "offset": "0",
          "decay": 0.99
        }
      }
    }
  }
}

クエリ中で使用している引数は以下の4つになります。
origin: 減衰を計算する原点となる日時。この日時とドキュメントの日時がどれくらい離れているかでスコアが計算されます。
scale: 原点から距離を計算するときの単位。
offset: スコアを減衰させない範囲。
decay: どれくらいスコアを減衰させるか。

このようにscriptとして関数を呼び出すことで、scoreを調整することができます。

実際に試してみる。

データは以下の3つを投入します。

timestamp title content
2019-12-08 12:00:00 Micrometerで取得したデータをKibanaで可視化してみました Elastic Stackを活用しているAcroquestとしてはせっかくElasticsearchにも保存できるのにこれはもったいない…なら、うちで作るしかない!ということでMicrometer用のKibanaダッシュボードを作ってみました。
2018-12-20 12:00:00 ElasticsearchのRanking Evaluation APIについて整理してみた Ranking Evaluation APIは、検索クエリに対する検索結果の妥当性を評価するためのAPIです。Elasticsearchのバージョン6.2以降で利用することができます。
2018-05-08 12:00:00 Elasticsearchの圧縮方式の比較 Elasticsearchを使っているとストレージの使用量を節約したいと思う方は多いのではないでしょうか。Elasticsearchはデータを格納するときにデフォルトでLZ4という圧縮方式でデータ圧縮を行っていますが、実はLZ4よりも圧縮率の高いbest_compressionという圧縮方式を利用することもできます。

まずは、普通に検索してみます。

GET datetime_docs/_search
{
  "query": {
    "multi_match": {
      "query": "elasticsearch",
      "fields": ["title", "content"]
    }
  }
}

結果

{
        "_index" : "datetime_docs",
        "_type" : "_doc",
        "_id" : "3",
        "_score" : 0.5543933,
        "_source" : {
          "title" : "Elasticsearchの圧縮方式の比較",
          "content" : """
  Elasticsearchを使っているとストレージの使用量を節約したいと思う方は多いのではないでしょうか。
Elasticsearchはデータを格納するときにデフォルトでLZ4という圧縮方式でデータ圧縮を行っていますが、
実はLZ4よりも圧縮率の高いbest_compressionという圧縮方式を利用することもできます。
""",
          "timestamp" : "2018-05-08T12:00:00"
        }
      },
      {
        "_index" : "datetime_docs",
        "_type" : "_doc",
        "_id" : "2",
        "_score" : 0.46122766,
        "_source" : {
          "title" : "ElasticsearchのRanking Evaluation APIについて整理してみた",
          "content" : "  Ranking Evaluation APIは、検索クエリに対する検索結果の妥当性を評価するためのAPIです。\nElasticsearchのバージョン6.2以降で利用することができます。",
          "timestamp" : "2018-12-20T12:00:00"
        }
      },
      {
        "_index" : "datetime_docs",
        "_type" : "_doc",
        "_id" : "1",
        "_score" : 0.150402,
        "_source" : {
          "title" : "Micrometerで取得したデータをKibanaで可視化してみました",
          "content" : "  Elastic Stackを活用しているAcroquestとしてはせっかくElasticsearchにも保存できるのにこれはもったいない…なら、うちで作るしかない!\nということでMicrometer用のKibanaダッシュボードを作ってみました。",
          "timestamp" : "2019-12-08T12:00:00Z"
        }
      }

結果としては、キーワードが頻繁に出てくるドキュメントが上位に来ていますが、古いものが上位に来てしまっています。
これを、新しいドキュメントがより上位に来やすくします。

GET datetime_docs/_search
{
  "query": {
    "script_score": {
      "query": {
        "multi_match": {
          "query": "elasticsearch",
          "fields": ["title","content"]
        }
      },
      "script": {
        "source": "_score + decayDateGauss(params.origin, params.scale, params.offset, params.decay, doc['timestamp'].value)",
        "params": {
          "origin": "2019-12-13T00:00:00Z",
          "scale": "30d",
          "offset": "0",
          "decay": 0.99
        }
      }
    }
  }
}

script内の_score変数にはquery内で実行した単語検索によるスコアが入ります。
つまり、このクエリでは単語検索のスコア+時間で減衰したスコアの値を最終的なスコアにしています。
こちらの結果は以下のようになります。

      {
        "_index" : "datetime_docs",
        "_type" : "_doc",
        "_id" : "1",
        "_score" : 1.1501759,
        "_source" : {
          "title" : "Micrometerで取得したデータをKibanaで可視化してみました",
          "content" : "  Elastic Stackを活用しているAcroquestとしてはせっかくElasticsearchにも保存できるのにこれはもったいない…なら、うちで作るしかない!\nということでMicrometer用のKibanaダッシュボードを作ってみました。",
          "timestamp" : "2019-12-08T12:00:00Z"
        }
      },
      {
        "_index" : "datetime_docs",
        "_type" : "_doc",
        "_id" : "2",
        "_score" : 0.7012034,
        "_source" : {
          "title" : "ElasticsearchのRanking Evaluation APIについて整理してみた",
          "content" : "  Ranking Evaluation APIは、検索クエリに対する検索結果の妥当性を評価するためのAPIです。\nElasticsearchのバージョン6.2以降で利用することができます。",
          "timestamp" : "2018-12-20T12:00:00"
        }
      },
      {
        "_index" : "datetime_docs",
        "_type" : "_doc",
        "_id" : "3",
        "_score" : 0.57671785,
        "_source" : {
          "title" : "Elasticsearchの圧縮方式の比較",
          "content" : """
  Elasticsearchを使っているとストレージの使用量を節約したいと思う方は多いのではないでしょうか。
Elasticsearchはデータを格納するときにデフォルトでLZ4という圧縮方式でデータ圧縮を行っていますが、
実はLZ4よりも圧縮率の高いbest_compressionという圧縮方式を利用することもできます。
""",
          "timestamp" : "2018-05-08T12:00:00"
        }
      }

新しいドキュメントを上位に出すことができました。
あまりにも新しいものが上位に来すぎてしまう場合には、scaleやdecayの値を調整することで、検索結果をチューニングすることができます。

まとめ

script score queryのdecay関数を使用して、文書の新しさを考慮した検索を簡単に実現しました。
クエリで簡単にスコアを調整できるのは、検索システムの運用上非常に便利ですね。
script score queryにはほかにも便利な関数がいくつか実装されているので、ぜひ活用してみてください。
それではまた。

明日は、mac_akibaさんの記事になります。ぜひお楽しみに!

Normalizerを利用してkeyword型のデータを加工する

こんにちは、ノムラです。
この記事はElastic Stack (Elasticsearch) Advent Calendar 2019の9日目の記事になります。

はじめに

データを可視化、集計する際以下のようなデータが別々のデータとして扱われ困ったことはないでしょうか?

  • 〇〇(株) と 〇〇株式会社
  • 斎藤 と 齋藤
  • Elasticsearch と elasticsearch
(株) と 株式会社で困った例

f:id:acro-engineer:20191209001620p:plain

本来であれば、アクロクエストテクノロジー(株)とアクロクエストテクノロジー株式会社は同一の会社名として扱いたいです。
一見、ElasticsearchにはAnalyzer機能があるため、上記問題は簡単に解決することができるように思えます。
しかし上記のAnalyzer機能はText型にのみ適用可能な機能です。
そのため、上の例のような会社名や苗字の集計時によく設定されるkeyword型には適用することができません。
※Text型として扱うと、〇〇株式会社 ⇒ 〇〇 + 株式会社 の2単語として集計されてしまうため集計で不都合が生じます

そのような場合は、Normalizerを使いましょう。

Normalizerについて

Normalizerとは

Normalizerはkeyword型に適用可能な、Analyzerに相当する機能になります。
www.elastic.co

Normalizerには、char_filterとfilterを定義することができ、
異体字の変換や、大文字化小文字化等の加工ができます。
tokenizerは、keyword tokenizerが適用されます。
※Normalizerはインデキシング時だけでなく、検索時にも入力キーワードへ適用されます。

Normalizerの設定方法

Index Mappingのsettingsに下記のように設定します。

PUT sample_index
{
  "settings": {
    "analysis": {
      "normalizer": {
        "my_normalizer": {
          "type": "custom",
          "char_filter": [
            "my_char_filter"
          ],
          "filter": [
            "lowercase"
          ]
        }
      },
      "char_filter": {
        "my_char_filter": {
          "type": "mapping",
          "mappings": [
            "(株) => 株式会社",
            "齋 => 斎"
          ]
        }
      }
    }
  },
  "mappings": {
    "properties": {
      "company_name": {
        "type": "keyword",
        "normalizer": "my_normalizer"
      },
      "first_name": {
        "type": "keyword",
        "normalizer": "my_normalizer"
      },
      "product_name": {
        "type": "keyword",
        "normalizer": "my_normalizer"
      }
    }
  }
}

char_filterのmappingsで具体的にどのように変換するかを定義します。
ここでは、

  • (株) を 株式会社 へ
  • 齋 を 斎 へ

と変換を定義しています。
また、filterでlowercaseへの変換を定義しています。

データの登録

サンプルデータとして下記のデータを登録します。

# (株) ⇒ 株式会社
PUT sample_index/_doc/1
{
  "company_name": "アクロクエストテクノロジー(株)"
}
PUT sample_index/_doc/2
{
  "company_name": "アクロクエストテクノロジー株式会社"
}

# 齋 ⇒ 斎
PUT sample_index/_doc/3
{
  "name": "斎藤"
}
PUT sample_index/_doc/4
{
  "name": "齋藤"
}

# 大文字小文字
PUT sample_index/_doc/5
{
  "product_name": "Elasticsearch"
}
PUT sample_index/_doc/6
{
  "product_name": "elasticsearch"
}

適用結果

それぞれ以下のように、

でそれぞれ統一されて集計されています。

(株) ⇒ 株式会社

# 会社名で集計
GET sample_index/_search
{
  "size": 0, 
  "aggs": {
    "company_name": {
      "terms": {
        "field": "company_name",
        "size": 10
      }
    }
  }
}

# 集計結果
{
  "took" : 1045,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 6,
      "relation" : "eq"
    },
    "max_score" : null,
    "hits" : [ ]
  },
  "aggregations" : {
    "company_name" : {
      "doc_count_error_upper_bound" : 0,
      "sum_other_doc_count" : 0,
      "buckets" : [
        {
          "key" : "アクロクエストテクノロジー株式会社",
          "doc_count" : 2
        }
      ]
    }
  }
}

齋 ⇒ 斎

# 名前で集計
GET sample_index/_search
{
  "size": 0, 
  "aggs": {
    "name": {
      "terms": {
        "field": "name",
        "size": 10
      }
    }
  }
}

# 集計結果
{
  "took" : 8,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 6,
      "relation" : "eq"
    },
    "max_score" : null,
    "hits" : [ ]
  },
  "aggregations" : {
    "name" : {
      "doc_count_error_upper_bound" : 0,
      "sum_other_doc_count" : 0,
      "buckets" : [
        {
          "key" : "斎藤",
          "doc_count" : 2
        }
      ]
    }
  }
}

大文字 ⇒ 小文字

# 製品名で集計
GET sample_index/_search
{
  "size": 0, 
  "aggs": {
    "name": {
      "terms": {
        "field": "product_name",
        "size": 10
      }
    }
  }
}

# 集計結果
{
  "took" : 8,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 6,
      "relation" : "eq"
    },
    "max_score" : null,
    "hits" : [ ]
  },
  "aggregations" : {
    "name" : {
      "doc_count_error_upper_bound" : 0,
      "sum_other_doc_count" : 0,
      "buckets" : [
        {
          "key" : "elasticsearch",
          "doc_count" : 2
        }
      ]
    }
  }
}

おわりに

今回はNormalizerを利用して、keyword型にchar_filterとfilterを適用し異体字や、大文字小文字を統一する方法について書きました。
私自身、Normalizerについて知るまではLogstashやスクリプト側で加工する必要があると思っており、
その点NormalizerはElasticsearchの設定のみで可能なので簡単で、とても便利だと感じました。

上記の例以外にも様々なことに利用可能だと思うので皆さんも是非触ってみてください。

10日目はhttps://qiita.com/froakie0021さんです。お楽しみに。

Acroquest Technologyでは、キャリア採用を行っています。

  • ディープラーニング等を使った自然言語/画像/音声/動画解析の研究開発
  • Elasticsearch等を使ったデータ収集/分析/可視化
  • マイクロサービス、DevOps、最新のOSSを利用する開発プロジェクト
  • 書籍・雑誌等の執筆や、社内外での技術の発信・共有によるエンジニアとしての成長

 
少しでも上記に興味を持たれた方は、是非以下のページをご覧ください。

Kaggle Masterと働きたい尖ったエンジニアWanted! - Acroquest Technology株式会社のエンジニアの求人 - Wantedlywww.wantedly.com