Elasticsearchで分散表現を使った類似文書検索
2019-09-02

https://www.pexels.com/photo/aerial-view-of-house-village-2255938/

概要

Elasticseachに分散表現のベクトルに対する類似文書検索が実装されたということで、以下のElasticのブログ記事を参考に類似文書検索を試してみました。

Text similarity search in Elasticsearch using vector fields | Elastic Blog

類似文書検索とは、与えられたクエリの文書と似ている文書を文書集合内から検索する技術です。この際に必要となるのが「似ている」という概念で、計算機上でどうやって2つの文書間の類似度を数値として表現するかがポイントになります。例えば、互いの文書に出現する単語の一致度や重複度合いを測ったり、TF-IDFやBM25などで文書をベクトル化して比較する方法があります。ただしこれらの方法では、言い換え表現や表記の違いにより同じ意味の単語が異なる単語だと判定されたり、文書の中では重要でない単語に強く影響されるなどの問題点がありました。

そこでこの記事では、意味的な空間上に単語や文書を埋め込む分散表現という手法を用いて、任意の文書を数百次元のベクトルに変換します。そして類似度の計算では、2つの文書それぞれの分散表現のベクトルのコサイン類似度を計算することによって、文書間の意味的な近さ/遠さを表現します。検索の際には、与えられたクエリ文書をベクトル化したあと、文書集合内のすべての文書のベクトルと類似度を計算し降順に並べ替えることで、類似文章が得られるというわけです。

それではElasticsearchの環境構築から類似文書検索までを順に解説していきます。本記事では重要なコードの部分のみ提示していますが、全体の実験コードはこちらのレポジトリにありますので必要に応じて参照ください。

tl;dr

  • Elasticsearchでベクトルの類似文書検索機能が実装された
  • Wikipedia日本語記事全116万エントリーに対して検索時間は約0.8秒
  • Elasticsearchの既存の検索機能と組み合わせることが可能

方法

1. Elasticsearchの設定

Dockerを使ってElasticsearchを立ち上げます。Elasticsearchでの高次元ベクトルのフィールドタイプ対応はバージョン7.0、検索機能はバージョン7.3からサポートされているため、docker.elastic.coで提供されているバージョン7.3.1を利用しました。以下のようにdocker-compose.yamlを定義してdocker-compose upで起動します。

# docker-compose.yaml
version: '3.3'
services:
  elasticsearch:
    image: docker.elastic.co/elasticsearch/elasticsearch:7.3.1
    ports:
      - "9200:9200"
    volumes:
      - es-data:/usr/share/elasticsearch/data
    tty: true
    environment:
      discovery.type: single-node
volumes:
  es-data:
    driver: local

立ち上がった後はcurl http://127.0.0.1:9200/_cat/healthで結果が帰ってくればOKです。

2. Wikipediaの本文を文ベクトルに変換してElasticsearchに投入する

今回はある程度現実的な状況を再現するため、データセットはWikipediaの日本語のすべてのエントリーを使用しました。Wikipediaのエントリーに含まれるテキストをダンプデータから抽出し、分散表現を計算するとともにElasticsearchにインポートします。文書の分散表現の計算には、SWEMのaverage-poolingを利用します。手法の詳細はこちらの記事を参考ください。

まず index.jsonでインデックスのマッピングタイプを指定します。文書ベクトルには"type": "dense_vector"、次元数を"dims": 200で指定します。

# index.json
"properties": {
  "title": {
    "type": "text"
  },
  "text": {
    "type": "text"
  },
  "text_vector": {
    "type": "dense_vector",
    "dims": 200
  }
}

あとはElasticsearchに文書およびその文書の分散表現を登録していくだけです。注意点として、PythonのElasticsearchパッケージを利用する場合は、dense_vectorにはnumpyのndarrayではなくPythonのリスト型に変換したものを渡す必要があります。

3. ベクトルのフィールドを対象に検索

これでやっと準備は整いました。あとは類似文書検索のクエリを投げるだけですが、当然ながらElasticsearchにはクエリから分散表現を作成する機能はありません。そのため検索の際には、クエリを受け付けた後にPython側で文の分散表現に変換し、そのベクトルをElasticsearchに渡す形で実現します。

# query string to sentence embedding vector
query_vector = swem.average_pooling(query).tolist()

script_query = {
    "script_score": {
        "query": {"match_all": {}},
        "script": {
            "source": "cosineSimilarity(params.query_vector, doc['text_vector']) + 1.0",
            "params": {"query_vector": query_vector}
        }
    }
}

response = client.search(
    index=INDEX_NAME,
    body={
        "size": SEARCH_SIZE,
        "query": script_query,
        "_source": {"includes": ["title", "text"]}
    }
)

