🍊

Elasticsearchで古典和歌の検索体験を上げてみる(テキスト分析, ユーザー辞書, クエリ)

2024/09/16に公開

はじめに

私は万葉集が大好きなのですが、奈良時代を代表するこの素晴らしい万葉集の和歌総数はなんと約4500首。長いですね!

最近ふと思い出したのですが、大学で古典文学を研究していた際には、「”あかねさす”って枕詞はどうやって歌われることが多いのかな〜」とか「”額田王(ぬかたのおおきみ)”はこれ以外にどんな和歌を詠んだのかしら」とか、そういう視点で類似する和歌を探したりしていました。そしてその度に分厚い本を開いてめちゃ時間をかけて用例を調べていたのですよね。なんといっても4500首ですし…。

そこで、全文検索エンジンと和歌の検索は結構相性が良いのでは?と思い始めました。折しも先日業務でElasticsearchに触れる機会がありましたが、テキスト分析周辺は全く関与しない実装になっていたので、興味も湧いているところでした。

https://zenn.dev/castingone_dev/articles/def6f627bb389a
https://zenn.dev/castingone_dev/articles/911dd459b1ba80

この時は上記のようにGoの実装やパフォーマンス観点で大枠に触れるに過ぎなかったです。

今回は改めて、古典和歌を利用して検索体験を上げるためにElasticsearchをいじり倒してみたので、その備忘録をお送りします。

全文検索エンジンのぽいところを存分に楽しんで実証してみたので、なるべく頼ったリファレンスを添えつつ、検索クエリを利用してcurlでいい感じに古典和歌の検索ができるようになるまでの備忘録をまとめてみました。もしご興味があれば覗いてみてもらえたら嬉しいです。

下準備

初めにサクッとElasticsearch環境を作っておきます。

compose.yml
services:
  elasticsearch:
    build: .
    container_name: elasticsearch
    environment:
      - xpack.security.enabled=false
      - discovery.type=single-node
    ulimits:
      memlock:
        soft: -1
        hard: -1
    ports:
      - 9200:9200
Dockerfile
FROM docker.elastic.co/elasticsearch/elasticsearch:8.15.1

コンテナを立ち上げヘルスチェックを行い、特段問題ないことを確認しておきます。

$ docker compose up -d
# ヘルスチェック。念のためステータスがgreenになっていることを確認
$ curl -X GET "localhost:9200/_cat/health?v&pretty"
epoch      timestamp cluster        status node.total node.data shards pri relo init unassign pending_tasks max_task_wait_time active_shards_percent
1726408730 13:58:50  docker-cluster green           1         1      0   0    0    0        0             0                  -                100.0%

tokenizerで単語分割をする

ここからは、ざっくりと以下の流れで検索を実装していきます。

  • 最適なtokenizer探し
  • インデックスの作成
  • クエリの組み立て

テキスト分析で要になるのが、文章をどういう形で分割するかになります。たとえば「I have a pen.」は「I, have, a, pen」という形で分割しておくと、「have」や「pen」で検索できるようになる、という要領ですよね。この時分割される単語はトークンと呼ばれるため、単語を分割する機構はtokenizerと呼ばれます。

標準の機構を利用する

ということでまずは、tokenizerを利用して和歌を分割することについて考えました。

https://www.elastic.co/guide/en/elasticsearch/reference/current/analysis-tokenizers.html

tokenizerにはさまざまな種類があるため、どのようなtokenizerを利用するかが重要になってきそうです。tokenizerは事前にテストするcurlが叩けそうなのでここで試してみることにしました。オーソドックスなものから試していきます。

https://www.elastic.co/guide/en/elasticsearch/reference/current/test-analyzer.html

テストする和歌には、万葉集で最も有名な和歌の1つである「あかねさす 紫野(むらさきの)行き 標野(しめの)行き 野守(のもり)は見ずや 君が袖振る」を利用してみます。

POST http://localhost:9200/_analyze HTTP/1.1
Content-Type: application/json

{
  "tokenizer": "standard",
  "text": "あかねさす 紫野行き 標野行き 野守は見ずや 君が袖振る"
}

↓ response

HTTP/1.1 200 OK
X-elastic-product: Elasticsearch
content-type: application/json

{
  "tokens": [
    {
      "token": "あ",
      "start_offset": 0,
      "end_offset": 1,
      "type": "<HIRAGANA>",
      "position": 0
    },
    {
      "token": "か",
      "start_offset": 1,
      "end_offset": 2,
      "type": "<HIRAGANA>",
      "position": 1
    },
    {
      "token": "ね",
      "start_offset": 2,
      "end_offset": 3,
      "type": "<HIRAGANA>",
      "position": 2
    },
    {
      "token": "さ",
      "start_offset": 3,
      "end_offset": 4,
      "type": "<HIRAGANA>",
      "position": 3
    },
    {
      "token": "す",
      "start_offset": 4,
      "end_offset": 5,
      "type": "<HIRAGANA>",
      "position": 4
    },
    {
      "token": "紫",
      "start_offset": 6,
      "end_offset": 7,
      "type": "<IDEOGRAPHIC>",
      "position": 5
    },
    {
      "token": "野",
      "start_offset": 7,
      "end_offset": 8,
      "type": "<IDEOGRAPHIC>",
      "position": 6
    },
    {
      "token": "行",
      "start_offset": 9,
      "end_offset": 10,
      "type": "<IDEOGRAPHIC>",
      "position": 8
    },
    {
      "token": "き",
      "start_offset": 10,
      "end_offset": 11,
      "type": "<HIRAGANA>",
      "position": 9
    },
    ...以下略
  ]
}

おおよそ分かってはいましたが、なんか嫌な予感はしましたよね、この時。もう1つ試してみます。

POST http://localhost:9200/_analyze HTTP/1.1
Content-Type: application/json

