#!/usr/bin/env python # coding: utf-8 # # Kuromoji ユーザー辞書のカスタマイズによる日本語検索の精度改善 # ## 概要 # 形態素解析を使用して文章のトークン化を行う場合、辞書が単語認識のベースとなります。したがって、トークン分割の結果がユーザーから見て直感的かどうかは、辞書に依存します。 # # 日本語検索プラグインである Kuromoji では、デフォルトで備えている標準辞書に加えて、ユーザー辞書を追加することでトークン分割を適正化することができます。 # # 本ラボでは、Kuromoji の標準辞書のカバー範囲と、ユーザー辞書によるトークン分割の改善を実際に行っていきます。 # # ### ラボの構成 # # 本ラボでは、ノートブック環境(JupyterLab) および Amazon OpenSearch Service を使用します。 # # ## 事前作業 # ### パッケージインストール # In[1]: get_ipython().system("pip install opensearch-py requests-aws4auth 'awswrangler[opensearch]' --quiet") # ### インポート # In[2]: 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 # ### ヘルパー関数の定義 # 以降の処理を実行する際に必要なヘルパー関数を定義しておきます。 # In[3]: 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}.") # ### 共通変数のセット # In[4]: default_region = boto3.Session().region_name logging.getLogger().setLevel(logging.ERROR) # ### OpenSearch クラスターへの接続確認 # # OpenSearch クラスターへのネットワーク接続性が確保されており、OpenSearch の Security 機能により API リクエストが許可されているかを確認します。 # # レスポンスに cluster_name や cluster_uuid が含まれていれば、接続確認が無事完了したと判断できます # In[5]: 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() # ## Kuromoji 標準辞書 # # Kuromoji 標準辞書に登録されている単語は [mecab-ipadic-2.7.0-20070801.tar.gz](http://atilika.com/releases/mecab-ipadic/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,活用型,活用形,原形,読み,発音` # # 形容詞や動詞は、活用形ごとに辞書内にエントリが存在し、共通の原形が割り当てられています。活用形ごとに原形を伴って辞書に情報が登録されていることで、活用形の違いによる検索ヒット率の低下を、後段で解説する正規化処理で防ぐことができます。以下は一部ファイルの抜粋です。 # # ### Adj.csv.utf8.txt # ```csv # あたたかい,19,19,6948,形容詞,自立,*,*,形容詞・アウオ段,基本形,あたたかい,アタタカイ,アタタカイ # あたたかし,23,23,6953,形容詞,自立,*,*,形容詞・アウオ段,文語基本形,あたたかい,アタタカシ,アタタカシ # あたたかから,27,27,6953,形容詞,自立,*,*,形容詞・アウオ段,未然ヌ接続,あたたかい,アタタカカラ,アタタカカラ # あたたかかろ,25,25,6953,形容詞,自立,*,*,形容詞・アウオ段,未然ウ接続,あたたかい,アタタカカロ,アタタカカロ # あたたかかっ,33,33,6952,形容詞,自立,*,*,形容詞・アウオ段,連用タ接続,あたたかい,アタタカカッ,アタタカカッ # あたたかく,35,35,6952,形容詞,自立,*,*,形容詞・アウオ段,連用テ接続,あたたかい,アタタカク,アタタカク # ``` # # ### Verb.csv.utf8.txt # ```csv # すみわたる,772,772,9279,動詞,自立,*,*,五段・ラ行,基本形,すみわたる,スミワタル,スミワタル # すみわたら,780,780,9279,動詞,自立,*,*,五段・ラ行,未然形,すみわたる,スミワタラ,スミワタラ # すみわたん,782,782,9279,動詞,自立,*,*,五段・ラ行,未然特殊,すみわたる,スミワタン,スミワタン # すみわたろ,778,778,9279,動詞,自立,*,*,五段・ラ行,未然ウ接続,すみわたる,スミワタロ,スミワタロ # すみわたり,788,788,9279,動詞,自立,*,*,五段・ラ行,連用形,すみわたる,スミワタリ,スミワタリ # すみわたっ,786,786,9279,動詞,自立,*,*,五段・ラ行,連用タ接続,すみわたる,スミワタッ,スミワタッ # ``` # # ### Noun.verbal.csv.utf8.txt # #
# 名詞は活用形を持たないため、原形のみが登録されています。 #
# # ```csv # 確言,1283,1283,4467,名詞,サ変接続,*,*,*,*,確言,カクゲン,カクゲン # 行脚,1283,1283,4466,名詞,サ変接続,*,*,*,*,行脚,アンギャ,アンギャ # 微笑,1283,1283,4087,名詞,サ変接続,*,*,*,*,微笑,ビショウ,ビショー # ミート,1283,1283,4426,名詞,サ変接続,*,*,*,*,ミート,ミート,ミート # 含有,1283,1283,4467,名詞,サ変接続,*,*,*,*,含有,ガンユウ,ガンユー # ``` # ## ユーザー辞書 # エンジンに組み込まれているデフォルトの辞書は全ての固有名詞をカバーしないため、ユーザー辞書に語句を登録することでトークンの抽出が意図したとおりに行われるようになります。 # ### デフォルト辞書が機能しない例 # # 標準の Kuromoji Tokenizer では、**[紅まどんな(べにまどんな)](https://ja.wikipedia.org/wiki/%E6%84%9B%E5%AA%9B%E6%9E%9C%E8%A9%A6%E7%AC%AC28%E5%8F%B7)** というキーワードは **紅/ま/どんな** と分割されます。 # # 以下は _analyze API の実行例です # In[6]: 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 # 分割された各トークンの読みガナを確認すると、**アカ/マ/ドンナ** となっていることが確認できました。 # In[7]: 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 # ### ユーザー辞書の書式 # # Kuromoji はユーザー辞書として以下のフォーマットをサポートしています。 # # `<文字列>,<トークン 1> ... <トークン n>,<読みガナ 1> ... <読みガナ n>,<品詞タグ>` # # 1 つ目のエントリ**<文字列>**では処理対象の文字列を、2 つめのエントリ **<トークン 1> ... <トークン n>** では、入力された文字列の分割単位を、3 つめのエントリ **<読みガナ 1> ... <読みガナ n>** には、トークンの読みガナを、最後のエントリには品詞名を表すタグを記載します。品詞タグには`カスタム名詞`を用いるのが一般的です。 # # **紅まどんな** を **紅まどんな** のまま分割せずにトークン化したい場合は、以下のように記載します。 # # `紅まどんな,紅まどんな,ベニマドンナ,カスタム名詞` # # このエントリを user_dictionary_rules に追加して、改めて _analyze API を実行し、"紅まどんな" が単体のトークンとして抽出されたことを確認します。 # In[8]: 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 # kuromoji_readingform フィルタを追加して、トークンの読みガナも正しく処理されていることを確認します。 # In[9]: 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 # ユーザー辞書を活用することで、以下のようなトークン分割の調整を行うこともできます。 # # - **東京ゲートブリッジ** のように、デフォルトの挙動だと **東京/ゲート/ブリッジ** と 3 つに分割されてしまうトークンを **東京/ゲートブリッジ** と分割位置を調整する # - **アイストールラテ** のように単体のトークンとして認識されるものを アイス/トール/ラテ と分割する # # 以下はユーザー辞書追加前のトークン分割結果です # In[10]: 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 # 以下はユーザー辞書定義追加後のトークン分割結果です。 # In[11]: 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 # ## ユーザー辞書の適用 # 実際のインデックスにユーザー辞書をセットし、辞書の有無による検索精度を比較していきます。Amazon OpenSearch Service では、Kuromoji において以下 2 通りのユーザー辞書セット方法を提供しています。 # # - user_dictionary_rules オプションに直接ユーザー辞書エントリを定義 # - Amazon OpenSearch Service 独自の、カスタムパッケージ機能の利用 # # 本ラボでは user_dictionary_rules オプションを使用してユーザー辞書をインデックスにセットしていきます。 # ### 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 間のペイロードサイズを削減することができるなど、いくつかの面でメリットがあります。 # In[12]: 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)) # #### テストデータの投入 # テストデータを投入します。正しいデータと、元の正しいデータを並べ替えた不正なデータを登録し、検索結果の確認に用います。 # In[13]: 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)) # 本ラボではデータ登録時に意図的に Refresh オプションを無効化しているため、念のため Refresh API を実行し、登録されたドキュメントが確実に検索可能となるようにします # In[14]: 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 フィールドに対して検索を行います。 # In[15]: 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"]) # 上記の結果より、クエリテキストが **紅/ま/どんな** にトークン分割されてからマッチング処理が実行されているため、不要なデータもヒットしていることが確認できました。 # では、ユーザー辞書によってトークン分割が適正化されている **item.text_with_userdict** フィールドに対して同様のクエリを発行します。 # # In[16]: 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"]) # 正しく **紅まどんな** だけがヒットしました。また、ハイライトを見ると **紅まどんな** 全体が一つのトークンとして処理されていることが分かります。 # 同様に、**東京ゲートブリッジ** で item.text フィールドの検索を行うと、**東京ブリッジゲート** もヒットしてしまいました。これは **東京/ゲート/ブリッジ** と 3 つのトークンに分割されてしまっていることが理由です。 # In[17]: 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"]) # 対策としては match_phrase の利用が考えられますが、この場合、クエリに **東京ゲートブリッジ** 以外も含まれているとそちらも順序判定の対象となってしまいます。 # In[18]: 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"]) # ユーザー辞書によってトークン分割が適正化されている **item.text_with_userdict** フィールドに対して同様のクエリを発行します。 # # In[19]: 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"]) # **ゲートブリッジ** がトークン分割されないことで、正しいドキュメントだけを取得することができました。 # 最後にアイストールラテを検索していきます。アイストールラテを検索する際に、ラテのアイスでサイズはトール、と考えて **ラテ アイス トール** で検索を行います。 # # 検索対象のフィールドは、ユーザー辞書が適用されていない **item.text** フィールドです。 # In[20]: 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"]) # 残念ながらヒットしません。これは **アイストールラテ** で単一トークンと認識されているためです。実際にアイストールラテで検索した結果は以下の通りです。 # In[21]: 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"]) # ユーザー辞書によってトークン分割が適正化されている **item.text_with_userdict** フィールドに対して同様のクエリを発行します。 # # In[22]: 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"]) # 無事にヒットしました。アイストールラテが **アイス/トール/ラテ** の 3 つのトークンに分割されることで、順序が異なるキーワードによる検索でもヒットするようになりました。 # ## ユーザー辞書の更新 # ユーザー辞書は、検索要件やトレンドの変化に合わせて継続的なメンテナンスが必要です。Kuromoji のユーザー辞書更新はオンラインでインデックスに反映されないため、一般的には何らかの静止点を設けて更新作業を行う必要があります。 # # 辞書の更新を行う場合、一般的には以下 3 通りの作業方法から選択します。 # # ### 既存のインデックスに格納されたデータを利用して、登録されたデータの更新を実施 # 既存のインデックスに格納されたソースデータを利用して、インデックス内のデータの再登録を行う方式です。[Update by query][update-by-query] API を実行するだけで再登録ができるため最も楽に更新を実行できますが、以下の点に注意する必要があります。 # # - 辞書定義の更新処理のために、一時的にインデックスを [Close index][close-index] API で閉じる必要あり。この間はデータ更新も検索もできない # - データの再登録処理中は、辞書更新前/更新後のデータが混在する。この間の検索結果は一貫性が保てない # - 辞書の更新による問題が発生した場合は、更新前のエントリに戻して再度 update_by_query を実行する必要がある # - 大量のドキュメントが登録されているインデックスに対する update_by_query は時間がかかる # # 作業は以下の流れで行います # # 1. (カスタムパッケージを利用している場合は)カスタムパッケージを更新 # 1. インデックスを [Close index][close-index] API で閉じる # 1. (user_dictionary_rules) を使用している場合は、ここでユーザー辞書のエントリを更新 # 1. インデックスを [Open index][open-index] API で開ける # 1. [Update by query][update-by-query] API によるドキュメントの再登録を実行 # # # ### 更新後の辞書が適用された新規インデックスを作成し、データを再登録 # 新しい辞書定義を含む空の新規インデックスを作成し、既存インデックスからデータをコピー、テスト後にトラフィックを新規のインデックスに切り替える方式です。 # # [Update by query][update-by-query] API 方式はインデックスの close を伴うため、検索処理も一時的にストップします。一方でこちらの方式は、データ更新こそ停止断面を確保する必要がありますが、検索処理を止めずに辞書の切り替えが可能です。このため、多くの本番運用で採用されています。 # # インデックス内のドキュメント更新処理を停止できることが理想です。ドキュメント更新処理を停止できない場合は、両系更新を検討するとよいです。 # # 作業は以下の流れで行います。 # # 1. ユーザー辞書のエントリを更新 # 1. 新しい辞書エントリを元に、新規にインデックスを作成 # 1. インデックスに対する更新処理を停止 # 1. 新規のインデックスにデータを再登録 # 1. [Alias][alias] API を使用し、現行インデックスから新規インデックスにエイリアスを切り替え # 1. インデックスに対する更新処理を再開。以降は新規インデックスに対してデータ更新を行う # # 再登録は、初期登録時と同様に、マスターデータを外部から取得して Bulk API 等で書き込む方法と、[Reindex][reindex-data] API を使用する方法があります。Reindex API は、OpenSearch のインデックスに登録されたドキュメントを取得し、別のインデックスに書き込む機能です。 # # ### インデックスの両系更新 # 更新処理に伴うデータ登録の停止時間が取れない場合は、両系更新を検討することになります。 # ひとつ前のデータ再登録方式と似ていますが、既存インデックス用と新規インデックス用で、別々のデータ更新パイプライン(あるいはバッチ処理)を用意する必要がある点が異なります。 # # 1. ユーザー辞書のエントリを更新 # 1. 新しい辞書エントリを元に、新規にインデックスを作成 # 1. 既存のデータ更新パイプラインと同じ構成のパイプラインを、新規インデックス向けにも構築し、既存インデックスと新規インデックスそれぞれで、データが最新状態を保てる状態を確保する # 1. _alias API を使用し、現行インデックスから新規インデックスにエイリアスを切り替え # 1. インデックスに対する更新処理を再開。以降は新規インデックスに対してデータ更新を行う # # 本ラボでは、update_by_query、および reindex + alias による辞書更新方法を解説します。 # # [update-by-query]: https://opensearch.org/docs/latest/api-reference/document-apis/update-by-query/ # [open-index]: https://opensearch.org/docs/latest/api-reference/index-apis/open-index/ # [close-index]: https://opensearch.org/docs/latest/api-reference/index-apis/close-index/ # [alias]: https://opensearch.org/docs/latest/api-reference/index-apis/alias/ # [reindex-data]: https://opensearch.org/docs/latest/im-plugin/reindex-data/ # ### user_dictionary_rules オプションを使用したインデックスに対する辞書更新 # 今度は、ホットミルクティーとホットミルクラテの区切り位置を改善することで、さらに検索精度を上げていきましょう。 # #### デフォルトのトークン分割結果の確認 # デフォルトの Kuromoji analyzer の挙動を見てみましょう。ホットミルクティーは **ホッ/トミルクティー** と分割されています。 # In[23]: 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 # 同様にホットミルクラテも **ホッ/トミルクラテ** に分割されます。 # In[24]: 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 # 実際にホットミルクティーを登録して検索を行ってみましょう # In[25]: 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) # **ホットミルクティー** では item.text フィールドおよび item.text_with_userdict フィールド双方にヒットします。 # In[26]: 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"]) # **ミルクティー ホット** ではいずれのフィールドにもヒットしません。ホットミルクティーに対するユーザ辞書エントリがないので、これは想定通りの結果といえます。 # In[27]: 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"]) # ここで、ヒットしない理由を API を使って確かめていきましょう。 # # [Analyze][analyze] API と [Explain][explain] API を実行して、登録時と検索時のトークン分割の様子を比較していきます。 # # まずは、Analyze API を実行して、**ホットミルクティー** を登録する際に、文字列がどのようにトークン分割されているかを確認します。 # # [analyze]: https://opensearch.org/docs/latest/api-reference/analyze-apis/ # [explain]: https://opensearch.org/docs/latest/api-reference/explain/ # In[28]: 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 # 次に、Explain API を利用することで、クエリテキストがどのように分解されて内部で検索処理が行われているかを確認します。Analyzer API にクエリテキストを渡しても確認することができますが、Explain API ではクエリと対象のドキュメントを指定することで、実際に分割後のトークンごとにマッチするか確認できます。 # In[29]: 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)) # ドキュメント登録時のトークンと、クエリ時のトークン、いずれも **ホッ/トミルクティー**であるため、検索ヒットしたことが確認できました。 # # では、**ミルクティー ホット** ではどうでしょうか。 # In[30]: 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)) # **ミルクティー ホット** で検索を行った場合、クエリは **ミルク/ティー/ホット** にトークン分割されていることが分かります。 # # 一方、Analyze API 実行結果より、**ホットミルクティー** というドキュメントは、登録時に **ホッ/トミルクティー** というトークンに分割されていることが確認できています。 # # 検索時のトークンと、登録時のトークンにずれがあることが、**ミルクティー ホット** で **ホットミルクティー**が検索できない原因であると確認できました。 # # 登録されている時と同じトークン分割結果である **ホッ/トミルクティー** で検索してみましょう。このクエリは一見すると不自然ですが、登録時のトークンと同一であることからヒットします。 # In[31]: 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"]) # また、**ホッ** や **トミルクティー** など、単一のトークンで検索してもヒットしてしまいます。 # In[32]: 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"]) # In[33]: 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"]) # #### ユーザー辞書定義の更新 # analyze API を使用することで入力時のトークン分割の様子が、explain API を使用することで検索時のトークン分割の様子が分かりました。登録・検索時のトークン分割を一致させることが、検索精度向上の鍵であることも分かりました。 # # ここからは、**ミルクティー ホット** でもヒットするように、**ホットミルクティー** が **ホット/ミルク/ティー** で区切られるようにユーザー辞書を作成していきましょう。 # 既存インデックスの user_dictonary_rules を以下の通り更新します。 # 合わせて **ホットミルクラテ** 用のエントリーも追加しておきます。 # # 更新作業の前後で、Close API によるインデックスのクローズ、Open API によるインデックスのオープンを実行しています。 # #
# 本ラボで使用しているインデックスはサイズが小さいため、Close から Open も含む更新にかかる所要時間は 1 秒未満ですが、一般的に Close/Open にかかる時間はインデックスサイズに応じて増加していくため、本番環境で本作業を実施する場合は事前の検証が必要です。 #
# # # In[34]: get_ipython().run_cell_magic('time', '', '\ninex_name = "kuromoji-sample-with-user-dictionary-rules-v1"\n\nuser_dictionary_rules = [\n "紅まどんな,紅まどんな,ベニマドンナ,カスタム名詞",\n "東京ゲートブリッジ,東京 ゲートブリッジ,トウキョウ ゲートブリッジ,カスタム名詞",\n "アイストールラテ,アイス トール ラテ,アイス トール ラテ,カスタム名詞",\n "ホットミルクティー,ホット ミルク ティー,ホット ミルク ティー,カスタム名詞",\n "ホットミルクラテ,ホット ミルク ラテ,ホット ミルク ラテ,カスタム名詞"\n]\n\npayload = {\n "analysis": {\n "tokenizer": {\n "custom_kuromoji_tokenizer_with_userdict": {\n "type": "kuromoji_tokenizer",\n "user_dictionary_rules": user_dictionary_rules\n }\n }\n }\n}\n\nprint("Closing index...")\nresponse = opensearch_client.indices.close(index=index_name)\nprint(json.dumps(response, indent=2))\nprint("Closed successfully")\n\nprint("Updating index...")\nresponse = opensearch_client.indices.put_settings(index=index_name, body=payload)\nprint(json.dumps(response, indent=2))\nprint("Updated successfully")\n\nprint("Opening index...")\nresponse = opensearch_client.indices.open(index=index_name)\nprint(json.dumps(response, indent=2))\nprint("Opend successfully")\n') # 辞書の更新が終わったので、改めて item.text_with_userdict フィールドに対して **ミルクティー ホット** で検索してみましたが、結果は 0 件のままです。 # In[35]: 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 に対しては、辞書更新前はヒットしていた**ホットミルクティー** で検索にヒットしなくなってしまいました。これはなぜでしょうか? # In[36]: 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 でホットミルクティーをトークン分割すると、辞書更新前とは異なり **ホット/ミルク/ティー** で分割されることが確認できています。したがって、クエリは正常にトークン分割できているようです。 # In[37]: 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 # 原因は、トークン分割のタイミングによるものです。OpenSearch にドキュメントを格納する際、ドキュメントは Tokenizer によってトークン分割が行われます。このトークン分割はドキュメント格納時にのみ行われます。したがって、辞書を後から更新するだけでは、既に格納されているドキュメントのトークンは変化しません。 # # 実際に、辞書更新前のトークン分割結果である **ホッ/トミルクティー** で検索すると、依然としてこの組み合わせでもヒットします。 # In[38]: 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"]) # 試しに、先ほど辞書エントリに追加した **ホットミルクラテ** を登録します。 # # Analyze API の結果より、**ホットミルクラテ** は、格納時に **ホット/ミルク/ラテ** に分割されることが期待できます。正しくユーザー辞書が機能していることが分かります。 # In[39]: 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 # 実際に **ホットミルクラテ** を登録していきます。 # In[40]: 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) # 登録後に **ミルク/ラテ/ホット** で検索すると、正しくヒットすることが確認できました。 # ユーザー辞書のエントリ更新後に登録されたドキュメントは、更新後の辞書の影響を受けることが分かりました。 # In[41]: 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"]) # #### Update by query API によるデータの再登録 # # 既存のドキュメントを、新しい辞書エントリを元に改めてトークン分割しなおすためには、ドキュメントの再登録が必要となります。 # # 外部にマスターデータがある場合は、外部から改めてデータの全登録を行うことがお勧めですが、本セクションでは [Update by query][update-by-query] API を使用します。Update by query API を実行することで、インデックスに登録されたドキュメント自身のデータをもとに、ドキュメントの再登録を行うことができます。 # # Update by query は完了まで長時間要する場合があるため、wait_for_completion オプションに False をセットし、非同期で実行することを推奨します。 # # [update-by-query]: https://opensearch.org/docs/latest/api-reference/document-apis/update-by-query/ # In[42]: 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)) # wait_for_completion に False をセットして実行した場合、task id が返却されます。task id を元に [Get task][get-task] API を実行することで進捗を確認できます。 # # completed が true となっていれば処理は完了です。 # # [get-task]: https://opensearch.org/docs/latest/ml-commons-plugin/api/tasks-apis/get-task/ # In[43]: response = opensearch_client.tasks.get(task_id=task_id) print(json.dumps(response, indent=2, ensure_ascii=False)) # In[44]: index_name = "kuromoji-sample-with-user-dictionary-rules-v1" response = opensearch_client.indices.refresh(index_name) response = opensearch_client.indices.forcemerge(index_name) # ドキュメント再登録後は、無事 **ミルクティー ホット** で検索にヒットすることが確認できました。 # In[45]: 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"]) # ##### Update by query による部分更新 # Update by query 実行時にクエリパラメーターを追加することで、特定の条件に合致したドキュメントだけを洗い替えすることができます。クエリで更新対象のドキュメントの絞り込みが可能である場合は、Update by query による洗い替え時間を短縮することができます。 # # 例えば、更新時刻を示すフィールドを持っているドキュメントであれば、range クエリで特定時刻以前のドキュメントのみ再登録を行うことが可能です。 # In[46]: 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 API 実行結果より、status.total が 1 となっていることが確認できます。全件ではなく 1 件だけが洗い替えされたことが確認できました。 # In[47]: response = opensearch_client.tasks.get(task_id=task_id) print(json.dumps(response, indent=2, ensure_ascii=False)) # #### Reindex API によるデータの再登録 + Alias API によるアクセス先インデックスの切り替え # ここからは、Reindex API と Alias API を組み合わせた辞書更新について解説します。 # # 新たに **ホットルイボスティー** についても、**ホット/ルイボス/ティー** でトークン分割が行われるようにしていきます。 # # デフォルトでは **ホッ/トルイボスティー** と分かち書きされます。 # In[48]: 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 # 実際にホットルイボスティーを登録して、**ホットルイボスティー** および **ホット/ルイボス/ティー** で検索してみます。 # In[49]: 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) # In[50]: 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"]) # In[51]: 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 を通してみていきましょう。 # In[52]: 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)) # **ホット/ルイボス/ティー** の 3 トークンにマッチしないことが原因だと考えていましたが、実際はさらに**ルイボス** が **ルイ/ボス** に分割されていることが確認できました。 # # OpenSearch の検索クエリは、スペースで区切られた各キーワードに対して個別にトークン分割を行います。このため、**ルイボス**が**ルイ/ボス**にさらに分割されるような事象が発生します。 # # したがって、今回は以下 2 つのエントリを登録する必要があると考えます。 # # ``` # "ホットルイボスティー,ホット ルイボス ティー,ホット ルイボス ティー,カスタム名詞" # "ルイボス,ルイボス,ルイボス,カスタム名詞" # ``` # ##### エイリアスの登録 # エイリアスは、インデックスに付与可能な別名です。 # # エイリアスはインデックス間でオンラインでの付け替えが可能であるため、バージョンが複数存在するインデックスに対して、クライアントからは常に同じ名前でアクセスしたい場合に有用です。 # # エイリアスは、Alias API を使用して付与します。以下のサンプルコードでは、インデックス **kuromoji-sample-with-user-dictionary-rules-v1** にエイリアス **kuromoji-sample-with-user-dictionary-rules** をセットしています。 # In[53]: 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)) # is_write_index は、エイリアスを通じたデータの更新リクエストを許可するか否かを制御します。`false` をセットした場合、エイリアスは検索専用で機能します。 # # エイリアスは複数セットすることも可能であるため、書き込み可能なのエイリアスと検索専用のエイリアスを別々に持つことも可能です。 # # 以下のサンプルコードでは、新たに **kuromoji-sample-with-user-dictionary-rules-blue** という書き込み可能なエイリアスを追加しています。 # In[54]: 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)) # エイリアスに対して Search API を発行すると、検索結果が正しく返ることが確認できました。 # # 合わせて、結果に含まれるインデックス _index から、実体のインデックスは **kuromoji-sample-with-user-dictionary-rules-v1** であることも確認できました。 # # エイリアスによって、アプリケーションクライアントはインデックスが変わる都度、設定を更新する必要がなくなりました。 # In[55]: 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"]) # ##### 新規インデックスの作成 # ホットルイボスティーとルイボスのエントリを追加したユーザー辞書を持つインデックスを新規に作成していきます。 # まずは既存インデックスのマッピングおよび設定を取得します。 # In[56]: 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"] # マッピング定義はそのまま利用可能であることが確認できました。 # In[57]: print(json.dumps(mappings, indent=2, ensure_ascii=False)) # 一方で設定(settings) エントリ配下にはインデックス作成時に自動で付与される情報も含まれているため、これらは削除する必要があります。 # # - index.provided_name # - index.creation_date # - index.uuid # - index.version # In[58]: print(json.dumps(settings, indent=2, ensure_ascii=False)) # 不要なエントリ削除後の settings は以下の通りです。 # In[59]: 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)) # settings のユーザー辞書に、ホットルイボスティーのエントリを追加します。 # In[60]: 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)) # **kuromoji-sample-with-user-dictionary-rules-v1** から取得した mapping および、ユーザー辞書にエントリを加えた settings を使って、**kuromoji-sample-with-user-dictionary-rules-v2** インデックスを作成します。 # In[61]: 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)) # **kuromoji-sample-with-user-dictionary-rules-v2** インデックスに、**kuromoji-sample-with-user-dictionary-rules-green** エイリアスを付与します。 # In[62]: 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)) # ##### Reindex API によるドキュメントコピー # Reinedx API を使用して、v1 から v2 へドキュメントのコピーを行います。 # # Reindex API 実行に先立って、[Cat indices][cat-indices] API および [Cat aliases][cat-aliases] API でインデックス・エイリアスの一覧を取得しておきます。v1 はドキュメント数を示す docs.count が 8 である一方、v2 は 0 であることが確認できます。 # # [cat-indices]: https://opensearch.org/docs/latest/api-reference/cat/cat-indices/ # [cat-aliases]: https://opensearch.org/docs/latest/api-reference/cat/cat-aliases/ # In[63]: 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)) # Reindex API を実行します。source.index にはコピー元を、dest.index にはコピー先のインデックス名を指定します。 # # slices は Reindex の並列実効度を制御するパラメーターです。デフォルトは 1、つまりシリアルに実行されます。auto をセットすると、自動的に並列度が決定されます。 # # Update by query と同様に wait_for_completion パラメーターに false をセットすることで、Task ID が返却され進捗をチェックできます。 # In[64]: 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)) # completed が true になっていれば Reindex が完了したと判断できます。status.total が 8 であることから、v1 のドキュメントは全て v2 に登録できたと考えられます。 # In[65]: 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)) # Reindex API はスループットを重視して、デフォルトで Refresh オプションが無効化されています。追加で Refresh API を実行し、Reindex 後のドキュメントが確実に検索可能となるようにします # In[66]: 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][multi-search] API を使用します。 # # [multi-search]: https://opensearch.org/docs/latest/api-reference/multi-search/ # In[67]: 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 # ##### Reindex による部分更新 # Reindex 実行時にクエリパラメーターを追加することで、特定の条件に合致したドキュメントだけをコピーすることができます。クエリで更新対象のドキュメントの絞り込みが可能である場合は、Reindex 実行時間を短縮することが可能です。 # # 例えば、更新時刻を示すフィールドを持っているドキュメントであれば、range クエリで特定時刻以前のドキュメントのみ再登録を行うことが可能です。 # # 今回は、v1 にドキュメントが追加されたことを想定して、差分コピーを実行していきます。 # In[68]: 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) # In[69]: 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)) # 1 件ドキュメントがヒットしました。このクエリをもとに Reindex を実行します。なお、Reindex はエイリアスを source/dest で指定することが可能です。以下のコードでは、インデックス名の代わりにエイリアス名を指定して Reindex を実行しています。 # In[70]: 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 API 実行結果より、status.total が 1 となっていることが確認できます。全件ではなく 1 件だけが洗い替えされたことが確認できました。 # In[71]: 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)) # In[72]: index_name = "kuromoji-sample-with-user-dictionary-rules-green" response = opensearch_client.indices.refresh(index_name) response = opensearch_client.indices.forcemerge(index_name) # 検索処理を実行すると、v2 に新しいドキュメントがコピーされており、かつ検索結果に合わられることが確認できました。 # In[73]: 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 # ##### エイリアスの付け替え # 新しい辞書エントリを持つ v2 インデックスが無事準備できたため、エイリアスの向き先を v1 から v2 に変更します。 # エイリアスの付け替えは、Aliases API に remove アクションと add アクションをまとめて渡すことで実行できます。本処理はアトミックであり、実行中にクライアントリクエストがエラーになることはありません。 # In[74]: 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) # Cat aliases API の実行結果より、エイリアスが付け変わったことを確認できます。 # In[75]: print(opensearch_client.cat.aliases(name="kuromoji-sample-with-user-dictionary-rules*" ,v=True)) # 切り替え後のエイリアスに対して **ホット** で検索すると、ホットを含むドキュメントを正しく取得することができました。 # In[76]: 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"]) # ## まとめ # 本ラボでは、Kuromoji のユーザ辞書カスタマイズによる日本語検索の精度改善について学習しました。 # ## 後片付け # ### インデックス削除 # 本ワークショップで使用したインデックスを削除します。インデックスの削除は Delete index API で行います。インデックスを削除するとインデックス内のドキュメントも削除されます。 # In[77]: 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) # In[78]: 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) # インデックスに関連付けられたエイリアスは、対象のインデックスがすべて削除されると同時に削除されます。 # In[79]: print(opensearch_client.cat.aliases(name="kuromoji-sample-with-user-dictionary-rules*" ,v=True))