ここでは検索スコアの計算にcosineSimilarity関数を利用しています。なお、コサイン類似度自体に+1.0しているのは、Elasticsearchが負のスコアをサポートしていないからとのこと。

コード

実験条件

  • データ
    • Wikipediaテキスト: Cirrus Searchダンプデータjawiki-20190826-cirrussearch-content.json.gztextを利用
    • 文書数: 1,165,654件
  • 実行環境
    • Ubuntu 16.04.6 / 8 CPU / 32GB Memory
    • docker-compose: version 1.24.1
      • elasticsearch:7.3.1

実験 1: 全件対象に類似文書検索

まずはシンプルに、すべてのWikipediaのエントリーを対象に類似文書検索を行います。

クエリ

バーレーンの首都マナマ(マナーマとも)で現在開催されているユネスコ(国際連合教育科学文化機関)の第42回世界遺産委員会は日本の推薦していた「長崎と天草地方の潜伏キリシタン関連遺産」 (長崎県、熊本県)を30日、世界遺産に登録することを決定した。文化庁が同日発表した。日本国内の文化財の世界遺産登録は昨年に登録された福岡県の「『神宿る島』宗像・沖ノ島と関連遺産群」に次いで18件目。2013年の「富士山-信仰の対象と芸術の源泉」の文化遺産登録から6年連続となった。

潜伏キリシタン関連遺産、世界遺産登録 - ウィキニュース

検索結果

順位 記事タイトル               記事本文(冒頭)
1 日本遺産 日本遺産(にほんいさん、Japan Heritage)は、文化庁が認定した、地域の歴史的魅力や特色を通じて日本の文化・伝統を語るストーリーである。
2 武家の古都・鎌倉 「武家の古都・鎌倉」(ぶけのこと・かまくら、英語: Kamakura, Home of the SAMURAI)とは、神奈川県鎌倉市・横浜市・逗子市に残る歴史的建造物群を対象とする世界遺産暫定リスト掲載物件である。
3 「神宿る島」宗像・沖ノ島と関連遺産群 「神宿る島」宗像・沖ノ島と関連遺産群(「かみやどるしま」むなかた・おきのしまとかんれんいさんぐん)は、ユネスコの世界遺産リスト登録物件で、日本の世界遺産の中では21番目に登録された。
4 世界遺産委員会 世界遺産委員会(せかいいさんいいんかい)は、世界遺産に関して話し合うための国際連合教育科学文化機関の委員会。
5 百舌鳥・古市古墳群 百舌鳥・古市古墳群 -古代日本の墳墓群-(もず・ふるいちこふんぐん -こだいにほんのふんぼぐん-)は、大阪府堺市、羽曳野市、藤井寺市にある45件49基の古墳群の総称。
6 近世高岡の文化遺産群 近世高岡の文化遺産群(きんせいたかおかのぶんかいさんぐん)とは、富山県高岡市にある、同県と同市がユネスコの世界遺産への登録を目指している文化財の総称。
7 明治日本の産業革命遺産 製鉄・製鋼、造船、石炭産業 明治日本の産業革命遺産 製鉄・製鋼、造船、石炭産業(めいじにほんのさんぎょうかくめいいさん せいてつ・せいこう、ぞうせん、せきたんさんぎょう)は、2015年の第39回世界遺産委員会でUNESCOの世界遺産リストに登録された日本の世界遺産の一つであり、山口・福岡・佐賀・長崎・熊本・鹿児島・岩手・静岡の8県に点在する。
8 紀伊山地の霊場と参詣道 紀伊山地の霊場と参詣道(きいさんちのれいじょうとさんけいみち)は、和歌山県・奈良県・三重県にまたがる3つの霊場(吉野・大峰、熊野三山、高野山)と参詣道(熊野参詣道、大峯奥駈道、高野山町石道)を登録対象とする世界遺産(文化遺産)。
9 静岡県富士山世界遺産センター 静岡県富士山世界遺産センター(しずおかけんふじさんせかいいさんセンター)は、静岡県富士宮市にある博物館。
10 ドレスデン・エルベ渓谷 ドレスデン・エルベ渓谷(ドレスデン・エルベけいこく、ドイツ語: Dresdner Elbtal)とは、かつてユネスコの世界遺産に登録されていた物件である。

検索結果のWikipediaのエントリー10件を、類似度が高い順に表示しています。

ざっと見た限りでは世界遺産や日本の文化遺産に関わる記事が上位に来ており、きちんと分散表現をもとに類似文書検索ができていると思います。この類似度算出の精度に関しては文の分散表現を獲得するロジックに依存する部分なので、今回のElasticsearchの機能とはあまり関係ありません。

ちなみに、1位の「日本遺産」に違和感を感じますが、エントリー内に日本国内の世界遺産が列挙されていることから結果的に類似度が高くなったのではないかと考えられます。

検索時間