{
  "tokenizer": "whitespace",
  "text": "あかねさす 紫野行き 標野行き 野守は見ずや 君が袖振る"
}

↓ Response

HTTP/1.1 200 OK
X-elastic-product: Elasticsearch
content-type: application/json

{
  "tokens": [
    {
      "token": "あかねさす",
      ...
    },
    {
      "token": "紫野行き",
      ...
    },
    ...以下略
  ]
}

まず普通のtokenizerは使えなさそうです。また、五七五の空白で区切るとそれっぽくはなりますが、「紫野」「標野」などの分割ができない場合は、このキーワードの検索が絶望的になってしまいます。

そもそも日本語の検索が壊滅的ということで色々と探したところ、日本語のtokenizerは別にありそうです。日本語のテキスト分析に特化したkuromojiプラグインを利用することにします。

https://www.elastic.co/guide/en/elasticsearch/plugins/current/analysis-kuromoji.html

このプラグインをインストールするため、Dockerfileを書き換えましょう。

Dockerfile
FROM docker.elastic.co/elasticsearch/elasticsearch:8.15.1

+ RUN elasticsearch-plugin install analysis-kuromoji

改めてtokernizerを確認してみます。

POST http://localhost:9200/_analyze HTTP/1.1
Content-Type: application/json

{
  "tokenizer": "kuromoji_tokenizer",
  "text": "あかねさす紫野行き標野行き野守は見ずや君が袖振る"
}

↓ Response

HTTP/1.1 200 OK
X-elastic-product: Elasticsearch
content-type: application/json

{
  "tokens": [
    {
      "token": "あ",
      ...
    },
    {
      "token": "かね",
      ...
    },
    {
      "token": "さす",
      ...
    },
    {
      "token": "紫野",
      ...
    },
    {
      "token": "行き",
      ...
    },
    {
      "token": "標",
      ...
    },
    {
      "token": "野",
      ...
    },
    {
      "token": "行き",
      ...
    },
    ...以下略
  ]
}

現代語の「私は元気です」は、「私、は、元気、です」とうまいことトークン分割されます。この和歌内でも、かろうじて現代語にもありそうな「行き」や「振る」は分割されましたが、枕詞「あかねさす」や古語「紫野」「標野」などは全滅です。

古典和歌を上手に分割するには、標準整備やkuromojiプラグインだけでは厳しそうです。

ユーザー辞書を作ってカスタムする

古典和歌に特化したtokenizerは残念ながら存在しないため、自分で単語帳を作る方法を探します。kuromojiには地道に辞書追加する方法がありそうです!日本語って難しいので、古語以外でもちょこちょこ使われているんでしょうね。

https://www.elastic.co/guide/en/elasticsearch/plugins/current/analysis-kuromoji-tokenizer.html

辞書はElasticsearch内に置く必要があるので、ボリュームをマウントしておく必要がありそう。
ということで、compose.ymlを拡張していきます。

compose.yml
services:
  elasticsearch:
    build: .
    container_name: elasticsearch
    environment:
      - xpack.security.enabled=false
      - discovery.type=single-node
    ulimits:
      memlock:
        soft: -1
        hard: -1
    ports:
      - 9200:9200
+    volumes:
+      - ./ops/dic:/usr/share/elasticsearch/config/dic
kogo.txt
標野,標野,シメノ,しめの,名詞
紫野,紫野,ムラサキノ,むらさきの,名詞
野守,野守,ノモリ,のもり,名詞
袖,袖,ソデ,そで,名詞
振る,振る,フル,ふる,動詞
袖振る,袖振る,ソデフル,そでふる,動詞
あかねさす,あかねさす,アカネサス,枕詞
ぬばたまの,ぬばたまの,ヌバタマノ,枕詞
# docker上で念のためボリュームがマウントされているこを確認する
$ docker exec -it elasticsearch /bin/bash
elasticsearch@xxxxxxx:~$ cat /usr/share/elasticsearch/config/dic/kogo.txt 
標野,標野,シメノ,しめの,名詞
紫野,紫野,ムラサキノ,むらさきの,名詞
野守,野守,ノモリ,のもり,名詞
袖,袖,ソデ,そで,名詞
振る,振る,フル,ふる,動詞
袖振る,袖振る,ソデフル,そでふる,動詞
あかねさす,あかねさす,アカネサス,枕詞
ぬばたまの,ぬばたまの,ヌバタマノ,枕詞elasticsearch@21785e341145:~$ 

standardやkuromoji_tokenizerのように簡単にトークン分割をcurlでテストできないため、一度インデックスを作ってしまいます。

PUT http://localhost:9200/sample_dummy_index?pretty
Content-Type: application/json

{
  "settings": {
    "index": {
      "analysis": {
        "tokenizer": {
          "kuromoji_user_dict": {
            "type": "kuromoji_tokenizer",
            "mode": "extended",
            "discard_punctuation": "false",
            "user_dictionary": "dic/kogo.txt"
          }
        },
        "analyzer": {
          "my_analyzer": {
            "type": "custom",
            "tokenizer": "kuromoji_user_dict"
          }
        }
      }
    }
  }
}

以下のようにインデックスのアナライザに対してテストする方法があったので、やってみます。カスタム辞書の威力はテキメンで、かなりいい感じになりました。

POST http://localhost:9200/sample_dummy_index/_analyze HTTP/1.1
Content-Type: application/json

{
  "tokenizer": "kuromoji_user_dict",
  "text": "あかねさす紫野行き標野行き野守は見ずや君が袖振る"
}

↓ Response

HTTP/1.1 200 OK
X-elastic-product: Elasticsearch
content-type: application/json

