Techブログ - MNTSQ, Ltd.

リーガルテック・カンパニー「MNTSQ(モンテスキュー)」のTechブログです。

Elasticsearchを使ってテキストの出現単語を分析したい

MNTSQ Tech Blog TOP > 記事一覧 > Elasticsearchを使ってテキストの出現単語を分析したい

f:id:kensaku_m:20201130162446j:plain

MNTSQで検索エンジニアをしている溝口です。

MNTSQのテックブログの第二回目の投稿という、非常に栄誉ある役割を仰せつかって少し戸惑っています。

MNTSQでは自然言語処理を利用して容易に大量の契約書の検索をすることができるプロダクトを作っているのですが、データ(=契約書)の量が増えるにつれて同じ条件でも検索にヒットする結果の数は多くなっていきます。

その場合、検索結果の順序を検索条件に合致している順で並ぶように改善していくのですが、その前段階として扱っているデータ全体の傾向を掴みたくなることがあります。 今回はその一環として、データの中にどういった単語(term)がどのくらい含まれているのか?というのを調べる際の話にフォーカスします。

(目的は全く異なりますが、tag cloudのためのデータを上位100件とかでなく全件取りたいというようなものだと思ってください。)

MNTSQのプロダクトではElasticsearchを利用しており、できればElasticsearchの仕組みの中で上記ができると嬉しかったのですが、簡単に調べたところではそういう機能はなさそうだったので、上記を実現するべく何を考えたかを記事にしました。

なお、今回はわかりやすいように以下のようなmappingと登録データを仮定して話を進めていきます。

# mapping
curl -XPUT "localhost:9200/docs?pretty" -H 'Content-Type: application/json' -d '
{
  "mappings": {
    "properties": {
      "texts": {
        "type": "text"
      },
      "tags": {
        "type": "keyword"
      }
    }
  }
}'

# 登録データ
curl -XPUT "localhost:9200/docs/_doc/1" -H 'Content-Type: application/json' -d '
{
  "tags": [ "test", "てすと" ],
  "texts": "検索てきすと"
}'

curl -XPUT "localhost:9200/docs/_doc/2" -H 'Content-Type: application/json' -d '
{
  "tags": ["テスト", "てすと"],
  "texts": "検索したいテキスト"
}'

aggregationsを使う

これはすぐに思いつくところで、実際かつてElasticsearchにはterms APIというものがあったらしいのですが、terms APIを削除するというIssueが見つかります。

この削除されたAPIが残っていれば、私が今回やりたかったことをすぐに実現できたのかまでは調べていませんが、Elasticsearchのaggregation機能を使えば、確かにtermを簡単に収集できそうです。

また、例え収集するtermが多くてもcomposit aggregationsを使ってページングしていけば(長い道のりですが)欲しい情報は取れそうな気がします。

が、これには問題があります。 例えば、以下はうまく行きますが、

curl -XPOST "localhost:9200/docs/_search?pretty" -H 'Content-Type: application/json' -d '
{
  "aggs": {
    "terms_aggs": {
      "terms": {
        "field": "tags"
      }
    }
  },
  "size": 0
}'

以下だとうまくいきません。

curl -XPOST localhost:9200/docs/_search -H 'Content-Type: application/json' -d '
{
  "aggs": {
    "terms_aggs": {
      "terms": {
        "field": "texts"
      }
    }
  }
}'

残念ながらanalyzerを通したフィールドに対してaggregationをとるためにはそのフィールドにfielddata=trueをmappingの中で指定しなければなりません。

そして上記を設定すると多くのメモリ領域を使ってしまうことがあります。

termを集めるのはとりあえずテストデータの単語分布をみて「何か面白い傾向が取ればいいな〜」くらいのモチベーションなので、そのためにメモリを多めに予約するのは、あまり良くはない気がします。

上記から、fielddata=trueは最後の手段として考えるが、とりあえずは別のアイデアを考えることにします。

(テストデータでデータ数そこまで大きくないのでメモリそこまで食わない & 分析専用のElasticsearchやindexを用意して、必要な時に使えば良いという話なのですが、そうすると良いネタがなかったので、ご容赦ください)

termvectors APIを使う

次にElasticsearchのtermvectors APIを使うことを考えます。

termvectorとはtermのvectorなので、特定のフィールドにおけるtermの出現回数を格納したインデックスのデータで、termvectors APIはその情報を取得することができます。

