elasticsearchを使って全文検索してみたい

elasticsearchは検索エンジンの一種。類似するものにApache Solrなどがある。

検索エンジンでは、文書登録時に単語を抽出(日本語の場合は形態素解析という技術を使用)し、各単語からその単語が含まれているドキュメントを示すIDをひけるようにする索引である「転置インデックス」というものを用いて検索キーワードから文書を高速に見つけられるようにする。

対象ドキュメントから検索キーワードになりうる単語を抽出して転置インデックスを作成する機能をインデクサと呼び、その転置インデックスを使って検索する機能を提供するものをサーチャーと呼ぶ。

Dockerfileを準備、日本語対応のプラグインを追加。これをesというディレクトリの中に置いてみた。

FROM elasticsearch:8.3.3
RUN bin/elasticsearch-plugin install analysis-kuromoji

ちなみにファイルを別途ダウンロードしておいて、そのファイルをインストールするという方法もある。

FROM elasticsearch:8.3.3
RUN curl -O https://artifacts.elastic.co/downloads/elasticsearch-plugins/analysis-kuromoji/analysis-kuromoji-8.3.3.zip
RUN bin/elasticsearch-plugin install file:///usr/share/elasticsearch/analysis-kuromoji-8.3.3.zip

当然、ローカルファイルをコンテナに突っ込むこともできる。

FROM elasticsearch:8.3.3
COPY analysis-kuromoji-8.3.3.zip /usr/share/elasticsearch/
RUN bin/elasticsearch-plugin install file:///usr/share/elasticsearch/analysis-kuromoji-8.3.3.zip

このDockerfileを使ってelasticsearchを起動するdocker-compose.ymlを書いてみた。この記事の実験の範囲ではdockerコマンドで起動しても特に問題ないが、なんとなく。

services:
  elasticsearch:
    build: es
    ports:
      - 9200:9200
    environment:
      - discovery.type=single-node
      - xpack.security.enabled=false
    ulimits:
      memlock:
        soft: -1
        hard: -1

起動する

docker-compose up

個人情報テストデータジェネレータというありがたいサイトを使って、実験用データを生成

testdata.userlocal.jp

で、こんなコードを書いてみた。

import urllib.request
import csv
import json
import time


u = "http://localhost:9200/articles/"
hdrs = {"Content-Type": "application/json"}

#
# インデックス削除
#
def delete_index():
    try:
        req = urllib.request.Request(u, method="DELETE")
        with urllib.request.urlopen(req) as res:
            body = json.load(res)
            print(body)
            print("delete index ok")
    except:
        print("failed to delete index articles")
        pass

#
# インデックス作成
#
def create_index():
    req = urllib.request.Request(u, method="PUT")
    with urllib.request.urlopen(req) as res:
        body = json.load(res)
        print(body)
    print("create index ok")

#
# マッピングの登録
#
def set_mapping():
    data = json.dumps({
        "properties": {
            "content_id": {"type": "long"},
            "content": {"type": "text", "analyzer": "kuromoji"},
            "age": {"type": "long"}
        }
    }).encode("utf-8")
    req = urllib.request.Request(u, data, method="PUT", headers=hdrs)
    try:
        res = urllib.request.urlopen(req)
        body = json.load(res)
        print(body)
        print("set mappings ok")
    except urllib.error.HTTPError as e:
        print(e.code, e.reason, e.headers)

#
# ドキュメント追加
#
def insert_data(content_id, content, age):
    data = json.dumps({
        "content_id": i,
        "content": content,
        "age": age
    }).encode("utf-8")
    print(data)
    # IDを指定してデータ追加する場合はPOSTになる。
    # 指定せずにIDを自動生成する場合はPUT。
    req = urllib.request.Request(u+"_doc/" + str(i), data, method="POST", headers=hdrs)
    with urllib.request.urlopen(req) as res:
        body = json.load(res)
        print(body)

#
# ドキュメントIDを使ってデータを取得する
#
def get_data_by_id(id):
    time.sleep(1)
    print("=" * 72)
    url = u+"_doc/" + str(id)
    print(url)
    req = urllib.request.Request(url)
    with urllib.request.urlopen(req) as res:
        body = json.load(res)
        print("- " * 36)
        print(body)

#
# データ検索
#
def search_data(js):
    print("=" * 72)
    print(js)
    data = json.dumps(js).encode("utf-8")
    req = urllib.request.Request(u+"_search?pretty", data, headers=hdrs)
    with urllib.request.urlopen(req) as res:
        body = json.load(res)
        print("- " * 36)
        print(body)

##############################################################################

delete_index()
create_index()
set_mapping()
f = csv.reader(open("./dummy.csv"))
for i, line in enumerate(f):
    if i == 10000:
        break
    try:
        age = int(line[2])
        address = line[10]
        insert_data(i, address, age)
    except:
        pass


# 転置インデックス作成にちょっとだけ時間がかかるのでsleepする
time.sleep(1)

get_data_by_id(1)

search_data({
    "query": {
        "match" : {
            "content": {
                "query": "東京"
            }
        }
    },
    "from": 5,
    "size": 10,
    "sort": ["age"],
    "_source": ["content", "age"]
})

# 複数の条件をすべて満たしたものを抽出するには以下のようにして条件を列挙する
# 配列になっているが項目がひとつでも大丈夫
# highlightってところはおまけで、検索結果に引っかかったところを目立たせる指示
search_data({
    "query": {
        "bool": {
            "must": [
                {"match" :{"age": 45}},
                {"match": {"content": "神奈川"}}
            ]
        }
    },
    "highlight": {
        "fields": {
            "content": {}
        }
    },
    "from": 5,
    "size": 10,
    "_source": ["content", "age"]
})


search_data({
    "query": {
        "bool": {
            "must": [
                {"match" :{"content": "東京 品川"}}
            ]
        }
    },
    "highlight": {
        "fields": {
            "content": {}
        }
    },
    "from": 5,
    "size": 10,
    "_source": ["content", "age"]
})

これを実行したら無事検索結果が出てきました。