{
  "tokens": [
    {
      "token": "あかねさす",
      "start_offset": 0,
      "end_offset": 5,
      "type": "word",
      "position": 0
    },
    {
      "token": "紫野",
      "start_offset": 5,
      "end_offset": 7,
      "type": "word",
      "position": 1
    },
    {
      "token": "行き",
      "start_offset": 7,
      "end_offset": 9,
      "type": "word",
      "position": 2
    },
    {
      "token": "標野",
      "start_offset": 9,
      "end_offset": 11,
      "type": "word",
      "position": 3
    },
    {
      "token": "行き",
      "start_offset": 11,
      "end_offset": 13,
      "type": "word",
      "position": 4
    },
    {
      "token": "野守",
      "start_offset": 13,
      "end_offset": 15,
      "type": "word",
      "position": 5
    },
    {
      "token": "は",
      "start_offset": 15,
      "end_offset": 16,
      "type": "word",
      "position": 6
    },
    ...以下略
  ]
}

インデックスを作成する

今回はサンプルとして、和歌8首を利用します。フィールドは以下の通りです。

  • 和歌 waka
  • 部立 butate いわゆるカテゴリで、主に雑歌・挽歌・相聞歌などがある
  • 作者 author
  • 和歌番号 bangou
  • 巻数 maki
  • 読み下し文 yomikudashi こういう時に読みました、みたいな説明

このうち、和歌および読み下し文はこれまで作成したtokenizerのカスタム分析を利用していくイメージになります。

[
  {
    "waka": "あかねさす紫野行き標野行き野守は見ずや君が袖振る",
    "butate": "雑歌",
    "author": "額田王",
    "bangou": 20,
    "maki": 1,
    "yomikudashi": "天皇の、蒲生野に遊猟したまひし時に、額田王の作れる歌"
  },
  {
    "waka": "あかねさす日並べなくに我が恋は吉野の川の霧に立ちつつ",
    "butate": "雑歌",
    "author": "車持朝臣千年",
    "bangou": 916,
    "maki": 6,
    "yomikudashi": "右は、年月審らかならず。ただ、歌の類を以ちてこの次に載す。或る本に云はく、「養老七年五月、吉野の離宮に幸しし時に作る」といへり。"
  },
  {
    "waka": "紫草のにほへる妹を憎くあらば人妻ゆゑに我恋ひめやも",
    "butate": "雑歌",
    "author": "天武天皇",
    "bangou": 21,
    "maki": 1,
    "yomikudashi": "紀に曰はく「天皇七年丁卯の夏五月五日に、蒲生野に縦猟したまふ。時に大皇弟・諸王・内臣と群臣、悉皆に従ふ」といへり。"
  },
  {
    "waka": "あかねさす日は照らせれどぬばたまの夜渡る月の隠らく惜しも",
    "butate": "挽歌",
    "author": "柿本朝臣人麻呂",
    "bangou": 169,
    "maki": 2,
    "yomikudashi": ""
  },
  {
    "waka": "山吹の立ちよそひたる山清水汲みに行かめど道の知らなく",
    "butate": "挽歌",
    "author": "高市皇子",
    "bangou": 158,
    "maki": 2,
    "yomikudashi": "〔紀に曰はく「七年戊寅の夏四月丁亥の朔の癸巳、十市皇女卒然に病発りて宮の中に薨りましき」といへり。〕"
  },
  {
    "waka": "味酒三輪の山あをによし奈良の山の山の際にい隠るまで道の隈い積るまでにつばらにも見つつ行かむをしばしばも見放けむ山を心なく雲の隠さふべしや",
    "butate": "雑歌",
    "author": "額田王",
    "bangou": 17,
    "maki": 1,
    "yomikudashi": "額田王の近江国に下りし時に作れる歌、井戸王の即ち和へたる歌"
  },
  {
    "waka": "大伴の見つとは言はじあかねさし照れる月夜に直に逢へりとも",
    "butate": "相聞歌",
    "author": "賀茂女王",
    "bangou": 565,
    "maki": 4,
    "yomikudashi": "賀茂女王の歌一首"
  },
  {
    "waka": "君待つと我が恋ひ居れば我がやどの簾動かし秋の風吹く",
    "butate": "相聞歌",
    "author": "額田王",
    "bangou": 1606,
    "maki": 8,
    "yomikudashi": "額田王の近江天皇を思ひて作る歌一首"
  }
]

ではサクッとインデックスを作りましょう。和歌と読み下し文以外のマッピングは以下を見て最適なものをそれぞれ定義しました。

https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping-types.html

PUT http://localhost:9200/sample_index?pretty
Content-Type: application/json

{
  "settings": {
    ...dummyと同じ
  },
  "mappings": {
    "properties": {
      "waka": {
        "type": "text",
        "analyzer": "my_analyzer"
      },
      "butate": {
        "type": "keyword"
      },
      "author": {
        "type": "keyword"
      },
      "bangou": {
        "type": "integer"
      },
      "maki": {
        "type": "integer"
      },
      "yomikudashi": {
        "type": "text",
        "analyzer": "my_analyzer"
      }
    }
  }
}

念のため、意図した感じで設定が反映されるか確認してみましょう。

GET http://localhost:9200/sample_index HTTP/1.1
Content-Type: application/json

設定が問題なさそうなので、8首の和歌を登録をしておきます。

POST http://localhost:9200/sample_index/_doc?pretty
Content-Type: application/json

{
  "waka": "あかねさす紫野行き標野行き野守は見ずや君が袖振る",
  "butate": "雑歌",
  "author": "額田王",
  "bangou": 20,
  "maki": 1,
  "yomikudashi": "天皇の、蒲生野に遊猟したまひし時に、額田王の作れる歌"
}

クエリを組み立てしてみる

ここからが個人的にElasticsearchの面白いところです! クエリが本当に素晴らしく豊富で、もはや豊富すぎるのでドキュメントを全部は読みきれないのですが、必要そうなところをかい摘みながら書いていこうと思います。