termvectorはmappingの設定によって出現回数のほかに、

  • offsets(登録した文字列の先頭からN文字目からM文字目にtermが出現しているか)
  • positions(対象のtermは単語分割した後の何番目のtermになるのか)
  • payloads(termに結び付けて保存できる情報)

が含まれています。今回のmapping(デフォルト値)ではoffsetsとpositionsが含まれているので、それらは邪魔なのでレスポンスから除去します。あと、デフォルトではfield_statisticsという統計値を算出しますが、それも不要なので除去します。

field_statistics=false&offsets=false&positions=false

最終的なリクエストは以下です。

curl -XGET "localhost:9200/docs/_termvectors/1?fields=texts&field_statistics=false&offsets=false&positions=false&pretty"

以下のようなレスポンスを得られます。

{
  "_index" : "docs",
  "_type" : "_doc",
  "_id" : "1",
  "_version" : 1,
  "found" : true,
  "took" : 16,
  "term_vectors" : {
    "texts" : {
      "terms" : {
        "" : {
          "term_freq" : 1
        },
        "" : {
          "term_freq" : 1
        },
        "" : {
          "term_freq" : 1
        },
        "" : {
          "term_freq" : 1
        },
        "" : {
          "term_freq" : 1
        },
        "" : {
          "term_freq" : 1
        }
      }
    }
  }
}

良さそうですが、問題があります。それは、termvectors APIはドキュメントのidを指定しなければならないという点です。(URL Pathの最後の1です)

欲しいのは全ドキュメント中のtermの出現頻度なので、全ドキュメントのidを指定してループさせた上で、termごとに頻度を集計をすれば良いのですが、それならそもそもcomposit aggregationでコツコツページングしていった方が集計処理も必要なく、よっぽどシンプルです。

Lukeを使う

Lucene 8.1からはLuceneLukeというindexブラウザが同梱されています。

実は、Lukeを使えばterm一覧とその出現頻度は以下のようにメニューのExport termsから簡単に取得できます。

f:id:kensaku_m:20201130152414p:plain

取得されたterm一覧とその出現頻度は以下のようなものになります。

cat terms_texts_1606716894191.out 
い,1
き,1
し,1
す,1
た,1
て,1
と,1
テキスト,1
検,2
索,2

これで目的は達成できました。LukeはGUIが必要なのでデータをGUIが使える環境に移動する必要があって少し手間です。

データを動かさずにtermの情報を収集する方法を考えるとします。

直接indexを読みとるプログラムを書く

あまりElasticsearchの仕組みやLuceneのバンドル機能に固執せず、シンプルにLuceneのインデックスを読んでterm情報を抜き取れば良さそうと思い始めました。幸い確認するのはテスト用のノード一台だけなので、複数ノードの結果を合わせることは考えなくて良さそうです。

なので、簡単に以下のようなプログラムを書きました。

import java.io.IOException;
import java.nio.file.Paths;

import org.apache.lucene.index.*;
import org.apache.lucene.store.FSDirectory;

public class TermCollector {
    public static void main(String[] args) {
        String indexPath = args[0];
        String fieldName = args[1];

        try {
            IndexReader reader = DirectoryReader.open(FSDirectory.open(Paths.get(indexPath)));
            Terms terms = MultiTerms.getTerms(reader, fieldName);

            if (terms != null) {
                TermsEnum te = terms.iterator();
                while (te.next() != null) {
                    System.out.println(te.term().utf8ToString() + "\t" + te.totalTermFreq());
                }
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

(Luceneのバージョンは8.7.0です)

これを実行すると、以下のようになります。

い  1
き 1
し 1
す 1
た 1
て 1
と 1
テキスト    1
検 2
索 2

雑な感じはありますが今回の用途ではこれで良さそうです。

まとめ

当初の、Elasticsearchの仕組みの中で実現するというところはかないませんでしたが、簡単なLuceneのプログラムを書いてAnalyzerを通したテキストのtermを取得してみました。fielddata=trueを指定するとElasticsearchのAPIの仕組みの中で、GUIが使える環境ではLukeを使うと非常に簡単にできますが、Luceneは扱いやすいライブラリだと思うので、これくらいのことならば簡単なプログラムを作ってしまった方が良さそうです。

この記事を書いた人

f:id:kensaku_m:20201130161518p:plain

溝口泰史

MNTSQ社で検索エンジニアをしています。