!pip install opensearch-py requests-aws4auth 'awswrangler[opensearch]' --quiet
import boto3
import json
import time
import logging
import awswrangler as wr
import pandas as pd
import numpy as np
from opensearchpy import OpenSearch, RequestsHttpConnection, AWSV4SignerAuth
from ipywidgets import interact
以降の処理を実行する際に必要なヘルパー関数を定義しておきます。
def search_cloudformation_output(stackname, key):
cloudformation_client = boto3.client('cloudformation', region_name=default_region)
for output in cloudformation_client.describe_stacks(StackName=stackname)['Stacks'][0]['Outputs']:
if output['OutputKey'] == key:
return output['OutputValue']
raise ValueError(f"{key} is not found in outputs of {stackname}.")
default_region = boto3.Session().region_name
logging.getLogger().setLevel(logging.ERROR)
OpenSearch クラスターへのネットワーク接続性が確保されており、OpenSearch の Security 機能により API リクエストが許可されているかを確認します。
レスポンスに cluster_name や cluster_uuid が含まれていれば、接続確認が無事完了したと判断できます
cloudformation_stack_name = "search-lab-jp"
opensearch_cluster_endpoint = search_cloudformation_output(cloudformation_stack_name, "OpenSearchDomainEndpoint")
credentials = boto3.Session().get_credentials()
service_code = "es"
auth = AWSV4SignerAuth(credentials=credentials, region=default_region, service=service_code)
opensearch_client = OpenSearch(
hosts=[{"host": opensearch_cluster_endpoint, "port": 443}],
http_compress=True,
http_auth=auth,
use_ssl=True,
verify_certs=True,
connection_class = RequestsHttpConnection
)
opensearch_client.info()
{'name': '37fa7880d30e6918860bdb0e18c8e91d', 'cluster_name': '123456789012:opensearchservi-lsy27q89mdpe', 'cluster_uuid': 'yHC8ufTTRdWZqY-0J9kE9A', 'version': {'distribution': 'opensearch', 'number': '2.17.0', 'build_type': 'tar', 'build_hash': 'unknown', 'build_date': '2025-02-14T09:38:50.023788640Z', 'build_snapshot': False, 'lucene_version': '9.11.1', 'minimum_wire_compatibility_version': '7.10.0', 'minimum_index_compatibility_version': '7.0.0'}, 'tagline': 'The OpenSearch Project: https://opensearch.org/'}
Kuromoji 標準辞書に登録されている単語は mecab-ipadic-2.7.0-20070801.tar.gz 内のファイルから確認することができます。以下はファイルのリストです。
ファイル名 | 分類 | 例 |
---|---|---|
Adj.csv.utf8.txt | 形容詞 | 軽い、何気無い、優しい |
Adnominal.csv.utf8.txt | 連体詞 | 確固たる、いわゆる、おかしな |
Adverb.csv.utf8.txt | 副詞 | ぜったいに、多少、なにしろ |
Auxil.csv.utf8.txt | 助動詞 | です、ます、らしく、ある |
Conjunction.csv.utf8.txt | 接続詞 | なので、でも、なお、なら |
Filler.csv.utf8.txt | フィラーワード | あー、えー、うん、まあ |
Interjection.csv.utf8.txt | 感動詞(感嘆詞) | わあ、へー、おはよう |
Noun.adjv.csv.utf8.txt | 名詞(形容動詞語幹) | きらびやか、温厚、人一倍 |
Noun.adverbal.csv.utf8.txt | 名詞(副詞可能) | すべて、全員、近頃 |
Noun.csv.utf8.txt | 名詞(一般) | 氏名、コスト、足ぶみ、わたぼうし |
Noun.demonst.csv.utf8.txt | 名詞(代名詞) | 私、君、あれ、これ、それ |
Noun.nai.csv.utf8.txt | 名詞("ない" 形容詞語幹) | 申しわけ、しょうが、他愛 |
Noun.name.csv.utf8.txt | 名詞(固有名詞/人名) | ノーベル、蘇我蝦夷、長崎、頼朝 |
Noun.number.csv.utf8.txt | 名詞(数) | 百、1(全角)、ゼロ、ひと |
Noun.org.csv.utf8.txt | 名詞(固有名詞/組織) | 国会図書館、最高裁判所、造幣局 |
Noun.others.csv.utf8.txt | 名詞(非自立) | かぎり、はず、矢先、つもり |
Noun.place.csv.utf8.txt | 名詞(固有名詞/地域) | 関東、東京、目黒 |
Noun.proper.csv.utf8.txt | 名詞(固有名詞/一般) | アマゾン川、幕張メッセ、金毘羅山 |
Noun.verbal.csv.utf8.txt | 名詞(サ辺接続) | 改善、感謝、リスクヘッジ、こざっぱり |
Others.csv.utf8.txt | その他 | よ、ァ |
Postp-col.csv.utf8.txt | 助詞(格助詞) | にあたります、を通じて |
Postp.csv.utf8.txt | 助詞(特殊) | て、に、を、は、けども、ながら |
Prefix.csv.utf8.txt | 接頭詞 | 真、大、小、今 |
Suffix.csv.utf8.txt | 名詞(接尾/助数詞) | 人、係、メートル |
Symbol.csv.utf8.txt | 記号 | ¥、Σ、●、〒 |
Verb.csv.utf8.txt | 動詞 | 探し出す、学ぶ、ぬかるむ |
辞書のエントリファイルの書式は以下のようになっています。
表層形,左文脈ID,右文脈ID,コスト,品詞,品詞細分類1,品詞細分類2,品詞細分類3,活用型,活用形,原形,読み,発音
形容詞や動詞は、活用形ごとに辞書内にエントリが存在し、共通の原形が割り当てられています。活用形ごとに原形を伴って辞書に情報が登録されていることで、活用形の違いによる検索ヒット率の低下を、後段で解説する正規化処理で防ぐことができます。以下は一部ファイルの抜粋です。
csv
あたたかい,19,19,6948,形容詞,自立,*,*,形容詞・アウオ段,基本形,あたたかい,アタタカイ,アタタカイ
あたたかし,23,23,6953,形容詞,自立,*,*,形容詞・アウオ段,文語基本形,あたたかい,アタタカシ,アタタカシ
あたたかから,27,27,6953,形容詞,自立,*,*,形容詞・アウオ段,未然ヌ接続,あたたかい,アタタカカラ,アタタカカラ
あたたかかろ,25,25,6953,形容詞,自立,*,*,形容詞・アウオ段,未然ウ接続,あたたかい,アタタカカロ,アタタカカロ
あたたかかっ,33,33,6952,形容詞,自立,*,*,形容詞・アウオ段,連用タ接続,あたたかい,アタタカカッ,アタタカカッ
あたたかく,35,35,6952,形容詞,自立,*,*,形容詞・アウオ段,連用テ接続,あたたかい,アタタカク,アタタカク
csv
すみわたる,772,772,9279,動詞,自立,*,*,五段・ラ行,基本形,すみわたる,スミワタル,スミワタル
すみわたら,780,780,9279,動詞,自立,*,*,五段・ラ行,未然形,すみわたる,スミワタラ,スミワタラ
すみわたん,782,782,9279,動詞,自立,*,*,五段・ラ行,未然特殊,すみわたる,スミワタン,スミワタン
すみわたろ,778,778,9279,動詞,自立,*,*,五段・ラ行,未然ウ接続,すみわたる,スミワタロ,スミワタロ
すみわたり,788,788,9279,動詞,自立,*,*,五段・ラ行,連用形,すみわたる,スミワタリ,スミワタリ
すみわたっ,786,786,9279,動詞,自立,*,*,五段・ラ行,連用タ接続,すみわたる,スミワタッ,スミワタッ
csv
確言,1283,1283,4467,名詞,サ変接続,*,*,*,*,確言,カクゲン,カクゲン
行脚,1283,1283,4466,名詞,サ変接続,*,*,*,*,行脚,アンギャ,アンギャ
微笑,1283,1283,4087,名詞,サ変接続,*,*,*,*,微笑,ビショウ,ビショー
ミート,1283,1283,4426,名詞,サ変接続,*,*,*,*,ミート,ミート,ミート
含有,1283,1283,4467,名詞,サ変接続,*,*,*,*,含有,ガンユウ,ガンユー
エンジンに組み込まれているデフォルトの辞書は全ての固有名詞をカバーしないため、ユーザー辞書に語句を登録することでトークンの抽出が意図したとおりに行われるようになります。
標準の Kuromoji Tokenizer では、紅まどんな(べにまどんな) というキーワードは 紅/ま/どんな と分割されます。
以下は _analyze API の実行例です
payload = {
"text": ["紅まどんな"],
"tokenizer": {
"type": "kuromoji_tokenizer",
"mode": "search",
"discard_compound_token": True
}
}
response = opensearch_client.indices.analyze(
body=payload
)
df = pd.json_normalize(response["tokens"])
df
token | start_offset | end_offset | type | position | |
---|---|---|---|---|---|
0 | 紅 | 0 | 1 | word | 0 |
1 | ま | 1 | 2 | word | 1 |
2 | どんな | 2 | 5 | word | 2 |
分割された各トークンの読みガナを確認すると、アカ/マ/ドンナ となっていることが確認できました。
payload = {
"text": "紅まどんな",
"tokenizer": {
"type": "kuromoji_tokenizer",
"mode": "search",
"discard_compound_token": True
},
"filter": [
{
"type": "kuromoji_readingform",
"use_romaji": False
}
]
}
response = opensearch_client.indices.analyze(
body=payload
)
df = pd.json_normalize(response["tokens"])
df
token | start_offset | end_offset | type | position | |
---|---|---|---|---|---|
0 | アカ | 0 | 1 | word | 0 |
1 | マ | 1 | 2 | word | 1 |
2 | ドンナ | 2 | 5 | word | 2 |
Kuromoji はユーザー辞書として以下のフォーマットをサポートしています。
<文字列>,<トークン 1> ... <トークン n>,<読みガナ 1> ... <読みガナ n>,<品詞タグ>
1 つ目のエントリ<文字列>では処理対象の文字列を、2 つめのエントリ <トークン 1> ... <トークン n> では、入力された文字列の分割単位を、3 つめのエントリ <読みガナ 1> ... <読みガナ n> には、トークンの読みガナを、最後のエントリには品詞名を表すタグを記載します。品詞タグにはカスタム名詞
を用いるのが一般的です。
紅まどんな を 紅まどんな のまま分割せずにトークン化したい場合は、以下のように記載します。
紅まどんな,紅まどんな,ベニマドンナ,カスタム名詞
このエントリを user_dictionary_rules に追加して、改めて _analyze API を実行し、"紅まどんな" が単体のトークンとして抽出されたことを確認します。
payload = {
"text": "紅まどんな",
"tokenizer": {
"type": "kuromoji_tokenizer",
"mode": "search",
"discard_compound_token": True,
"user_dictionary_rules": ["紅まどんな,紅まどんな,ベニマドンナ,カスタム名詞"]
}
}
response = opensearch_client.indices.analyze(
body=payload
)
df = pd.json_normalize(response["tokens"])
df
token | start_offset | end_offset | type | position | |
---|---|---|---|---|---|
0 | 紅まどんな | 0 | 5 | word | 0 |
kuromoji_readingform フィルタを追加して、トークンの読みガナも正しく処理されていることを確認します。
payload = {
"text": "紅まどんな",
"tokenizer": {
"type": "kuromoji_tokenizer",
"mode": "search",
"discard_compound_token": True,
"user_dictionary_rules": ["紅まどんな,紅まどんな,ベニマドンナ,カスタム名詞"]
},
"filter": [
{
"type": "kuromoji_readingform",
"use_romaji": False
}
]
}
response = opensearch_client.indices.analyze(
body=payload
)
df = pd.json_normalize(response["tokens"])
df
token | start_offset | end_offset | type | position | |
---|---|---|---|---|---|
0 | ベニマドンナ | 0 | 5 | word | 0 |
ユーザー辞書を活用することで、以下のようなトークン分割の調整を行うこともできます。
以下はユーザー辞書追加前のトークン分割結果です
payload = {
"text": ["東京ゲートブリッジ", "アイストールラテ"],
"tokenizer": {
"type": "kuromoji_tokenizer",
"mode": "search",
"discard_compound_token": True,
"user_dictionary_rules": ["紅まどんな,紅まどんな,ベニマドンナ,カスタム名詞"]
}
}
response = opensearch_client.indices.analyze(
body=payload
)
df = pd.json_normalize(response["tokens"])
df
token | start_offset | end_offset | type | position | |
---|---|---|---|---|---|
0 | 東京 | 0 | 2 | word | 0 |
1 | ゲート | 2 | 5 | word | 1 |
2 | ブリッジ | 5 | 9 | word | 2 |
3 | アイストールラテ | 10 | 18 | word | 103 |
以下はユーザー辞書定義追加後のトークン分割結果です。
payload = {
"text": ["東京ゲートブリッジ", "アイストールラテ"],
"tokenizer": {
"type": "kuromoji_tokenizer",
"mode": "search",
"discard_compound_token": True,
"user_dictionary_rules": [
"紅まどんな,紅まどんな,ベニマドンナ,カスタム名詞",
"東京ゲートブリッジ,東京 ゲートブリッジ,トウキョウ ゲートブリッジ,カスタム名詞",
"アイストールラテ,アイス トール ラテ,アイス トール ラテ,カスタム名詞"
]
}
}
response = opensearch_client.indices.analyze(
body=payload
)
df = pd.json_normalize(response["tokens"])
df
token | start_offset | end_offset | type | position | |
---|---|---|---|---|---|
0 | 東京 | 0 | 2 | word | 0 |
1 | ゲートブリッジ | 2 | 9 | word | 1 |
2 | アイス | 10 | 13 | word | 102 |
3 | トール | 13 | 16 | word | 103 |
4 | ラテ | 16 | 18 | word | 104 |
実際のインデックスにユーザー辞書をセットし、辞書の有無による検索精度を比較していきます。Amazon OpenSearch Service では、Kuromoji において以下 2 通りのユーザー辞書セット方法を提供しています。
本ラボでは user_dictionary_rules オプションを使用してユーザー辞書をインデックスにセットしていきます。
前述の Analyzer API で使用した user_dictionary_rules オプションは、インデックスに対して適用することが可能です。 インデックスに対して直接エントリをセットできるため、動作確認を素早く行うことが可能です。
インデックスの定義内にエントリを含む性質上、大量のエントリを管理する場合はカスタムパッケージを使用した方がよいでしょう。
以降、user_dictionary_rules オプションを使用したユーザー辞書の適用方法を解説していきます。
item フィールドおよび、サブフィールドの item.text、item.text_with_userdict フィールドを持つインデックスを定義します。
item フィールドは keyword フィールドであるため、完全一致検索で用います。一方で item.text フィールドは Kuromoji のデフォルト辞書のみを使用してトークン分割を行います。item.text_with_userdict フィールドにはユーザー辞書を追加しています。
OpenSearch は、子フィールドを定義することで、親フィールドに投入した値を元に子フィールドごとに個別のインデックスを生成し、検索に使用することができます。単一フィールドへのデータ投入で複数のインデックスを作成できるため、クライアント - OpenSearch 間のペイロードサイズを削減することができるなど、いくつかの面でメリットがあります。
index_name = "kuromoji-sample-with-user-dictionary-rules-v1"
user_dictionary_rules = [
"紅まどんな,紅まどんな,ベニマドンナ,カスタム名詞",
"東京ゲートブリッジ,東京 ゲートブリッジ,トウキョウ ゲートブリッジ,カスタム名詞",
"アイストールラテ,アイス トール ラテ,アイス トール ラテ,カスタム名詞"
]
payload = {
"mappings": {
"properties": {
"id": {"type": "keyword"},
"item": {
"type": "keyword",
"fields": {
"text": {
"type": "text",
"analyzer": "custom_kuromoji_analyzer",
},
"text_with_userdict": {
"type": "text",
"analyzer": "custom_kuromoji_analyzer_with_userdict",
}
}
}
}
},
"settings": {
"index.number_of_shards": 1,
"index.number_of_replicas": 0,
"index.refresh_interval": -1,
"analysis": {
"analyzer": {
"custom_kuromoji_analyzer": {
"tokenizer": "custom_kuromoji_tokenizer"
},
"custom_kuromoji_analyzer_with_userdict": {
"tokenizer": "custom_kuromoji_tokenizer_with_userdict",
}
},
"tokenizer": {
"custom_kuromoji_tokenizer": {
"type": "kuromoji_tokenizer",
"mode": "search",
"discard_compound_token": True
},
"custom_kuromoji_tokenizer_with_userdict": {
"type": "kuromoji_tokenizer",
"mode": "search",
"discard_compound_token": True,
"user_dictionary_rules": user_dictionary_rules
}
}
}
}
}
try:
# 既に同名のインデックスが存在する場合、いったん削除を行う
print("# delete index")
response = opensearch_client.indices.delete(index=index_name)
print(json.dumps(response, indent=2))
except Exception as e:
print(e)
# インデックスの作成を行う
print("# create index")
response = opensearch_client.indices.create(index=index_name, body=payload)
print(json.dumps(response, indent=2))
# delete index NotFoundError(404, 'index_not_found_exception', 'no such index [kuromoji-sample-with-user-dictionary-rules-v1]', kuromoji-sample-with-user-dictionary-rules-v1, index_or_alias) # create index { "acknowledged": true, "shards_acknowledged": true, "index": "kuromoji-sample-with-user-dictionary-rules-v1" }
テストデータを投入します。正しいデータと、元の正しいデータを並べ替えた不正なデータを登録し、検索結果の確認に用います。
index_name = "kuromoji-sample-with-user-dictionary-rules-v1"
payload = f"""
{{"index": {{"_index": "{index_name}", "_id": "1a"}}}}
{{"item": "紅まどんな"}}
{{"index": {{"_index": "{index_name}", "_id": "1b"}}}}
{{"item": "どんな紅ま"}}
{{"index": {{"_index": "{index_name}", "_id": "2a"}}}}
{{"item": "東京ゲートブリッジ"}}
{{"index": {{"_index": "{index_name}", "_id": "2b"}}}}
{{"item": "東京ブリッジゲート"}}
{{"index": {{"_index": "{index_name}", "_id": "3"}}}}
{{"item": "アイストールラテ"}}
"""
response = opensearch_client.bulk(payload, refresh=False)
print(json.dumps(response, indent=2))
{ "took": 3, "errors": false, "items": [ { "index": { "_index": "kuromoji-sample-with-user-dictionary-rules-v1", "_id": "1a", "_version": 1, "result": "created", "_shards": { "total": 1, "successful": 1, "failed": 0 }, "_seq_no": 0, "_primary_term": 1, "status": 201 } }, { "index": { "_index": "kuromoji-sample-with-user-dictionary-rules-v1", "_id": "1b", "_version": 1, "result": "created", "_shards": { "total": 1, "successful": 1, "failed": 0 }, "_seq_no": 1, "_primary_term": 1, "status": 201 } }, { "index": { "_index": "kuromoji-sample-with-user-dictionary-rules-v1", "_id": "2a", "_version": 1, "result": "created", "_shards": { "total": 1, "successful": 1, "failed": 0 }, "_seq_no": 2, "_primary_term": 1, "status": 201 } }, { "index": { "_index": "kuromoji-sample-with-user-dictionary-rules-v1", "_id": "2b", "_version": 1, "result": "created", "_shards": { "total": 1, "successful": 1, "failed": 0 }, "_seq_no": 3, "_primary_term": 1, "status": 201 } }, { "index": { "_index": "kuromoji-sample-with-user-dictionary-rules-v1", "_id": "3", "_version": 1, "result": "created", "_shards": { "total": 1, "successful": 1, "failed": 0 }, "_seq_no": 4, "_primary_term": 1, "status": 201 } } ] }
本ラボではデータ登録時に意図的に Refresh オプションを無効化しているため、念のため Refresh API を実行し、登録されたドキュメントが確実に検索可能となるようにします
index_name = "kuromoji-sample-with-user-dictionary-rules-v1"
response = opensearch_client.indices.refresh(index_name)
response = opensearch_client.indices.forcemerge(index_name)
まず、紅まどんな
で、item.text フィールドに対して検索を行います。
index_name = "kuromoji-sample-with-user-dictionary-rules-v1"
query = "紅まどんな"
payload = {
"query": {
"match": {
"item.text": {
"query": query,
"operator": "and"
}
}
},
"highlight": {
"fields": {
"*" : {}
}
},
"_source": False,
"fields": ["item.text"]
}
response = opensearch_client.search(
index=index_name,
body=payload
)
pd.json_normalize(response["hits"]["hits"])
_index | _id | _score | fields.item.text | highlight.item.text | |
---|---|---|---|---|---|
0 | kuromoji-sample-with-user-dictionary-rules-v1 | 1a | 2.470895 | [紅まどんな] | [<em>紅</em><em>ま</em><em>どんな</em>] |
1 | kuromoji-sample-with-user-dictionary-rules-v1 | 1b | 2.470895 | [どんな紅ま] | [<em>どんな</em><em>紅</em><em>ま</em>] |
上記の結果より、クエリテキストが 紅/ま/どんな にトークン分割されてからマッチング処理が実行されているため、不要なデータもヒットしていることが確認できました。 では、ユーザー辞書によってトークン分割が適正化されている item.text_with_userdict フィールドに対して同様のクエリを発行します。
index_name = "kuromoji-sample-with-user-dictionary-rules-v1"
query = "紅まどんな"
payload = {
"query": {
"match": {
"item.text_with_userdict": {
"query": query,
"operator": "and"
}
}
},
"highlight": {
"fields": {
"*" : {}
}
},
"_source": False,
"fields": ["item.text_with_userdict"]
}
response = opensearch_client.search(
index=index_name,
body=payload
)
pd.json_normalize(response["hits"]["hits"])
_index | _id | _score | fields.item.text_with_userdict | highlight.item.text_with_userdict | |
---|---|---|---|---|---|
0 | kuromoji-sample-with-user-dictionary-rules-v1 | 1a | 1.820805 | [紅まどんな] | [<em>紅まどんな</em>] |
正しく 紅まどんな だけがヒットしました。また、ハイライトを見ると 紅まどんな 全体が一つのトークンとして処理されていることが分かります。 同様に、東京ゲートブリッジ で item.text フィールドの検索を行うと、東京ブリッジゲート もヒットしてしまいました。これは 東京/ゲート/ブリッジ と 3 つのトークンに分割されてしまっていることが理由です。
index_name = "kuromoji-sample-with-user-dictionary-rules-v1"
query = "東京ゲートブリッジ"
payload = {
"query": {
"match": {
"item.text": {
"query": query,
"operator": "and"
}
}
},
"highlight": {
"fields": {
"*" : {}
}
},
"_source": False,
"fields": ["item.text"]
}
response = opensearch_client.search(
index=index_name,
body=payload
)
pd.json_normalize(response["hits"]["hits"])
_index | _id | _score | fields.item.text | highlight.item.text | |
---|---|---|---|---|---|
0 | kuromoji-sample-with-user-dictionary-rules-v1 | 2a | 2.470895 | [東京ゲートブリッジ] | [<em>東京</em><em>ゲート</em><em>ブリッジ</em>] |
1 | kuromoji-sample-with-user-dictionary-rules-v1 | 2b | 2.470895 | [東京ブリッジゲート] | [<em>東京</em><em>ブリッジ</em><em>ゲート</em>] |
対策としては match_phrase の利用が考えられますが、この場合、クエリに 東京ゲートブリッジ 以外も含まれているとそちらも順序判定の対象となってしまいます。
index_name = "kuromoji-sample-with-user-dictionary-rules-v1"
query = "東京ゲートブリッジ"
payload = {
"query": {
"match_phrase": {
"item.text": {
"query": query,
}
}
},
"highlight": {
"fields": {
"*" : {}
}
},
"_source": False,
"fields": ["item.text"]
}
response = opensearch_client.search(
index=index_name,
body=payload
)
pd.json_normalize(response["hits"]["hits"])
_index | _id | _score | fields.item.text | highlight.item.text | |
---|---|---|---|---|---|
0 | kuromoji-sample-with-user-dictionary-rules-v1 | 2a | 2.470896 | [東京ゲートブリッジ] | [<em>東京</em><em>ゲート</em><em>ブリッジ</em>] |
ユーザー辞書によってトークン分割が適正化されている item.text_with_userdict フィールドに対して同様のクエリを発行します。
index_name = "kuromoji-sample-with-user-dictionary-rules-v1"
query = "東京ゲートブリッジ"
payload = {
"query": {
"match": {
"item.text_with_userdict": {
"query": query,
"operator": "and"
}
}
},
"highlight": {
"fields": {
"*" : {}
}
},
"_source": False,
"fields": ["item.text_with_userdict"]
}
response = opensearch_client.search(
index=index_name,
body=payload
)
pd.json_normalize(response["hits"]["hits"])
_index | _id | _score | fields.item.text_with_userdict | highlight.item.text_with_userdict | |
---|---|---|---|---|---|
0 | kuromoji-sample-with-user-dictionary-rules-v1 | 2a | 2.427258 | [東京ゲートブリッジ] | [<em>東京</em><em>ゲートブリッジ</em>] |
ゲートブリッジ がトークン分割されないことで、正しいドキュメントだけを取得することができました。 最後にアイストールラテを検索していきます。アイストールラテを検索する際に、ラテのアイスでサイズはトール、と考えて ラテ アイス トール で検索を行います。
検索対象のフィールドは、ユーザー辞書が適用されていない item.text フィールドです。
index_name = "kuromoji-sample-with-user-dictionary-rules-v1"
query = "ラテ アイス トール"
payload = {
"query": {
"match": {
"item.text": {
"query": query,
"operator": "and"
}
}
},
"highlight": {
"fields": {
"*" : {}
}
},
"_source": False,
"fields": ["item.text"]
}
response = opensearch_client.search(
index=index_name,
body=payload
)
pd.json_normalize(response["hits"]["hits"])
残念ながらヒットしません。これは アイストールラテ で単一トークンと認識されているためです。実際にアイストールラテで検索した結果は以下の通りです。
index_name = "kuromoji-sample-with-user-dictionary-rules-v1"
query = "アイストールラテ"
payload = {
"query": {
"match": {
"item.text": {
"query": query,
"operator": "and"
}
}
},
"highlight": {
"fields": {
"*" : {}
}
},
"_source": False,
"fields": ["item.text"]
}
response = opensearch_client.search(
index=index_name,
body=payload
)
pd.json_normalize(response["hits"]["hits"])
_index | _id | _score | fields.item.text | highlight.item.text | |
---|---|---|---|---|---|
0 | kuromoji-sample-with-user-dictionary-rules-v1 | 3 | 1.852711 | [アイストールラテ] | [<em>アイストールラテ</em>] |
ユーザー辞書によってトークン分割が適正化されている item.text_with_userdict フィールドに対して同様のクエリを発行します。
index_name = "kuromoji-sample-with-user-dictionary-rules-v1"
query = "ラテ アイス トール"
payload = {
"query": {
"match": {
"item.text_with_userdict": {
"query": query,
"operator": "and",
}
}
},
"highlight": {
"fields": {
"*" : {}
}
},
"_source": False,
"fields": ["item.text_with_userdict"]
}
response = opensearch_client.search(
index=index_name,
body=payload
)
pd.json_normalize(response["hits"]["hits"])
_index | _id | _score | fields.item.text_with_userdict | highlight.item.text_with_userdict | |
---|---|---|---|---|---|
0 | kuromoji-sample-with-user-dictionary-rules-v1 | 3 | 3.773007 | [アイストールラテ] | [<em>アイス</em><em>トール</em><em>ラテ</em>] |
無事にヒットしました。アイストールラテが アイス/トール/ラテ の 3 つのトークンに分割されることで、順序が異なるキーワードによる検索でもヒットするようになりました。
ユーザー辞書は、検索要件やトレンドの変化に合わせて継続的なメンテナンスが必要です。Kuromoji のユーザー辞書更新はオンラインでインデックスに反映されないため、一般的には何らかの静止点を設けて更新作業を行う必要があります。
辞書の更新を行う場合、一般的には以下 3 通りの作業方法から選択します。
既存のインデックスに格納されたソースデータを利用して、インデックス内のデータの再登録を行う方式です。Update by query API を実行するだけで再登録ができるため最も楽に更新を実行できますが、以下の点に注意する必要があります。
作業は以下の流れで行います
新しい辞書定義を含む空の新規インデックスを作成し、既存インデックスからデータをコピー、テスト後にトラフィックを新規のインデックスに切り替える方式です。
Update by query API 方式はインデックスの close を伴うため、検索処理も一時的にストップします。一方でこちらの方式は、データ更新こそ停止断面を確保する必要がありますが、検索処理を止めずに辞書の切り替えが可能です。このため、多くの本番運用で採用されています。
インデックス内のドキュメント更新処理を停止できることが理想です。ドキュメント更新処理を停止できない場合は、両系更新を検討するとよいです。
作業は以下の流れで行います。
再登録は、初期登録時と同様に、マスターデータを外部から取得して Bulk API 等で書き込む方法と、Reindex API を使用する方法があります。Reindex API は、OpenSearch のインデックスに登録されたドキュメントを取得し、別のインデックスに書き込む機能です。
更新処理に伴うデータ登録の停止時間が取れない場合は、両系更新を検討することになります。 ひとつ前のデータ再登録方式と似ていますが、既存インデックス用と新規インデックス用で、別々のデータ更新パイプライン(あるいはバッチ処理)を用意する必要がある点が異なります。
本ラボでは、update_by_query、および reindex + alias による辞書更新方法を解説します。
今度は、ホットミルクティーとホットミルクラテの区切り位置を改善することで、さらに検索精度を上げていきましょう。
デフォルトの Kuromoji analyzer の挙動を見てみましょう。ホットミルクティーは ホッ/トミルクティー と分割されています。
payload = {
"text": ["ホットミルクティー"],
"tokenizer": {
"type": "kuromoji_tokenizer",
"mode": "search",
"discard_compound_token": True
}
}
response = opensearch_client.indices.analyze(
body=payload
)
df = pd.json_normalize(response["tokens"])
df
token | start_offset | end_offset | type | position | |
---|---|---|---|---|---|
0 | ホッ | 0 | 2 | word | 0 |
1 | トミルクティー | 2 | 9 | word | 1 |
同様にホットミルクラテも ホッ/トミルクラテ に分割されます。
payload = {
"text": ["ホットミルクラテ"],
"tokenizer": {
"type": "kuromoji_tokenizer",
"mode": "search",
"discard_compound_token": True
}
}
response = opensearch_client.indices.analyze(
body=payload
)
df = pd.json_normalize(response["tokens"])
df
token | start_offset | end_offset | type | position | |
---|---|---|---|---|---|
0 | ホッ | 0 | 2 | word | 0 |
1 | トミルクラテ | 2 | 8 | word | 1 |
実際にホットミルクティーを登録して検索を行ってみましょう
index_name = "kuromoji-sample-with-user-dictionary-rules-v1"
payload = f"""
{{"index": {{"_index": "{index_name}", "_id": "4"}}}}
{{"item": "ホットミルクティー"}}
"""
response = opensearch_client.bulk(payload, refresh=False)
print(json.dumps(response, indent=2))
response = opensearch_client.indices.refresh(index_name)
response = opensearch_client.indices.forcemerge(index_name)
{ "took": 3, "errors": false, "items": [ { "index": { "_index": "kuromoji-sample-with-user-dictionary-rules-v1", "_id": "4", "_version": 1, "result": "created", "_shards": { "total": 1, "successful": 1, "failed": 0 }, "_seq_no": 5, "_primary_term": 1, "status": 201 } } ] }
ホットミルクティー では item.text フィールドおよび item.text_with_userdict フィールド双方にヒットします。
index_name = "kuromoji-sample-with-user-dictionary-rules-v1"
query = "ホットミルクティー"
payload = {
"query": {
"multi_match": {
"fields": ["item.text", "item.text_with_userdict"],
"query": query,
"operator": "and"
}
},
"highlight": {
"fields": {
"*" : {}
}
},
"_source": False,
"fields": ["item.text", "item.text_with_userdict"]
}
response = opensearch_client.search(
index=index_name,
body=payload
)
pd.json_normalize(response["hits"]["hits"])
_index | _id | _score | fields.item.text | fields.item.text_with_userdict | highlight.item.text | highlight.item.text_with_userdict | |
---|---|---|---|---|---|---|---|
0 | kuromoji-sample-with-user-dictionary-rules-v1 | 4 | 3.355425 | [ホットミルクティー] | [ホットミルクティー] | [<em>ホッ</em><em>トミルクティー</em>] | [<em>ホッ</em><em>トミルクティー</em>] |
ミルクティー ホット ではいずれのフィールドにもヒットしません。ホットミルクティーに対するユーザ辞書エントリがないので、これは想定通りの結果といえます。
index_name = "kuromoji-sample-with-user-dictionary-rules-v1"
query = "ミルクティー ホット"
payload = {
"query": {
"multi_match": {
"fields": ["item.text", "item.text_with_userdict"],
"query": query,
"operator": "and"
}
},
"highlight": {
"fields": {
"*" : {}
}
},
"_source": False,
"fields": ["item.text", "item.text_with_userdict"]
}
response = opensearch_client.search(
index=index_name,
body=payload
)
pd.json_normalize(response["hits"]["hits"])
index_name = "kuromoji-sample-with-user-dictionary-rules-v1"
payload = {
"text": "ホットミルクティー",
"analyzer": "custom_kuromoji_analyzer_with_userdict"
}
response = opensearch_client.indices.analyze(
index=index_name,
body=payload
)
df = pd.json_normalize(response["tokens"])
df
token | start_offset | end_offset | type | position | |
---|---|---|---|---|---|
0 | ホッ | 0 | 2 | word | 0 |
1 | トミルクティー | 2 | 9 | word | 1 |
次に、Explain API を利用することで、クエリテキストがどのように分解されて内部で検索処理が行われているかを確認します。Analyzer API にクエリテキストを渡しても確認することができますが、Explain API ではクエリと対象のドキュメントを指定することで、実際に分割後のトークンごとにマッチするか確認できます。
index_name = "kuromoji-sample-with-user-dictionary-rules-v1"
query = "ホットミルクティー"
payload = {
"query": {
"match": {
"item.text": {
"query": query,
"operator": "and"
}
}
},
}
response = opensearch_client.explain(
index=index_name,
body=payload,
id=4 #ドキュメント"ホットミルクティー"の ID
)
print(json.dumps(response, indent=2, ensure_ascii=False))
{ "_index": "kuromoji-sample-with-user-dictionary-rules-v1", "_id": "4", "matched": true, "explanation": { "value": 3.3554246, "description": "sum of:", "details": [ { "value": 1.6777123, "description": "weight(item.text:ホッ in 0) [PerFieldSimilarity], result of:", "details": [ { "value": 1.6777123, "description": "score(freq=1.0), computed as boost * idf * tf from:", "details": [ { "value": 2.2, "description": "boost", "details": [] }, { "value": 1.5404451, "description": "idf, computed as log(1 + (N - n + 0.5) / (n + 0.5)) from:", "details": [ { "value": 1, "description": "n, number of documents containing term", "details": [] }, { "value": 6, "description": "N, total number of documents with field", "details": [] } ] }, { "value": 0.49504948, "description": "tf, computed as freq / (freq + k1 * (1 - b + b * dl / avgdl)) from:", "details": [ { "value": 1.0, "description": "freq, occurrences of term within document", "details": [] }, { "value": 1.2, "description": "k1, term saturation parameter", "details": [] }, { "value": 0.75, "description": "b, length normalization parameter", "details": [] }, { "value": 2.0, "description": "dl, length of field", "details": [] }, { "value": 2.5, "description": "avgdl, average length of field", "details": [] } ] } ] } ] }, { "value": 1.6777123, "description": "weight(item.text:トミルクティー in 0) [PerFieldSimilarity], result of:", "details": [ { "value": 1.6777123, "description": "score(freq=1.0), computed as boost * idf * tf from:", "details": [ { "value": 2.2, "description": "boost", "details": [] }, { "value": 1.5404451, "description": "idf, computed as log(1 + (N - n + 0.5) / (n + 0.5)) from:", "details": [ { "value": 1, "description": "n, number of documents containing term", "details": [] }, { "value": 6, "description": "N, total number of documents with field", "details": [] } ] }, { "value": 0.49504948, "description": "tf, computed as freq / (freq + k1 * (1 - b + b * dl / avgdl)) from:", "details": [ { "value": 1.0, "description": "freq, occurrences of term within document", "details": [] }, { "value": 1.2, "description": "k1, term saturation parameter", "details": [] }, { "value": 0.75, "description": "b, length normalization parameter", "details": [] }, { "value": 2.0, "description": "dl, length of field", "details": [] }, { "value": 2.5, "description": "avgdl, average length of field", "details": [] } ] } ] } ] } ] } }
ドキュメント登録時のトークンと、クエリ時のトークン、いずれも ホッ/トミルクティーであるため、検索ヒットしたことが確認できました。
では、ミルクティー ホット ではどうでしょうか。
index_name = "kuromoji-sample-with-user-dictionary-rules-v1"
query = "ミルクティー ホット"
payload = {
"query": {
"match": {
"item.text": {
"query": query,
"operator": "and"
}
}
},
}
response = opensearch_client.explain(
index=index_name,
body=payload,
id=4 #ドキュメント"ホットミルクティー"の ID
)
print(json.dumps(response, indent=2, ensure_ascii=False))
{ "_index": "kuromoji-sample-with-user-dictionary-rules-v1", "_id": "4", "matched": false, "explanation": { "value": 0.0, "description": "Failure to meet condition(s) of required/prohibited clause(s)", "details": [ { "value": 0.0, "description": "no match on required clause (item.text:ミルク)", "details": [ { "value": 0.0, "description": "no matching term", "details": [] } ] }, { "value": 0.0, "description": "no match on required clause (item.text:ティー)", "details": [ { "value": 0.0, "description": "no matching term", "details": [] } ] }, { "value": 0.0, "description": "no match on required clause (item.text:ホット)", "details": [ { "value": 0.0, "description": "no matching term", "details": [] } ] } ] } }
ミルクティー ホット で検索を行った場合、クエリは ミルク/ティー/ホット にトークン分割されていることが分かります。
一方、Analyze API 実行結果より、ホットミルクティー というドキュメントは、登録時に ホッ/トミルクティー というトークンに分割されていることが確認できています。
検索時のトークンと、登録時のトークンにずれがあることが、ミルクティー ホット で ホットミルクティーが検索できない原因であると確認できました。
登録されている時と同じトークン分割結果である ホッ/トミルクティー で検索してみましょう。このクエリは一見すると不自然ですが、登録時のトークンと同一であることからヒットします。
index_name = "kuromoji-sample-with-user-dictionary-rules-v1"
query = "ホッ トミルクティー"
payload = {
"query": {
"match": {
"item.text": {
"query": query,
"operator": "and"
}
}
},
"highlight": {
"fields": {
"*" : {}
}
},
"_source": False,
"fields": ["item.text"]
}
response = opensearch_client.search(
index=index_name,
body=payload
)
pd.json_normalize(response["hits"]["hits"])
_index | _id | _score | fields.item.text | highlight.item.text | |
---|---|---|---|---|---|
0 | kuromoji-sample-with-user-dictionary-rules-v1 | 4 | 3.355425 | [ホットミルクティー] | [<em>ホッ</em><em>トミルクティー</em>] |
また、ホッ や トミルクティー など、単一のトークンで検索してもヒットしてしまいます。
index_name = "kuromoji-sample-with-user-dictionary-rules-v1"
query = "ホッ"
payload = {
"query": {
"match": {
"item.text": {
"query": query,
"operator": "and"
}
}
},
"highlight": {
"fields": {
"*" : {}
}
},
"_source": False,
"fields": ["item.text"]
}
response = opensearch_client.search(
index=index_name,
body=payload
)
pd.json_normalize(response["hits"]["hits"])
_index | _id | _score | fields.item.text | highlight.item.text | |
---|---|---|---|---|---|
0 | kuromoji-sample-with-user-dictionary-rules-v1 | 4 | 1.677712 | [ホットミルクティー] | [<em>ホッ</em>トミルクティー] |
index_name = "kuromoji-sample-with-user-dictionary-rules-v1"
query = "トミルクティー"
payload = {
"query": {
"match": {
"item.text": {
"query": query,
"operator": "and"
}
}
},
"highlight": {
"fields": {
"*" : {}
}
},
"_source": False,
"fields": ["item.text"]
}
response = opensearch_client.search(
index=index_name,
body=payload
)
pd.json_normalize(response["hits"]["hits"])
_index | _id | _score | fields.item.text | highlight.item.text | |
---|---|---|---|---|---|
0 | kuromoji-sample-with-user-dictionary-rules-v1 | 4 | 1.677712 | [ホットミルクティー] | [ホッ<em>トミルクティー</em>] |
analyze API を使用することで入力時のトークン分割の様子が、explain API を使用することで検索時のトークン分割の様子が分かりました。登録・検索時のトークン分割を一致させることが、検索精度向上の鍵であることも分かりました。
ここからは、ミルクティー ホット でもヒットするように、ホットミルクティー が ホット/ミルク/ティー で区切られるようにユーザー辞書を作成していきましょう。
既存インデックスの user_dictonary_rules を以下の通り更新します。 合わせて ホットミルクラテ 用のエントリーも追加しておきます。
更新作業の前後で、Close API によるインデックスのクローズ、Open API によるインデックスのオープンを実行しています。
%%time
inex_name = "kuromoji-sample-with-user-dictionary-rules-v1"
user_dictionary_rules = [
"紅まどんな,紅まどんな,ベニマドンナ,カスタム名詞",
"東京ゲートブリッジ,東京 ゲートブリッジ,トウキョウ ゲートブリッジ,カスタム名詞",
"アイストールラテ,アイス トール ラテ,アイス トール ラテ,カスタム名詞",
"ホットミルクティー,ホット ミルク ティー,ホット ミルク ティー,カスタム名詞",
"ホットミルクラテ,ホット ミルク ラテ,ホット ミルク ラテ,カスタム名詞"
]
payload = {
"analysis": {
"tokenizer": {
"custom_kuromoji_tokenizer_with_userdict": {
"type": "kuromoji_tokenizer",
"user_dictionary_rules": user_dictionary_rules
}
}
}
}
print("Closing index...")
response = opensearch_client.indices.close(index=index_name)
print(json.dumps(response, indent=2))
print("Closed successfully")
print("Updating index...")
response = opensearch_client.indices.put_settings(index=index_name, body=payload)
print(json.dumps(response, indent=2))
print("Updated successfully")
print("Opening index...")
response = opensearch_client.indices.open(index=index_name)
print(json.dumps(response, indent=2))
print("Opend successfully")
Closing index... { "acknowledged": true, "shards_acknowledged": true, "indices": { "kuromoji-sample-with-user-dictionary-rules-v1": { "closed": true } } } Closed successfully Updating index... { "acknowledged": true } Updated successfully Opening index... { "acknowledged": true, "shards_acknowledged": true } Opend successfully CPU times: user 9.22 ms, sys: 157 μs, total: 9.38 ms Wall time: 374 ms
辞書の更新が終わったので、改めて item.text_with_userdict フィールドに対して ミルクティー ホット で検索してみましたが、結果は 0 件のままです。
index_name = "kuromoji-sample-with-user-dictionary-rules-v1"
query = "ミルクティー ホット"
payload = {
"query": {
"match": {
"item.text_with_userdict": {
"query": query,
"operator": "and"
}
}
},
"highlight": {
"fields": {
"*" : {}
}
},
"_source": False,
"fields": ["item.text"]
}
response = opensearch_client.search(
index=index_name,
body=payload
)
pd.json_normalize(response["hits"]["hits"])
また、ユーザー辞書をセットしている item.text_with_userdict に対しては、辞書更新前はヒットしていたホットミルクティー で検索にヒットしなくなってしまいました。これはなぜでしょうか?
index_name = "kuromoji-sample-with-user-dictionary-rules-v1"
query = "ホットミルクティー"
payload = {
"query": {
"match": {
"item.text_with_userdict": {
"query": query,
"operator": "and"
}
}
},
"highlight": {
"fields": {
"*" : {}
}
},
"_source": False,
"fields": ["item.text"]
}
response = opensearch_client.search(
index=index_name,
body=payload
)
pd.json_normalize(response["hits"]["hits"])
Analyze API でホットミルクティーをトークン分割すると、辞書更新前とは異なり ホット/ミルク/ティー で分割されることが確認できています。したがって、クエリは正常にトークン分割できているようです。
index_name = "kuromoji-sample-with-user-dictionary-rules-v1"
payload = {
"text": "ホットミルクティー",
"analyzer": "custom_kuromoji_analyzer_with_userdict"
}
response = opensearch_client.indices.analyze(
index=index_name,
body=payload
)
df = pd.json_normalize(response["tokens"])
df
token | start_offset | end_offset | type | position | |
---|---|---|---|---|---|
0 | ホット | 0 | 3 | word | 0 |
1 | ミルク | 3 | 6 | word | 1 |
2 | ティー | 6 | 9 | word | 2 |
原因は、トークン分割のタイミングによるものです。OpenSearch にドキュメントを格納する際、ドキュメントは Tokenizer によってトークン分割が行われます。このトークン分割はドキュメント格納時にのみ行われます。したがって、辞書を後から更新するだけでは、既に格納されているドキュメントのトークンは変化しません。
実際に、辞書更新前のトークン分割結果である ホッ/トミルクティー で検索すると、依然としてこの組み合わせでもヒットします。
index_name = "kuromoji-sample-with-user-dictionary-rules-v1"
query = "ホッ トミルクティー"
payload = {
"query": {
"match": {
"item.text_with_userdict": {
"query": query,
"operator": "and"
}
}
},
"highlight": {
"fields": {
"*" : {}
}
},
"_source": False,
"fields": ["item.text", "item.text_with_userdict"]
}
response = opensearch_client.search(
index=index_name,
body=payload
)
pd.json_normalize(response["hits"]["hits"])
_index | _id | _score | fields.item.text | fields.item.text_with_userdict | |
---|---|---|---|---|---|
0 | kuromoji-sample-with-user-dictionary-rules-v1 | 4 | 3.272118 | [ホットミルクティー] | [ホットミルクティー] |
試しに、先ほど辞書エントリに追加した ホットミルクラテ を登録します。
Analyze API の結果より、ホットミルクラテ は、格納時に ホット/ミルク/ラテ に分割されることが期待できます。正しくユーザー辞書が機能していることが分かります。
index_name = "kuromoji-sample-with-user-dictionary-rules-v1"
payload = {
"text": "ホットミルクラテ",
"analyzer": "custom_kuromoji_analyzer_with_userdict"
}
response = opensearch_client.indices.analyze(
index=index_name,
body=payload
)
df = pd.json_normalize(response["tokens"])
df
token | start_offset | end_offset | type | position | |
---|---|---|---|---|---|
0 | ホット | 0 | 3 | word | 0 |
1 | ミルク | 3 | 6 | word | 1 |
2 | ラテ | 6 | 8 | word | 2 |
実際に ホットミルクラテ を登録していきます。
index_name = "kuromoji-sample-with-user-dictionary-rules-v1"
payload = f"""
{{"index": {{"_index": "{index_name}", "_id": "5"}}}}
{{"item": "ホットミルクラテ"}}
"""
response = opensearch_client.bulk(payload, refresh=False)
print(json.dumps(response, indent=2))
response = opensearch_client.indices.refresh(index_name)
response = opensearch_client.indices.forcemerge(index_name)
{ "took": 5, "errors": false, "items": [ { "index": { "_index": "kuromoji-sample-with-user-dictionary-rules-v1", "_id": "5", "_version": 1, "result": "created", "_shards": { "total": 1, "successful": 1, "failed": 0 }, "_seq_no": 6, "_primary_term": 3, "status": 201 } } ] }
登録後に ミルク/ラテ/ホット で検索すると、正しくヒットすることが確認できました。 ユーザー辞書のエントリ更新後に登録されたドキュメントは、更新後の辞書の影響を受けることが分かりました。
index_name = "kuromoji-sample-with-user-dictionary-rules-v1"
query = "ホット ミルク ラテ"
payload = {
"query": {
"match": {
"item.text_with_userdict": {
"query": query,
"operator": "and"
}
}
},
"highlight": {
"fields": {
"*" : {}
}
},
"_source": False,
"fields": ["item.text"]
}
response = opensearch_client.search(
index=index_name,
body=payload
)
pd.json_normalize(response["hits"]["hits"])
_index | _id | _score | fields.item.text | highlight.item.text_with_userdict | |
---|---|---|---|---|---|
0 | kuromoji-sample-with-user-dictionary-rules-v1 | 5 | 4.115007 | [ホットミルクラテ] | [<em>ホット</em><em>ミルク</em><em>ラテ</em>] |
既存のドキュメントを、新しい辞書エントリを元に改めてトークン分割しなおすためには、ドキュメントの再登録が必要となります。
外部にマスターデータがある場合は、外部から改めてデータの全登録を行うことがお勧めですが、本セクションでは Update by query API を使用します。Update by query API を実行することで、インデックスに登録されたドキュメント自身のデータをもとに、ドキュメントの再登録を行うことができます。
Update by query は完了まで長時間要する場合があるため、wait_for_completion オプションに False をセットし、非同期で実行することを推奨します。
index_name = "kuromoji-sample-with-user-dictionary-rules-v1"
response = opensearch_client.update_by_query(
index=index_name,
wait_for_completion=False
)
task_id = response["task"]
print(json.dumps(response, indent=2, ensure_ascii=False))
{ "task": "kdnLueFaT0quni1aPfT5-g:383631" }
wait_for_completion に False をセットして実行した場合、task id が返却されます。task id を元に Get task API を実行することで進捗を確認できます。
completed が true となっていれば処理は完了です。
response = opensearch_client.tasks.get(task_id=task_id)
print(json.dumps(response, indent=2, ensure_ascii=False))
{ "completed": false, "task": { "node": "kdnLueFaT0quni1aPfT5-g", "id": 383631, "type": "transport", "action": "indices:data/write/update/byquery", "status": { "total": 7, "updated": 7, "created": 0, "deleted": 0, "batches": 1, "version_conflicts": 0, "noops": 0, "retries": { "bulk": 0, "search": 0 }, "throttled_millis": 0, "requests_per_second": -1.0, "throttled_until_millis": 0 }, "description": "update-by-query [kuromoji-sample-with-user-dictionary-rules-v1]", "start_time_in_millis": 1741668978283, "running_time_in_nanos": 28878971, "cancellable": true, "cancelled": false, "headers": {}, "resource_stats": { "average": { "cpu_time_in_nanos": 0, "memory_in_bytes": 0 }, "total": { "cpu_time_in_nanos": 0, "memory_in_bytes": 0 }, "min": { "cpu_time_in_nanos": 0, "memory_in_bytes": 0 }, "max": { "cpu_time_in_nanos": 0, "memory_in_bytes": 0 }, "thread_info": { "thread_executions": 0, "active_threads": 0 } } } }
index_name = "kuromoji-sample-with-user-dictionary-rules-v1"
response = opensearch_client.indices.refresh(index_name)
response = opensearch_client.indices.forcemerge(index_name)
ドキュメント再登録後は、無事 ミルクティー ホット で検索にヒットすることが確認できました。
index_name = "kuromoji-sample-with-user-dictionary-rules-v1"
query = "ミルク ティー ホット"
payload = {
"query": {
"match": {
"item.text_with_userdict": {
"query": query,
"operator": "and"
}
}
},
"highlight": {
"fields": {
"*" : {}
}
},
"_source": False,
"fields": ["item.text_with_userdict"]
}
response = opensearch_client.search(
index=index_name,
body=payload
)
pd.json_normalize(response["hits"]["hits"])
_index | _id | _score | fields.item.text_with_userdict | highlight.item.text_with_userdict | |
---|---|---|---|---|---|
0 | kuromoji-sample-with-user-dictionary-rules-v1 | 4 | 4.818888 | [ホットミルクティー] | [<em>ホット</em><em>ミルク</em><em>ティー</em>] |
Update by query 実行時にクエリパラメーターを追加することで、特定の条件に合致したドキュメントだけを洗い替えすることができます。クエリで更新対象のドキュメントの絞り込みが可能である場合は、Update by query による洗い替え時間を短縮することができます。
例えば、更新時刻を示すフィールドを持っているドキュメントであれば、range クエリで特定時刻以前のドキュメントのみ再登録を行うことが可能です。
index_name = "kuromoji-sample-with-user-dictionary-rules-v1"
query = "ホットミルクティー"
payload = {
"query": {
"match": {
"item.text": {
"query": query,
"operator": "and"
}
}
}
}
response = opensearch_client.update_by_query(
index=index_name,
body=payload,
wait_for_completion=False
)
task_id = response["task"]
print(json.dumps(response, indent=2, ensure_ascii=False))
{ "task": "kdnLueFaT0quni1aPfT5-g:383662" }
Task API 実行結果より、status.total が 1 となっていることが確認できます。全件ではなく 1 件だけが洗い替えされたことが確認できました。
response = opensearch_client.tasks.get(task_id=task_id)
print(json.dumps(response, indent=2, ensure_ascii=False))
{ "completed": false, "task": { "node": "kdnLueFaT0quni1aPfT5-g", "id": 383662, "type": "transport", "action": "indices:data/write/update/byquery", "status": { "total": 1, "updated": 1, "created": 0, "deleted": 0, "batches": 1, "version_conflicts": 0, "noops": 0, "retries": { "bulk": 0, "search": 0 }, "throttled_millis": 0, "requests_per_second": -1.0, "throttled_until_millis": 0 }, "description": "update-by-query [kuromoji-sample-with-user-dictionary-rules-v1]", "start_time_in_millis": 1741668978469, "running_time_in_nanos": 15650497, "cancellable": true, "cancelled": false, "headers": {}, "resource_stats": { "average": { "cpu_time_in_nanos": 0, "memory_in_bytes": 0 }, "total": { "cpu_time_in_nanos": 0, "memory_in_bytes": 0 }, "min": { "cpu_time_in_nanos": 0, "memory_in_bytes": 0 }, "max": { "cpu_time_in_nanos": 0, "memory_in_bytes": 0 }, "thread_info": { "thread_executions": 0, "active_threads": 0 } } } }
ここからは、Reindex API と Alias API を組み合わせた辞書更新について解説します。
新たに ホットルイボスティー についても、ホット/ルイボス/ティー でトークン分割が行われるようにしていきます。
デフォルトでは ホッ/トルイボスティー と分かち書きされます。
index_name = "kuromoji-sample-with-user-dictionary-rules-v1"
payload = {
"text": "ホットルイボスティー",
"analyzer": "custom_kuromoji_analyzer_with_userdict"
}
response = opensearch_client.indices.analyze(
index=index_name,
body=payload
)
df = pd.json_normalize(response["tokens"])
df
token | start_offset | end_offset | type | position | |
---|---|---|---|---|---|
0 | ホッ | 0 | 2 | word | 0 |
1 | トルイボスティー | 2 | 10 | word | 1 |
実際にホットルイボスティーを登録して、ホットルイボスティー および ホット/ルイボス/ティー で検索してみます。
index_name = "kuromoji-sample-with-user-dictionary-rules-v1"
payload = f"""
{{"index": {{"_index": "{index_name}", "_id": "6"}}}}
{{"item": "ホットルイボスティー"}}
"""
response = opensearch_client.bulk(payload, refresh=False)
print(json.dumps(response, indent=2))
response = opensearch_client.indices.refresh(index_name)
response = opensearch_client.indices.forcemerge(index_name)
{ "took": 5, "errors": false, "items": [ { "index": { "_index": "kuromoji-sample-with-user-dictionary-rules-v1", "_id": "6", "_version": 1, "result": "created", "_shards": { "total": 1, "successful": 1, "failed": 0 }, "_seq_no": 15, "_primary_term": 3, "status": 201 } } ] }
index_name = "kuromoji-sample-with-user-dictionary-rules-v1"
query = "ホットルイボスティー"
payload = {
"query": {
"multi_match": {
"fields": ["item.text", "item.text_with_userdict"],
"query": query,
"operator": "and"
}
},
"highlight": {
"fields": {
"*" : {}
}
},
"_source": False,
"fields": ["item.text", "item.text_with_userdict"]
}
response = opensearch_client.search(
index=index_name,
body=payload
)
pd.json_normalize(response["hits"]["hits"])
_index | _id | _score | fields.item.text | fields.item.text_with_userdict | highlight.item.text | highlight.item.text_with_userdict | |
---|---|---|---|---|---|---|---|
0 | kuromoji-sample-with-user-dictionary-rules-v1 | 6 | 4.731819 | [ホットルイボスティー] | [ホットルイボスティー] | [<em>ホッ</em><em>トルイボスティー</em>] | [<em>ホッ</em><em>トルイボスティー</em>] |
index_name = "kuromoji-sample-with-user-dictionary-rules-v1"
query = "ホット ルイボス ティー"
payload = {
"query": {
"multi_match": {
"fields": ["item.text", "item.text_with_userdict"],
"query": query,
"operator": "and"
}
},
"highlight": {
"fields": {
"*" : {}
}
},
"_source": False,
"fields": ["item.text", "item.text_with_userdict"]
}
response = opensearch_client.search(
index=index_name,
body=payload
)
pd.json_normalize(response["hits"]["hits"])
ホットルイボスティー ではヒットし、 ホット/ルイボス/ティー ではヒットしないことが確認できました。 ホット/ルイボス/ティー ではなぜヒットしなかったのか、念のため Explain API を通してみていきましょう。
index_name = "kuromoji-sample-with-user-dictionary-rules-v1"
query = "ホット ルイボス ティー"
payload = {
"query": {
"match": {
"item.text": {
"query": query,
"operator": "and"
}
}
},
}
response = opensearch_client.explain(
index=index_name,
body=payload,
id=6 #ドキュメント"ホットミルクティー"の ID
)
print(json.dumps(response, indent=2, ensure_ascii=False))
{ "_index": "kuromoji-sample-with-user-dictionary-rules-v1", "_id": "6", "matched": false, "explanation": { "value": 0.0, "description": "Failure to meet condition(s) of required/prohibited clause(s)", "details": [ { "value": 0.0, "description": "no match on required clause (item.text:ホット)", "details": [ { "value": 0.0, "description": "no matching term", "details": [] } ] }, { "value": 0.0, "description": "no match on required clause (item.text:ルイ)", "details": [ { "value": 0.0, "description": "no matching term", "details": [] } ] }, { "value": 0.0, "description": "no match on required clause (item.text:ボス)", "details": [ { "value": 0.0, "description": "no matching term", "details": [] } ] }, { "value": 0.0, "description": "no match on required clause (item.text:ティー)", "details": [ { "value": 0.0, "description": "no matching term", "details": [] } ] } ] } }
ホット/ルイボス/ティー の 3 トークンにマッチしないことが原因だと考えていましたが、実際はさらにルイボス が ルイ/ボス に分割されていることが確認できました。
OpenSearch の検索クエリは、スペースで区切られた各キーワードに対して個別にトークン分割を行います。このため、ルイボスがルイ/ボスにさらに分割されるような事象が発生します。
したがって、今回は以下 2 つのエントリを登録する必要があると考えます。
"ホットルイボスティー,ホット ルイボス ティー,ホット ルイボス ティー,カスタム名詞"
"ルイボス,ルイボス,ルイボス,カスタム名詞"
エイリアスは、インデックスに付与可能な別名です。
エイリアスはインデックス間でオンラインでの付け替えが可能であるため、バージョンが複数存在するインデックスに対して、クライアントからは常に同じ名前でアクセスしたい場合に有用です。
エイリアスは、Alias API を使用して付与します。以下のサンプルコードでは、インデックス kuromoji-sample-with-user-dictionary-rules-v1 にエイリアス kuromoji-sample-with-user-dictionary-rules をセットしています。
index_name = "kuromoji-sample-with-user-dictionary-rules-v1"
alias_name = "kuromoji-sample-with-user-dictionary-rules"
payload = {
"alias": alias_name,
"is_write_index": False
}
opensearch_client.indices.put_alias(index=index_name, body=payload)
response = opensearch_client.indices.get_alias(index=index_name)
print(json.dumps(response, indent=2, ensure_ascii=False))
{ "kuromoji-sample-with-user-dictionary-rules-v1": { "aliases": { "kuromoji-sample-with-user-dictionary-rules": { "is_write_index": false } } } }
is_write_index は、エイリアスを通じたデータの更新リクエストを許可するか否かを制御します。false
をセットした場合、エイリアスは検索専用で機能します。
エイリアスは複数セットすることも可能であるため、書き込み可能なのエイリアスと検索専用のエイリアスを別々に持つことも可能です。
以下のサンプルコードでは、新たに kuromoji-sample-with-user-dictionary-rules-blue という書き込み可能なエイリアスを追加しています。
index_name = "kuromoji-sample-with-user-dictionary-rules-v1"
alias_name = "kuromoji-sample-with-user-dictionary-rules-blue"
payload = {
"alias": alias_name,
"is_write_index": True
}
opensearch_client.indices.put_alias(index=index_name, body=payload)
response = opensearch_client.indices.get_alias(index=index_name)
print(json.dumps(response, indent=2, ensure_ascii=False))
{ "kuromoji-sample-with-user-dictionary-rules-v1": { "aliases": { "kuromoji-sample-with-user-dictionary-rules": { "is_write_index": false }, "kuromoji-sample-with-user-dictionary-rules-blue": { "is_write_index": true } } } }
エイリアスに対して Search API を発行すると、検索結果が正しく返ることが確認できました。
合わせて、結果に含まれるインデックス _index から、実体のインデックスは kuromoji-sample-with-user-dictionary-rules-v1 であることも確認できました。
エイリアスによって、アプリケーションクライアントはインデックスが変わる都度、設定を更新する必要がなくなりました。
index_name = "kuromoji-sample-with-user-dictionary-rules"
query = "ホット ミルク ラテ"
payload = {
"query": {
"multi_match": {
"query": query,
"fields": ["item.text", "item.text_with_userdict"],
"operator": "and"
}
},
"highlight": {
"fields": {
"*" : {}
}
},
"_source": False,
"fields": ["item.text"]
}
response = opensearch_client.search(
index=index_name,
body=payload
)
pd.json_normalize(response["hits"]["hits"])
_index | _id | _score | fields.item.text | highlight.item.text_with_userdict | |
---|---|---|---|---|---|
0 | kuromoji-sample-with-user-dictionary-rules-v1 | 5 | 3.685839 | [ホットミルクラテ] | [<em>ホット</em><em>ミルク</em><em>ラテ</em>] |
ホットルイボスティーとルイボスのエントリを追加したユーザー辞書を持つインデックスを新規に作成していきます。 まずは既存インデックスのマッピングおよび設定を取得します。
index_name = "kuromoji-sample-with-user-dictionary-rules-v1"
response = opensearch_client.indices.get(index=index_name)
mappings = response[index_name]["mappings"]
settings = response[index_name]["settings"]
マッピング定義はそのまま利用可能であることが確認できました。
print(json.dumps(mappings, indent=2, ensure_ascii=False))
{ "properties": { "id": { "type": "keyword" }, "item": { "type": "keyword", "fields": { "text": { "type": "text", "analyzer": "custom_kuromoji_analyzer" }, "text_with_userdict": { "type": "text", "analyzer": "custom_kuromoji_analyzer_with_userdict" } } } } }
一方で設定(settings) エントリ配下にはインデックス作成時に自動で付与される情報も含まれているため、これらは削除する必要があります。
print(json.dumps(settings, indent=2, ensure_ascii=False))
{ "index": { "replication": { "type": "DOCUMENT" }, "refresh_interval": "-1", "number_of_shards": "1", "provided_name": "kuromoji-sample-with-user-dictionary-rules-v1", "creation_date": "1741668977150", "analysis": { "analyzer": { "custom_kuromoji_analyzer": { "tokenizer": "custom_kuromoji_tokenizer" }, "custom_kuromoji_analyzer_with_userdict": { "tokenizer": "custom_kuromoji_tokenizer_with_userdict" } }, "tokenizer": { "custom_kuromoji_tokenizer_with_userdict": { "mode": "search", "discard_compound_token": "true", "type": "kuromoji_tokenizer", "user_dictionary_rules": [ "紅まどんな,紅まどんな,ベニマドンナ,カスタム名詞", "東京ゲートブリッジ,東京 ゲートブリッジ,トウキョウ ゲートブリッジ,カスタム名詞", "アイストールラテ,アイス トール ラテ,アイス トール ラテ,カスタム名詞", "ホットミルクティー,ホット ミルク ティー,ホット ミルク ティー,カスタム名詞", "ホットミルクラテ,ホット ミルク ラテ,ホット ミルク ラテ,カスタム名詞" ] }, "custom_kuromoji_tokenizer": { "mode": "search", "type": "kuromoji_tokenizer", "discard_compound_token": "true" } } }, "number_of_replicas": "0", "uuid": "QKiqt1xlQAuWW1Z_VwBtSg", "version": { "created": "136387827" } } }
不要なエントリ削除後の settings は以下の通りです。
keys = [
"provided_name",
"creation_date",
"uuid",
"version"
]
try:
for key in keys:
settings.get("index").pop(key, None)
except KeyError as e:
print(e)
print(json.dumps(settings, indent=2, ensure_ascii=False))
{ "index": { "replication": { "type": "DOCUMENT" }, "refresh_interval": "-1", "number_of_shards": "1", "analysis": { "analyzer": { "custom_kuromoji_analyzer": { "tokenizer": "custom_kuromoji_tokenizer" }, "custom_kuromoji_analyzer_with_userdict": { "tokenizer": "custom_kuromoji_tokenizer_with_userdict" } }, "tokenizer": { "custom_kuromoji_tokenizer_with_userdict": { "mode": "search", "discard_compound_token": "true", "type": "kuromoji_tokenizer", "user_dictionary_rules": [ "紅まどんな,紅まどんな,ベニマドンナ,カスタム名詞", "東京ゲートブリッジ,東京 ゲートブリッジ,トウキョウ ゲートブリッジ,カスタム名詞", "アイストールラテ,アイス トール ラテ,アイス トール ラテ,カスタム名詞", "ホットミルクティー,ホット ミルク ティー,ホット ミルク ティー,カスタム名詞", "ホットミルクラテ,ホット ミルク ラテ,ホット ミルク ラテ,カスタム名詞" ] }, "custom_kuromoji_tokenizer": { "mode": "search", "type": "kuromoji_tokenizer", "discard_compound_token": "true" } } }, "number_of_replicas": "0" } }
settings のユーザー辞書に、ホットルイボスティーのエントリを追加します。
entries = [
"ホットルイボスティー,ホット ルイボス ティー,ホット ルイボス ティー,カスタム名詞",
"ルイボス,ルイボス,ルイボス,カスタム名詞"
]
for entry in entries:
if entry not in settings["index"]["analysis"]["tokenizer"]["custom_kuromoji_tokenizer_with_userdict"]["user_dictionary_rules"]:
settings["index"]["analysis"]["tokenizer"]["custom_kuromoji_tokenizer_with_userdict"]["user_dictionary_rules"].append(entry)
print(json.dumps(settings, indent=2, ensure_ascii=False))
{ "index": { "replication": { "type": "DOCUMENT" }, "refresh_interval": "-1", "number_of_shards": "1", "analysis": { "analyzer": { "custom_kuromoji_analyzer": { "tokenizer": "custom_kuromoji_tokenizer" }, "custom_kuromoji_analyzer_with_userdict": { "tokenizer": "custom_kuromoji_tokenizer_with_userdict" } }, "tokenizer": { "custom_kuromoji_tokenizer_with_userdict": { "mode": "search", "discard_compound_token": "true", "type": "kuromoji_tokenizer", "user_dictionary_rules": [ "紅まどんな,紅まどんな,ベニマドンナ,カスタム名詞", "東京ゲートブリッジ,東京 ゲートブリッジ,トウキョウ ゲートブリッジ,カスタム名詞", "アイストールラテ,アイス トール ラテ,アイス トール ラテ,カスタム名詞", "ホットミルクティー,ホット ミルク ティー,ホット ミルク ティー,カスタム名詞", "ホットミルクラテ,ホット ミルク ラテ,ホット ミルク ラテ,カスタム名詞", "ホットルイボスティー,ホット ルイボス ティー,ホット ルイボス ティー,カスタム名詞", "ルイボス,ルイボス,ルイボス,カスタム名詞" ] }, "custom_kuromoji_tokenizer": { "mode": "search", "type": "kuromoji_tokenizer", "discard_compound_token": "true" } } }, "number_of_replicas": "0" } }
kuromoji-sample-with-user-dictionary-rules-v1 から取得した mapping および、ユーザー辞書にエントリを加えた settings を使って、kuromoji-sample-with-user-dictionary-rules-v2 インデックスを作成します。
index_name = "kuromoji-sample-with-user-dictionary-rules-v2"
payload = {
"mappings": mappings,
"settings": settings
}
try:
# 既に同名のインデックスが存在する場合、いったん削除を行う
print("# delete index")
response = opensearch_client.indices.delete(index=index_name)
print(json.dumps(response, indent=2))
except Exception as e:
print(e)
# インデックスの作成を行う
print("# create index")
response = opensearch_client.indices.create(index=index_name, body=payload)
print(json.dumps(response, indent=2))
# delete index NotFoundError(404, 'index_not_found_exception', 'no such index [kuromoji-sample-with-user-dictionary-rules-v2]', kuromoji-sample-with-user-dictionary-rules-v2, index_or_alias) # create index { "acknowledged": true, "shards_acknowledged": true, "index": "kuromoji-sample-with-user-dictionary-rules-v2" }
kuromoji-sample-with-user-dictionary-rules-v2 インデックスに、kuromoji-sample-with-user-dictionary-rules-green エイリアスを付与します。
index_name = "kuromoji-sample-with-user-dictionary-rules-v2"
alias_name = "kuromoji-sample-with-user-dictionary-rules-green"
payload = {
"alias": alias_name,
"is_write_index": True
}
opensearch_client.indices.put_alias(index=index_name, body=payload)
response = opensearch_client.indices.get_alias(index=index_name)
print(json.dumps(response, indent=2, ensure_ascii=False))
{ "kuromoji-sample-with-user-dictionary-rules-v2": { "aliases": { "kuromoji-sample-with-user-dictionary-rules-green": { "is_write_index": true } } } }
Reinedx API を使用して、v1 から v2 へドキュメントのコピーを行います。
Reindex API 実行に先立って、Cat indices API および Cat aliases API でインデックス・エイリアスの一覧を取得しておきます。v1 はドキュメント数を示す docs.count が 8 である一方、v2 は 0 であることが確認できます。
print(opensearch_client.cat.indices(index="kuromoji-sample-with-user-dictionary-rules*", v=True))
print(opensearch_client.cat.aliases(name="kuromoji-sample-with-user-dictionary-rules*" ,v=True))
health status index uuid pri rep docs.count docs.deleted store.size pri.store.size green open kuromoji-sample-with-user-dictionary-rules-v1 QKiqt1xlQAuWW1Z_VwBtSg 1 0 8 8 26.9kb 26.9kb green open kuromoji-sample-with-user-dictionary-rules-v2 wKM05wx2RD6yEpUKv0NfOA 1 0 0 0 208b 208b alias index filter routing.index routing.search is_write_index kuromoji-sample-with-user-dictionary-rules kuromoji-sample-with-user-dictionary-rules-v1 - - - false kuromoji-sample-with-user-dictionary-rules-blue kuromoji-sample-with-user-dictionary-rules-v1 - - - true kuromoji-sample-with-user-dictionary-rules-green kuromoji-sample-with-user-dictionary-rules-v2 - - - true
Reindex API を実行します。source.index にはコピー元を、dest.index にはコピー先のインデックス名を指定します。
slices は Reindex の並列実効度を制御するパラメーターです。デフォルトは 1、つまりシリアルに実行されます。auto をセットすると、自動的に並列度が決定されます。
Update by query と同様に wait_for_completion パラメーターに false をセットすることで、Task ID が返却され進捗をチェックできます。
payload = {
"source":{
"index":"kuromoji-sample-with-user-dictionary-rules-v1" #v1
},
"dest":{
"index":"kuromoji-sample-with-user-dictionary-rules-v2" #v2
}
}
response = opensearch_client.reindex(body=payload, slices="auto", wait_for_completion=False)
task_id = response["task"]
print(json.dumps(response, indent=2, ensure_ascii=False))
{ "task": "kdnLueFaT0quni1aPfT5-g:383726" }
completed が true になっていれば Reindex が完了したと判断できます。status.total が 8 であることから、v1 のドキュメントは全て v2 に登録できたと考えられます。
response = opensearch_client.tasks.get(task_id=task_id)
while response["completed"] == False:
time.sleep(1)
response = opensearch_client.tasks.get(task_id=task_id)
print(json.dumps(response, indent=2, ensure_ascii=False))
{ "completed": true, "task": { "node": "kdnLueFaT0quni1aPfT5-g", "id": 383726, "type": "transport", "action": "indices:data/write/reindex", "status": { "total": 8, "updated": 0, "created": 8, "deleted": 0, "batches": 1, "version_conflicts": 0, "noops": 0, "retries": { "bulk": 0, "search": 0 }, "throttled_millis": 0, "requests_per_second": -1.0, "throttled_until_millis": 0 }, "description": "reindex from [kuromoji-sample-with-user-dictionary-rules-v1] to [kuromoji-sample-with-user-dictionary-rules-v2]", "start_time_in_millis": 1741668979007, "running_time_in_nanos": 18407955, "cancellable": true, "cancelled": false, "headers": {} }, "response": { "took": 8, "timed_out": false, "total": 8, "updated": 0, "created": 8, "deleted": 0, "batches": 1, "version_conflicts": 0, "noops": 0, "retries": { "bulk": 0, "search": 0 }, "throttled": "0s", "throttled_millis": 0, "requests_per_second": -1.0, "throttled_until": "0s", "throttled_until_millis": 0, "failures": [] } }
Reindex API はスループットを重視して、デフォルトで Refresh オプションが無効化されています。追加で Refresh API を実行し、Reindex 後のドキュメントが確実に検索可能となるようにします
index_name = "kuromoji-sample-with-user-dictionary-rules-v2"
response = opensearch_client.indices.refresh(index_name)
response = opensearch_client.indices.forcemerge(index_name)
v1 と v2 インデックスに対して ホットルイボスティー および ホット ルイボス ティー で検索すると、後者のクエリでヒットするのは v2 だけであることが確認できました。
OpenSearcn では複数のインデックスに対して単一の API で検索を行うことが可能です。複数のインデックス名をカンマ区切りで与えるほか、ワイルドカードを使用することもできます。以下はワイルドカードを使用するパターンです。
また、複数の検索クエリによる検索を一括でリクエストし、結果をまとめて取得することもできます。 Multi search API を使用します。
index_name = "kuromoji-sample-with-user-dictionary-rules-v*"
queries = ["ホットルイボスティー", "ホット ルイボス ティー"]
payload_list = []
for query in queries:
payload = {
"query": {
"multi_match": {
"fields": ["item.text_with_userdict"],
"query": query,
"operator": "and"
}
},
"highlight": {
"fields": {
"*" : {}
}
},
"_source": False,
"fields": ["item.text", "item.text_with_userdict"]
}
payload_list.append({"index": index_name})
payload_list.append(payload)
payload_str = ""
for payload in payload_list:
payload_str += '%s \n' %json.dumps(payload, ensure_ascii=False)
#response = opensearch_client.msearch(payload)
#response
print(payload_str)
responses = opensearch_client.msearch(payload_str)["responses"]
df_concat = pd.DataFrame()
for i,response in enumerate(responses):
query = queries[i]
df = pd.json_normalize(response["hits"]["hits"])
df["query"] = query
df_concat = pd.concat([df_concat, df])
df_concat = df_concat.reindex(columns=["query", "_index", "_id", "_score", "fields.item.text", "fields.item.text_with_userdict", "highlight.item.text", "highlight.item.text_with_userdict"])
df_concat
{"index": "kuromoji-sample-with-user-dictionary-rules-v*"} {"query": {"multi_match": {"fields": ["item.text_with_userdict"], "query": "ホットルイボスティー", "operator": "and"}}, "highlight": {"fields": {"*": {}}}, "_source": false, "fields": ["item.text", "item.text_with_userdict"]} {"index": "kuromoji-sample-with-user-dictionary-rules-v*"} {"query": {"multi_match": {"fields": ["item.text_with_userdict"], "query": "ホット ルイボス ティー", "operator": "and"}}, "highlight": {"fields": {"*": {}}}, "_source": false, "fields": ["item.text", "item.text_with_userdict"]}
query | _index | _id | _score | fields.item.text | fields.item.text_with_userdict | highlight.item.text | highlight.item.text_with_userdict | |
---|---|---|---|---|---|---|---|---|
0 | ホットルイボスティー | kuromoji-sample-with-user-dictionary-rules-v1 | 6 | 4.731819 | [ホットルイボスティー] | [ホットルイボスティー] | NaN | [<em>ホッ</em><em>トルイボスティー</em>] |
1 | ホットルイボスティー | kuromoji-sample-with-user-dictionary-rules-v2 | 6 | 3.795349 | [ホットルイボスティー] | [ホットルイボスティー] | NaN | [<em>ホット</em><em>ルイボス</em><em>ティー</em>] |
0 | ホット ルイボス ティー | kuromoji-sample-with-user-dictionary-rules-v2 | 6 | 3.795349 | [ホットルイボスティー] | [ホットルイボスティー] | NaN | [<em>ホット</em><em>ルイボス</em><em>ティー</em>] |
Reindex 実行時にクエリパラメーターを追加することで、特定の条件に合致したドキュメントだけをコピーすることができます。クエリで更新対象のドキュメントの絞り込みが可能である場合は、Reindex 実行時間を短縮することが可能です。
例えば、更新時刻を示すフィールドを持っているドキュメントであれば、range クエリで特定時刻以前のドキュメントのみ再登録を行うことが可能です。
今回は、v1 にドキュメントが追加されたことを想定して、差分コピーを実行していきます。
index_name = "kuromoji-sample-with-user-dictionary-rules-v1"
payload = f"""
{{"index": {{"_index": "{index_name}", "_id": "6a"}}}}
{{"item": "ホットルイボスティーを飲む"}}
"""
response = opensearch_client.bulk(payload, refresh=False)
print(json.dumps(response, indent=2))
response = opensearch_client.indices.refresh(index_name)
response = opensearch_client.indices.forcemerge(index_name)
{ "took": 5, "errors": false, "items": [ { "index": { "_index": "kuromoji-sample-with-user-dictionary-rules-v1", "_id": "6a", "_version": 1, "result": "created", "_shards": { "total": 1, "successful": 1, "failed": 0 }, "_seq_no": 16, "_primary_term": 3, "status": 201 } } ] }
index_name = "kuromoji-sample-with-user-dictionary-rules-v1"
query = "ホットルイボスティーを飲む"
payload = {
"query": {
"match": {
"item.text": {
"query": query,
"operator": "and"
}
}
}
}
response = opensearch_client.search(
index=index_name,
body=payload,
)
print(json.dumps(response, indent=2, ensure_ascii=False))
{ "took": 1, "timed_out": false, "_shards": { "total": 1, "successful": 1, "skipped": 0, "failed": 0 }, "hits": { "total": { "value": 1, "relation": "eq" }, "max_score": 6.2392845, "hits": [ { "_index": "kuromoji-sample-with-user-dictionary-rules-v1", "_id": "6a", "_score": 6.2392845, "_source": { "item": "ホットルイボスティーを飲む" } } ] } }
1 件ドキュメントがヒットしました。このクエリをもとに Reindex を実行します。なお、Reindex はエイリアスを source/dest で指定することが可能です。以下のコードでは、インデックス名の代わりにエイリアス名を指定して Reindex を実行しています。
query = "ホットルイボスティーを飲む"
payload = {
"source":{
"index":"kuromoji-sample-with-user-dictionary-rules-blue", #v1
"query": {
"match": {
"item.text": {
"query": query,
"operator": "and"
}
}
}
},
"dest":{
"index":"kuromoji-sample-with-user-dictionary-rules-green" #v2
}
}
response = opensearch_client.reindex(body=payload, slices="auto", wait_for_completion=False)
task_id = response["task"]
print(json.dumps(response, indent=2, ensure_ascii=False))
{ "task": "kdnLueFaT0quni1aPfT5-g:383778" }
Task API 実行結果より、status.total が 1 となっていることが確認できます。全件ではなく 1 件だけが洗い替えされたことが確認できました。
response = opensearch_client.tasks.get(task_id=task_id)
while response["completed"] == False:
time.sleep(1)
response = opensearch_client.tasks.get(task_id=task_id)
print(json.dumps(response, indent=2, ensure_ascii=False))
{ "completed": true, "task": { "node": "kdnLueFaT0quni1aPfT5-g", "id": 383778, "type": "transport", "action": "indices:data/write/reindex", "status": { "total": 1, "updated": 0, "created": 1, "deleted": 0, "batches": 1, "version_conflicts": 0, "noops": 0, "retries": { "bulk": 0, "search": 0 }, "throttled_millis": 0, "requests_per_second": -1.0, "throttled_until_millis": 0 }, "description": "reindex from [kuromoji-sample-with-user-dictionary-rules-blue] to [kuromoji-sample-with-user-dictionary-rules-green]", "start_time_in_millis": 1741668980224, "running_time_in_nanos": 10100198, "cancellable": true, "cancelled": false, "headers": {} }, "response": { "took": 9, "timed_out": false, "total": 1, "updated": 0, "created": 1, "deleted": 0, "batches": 1, "version_conflicts": 0, "noops": 0, "retries": { "bulk": 0, "search": 0 }, "throttled": "0s", "throttled_millis": 0, "requests_per_second": -1.0, "throttled_until": "0s", "throttled_until_millis": 0, "failures": [] } }
index_name = "kuromoji-sample-with-user-dictionary-rules-green"
response = opensearch_client.indices.refresh(index_name)
response = opensearch_client.indices.forcemerge(index_name)
検索処理を実行すると、v2 に新しいドキュメントがコピーされており、かつ検索結果に合わられることが確認できました。
index_name = "kuromoji-sample-with-user-dictionary-rules-v*"
queries = ["ホットルイボスティー", "ホット ルイボス ティー"]
payload_list = []
for query in queries:
payload = {
"query": {
"multi_match": {
"fields": ["item.text_with_userdict"],
"query": query,
"operator": "and"
}
},
"highlight": {
"fields": {
"*" : {}
}
},
"_source": False,
"fields": ["item.text", "item.text_with_userdict"]
}
payload_list.append({"index": index_name})
payload_list.append(payload)
payload_str = ""
for payload in payload_list:
payload_str += '%s \n' %json.dumps(payload, ensure_ascii=False)
#response = opensearch_client.msearch(payload)
#response
print(payload_str)
responses = opensearch_client.msearch(payload_str)["responses"]
df_concat = pd.DataFrame()
for i,response in enumerate(responses):
query = queries[i]
df = pd.json_normalize(response["hits"]["hits"])
df["query"] = query
df_concat = pd.concat([df_concat, df])
df_concat = df_concat.reindex(columns=["query", "_index", "_id", "_score", "fields.item.text", "fields.item.text_with_userdict", "highlight.item.text", "highlight.item.text_with_userdict"])
df_concat
{"index": "kuromoji-sample-with-user-dictionary-rules-v*"} {"query": {"multi_match": {"fields": ["item.text_with_userdict"], "query": "ホットルイボスティー", "operator": "and"}}, "highlight": {"fields": {"*": {}}}, "_source": false, "fields": ["item.text", "item.text_with_userdict"]} {"index": "kuromoji-sample-with-user-dictionary-rules-v*"} {"query": {"multi_match": {"fields": ["item.text_with_userdict"], "query": "ホット ルイボス ティー", "operator": "and"}}, "highlight": {"fields": {"*": {}}}, "_source": false, "fields": ["item.text", "item.text_with_userdict"]}
query | _index | _id | _score | fields.item.text | fields.item.text_with_userdict | highlight.item.text | highlight.item.text_with_userdict | |
---|---|---|---|---|---|---|---|---|
0 | ホットルイボスティー | kuromoji-sample-with-user-dictionary-rules-v1 | 6 | 3.981909 | [ホットルイボスティー] | [ホットルイボスティー] | NaN | [<em>ホッ</em><em>トルイボスティー</em>] |
1 | ホットルイボスティー | kuromoji-sample-with-user-dictionary-rules-v2 | 6 | 3.184518 | [ホットルイボスティー] | [ホットルイボスティー] | NaN | [<em>ホット</em><em>ルイボス</em><em>ティー</em>] |
2 | ホットルイボスティー | kuromoji-sample-with-user-dictionary-rules-v1 | 6a | 2.952800 | [ホットルイボスティーを飲む] | [ホットルイボスティーを飲む] | NaN | [<em>ホッ</em><em>トルイボスティー</em>を飲む] |
3 | ホットルイボスティー | kuromoji-sample-with-user-dictionary-rules-v2 | 6a | 2.490181 | [ホットルイボスティーを飲む] | [ホットルイボスティーを飲む] | NaN | [<em>ホット</em><em>ルイボス</em><em>ティー</em>を飲む] |
0 | ホット ルイボス ティー | kuromoji-sample-with-user-dictionary-rules-v2 | 6 | 3.184518 | [ホットルイボスティー] | [ホットルイボスティー] | NaN | [<em>ホット</em><em>ルイボス</em><em>ティー</em>] |
1 | ホット ルイボス ティー | kuromoji-sample-with-user-dictionary-rules-v2 | 6a | 2.490181 | [ホットルイボスティーを飲む] | [ホットルイボスティーを飲む] | NaN | [<em>ホット</em><em>ルイボス</em><em>ティー</em>を飲む] |
新しい辞書エントリを持つ v2 インデックスが無事準備できたため、エイリアスの向き先を v1 から v2 に変更します。 エイリアスの付け替えは、Aliases API に remove アクションと add アクションをまとめて渡すことで実行できます。本処理はアトミックであり、実行中にクライアントリクエストがエラーになることはありません。
source_index_name = "kuromoji-sample-with-user-dictionary-rules-v1"
dest_index_name = "kuromoji-sample-with-user-dictionary-rules-v2"
alias_name = "kuromoji-sample-with-user-dictionary-rules"
payload = {
"actions": [
{
"remove": {
"index": source_index_name,
"alias": alias_name
}
},
{
"add": {
"index": dest_index_name,
"alias": alias_name
}
}
]
}
opensearch_client.indices.update_aliases(body=payload)
{'acknowledged': True}
Cat aliases API の実行結果より、エイリアスが付け変わったことを確認できます。
print(opensearch_client.cat.aliases(name="kuromoji-sample-with-user-dictionary-rules*" ,v=True))
alias index filter routing.index routing.search is_write_index kuromoji-sample-with-user-dictionary-rules-blue kuromoji-sample-with-user-dictionary-rules-v1 - - - true kuromoji-sample-with-user-dictionary-rules kuromoji-sample-with-user-dictionary-rules-v2 - - - - kuromoji-sample-with-user-dictionary-rules-green kuromoji-sample-with-user-dictionary-rules-v2 - - - true
切り替え後のエイリアスに対して ホット で検索すると、ホットを含むドキュメントを正しく取得することができました。
index_name = "kuromoji-sample-with-user-dictionary-rules"
query = "ホット"
payload = {
"query": {
"multi_match": {
"query": query,
"fields": ["item.text", "item.text_with_userdict"],
"operator": "and"
}
},
"highlight": {
"fields": {
"*" : {}
}
},
"_source": False,
"fields": ["item.text"]
}
response = opensearch_client.search(
index=index_name,
body=payload
)
pd.json_normalize(response["hits"]["hits"])
_index | _id | _score | fields.item.text | highlight.item.text_with_userdict | |
---|---|---|---|---|---|
0 | kuromoji-sample-with-user-dictionary-rules-v2 | 5 | 0.786138 | [ホットミルクラテ] | [<em>ホット</em>ミルクラテ] |
1 | kuromoji-sample-with-user-dictionary-rules-v2 | 4 | 0.786138 | [ホットミルクティー] | [<em>ホット</em>ミルクティー] |
2 | kuromoji-sample-with-user-dictionary-rules-v2 | 6 | 0.786138 | [ホットルイボスティー] | [<em>ホット</em>ルイボスティー] |
3 | kuromoji-sample-with-user-dictionary-rules-v2 | 6a | 0.614733 | [ホットルイボスティーを飲む] | [<em>ホット</em>ルイボスティーを飲む] |
本ラボでは、Kuromoji のユーザ辞書カスタマイズによる日本語検索の精度改善について学習しました。
本ワークショップで使用したインデックスを削除します。インデックスの削除は Delete index API で行います。インデックスを削除するとインデックス内のドキュメントも削除されます。
index_name = "kuromoji-sample-with-user-dictionary-rules-v1"
try:
response = opensearch_client.indices.delete(index=index_name)
print(json.dumps(response, indent=2))
except Exception as e:
print(e)
{ "acknowledged": true }
index_name = "kuromoji-sample-with-user-dictionary-rules-v2"
try:
response = opensearch_client.indices.delete(index=index_name)
print(json.dumps(response, indent=2))
except Exception as e:
print(e)
{ "acknowledged": true }
インデックスに関連付けられたエイリアスは、対象のインデックスがすべて削除されると同時に削除されます。
print(opensearch_client.cat.aliases(name="kuromoji-sample-with-user-dictionary-rules*" ,v=True))
alias index filter routing.index routing.search is_write_index