今回は、ユースケースを考えて以下2つの検索を考えてみることにしました。

  1. キーワードに合致するデータを探す
  2. 該当和歌と関連度合いの高い和歌を探す

まずは8首がきちんと登録されていることを確認します。

GET http://localhost:9200/sample_index/_search HTTP/1.1
Content-Type: application/json

{
  "fields": [
    "waka",
    "butate",
    "author",
    "bangou",
    "maki",
    "yomikudashi"
  ]
}

キーワードに合致するデータを探す

UIのイメージとしては、テキストフィールドがぽんっと一つ置いてあり、そこにユーザーは気になるキーワードを入れるというイメージです。まずは「あかねさす」を検索された時のことを考えてクエリを組み立ててみました。

matchというのがごく基本的な検索クエリで、こちらが必要です。

https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-match-query.html

matchだけでも同じ結果が上がるのですが、今回はあえて、よく使うboolean queryもおり混ぜて組み立てました。boolean queryは複数の条件検索を行う際に必ずと言って良いほど利用するイメージです(前述した通り、今回は1件なのでなくてもOKというわけです)。

https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-bool-query.html

ここでは、「あかねさす」という枕詞がある3首が返ってくれば成功です。

GET http://localhost:9200/sample_index/_search HTTP/1.1
Content-Type: application/json

{
  "query": {
    "bool": {
      "must": {
        "match": {
          "waka": {
            "query": "あかねさす"
          }
        }
      }
    }
  },
  "fields": [
    "waka"
  ],
  "_source": false
}

↓ Response

HTTP/1.1 200 OK
X-elastic-product: Elasticsearch
content-type: application/json

{
  "took": 12,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": {
      "value": 3,
      "relation": "eq"
    },
    "max_score": 0.9672544,
    "hits": [
      {
        "_index": "sample_index",
        "_id": "FHEz9pEBn3-xjQXt3cxE",
        "_score": 0.9672544,
        "fields": {
          "waka": [
            "あかねさす紫野行き標野行き野守は見ずや君が袖振る"
          ]
        }
      },
      {
        "_index": "sample_index",
        "_id": "F3E09pEBn3-xjQXtNswS",
        "_score": 0.9672544,
        "fields": {
          "waka": [
            "あかねさす日は照らせれどぬばたまの夜渡る月の隠らく惜しも"
          ]
        }
      },
      {
        "_index": "sample_index",
        "_id": "FXEz9pEBn3-xjQXt-cxr",
        "_score": 0.926412,
        "fields": {
          "waka": [
            "あかねさす日並べなくに我が恋は吉野の川の霧に立ちつつ"
          ]
        }
      }
    ]
  }
}

テキストフィールドの横に、絞り込み検索を行えるラジオボタンがあるイメージもしてみます。テキストフィールドに「あかねさす」を、ラジオボタンで「挽歌」を選択したとしたら、UIにもよりますが順当にいけば「あかねさす」かつ「挽歌」になりそうですよね。クエリを少し変更しなければなりません。

この複数条件検索で、boolean queryが威力を発揮します。

GET http://localhost:9200/sample_index/_search HTTP/1.1
Content-Type: application/json

{
  "query": {
    "bool": {
      "must": {
        "match": {
          "waka": {
            "query": "あかねさす"
          }
        }
      },
      "filter": {
        "term": {
          "butate": "挽歌"
        }
      }
    }
  },
  "fields": [
    "waka",
    "butate"
  ],
  "_source": false
}

↓ Response

HTTP/1.1 200 OK
X-elastic-product: Elasticsearch
content-type: application/json

{
  "took": 6,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": {
      "value": 1,
      "relation": "eq"
    },
    "max_score": 0.9672544,
    "hits": [
      {
        "_index": "sample_index",
        "_id": "F3E09pEBn3-xjQXtNswS",
        "_score": 0.9672544,
        "_source": {
          "waka": "あかねさす日は照らせれどぬばたまの夜渡る月の隠らく惜しも",
          "butate": "挽歌",
          "author": "柿本朝臣人麻呂",
          "bangou": 169,
          "maki": 2,
          "yomikudashi": ""
        },
        "fields": {
          "butate": [
            "挽歌"
          ],
          "waka": [
            "あかねさす日は照らせれどぬばたまの夜渡る月の隠らく惜しも"
          ]
        }
      }
    ]
  }
}

期待値通り、「あかねさす」で絞り込んだ3首の中から部立が挽歌である「あかねさす日は〜」が抽出されました。

boolean queryについて詳しく見ていく前に、Elasticsearchの関連度(relevance score)について軽く触れます。先ほどからレスポンスに対して_scoreという数値がつけられているのですが、これは「検索に対してどれくらい関連度が高いか」をスコアリングしたものになります。このスコアリングはMySQLをはじめとしたリレーショナルDBにはない、全文検索エンジン独特の概念です。クエリを書く時はこのスコアリングについても注意しながら組み立てる必要があります。

https://www.elastic.co/guide/en/elasticsearch/reference/current/query-filter-context.html#relevance-scores

boolean queryは、must, should, must_not, filterの4つの区分で検索をして、その結果をよしなに組み合わせてくれます。

  • must … 必ず当てはまる条件。関連度スコアに関係する。この中の条件はAND検索となる。
  • should … いくつかは当てはまる条件。関連度スコアに関係する。この中の条件はOR条件となる。
  • filter … 必ず当てはまる条件。関連度スコアには関係しない。

mustとshouldの違い、mustとfilterの違いを理解して実装しようとすると、検索条件のANDやORに加えて、関連度スコアも踏まえて設計する必要があるわけですね。

話を和歌の検索に戻すと、ラジオボタンを「挽歌」にした場合は問答無用で検索結果から外さなければならないため、filterの条件が追加されるというわけです。