文の分散表現を計算する部分とElasticsearchに検索クエリを投げて結果が返ってくる部分の2つを計測しました。それぞれの結果は以下のようになりました。

  • embedding time: 0.84 ms
  • search time: 879.49 ms

全体の実行時間としては1.0秒かからないくらいで、その殆どはElasticsearchの類似度検索にかかっているようです。ここではmatch_allというクエリすべての文書を対象にコサイン類似度を計算しているため、さすがに100万件を超えると少し処理が重いようです。ただ今回の検証ではElasticsearch自体のチューニングはおこなっていないため、計算機のリソースや設定次第でもう少し早くなる可能性はあると思います。


実験 2: 条件を指定して類似文書検索

Elasticsearchは豊富な検索機能があり、それを使うことで分散表現による検索以上の絞り込みが可能になります。この実験では試しに、「〜川」というWikipediaタイトルのエントリーに限定して、その類似文書を検索してみます。

"script_score": {
    "query": {"wildcard": {
        "title": {
            "value": "*川"
        }
    }},

クエリ

レヒ川(独: Lech)は、オーストリアとドイツを流れる河川。ドナウ川の支流で、長さは264km。水源はオーストリア・フォアアールベルク州のフォルマリン川で、北北東に流れてドイツとの国境で高さ12mのレヒ滝(ドイツ語版)を形成し、その後狭い渓谷となる。ドイツ国内ではフュッセン、アウクスブルクなどを通り、ドナウ川に合流する。

レヒ川 - Wikipedia

検索結果

順位 記事タイトル               記事本文(冒頭)
1 レヒ川 レヒ川(独: Lech)は、オーストリアとドイツを流れる河川。
2 イン川 「イン川(インがわ、英語: Inn river)は、スイス・オーストリア・ドイツの3ヶ国にわたって流れる川で、ドナウ川の支川のひとつ。
3 オーデル川 オーデル川(独: Oder, 波: Odra)は、中央ヨーロッパを流れる河川である。
4 イルメナウ川 イルメナウ川(独: Ilmenau)は、ドイツのニーダーザクセン州を流れる川で、エルベ川の支流である。
5 ライネ川 ライネ川(ライネがわ、ドイツ語: Leine)は、ドイツのテューリンゲン州やニーダーザクセン州などを流れる長さ281kmの河川で、アラー川の支流である。
6 ナイセ川 ナイセ川(ドイツ語:Neiße)はオーデル川の支流。チェコ北部に源流を発し、第二次世界大戦後に設定されたドイツとポーランドの国境であるオーデル・ナイセ線の一部を成す。
7 ザーレ川 ザーレ川(ザーレがわ、Saale)は、ザクセンのザーレ川(Sächsische Saale)、あるいはテューリンゲンのザーレ川(Thüringische Saale)とも呼ばれ、バイエルン州、テューリンゲン州、ザクセン=アンハルト州を流れる全長413kmの河川で、モルダウ川に次いでエルベ川第2の支流である。
8 フレンキシェ・レーツァト川 フレンキシェ・レーツァト川(Fränkische Rezat、フランケンのレーツァト川)は、全長65km。レドニッツ川左岸、すなわち西側の源流で、ドイツ、バイエルン州ミッテルフランケンを流れる川。
9 アディジェ川 アディジェ川(イタリア語: Adige)は、イタリア北部を流れアドリア海に注ぐ、イタリアで2番目に長い川である。
10 ムール川 ムール川(Mura)は、ヨーロッパ中央部、主にオーストリアを流れる河川。ドナウ川支流のドラーヴァ川の支流である。

検索クエリで指定したとおり「〜川」に関する記事のみを抽出できていることがわかります。また、検索クエリに対して地理的にヨーロッパ近辺の川が類似記事として上位にきており、文章中に登場する地名などの意味的な近さを捉えて類似文書を検索できていそうです。

検索時間

では検索時間はどうでしょうか?全件を対象にした検索では0.8秒ほどかかっていましたが、条件を指定した状態での検索では約0.04秒と高速に動作しており、絞り込みの効果が発揮されていると言えます。

  • embedding time: 0.71 ms
  • search time: 40.95 ms

まとめ

今回はElasticsearchを使って分散表現を使った類似文書検索を試してみました。実行速度には課題が残るものの、分散表現のベクトルを特定のフィールドに入れるだけという手軽さはとても良いと思います。

こうしたベクトル表現を使った類似アイテムの検索では、現在のところ近似最近傍探索のパッケージを利用して独自に検索APIを実装することが多いです。そうしたスクラッチでの開発には専門的な知識が必要であり、また高速化や多重化など運用上のコストが大きくなるため、Elasticsearchという広く使われている検索エンジンをそのまま利用できるというのは、開発と運用ともに多くのメリットがあるのではないかと思います。

参考

このエントリーをはてなブックマークに追加