2021/02/16
[Python]Elasticsearchで日本語と英語の全文検索
概要
日本語や英語の記事データを全文検索したい要件が出てきたため、Elasticsearchを使うとどのようにしなければいけないのかを確認したかったため、テスト実装を行いました。
- 使用したversionは
7.11.0
です。 - 実装言語はPythonです。
Elasticsearchの基礎知識
RDBとの比較
おおよそ以下のような関係だとわかりました。
RDB | Elasticsearch | Note |
---|---|---|
Database | Index | |
Table | Type | 7.0より廃止 |
Row | Document | |
Column | Field |
Elasticsearchでは以下のように設計すると良さそうです。
- 検索を行う単位ごとにIndexを設計します
- IndexはRDBのテーブルのような感じで設計してよく、他言語の切り替え等で、検索方法が異なる場合はIndexを分けてしまった方が良さそうです。
- 例えばお知らせ(annoucement)データを他言語で検索するニーズがあるのであれば、分割しておきます。
- announcement_ja
- announcement_en
- 例えばお知らせ(annoucement)データを他言語で検索するニーズがあるのであれば、分割しておきます。
Elasticsearchの検索処理概要
本の後ろにある索引を考えてみてください。本の重要な用語は、ページ番号とともにソートおよびリストされているため、その用語の場所がすぐにわかります。Elasticsearchの全文検索でも、同様な転置インデックスを使用しています。
本の後ろにある、索引と同じような感じで検索しているみたいです。
docker-composeで事前準備
docker-compose.yaml
version: "3"
services:
elasticsearch:
build:
context: elasticsearch
dockerfile: Dockerfile
ports:
- "9200:9200"
- "9300:9300"
environment:
- "discovery.type=single-node"
volumes:
- ./.docker-volumes/elasticsearch:/usr/share/elasticsearch/data
kibana:
image: docker.elastic.co/kibana/kibana:7.11.0
ports:
- 5601:5601
elasticsearch/Dockerfile
FROM docker.elastic.co/elasticsearch/elasticsearch:7.11.0
# install Japanese (kuromoji) analysis plugin
# https://www.elastic.co/guide/en/elasticsearch/plugins/current/analysis-kuromoji.html
RUN elasticsearch-plugin install analysis-kuromoji
# install icu analysis plugin
# https://www.elastic.co/guide/en/elasticsearch/plugins/current/analysis-icu.html
RUN elasticsearch-plugin install analysis-icu
本番環境では、(必要に応じてマルチクラスター構成をとって)k8s上にdeployして使いたいなと思っています。
Pythonでの実装
client
ここに記載の通り
The client is thread safe and can be used in a multi threaded environment. Best practice is to create a single global instance of the client and use it throughout your application.
global変数にconnectionを格納して使用して良さそうでした。
from elasticsearch import Elasticsearch
elasticsearch_connections = None
def init():
global elasticsearch_connections
elasticsearch_connections = Elasticsearch(["localhost"], maxsize=25)
def conn():
global elasticsearch_connections
if elasticsearch_connections is None:
init()
return elasticsearch_connections
日本語の全文検索
import unittest
class TestElasticsearchDriver(unittest.TestCase):
def test_ja_text_search(self):
es = conn() # 先程定義したclient
index_name = "announcement_contents_ja"
index_body = {
"settings": {
"analysis": {
"char_filter": {
"normalize": {
"type": "icu_normalizer",
"name": "nfkc",
"mode": "compose"
}
},
"tokenizer": {
"ja_kuromoji_tokenizer": {
"mode": "search",
"type": "kuromoji_tokenizer",
},
"ja_ngram_tokenizer": {
"type": "ngram",
"min_gram": 2,
"max_gram": 2,
"token_chars": [
"letter",
"digit"
],
},
},
"analyzer": {
"ja_kuromoji_index_analyzer": {
"type": "custom",
"char_filter": [
"normalize",
"html_strip"
],
"tokenizer": "ja_kuromoji_tokenizer",
"filter": [
"kuromoji_baseform",
"kuromoji_part_of_speech",
"cjk_width",
"ja_stop",
"kuromoji_stemmer",
"lowercase"
]
},
"ja_kuromoji_search_analyzer": {
"type": "custom",
"char_filter": [
"normalize",
"html_strip"
],
"tokenizer": "ja_kuromoji_tokenizer",
"filter": [
"kuromoji_baseform",
"kuromoji_part_of_speech",
"cjk_width",
"ja_stop",
"kuromoji_stemmer",
"lowercase"
]
},
"ja_ngram_index_analyzer": {
"type": "custom",
"char_filter": [
"normalize",
"html_strip"
],
"tokenizer": "ja_ngram_tokenizer",
"filter": [
"lowercase"
]
},
"ja_ngram_search_analyzer": {
"type": "custom",
"char_filter": [
"normalize",
"html_strip"
],
"tokenizer": "ja_ngram_tokenizer",
"filter": [
"lowercase"
]
}
}
}
},
"mappings": {
"properties": {
"id": {
"type": "long"
},
"title": {
"type": "text",
"search_analyzer": "ja_kuromoji_search_analyzer",
"analyzer": "ja_kuromoji_index_analyzer"
},
"subtitle": {
"type": "text",
"search_analyzer": "ja_kuromoji_search_analyzer",
"analyzer": "ja_kuromoji_index_analyzer"
},
"body": {
"type": "text",
"search_analyzer": "ja_kuromoji_search_analyzer",
"analyzer": "ja_kuromoji_index_analyzer",
"fields": {
"ngram": {
"type": "text",
"search_analyzer": "ja_ngram_search_analyzer",
"analyzer": "ja_ngram_index_analyzer"
}
}
}
}
}
}
if es.indices.exists(index=index_name):
es.indices.delete(index=index_name)
es.indices.create(index=index_name, body=index_body)
indices = es.cat.indices(index=index_name, h="index").splitlines()
# インデックスの表示
for index in indices:
self.assertEqual(first=index_name, second=index)
# インデックスの存在
self.assertTrue(es.indices.exists(index=index_name))
announcement_content = {
"id": 1,
"title": "システムメンテナンスのお知らせ",
"subtitle": "2021年3月12日22:00よりシステムメンテナンスを実施します。",
"body": "<h1>システムメンテナンスを実施します。</h1>",
}
# ドキュメントの登録
es.create(index=index_name, id=announcement_content["id"], body=announcement_content)
es.indices.refresh(index=index_name)
# ドキュメントの検索
# see https://blog.chocolapod.net/momokan/entry/114
search_body = {
"query": {
"bool": {
"must": [
{
"multi_match": {
"query": "システム",
"fields": [
"body.ngram^1"
],
"type": "phrase"
}
}
],
"should": [
{
"multi_match": {
"query": "システム",
"fields": [
"body^1"
],
"type": "phrase"
}
}
]
}
}
}
results = es.search(index=index_name, body=search_body, size=3)
self.assertEqual(first=1, second=len(results["hits"]["hits"]))
html_tag_should_be_ignored_query = {
"query": {
"bool": {
"must": [
{
"multi_match": {
"query": "h1",
"fields": [
"body.ngram^1"
],
"type": "phrase"
}
}
],
"should": [
{
"multi_match": {
"query": "h1",
"fields": [
"body^1"
],
"type": "phrase"
}
}
]
}
}
}
results = es.search(index=index_name, body=html_tag_should_be_ignored_query, size=3)
self.assertEqual(first=0, second=len(results["hits"]["hits"]))
英語の全文検索
import unittest
class TestElasticsearchDriver(unittest.TestCase):
def test_en_text_search(self):
es = conn()
index_name = "announcement_contents_en"
index_body = {
"settings": {
"analysis": {
"filter": {
"english_stop": {
"type": "stop",
"stopwords": "_english_"
},
"english_stemmer": {
"type": "stemmer",
"language": "english"
},
"english_possessive_stemmer": {
"type": "stemmer",
"language": "possessive_english"
}
},
"analyzer": {
"rebuilt_english": {
"tokenizer": "standard",
"filter": [
"english_possessive_stemmer",
"lowercase",
"english_stop",
"english_stemmer"
]
}
}
}
},
"mappings": {
"properties": {
"id": {
"type": "long"
},
"title": {
"type": "text",
"search_analyzer": "rebuilt_english",
"analyzer": "rebuilt_english"
},
"subtitle": {
"type": "text",
"search_analyzer": "rebuilt_english",
"analyzer": "rebuilt_english"
},
"body": {
"type": "text",
"search_analyzer": "rebuilt_english",
"analyzer": "rebuilt_english"
}
}
}
}
if es.indices.exists(index=index_name):
es.indices.delete(index=index_name)
es.indices.create(index=index_name, body=index_body)
indices = es.cat.indices(index=index_name, h="index").splitlines()
# インデックスの表示
for index in indices:
self.assertEqual(first=index_name, second=index)
# インデックスの存在
self.assertTrue(es.indices.exists(index=index_name))
announcement_content = {
"id": 1,
"title": "System maintenance announcement",
"subtitle": "We plan to have a short system maintenance from 12th.Mar.2021 22:00.",
"body": "<h1>System maintenance schedule</h1>",
}
# ドキュメントの登録
es.create(index=index_name, id=announcement_content["id"], body=announcement_content)
es.indices.refresh(index=index_name)
# ドキュメントの検索
# see https://blog.chocolapod.net/momokan/entry/114
search_body = {
"query": {
"bool": {
"must": [
{
"multi_match": {
"query": "system",
"fields": [
"body^1"
],
"type": "phrase"
}
}
]
}
}
}
results = es.search(index=index_name, body=search_body, size=3)
self.assertEqual(first=1, second=len(results["hits"]["hits"]))
stop_ignored_query = {
"query": {
"bool": {
"must": [
{
"multi_match": {
"query": ".",
"fields": [
"body^1"
],
"type": "phrase"
}
}
]
}
}
}
results = es.search(index=index_name, body=stop_ignored_query, size=3)
self.assertEqual(first=0, second=len(results["hits"]["hits"]))
References
- How to implement Japanese full-text search in Elasticsearch | Elastic Blog
- Python Elasticsearch Client — Elasticsearch 7.11.0 documentation
- タイプレスAPIに移行する:Elasticsearch 7.0の変更点 | Elastic Blog
- Language analyzers | Elasticsearch Reference [7.11] | Elastic
以上です。
関連する記事
[Python]ハイフンなし電話番号からハイフン付きに復元
Pythonでハイフンなしの日本の電話番号をハイフン付きのものに変換する
Elasticsearchサーバーをローカルで立てて、Rustを使ってアクセスしてみる
ElasticsearchへのアクセスをRustを使って実装できるようにしてみました。
GKEにシングルノード構成のElasticsarchをデプロイする
小規模アプリ向けにGKE上にシングルノード構成のElasticsarchをデプロイしました。
[Python]BeautifulSoup4でhtmlの解析
BeautifulSoup4というPythonのライブラリを使って、特定のURLのコンテンツを取得し、タイトルや説明文を取得できるようにしました。