では次にテキストフィルタで「あかねさす ぬばたまの」と検索された時のことを考えてみます。この場合3パターンが考えられます。

  1. 「あかねさす ぬばたまの」をOR検索する
  2. 「あかねさす ぬばたまの」をAND検索する
  3. 「あかねさす ぬばたまの」をOR検索しつつ関連度スコアが高いものを優先する

1はテキストフィールドとして最もイケていなさそうに感じましたので、2,3の両軸で考えてみました。

まずは1のAND検索についてです。

matchをそのまま使ってもいけるのですが、フリーワードで検索された場合、waka以外のフィールドでも引っ掛けたいことがありますので、multi_matchを利用するように今回から変更します。たとえば土地や花の名前は、和歌中だけでなく読み下し文でも出る可能性が十分ありえるので。

https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-multi-match-query.html

また、shouldはOR検索ですが、その最低条件をminimum_should_matchプロパティを利用して付け加えることができます。その柔軟性を活かして以下のように2つの条件に対して「最低2つマッチする」と設定すると、上手に検索結果を出すことができました。

GET http://localhost:9200/sample_index/_search HTTP/1.1
Content-Type: application/json

{
  "query": {
    "bool": {
      "should": [
        {
          "multi_match": {
            "query": "あかねさす",
            "fields": ["waka", "yomikudashi", "butate", "author"]
          }
        },
        {
          "multi_match": {
            "query": "ぬばたまの",
            "fields": ["waka", "yomikudashi", "butate", "author"]
          }
        }
      ],
      "minimum_should_match": 2
    }
  },
  "fields": [
    "waka"
  ],
  "_source": false
}

↓ Response
HTTP/1.1 200 OK
X-elastic-product: Elasticsearch
content-type: application/json

{
  "took": 11,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": {
      "value": 1,
      "relation": "eq"
    },
    "max_score": 2.9258888,
    "hits": [
      {
        "_index": "sample_index",
        "_id": "F3E09pEBn3-xjQXtNswS",
        "_score": 2.9258888,
        "fields": {
          "waka": [
            "あかねさす日は照らせれどぬばたまの夜渡る月の隠らく惜しも"
          ]
        }
      }
    ]
  }
}

続いて3のOR検索+関連度スコア出しです。
shouldは関連度スコアが関係するため、上の方のminimum_should_matchを1にすればうまくいくのですが、下記のようにしても出せます。

GET http://localhost:9200/sample_index/_search HTTP/1.1
Content-Type: application/json

{
  "query": {
    "multi_match": {
      "query": "あかねさす ぬばたまの",
      "fields": ["waka", "yomikudashi", "butate", "author", "butate"]
    }
  },
  "fields": [
    "waka"
  ],
  "_source": false
}

↓ Response

HTTP/1.1 200 OK
X-elastic-product: Elasticsearch
content-type: application/json

{
  "took": 10,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": {
      "value": 3,
      "relation": "eq"
    },
    "max_score": 2.9258888,
    "hits": [
      {
        "_index": "sample_index",
        "_id": "F3E09pEBn3-xjQXtNswS",
        "_score": 2.9258888,
        "fields": {
          "waka": [
            "あかねさす日は照らせれどぬばたまの夜渡る月の隠らく惜しも"
          ]
        }
      },
      {
        "_index": "sample_index",
        "_id": "FHEz9pEBn3-xjQXt3cxE",
        "_score": 0.9672544,
        "fields": {
          "waka": [
            "あかねさす紫野行き標野行き野守は見ずや君が袖振る"
          ]
        }
      },
      {
        "_index": "sample_index",
        "_id": "FXEz9pEBn3-xjQXt-cxr",
        "_score": 0.926412,
        "fields": {
          "waka": [
            "あかねさす日並べなくに我が恋は吉野の川の霧に立ちつつ"
          ]
        }
      }
    ]
  }
}

「あかねさす」「ぬばたまの」が両方入っている「あかねさす日は〜」が1番目に来ており、かつ2,3番目より関連度スコアが高くなっていることがわかります。いい感じになりました!

該当和歌と関連度合いの高い和歌を探す

次に、ある和歌に関連する和歌を検索してみたいというユースケースを考えてみました。
私は研究者ではありませんが、関連度としては以下のような優先順位で上の方に来ると嬉しいのかなと勝手に思いました。

優先度 条件 意図
読み下し文に関連がある場合 前後の和歌では、部立を通して長歌と反歌になっているなど関連がある可能性が高い
作者・部立・巻が同じ場合 -
和歌のキーワードに類似がある場合 -

ここでは「あかねさす紫野行き標野行き野守は見ずや君が袖振る」に関連する和歌を探していくことにしましょう。
組み立て順序としては、まず1つ1つの条件を作っていき、最後に複数条件に直して1つのクエリにしてみます。

読み下し文との関連度スコアから見ていきますが、関連度合いは70%くらい似ているものを抽出してみます。色々といじりましたが、このあたりが1番良さそうかな〜と思ったので、%についてはキメです。

GET http://localhost:9200/sample_index/_search HTTP/1.1
Content-Type: application/json

{
  "query": {
    "bool": {
      "must": [
        {
          "match": {
            "yomikudashi": {
              "query": "天皇の、蒲生野に遊猟したまひし時に、額田王の作れる歌",
              "fuzziness": "AUTO",
              "minimum_should_match": "70%"
            }     
          }
        }
      ]
    }
  },
  "fields": [
    "waka",
    "bangou"
  ],
  "_source": false
}


↓ Response

HTTP/1.1 200 OK
X-elastic-product: Elasticsearch
content-type: application/json

{
  "took": 25,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": {
      "value": 3,
      "relation": "eq"
    },
    "max_score": 18.868063,
    "hits": [
      {
        "_index": "sample_index",
        "_id": "FHEz9pEBn3-xjQXt3cxE",
        "_score": 18.868063,
        "fields": {
          "waka": [
            "あかねさす紫野行き標野行き野守は見ずや君が袖振る"
          ],
          "bangou": [
            20
          ]
        }
      },
      {
        "_index": "sample_index",
        "_id": "GXE09pEBn3-xjQXtbsxC",
        "_score": 7.0220222,
        "fields": {
          "waka": [
            "味酒三輪の山あをによし奈良の山の山の際にい隠るまで道の隈い積るまでにつばらにも見つつ行かむをしばしばも見放けむ山を心なく雲の隠さふべしや"
          ],
          "bangou": [
            17
          ]
        }
      },
      {
        "_index": "sample_index",
        "_id": "FnE09pEBn3-xjQXtFczw",
        "_score": 5.1709304,
        "fields": {
          "waka": [
            "紫草のにほへる妹を憎くあらば人妻ゆゑに我恋ひめやも"
          ],
          "bangou": [
            21
          ]
        }
      }
    ]
  }
}

関連度の高い読み下し文になっているものが3首があり、1首目は圧倒的に高いスコアになっていますがこれは同じ和歌だからですね。また、実は3首目は検索対象である「あかねさす」に対する返歌であり、関連度が最も高くあって欲しいものになっています…。純粋な関連度では2首目に抜かれてしまっているのが気にはなりますが、一旦置いといて次も作っていきましょう。

次に作者・部立・巻が同じ場合についてですが、これはこの中で優先度をもう少し決めておきたい感じがします。具体的には以下のような感じでしょうか?

優先度 条件
作者・部立・巻すべてが同じ場合
作者が同じ かつ 部立か巻のどちらかが同じ場合
作者が同じ場合

こうしてみると、shouldで最低条件をつけつつ、優先度に応じて関連度スコアを独自に操作したい気分になってきます。そこで登場するのがboostという概念です。boostを利用すると、自分たちで関連度スコアの付け方を一部カスタムできるような感じです。

https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-boosting-query.html

少し複雑になりましたが、minimum_should_matchを1にして、額田王が作った歌であれば基準として3.0のスコアをつけつつ、優先度順にスコアを0.1〜0.5の間で重みづけしていきます。

GET http://localhost:9200/sample_index/_search HTTP/1.1
Content-Type: application/json

{
  "query": {
    "bool": {
      "should": [
        {
          "bool": {
            "must": [
              { "term": { "author": "額田王" }},
              { "term": { "maki": 1 }},
              { "term": { "butate": "雑歌" }}
            ],
            "boost": 0.5
          }
        },
        {
          "bool": {
            "must": [
              { "term": { "author": "額田王" }},
              { "term": { "butate": "雑歌" }}
            ],
            "boost": 0.3
          }
        },
        {
          "bool": {
            "must": [
              { "term": { "author": "額田王" }},
              { "term": { "maki": 1 }}
            ],
            "boost": 0.3
          }
        },
        {
          "bool": {
            "must": [
              { "term": { "author": "額田王" }}
            ],
            "boost": 3.0
          }
        }
      ],
      "minimum_should_match": 1
    }
  },
  "fields": [
    "waka",
    "butate"
  ],
  "_source": false
}

↓ Response
HTTP/1.1 200 OK
X-elastic-product: Elasticsearch
content-type: application/json

{
  "took": 22,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": {
      "value": 3,
      "relation": "eq"
    },
    "max_score": 4.837918,
    "hits": [
      {
        "_index": "sample_index",
        "_id": "FHEz9pEBn3-xjQXt3cxE",
        "_score": 4.837918,
        "fields": {
          "butate": [
            "雑歌"
          ],
          "waka": [
            "あかねさす紫野行き標野行き野守は見ずや君が袖振る"
          ]
        }
      },
      {
        "_index": "sample_index",
        "_id": "GXE09pEBn3-xjQXtbsxC",
        "_score": 4.837918,
        "fields": {
          "butate": [
            "雑歌"
          ],
          "waka": [
            "味酒三輪の山あをによし奈良の山の山の際にい隠るまで道の隈い積るまでにつばらにも見つつ行かむをしばしばも見放けむ山を心なく雲の隠さふべしや"
          ]
        }
      },
      {
        "_index": "sample_index",
        "_id": "G3F39pEBn3-xjQXtr8x1",
        "_score": 2.8333848,
        "fields": {
          "butate": [
            "相聞歌"
          ],
          "waka": [
            "君待つと我が恋ひ居れば我がやどの簾動かし秋の風吹く"
          ]
        }
      }
    ]
  }
}

「あかねさす」以外の額田王の和歌のうち、同じ雑歌は関連度が上がっているのがわかります。こちらはいい感じになりました。

最後の条件は、和歌をマッチさせれば良いだけなので、mutchクエリを利用したらOKです。そのため、こちらは省略してしまいます。

それでは、3つの条件を付けて関連度が高い順にしてみました。また、該当和歌は関連度順に出すと1番上に出てしまうため、除外しておきます。

GET http://localhost:9200/sample_index/_search HTTP/1.1
Content-Type: application/json

{
  "query": {
    "bool": {
      "should": [
        {
          "match": {
            "yomikudashi": {
              "query": "天皇の、蒲生野に遊猟したまひし時に、額田王の作れる歌",
              "fuzziness": "AUTO",
              "minimum_should_match": "70%"
            }
          }
        },
        {
          "bool": {
            "should": [
              {
                "bool": {
                  "must": [
                    { "term": { "author": "額田王" }},
                    { "term": { "maki": 1 }},
                    { "term": { "butate": "雑歌" }}
                  ],
                  "boost": 0.5
                }
              },
              {
                "bool": {
                  "must": [
                    { "term": { "author": "額田王" }},
                    { "term": { "butate": "雑歌" }}
                  ],
                  "boost": 0.3
                }
              },
              {
                "bool": {
                  "must": [
                    { "term": { "author": "額田王" }},
                    { "term": { "maki": 1 }}
                  ],
                  "boost": 0.3
                }
              },
              {
                "bool": {
                  "must": [
                    { "term": { "author": "額田王" }}
                  ],
                  "boost": 3.0
                }
              }
            ],
            "minimum_should_match": 1
          }
        },
        {
          "bool": {
            "must": {
              "match": {
                "waka": {
                  "query": "あかねさす紫野行き標野行き野守は見ずや君が袖振る"
                }
              }
            },
            "boost": 1.0
          }
        }
      ],
      "minimum_should_match": 1,
      "must_not": [
        { "term": { "bangou": 20 } }
      ]
    }
  },
  "fields": [
    "waka"
  ],
  "_source": false
}

↓ Response

HTTP/1.1 200 OK
X-elastic-product: Elasticsearch
content-type: application/json

{
  "took": 33,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": {
      "value": 6,
      "relation": "eq"
    },
    "max_score": 14.325192,
    "hits": [
      {
        "_index": "sample_index",
        "_id": "GXE09pEBn3-xjQXtbsxC",
        "_score": 14.325192,
        "fields": {
          "waka": [
            "味酒三輪の山あをによし奈良の山の山の際にい隠るまで道の隈い積るまでにつばらにも見つつ行かむをしばしばも見放けむ山を心なく雲の隠さふべしや"
          ]
        }
      },
      {
        "_index": "sample_index",
        "_id": "FnE09pEBn3-xjQXtFczw",
        "_score": 6.616287,
        "fields": {
          "waka": [
            "紫草のにほへる妹を憎くあらば人妻ゆゑに我恋ひめやも"
          ]
        }
      },
      {
        "_index": "sample_index",
        "_id": "G3F39pEBn3-xjQXtr8x1",
        "_score": 4.978849,
        "fields": {
          "waka": [
            "君待つと我が恋ひ居れば我がやどの簾動かし秋の風吹く"
          ]
        }
      },
      {
        "_index": "sample_index",
        "_id": "F3E09pEBn3-xjQXtNswS",
        "_score": 1.8997283,
        "fields": {
          "waka": [
            "あかねさす日は照らせれどぬばたまの夜渡る月の隠らく惜しも"
          ]
        }
      },
      {
        "_index": "sample_index",
        "_id": "FXEz9pEBn3-xjQXt-cxr",
        "_score": 1.818044,
        "fields": {
          "waka": [
            "あかねさす日並べなくに我が恋は吉野の川の霧に立ちつつ"
          ]
        }
      },
      {
        "_index": "sample_index",
        "_id": "GnE09pEBn3-xjQXtiMzD",
        "_score": 1.7430946,
        "fields": {
          "waka": [
            "大伴の見つとは言はじあかねさし照れる月夜に直に逢へりとも"
          ]
        }
      }
    ]
  }
}

いい感じに関連度スコアになっている気がしますが、よくみるとうまくいっていません。私が優先度高に読み下し文が似ているものをおいた理由は、前後関係において歌につながりがある場合には、真っ先にそちらを出したいと考えていたためでした。今回対象の20番「あかねさす」は、21番の「紫草の」と続く和歌であり、反歌になる関係のため、そちらが真っ先に来て欲しかったのです。

21番よりも先に来てしまった17番は額田王の和歌でもあるため、優先度中の関連度スコア上乗せもあったのかなと思っています。

そこで、まずは優先度高の条件に、和歌の番号が近い場合にはよりスコアを上げるという調整を加えられないかを検討しました。複雑なスコアの計算は以下を参考にして実装しています。

https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-function-score-query.html

GET http://localhost:9200/sample_index/_search HTTP/1.1
Content-Type: application/json

{
  "query": {
    "bool": {
      "must": [
        {
          "function_score": {
            "query": {
              "bool": {
                "must": [
                  {
                    "match": {
                      "yomikudashi": {
                        "query": "天皇の、蒲生野に遊猟したまひし時に、額田王の作れる歌",
                        "fuzziness": "AUTO",
                        "minimum_should_match": "70%"
                      }
                    }
                  }
                ]
              }
            },
            "functions": [
              {
                "weight": 4.0
              },
              {
                "gauss": {
                  "bangou": {
                    "origin": 20,
                    "scale": 2,
                    "offset": 0,
                    "decay": 0.1
                  }
                }
              }
            ],
            "score_mode": "multiply",
            "boost_mode": "multiply"
          }
        }
      ],
      "must_not": [
        { "term": { "bangou": 20 } }
      ]
    }
  },
  "fields": [
    "waka",
    "bangou"
  ],
  "_source": false
}

↓ Response

HTTP/1.1 200 OK
X-elastic-product: Elasticsearch
content-type: application/json

{
  "took": 24,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": {
      "value": 2,
      "relation": "eq"
    },
    "max_score": 13.288582,
    "hits": [
      {
        "_index": "sample_index",
        "_id": "FnE09pEBn3-xjQXtFczw",
        "_score": 13.288582,
        "fields": {
          "waka": [
            "紫草のにほへる妹を憎くあらば人妻ゆゑに我恋ひめやも"
          ],
          "bangou": [
            21
          ]
        }
      },
      {
        "_index": "sample_index",
        "_id": "GXE09pEBn3-xjQXtbsxC",
        "_score": 0.17481123,
        "fields": {
          "waka": [
            "味酒三輪の山あをによし奈良の山の山の際にい隠るまで道の隈い積るまでにつばらにも見つつ行かむをしばしばも見放けむ山を心なく雲の隠さふべしや"
          ],
          "bangou": [
            17
          ]
        }
      }
    ]
  }
}

「あかねさす」20番の基準に対して、離れていればいるほど関連度がマイナスになっていくイメージですね。番号が遠い場合はほぼ関連がない和歌になるため、これでうまくいきそうです。かなり複雑になってしまいました。

それでは改めて、組み立てられたクエリの完成形です。

GET http://localhost:9200/sample_index/_search HTTP/1.1
Content-Type: application/json

{
  "query": {
    "bool": {
      "should": [
        {
          "function_score": {
            "query": {
              "bool": {
                "must": [
                  {
                    "match": {
                      "yomikudashi": {
                        "query": "天皇の、蒲生野に遊猟したまひし時に、額田王の作れる歌",
                        "fuzziness": "AUTO",
                        "minimum_should_match": "70%"
                      }
                    }
                  }
                ]
              }
            },
            "functions": [
              {
                "weight": 4.0
              },
              {
                "gauss": {
                  "bangou": {
                    "origin": 20,
                    "scale": 2,
                    "offset": 0,
                    "decay": 0.1
                  }
                }
              }
            ],
            "score_mode": "multiply",
            "boost_mode": "multiply"
          }
        },
        {
          "bool": {
            "should": [
              {
                "bool": {
                  "must": [
                    { "term": { "author": "額田王" }},
                    { "term": { "maki": 1 }},
                    { "term": { "butate": "雑歌" }}
                  ],
                  "boost": 0.5
                }
              },
              {
                "bool": {
                  "must": [
                    { "term": { "author": "額田王" }},
                    { "term": { "butate": "雑歌" }}
                  ],
                  "boost": 0.3
                }
              },
              {
                "bool": {
                  "must": [
                    { "term": { "author": "額田王" }},
                    { "term": { "maki": 1 }}
                  ],
                  "boost": 0.3
                }
              },
              {
                "bool": {
                  "must": [
                    { "term": { "author": "額田王" }}
                  ],
                  "boost": 3.0
                }
              }
            ],
            "minimum_should_match": 1
          }
        },
        {
          "bool": {
            "must": {
              "match": {
                "waka": {
                  "query": "あかねさす紫野行き標野行き野守は見ずや君が袖振る"
                }
              }
            },
            "boost": 1.0
          }
        }
      ],
      "minimum_should_match": 1,
      "must_not": [
        { "term": { "bangou": 20 } }
      ]
    }
  },
  "fields": [
    "waka",
    "bangou",
    "author",
    "butate"
  ],
  "_source": false
}

↓ Response

HTTP/1.1 200 OK
X-elastic-product: Elasticsearch
content-type: application/json

{
  "took": 18,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": {
      "value": 6,
      "relation": "eq"
    },
    "max_score": 13.997166,
    "hits": [
      {
        "_index": "sample_index",
        "_id": "FnE09pEBn3-xjQXtFczw",
        "_score": 13.997166,
        "fields": {
          "butate": [
            "雑歌"
          ],
          "bangou": [
            21
          ],
          "author": [
            "天武天皇"
          ],
          "waka": [
            "紫草のにほへる妹を憎くあらば人妻ゆゑに我恋ひめやも"
          ]
        }
      },
      {
        "_index": "sample_index",
        "_id": "GXE09pEBn3-xjQXtbsxC",
        "_score": 6.7284236,
        "fields": {
          "butate": [
            "雑歌"
          ],
          "bangou": [
            17
          ],
          "author": [
            "額田王"
          ],
          "waka": [
            "味酒三輪の山あをによし奈良の山の山の際にい隠るまで道の隈い積るまでにつばらにも見つつ行かむをしばしばも見放けむ山を心なく雲の隠さふべしや"
          ]
        }
      },
      {
        "_index": "sample_index",
        "_id": "G3F39pEBn3-xjQXtr8x1",
        "_score": 4.978849,
        "fields": {
          "butate": [
            "相聞歌"
          ],
          "bangou": [
            1606
          ],
          "author": [
            "額田王"
          ],
          "waka": [
            "君待つと我が恋ひ居れば我がやどの簾動かし秋の風吹く"
          ]
        }
      },
      {
        "_index": "sample_index",
        "_id": "F3E09pEBn3-xjQXtNswS",
        "_score": 1.8997283,
        "fields": {
          "butate": [
            "挽歌"
          ],
          "bangou": [
            169
          ],
          "author": [
            "柿本朝臣人麻呂"
          ],
          "waka": [
            "あかねさす日は照らせれどぬばたまの夜渡る月の隠らく惜しも"
          ]
        }
      },
      {
        "_index": "sample_index",
        "_id": "FXEz9pEBn3-xjQXt-cxr",
        "_score": 1.818044,
        "fields": {
          "butate": [
            "雑歌"
          ],
          "bangou": [
            916
          ],
          "author": [
            "車持朝臣千年"
          ],
          "waka": [
            "あかねさす日並べなくに我が恋は吉野の川の霧に立ちつつ"
          ]
        }
      },
      {
        "_index": "sample_index",
        "_id": "GnE09pEBn3-xjQXtiMzD",
        "_score": 1.7430946,
        "fields": {
          "butate": [
            "相聞歌"
          ],
          "bangou": [
            565
          ],
          "author": [
            "賀茂女王"
          ],
          "waka": [
            "大伴の見つとは言はじあかねさし照れる月夜に直に逢へりとも"
          ]
        }
      }
    ]
  }
}

優先度順になっていそうですね。いい感じに関連度スコア順(関連する順)に並ばせることができました。

おわりに

長くなってしまいましたが、テキスト解析の検証から検索クエリの組み立てまで、学んだことを備忘録として残してみました。まだ知らないことが多くありそうですが、改めてテキスト分析を利用した検索体験について、とても可能性を感じて良い学びを得ることができたなと思っています。

しかし、これが本当に実現したら万葉集を詠む力がとってもつきそうです。当時の風流をもっと理解できると、より面白く詠めると思っています。それにつけても、万葉古語専用のtokenizerがほしいな〜

ここまで読んでくださり、ありがとうございました。

Discussion