#!/usr/bin/env python # coding: utf-8 # # 日本語全文検索の実装 # ## 概要 # 日本語の全部検索を実装する場合は、以下のような事項について考慮が必要です。 # # - 多様な文字種: 日本語検索では、ひらがな、カタカナ、漢字、英数字(半角・全角)、特殊記号(①や㌢など)、顔文字など、様々な文字種を取り扱う。 # - 表記ゆれへの対応。表記が異なっていても同じ語句として扱う必要がある。 # - 文字種、全角半角、大文字小文字の違いで発生する揺らぎ(あいふぉん、アイフォン、アイフォーン、iphone、i-Phone、iPhone、iphone など) # - 末尾の長音記号(ー)の有無による揺らぎ(コンピューターとコンピュータ) # - 長音記号とカタカナによる揺らぎ(サラダボールとサラダボウルは同じ単語として処理する必要があるが、バレエとバレーは異なる単語として処理する必要がある) # - 漢字の踊り字による揺らぎ(明明白白、明々白々) # - 複合語の処理: 複数の語句が結合した複合語は、一つの単語として処理する要件が存在する(山桜、東京タワー、エアバスA300、ホームページ、瀬戸内しまなみ海道 など) # - 類義語の処理類似するキーワードで検索できるようにする必要がある。(正確/的確/明確/確実/確か など) # # 本ラボでは、OpenSearch で日本語検索実装上の課題にどのように対応するかを解説していきます。 # # ### ラボの構成 # # 本ラボでは、ノートブック環境(JupyterLab) および Amazon OpenSearch Service を使用します。 # # # ### 使用するデータセット # 本ラボでは、[JGLUE][jglue] 内の FAQ データセットである [JSQuAD][jsquad] を使用します。 # # [jglue]: https://github.com/yahoojapan/JGLUE # [jsquad]: https://github.com/yahoojapan/JGLUE/tree/main/datasets/jsquad-v1.3 # ## 事前作業 # ### パッケージインストール # In[48]: get_ipython().system('pip install opensearch-py requests-aws4auth "awswrangler[opensearch]" --quiet') # ### インポート # In[49]: import boto3 import json 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[50]: 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[51]: default_region = boto3.Session().region_name logging.getLogger().setLevel(logging.ERROR) # ### OpenSearch クラスターへの接続確認 # # OpenSearch クラスターへのネットワーク接続性が確保されており、OpenSearch の Security 機能により API リクエストが許可されているかを確認します。 # # レスポンスに cluster_name や cluster_uuid が含まれていれば、接続確認が無事完了したと判断できます # In[52]: 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() # ## 日本語検索ウォークスルー # ### OpenSearch におけるテキスト処理の全体像 # # 全文検索の対象となるデータは、以下の流れで処理され、転置インデックスに登録されます。 # # # # 以降のセクションでは、各フェーズで登場するコンポーネントの解説と、具体的なコンポーネントの動作を見ていきます。 # ### Tokenizer # Tokenizer は入力されたテキストを自身のロジックに基づいて分割するコンポーネントです。日本語検索では形態素解析を用いる手法、もしくは n-Gram という N 文字ずつテキストを区切る手法が一般的に用いられます。各手法について実際の挙動を見ていきましょう。 # #### N-Gram # N-Gram はテキストから N 文字ずつ取り出してトークン化する手法です。 一文字ずつ取り出すことを uni-gram、二文字ずつ切り取ることを bi-gram、三文字ずつ切り取ることを tri-gram などと呼びます。 # # ここでは、N-Gram tokenizer で、以下の文字列を 2 文字ずつトークン化した結果を見ていきます。トークンにホワイトスペースや記号が含まれないように、**token_chars** パラメーターで制御を行っています。 # # "大阪府の関西国際空港(KIX)から東京都の羽田空港(HND)までのフライト時間はおよそ 70 分です" # In[53]: payload = { "text": "大阪府の関西国際空港(KIX)から東京都の羽田空港(HND)までのフライト時間はおよそ 70 分です。", "tokenizer": { "type": "ngram", "min_gram": 2, "max_gram": 2, "token_chars": ["letter", "digit"] } } response = opensearch_client.indices.analyze( body=payload ) df_bigram = pd.json_normalize(response["tokens"]) df_bigram # 上記の例では、文章を 1 文字ずつずらしながら、2 文字のトークンが抽出されたことがわかります。N-Gram は N 文字ずつトークンを抽出することから、未知語に対するヒット率の向上が期待できます。 # # 一方、検索ノイズの増加については考慮が必要です。抽出されたトークンには **"京都"** も含まれているため、`京都`で検索を行った際に無関係の本文章がヒットしてしまいます。 # # 検索ノイズを削減するテクニックとしては以下のようなものが考えられます。 # # - 複数の N-Gram (bi-gram と tri-gram など)インデックスを併用し、ユーザーが入力した検索キーワードの長さに応じて、アプリケーション側で処理を分岐させる # - 形態素解析と組み合わせる # - トークンフィルターを適用し、"です" や "ます" などの不要な語句(ストップワード)で構成されるトークンを除去する #
# N-gram の最小文字数と最大文字数に 2 以上の差がある場合の設定 # # ngram tokenizer の min_gram および max_gram に 2 以上の差がある場合は、インデックスに [index.max_ngram_diff][index-settings] の設定を追加する必要があります。追加されていない場合、以下のようなエラーが発生します。 #
# # [index-settings]: https://opensearch.org/docs/latest/install-and-configure/configuring-opensearch/index-settings/ # In[54]: payload = { "text": "大阪府の関西国際空港(KIX)から東京都の羽田空港(HND)までのフライト時間はおよそ 70 分です。", "tokenizer": { "type": "ngram", "min_gram": 1, "max_gram": 3, } } try: response = opensearch_client.indices.analyze( body=payload ) df_bigram = pd.json_normalize(response["tokens"]) df_bigram except Exception as e: print(e) # #### 形態素解析 # 形態素解析を用いることで、単語の品詞情報が格納された辞書や文法に基づくトークン分割を行えます。 # # 例えば、吾輩は猫である。 という文章を形態素解析エンジンで処理すると、吾輩 / は / 猫 / で / ある / 。 と自然に分割されたトークンが取得できます。 # # OpenSearch では、Sudachi もしくは Kuromoji を利用可能です。以降のセクションでは、各エンジンごとの動作を解説していきます。 # ##### Kuromoji # Kuromoji は Java で実装されたオープンソースの日本語形態素解析ツールです。[atilika][atilika] により開発、Apache Software Foundation に寄贈されており、OpenSearch のベースである Apache Lucene に組み込まれています。Amazon OpenSearch Service および Amazon OpenSearch Serverless では、デフォルトで Kuromoji が利用可能です。 # # OSS 版の OpenSearch でも、標準の[日本語プラグイン][additional-plugins]として登録されているため、`opensearch-plugin install analysis-kuromoji` コマンドで導入が可能です。 # # kuromoji_tokenizer は、以下 3 つの分割モードをサポートしています。 # # - normal: デフォルトのモード。最も長い分割単位でトークンを出力。複合トークンの分割は行わない。 # - search: 検索に特化したモード。複合トークンの分割も合わせて行う。 # - extended: search の動作に加えて、未知語をユニグラム(1 文字トークン)として出力する # # 各モードごとの実行結果を見ていきましょう。 # # [atilika]: https://www.atilika.org/ # [additional-plugins]: https://opensearch.org/docs/latest/install-and-configure/additional-plugins/index/ # **normal モード** # # カッコなどの記号や句読点がトークンに含まれていないのは、kuromoji tokenizer の **discard_punctuation** オプションがデフォルトで `true` になっているためです。記号や句読点をトークンとして含める場合は同設定を `false` にセットします。 # In[55]: payload = { "text": "大阪府の関西国際空港(KIX)から東京都の羽田空港(HND)までのフライト時間はおよそ 70 分です。", "tokenizer": { "type": "kuromoji_tokenizer", "mode": "normal", "discard_punctuation": True #デフォルト } } response = opensearch_client.indices.analyze( body=payload ) df_kuromoji_normal = pd.json_normalize(response["tokens"]) df_kuromoji_normal df_kuromoji_normal # **search モード** # In[56]: payload = { "text": "大阪府の関西国際空港(KIX)から東京都の羽田空港(HND)までのフライト時間はおよそ 70 分です", "tokenizer": { "type": "kuromoji_tokenizer", "mode": "search" } } response = opensearch_client.indices.analyze( body=payload ) df_kuromoji_search = pd.json_normalize(response["tokens"]) df_kuromoji_search # **extended モード** # In[57]: payload = { "text": "大阪府の関西国際空港(KIX)から東京都の羽田空港(HND)までのフライト時間はおよそ 70 分です", "tokenizer": { "type": "kuromoji_tokenizer", "mode": "extended" } } response = opensearch_client.indices.analyze( body=payload ) df_kuromoji_extended = pd.json_normalize(response["tokens"]) df_kuromoji_extended # **normal/search/extended モードの比較** # 3 つのモードを比較します。normal -> search -> extended の順にトークンが増加する様子が分かります。 # In[58]: df_kuromoji_search_and_normal = pd.merge(df_kuromoji_search, df_kuromoji_normal, on=["start_offset", "end_offset"], how="left", suffixes=["_kuromoji_search","_kuromoji_normal"]).drop(["type_kuromoji_search","type_kuromoji_normal","positionLength","position_kuromoji_search", "position_kuromoji_normal"],axis=1).reindex(["start_offset", "end_offset", "token_kuromoji_search", "token_kuromoji_normal"],axis=1).fillna("") df_kuromoji_extended_and_normal = pd.merge(df_kuromoji_extended, df_kuromoji_normal, on=["start_offset", "end_offset"], how="left", suffixes=["_kuromoji_extended","_kuromoji_normal"]).drop(["type_kuromoji_extended","type_kuromoji_normal","positionLength","position_kuromoji_extended","position_kuromoji_normal"],axis=1).reindex(["start_offset", "end_offset", "token_kuromoji_extended", "token_kuromoji_normal"],axis=1) df_kuromoji = pd.merge(df_kuromoji_extended_and_normal, df_kuromoji_search_and_normal, on=["start_offset"], how="left").drop(["token_kuromoji_normal_x"],axis=1).rename(columns={"token_kuromoji_normal_y": "token_kuromoji_normal"}).reindex(["start_offset", "token_kuromoji_extended", "token_kuromoji_search", "token_kuromoji_normal"],axis=1).fillna("") df_kuromoji # なお、search もしくは extended モードで、分割前の複合語を破棄する場合は、**discard_compound_token** に `true` をセットします。以下は search モードにおける **discard_compound_token** パラメーターによる結果の違いです。 # In[59]: payload = { "text": "大阪府の関西国際空港(KIX)から東京都の羽田空港(HND)までのフライト時間はおよそ 70 分です", "tokenizer": { "type": "kuromoji_tokenizer", "mode": "search", "discard_compound_token": True } } response = opensearch_client.indices.analyze( body=payload ) df_kuromoji_search_discard_compound_token = pd.json_normalize(response["tokens"]) df_kuromoji_search_results = pd.merge(df_kuromoji_search, df_kuromoji_search_discard_compound_token, on=["start_offset", "end_offset"], how="left", suffixes=["_without_discard_compound_token","_with_discard_compound_token"]).drop(["type_without_discard_compound_token","type_with_discard_compound_token","positionLength","position_without_discard_compound_token", "position_with_discard_compound_token"],axis=1).reindex(["start_offset", "end_offset", "token_without_discard_compound_token", "token_with_discard_compound_token"],axis=1).fillna("") df_kuromoji_search_results # ##### Sudachi # [Sudachi][sudachi] は Works Applications によって開発されている形態素解析エンジンです。Kuromoji と比較して以下の点が優れています。 # # - システム辞書が継続的にメンテナンスされていること。Kuromoji の標準辞書は 2007 年でメンテナンスが止まっています。一方、Sudachi のシステム辞書は 2024 年現在も継続的にメンテナンスされています。 # - UniDic ショートユニットから固有表現の抽出まで、テキスト分割モードを柔軟に選択可能。カスタム辞書内でも語句ごとに分割モードごとの指定が可能。 # - 豊富な正規化機能。カスタム語句についても、辞書内で正規化された表現を定義することが可能 # # プラットフォームによってサポート状況や利用方法が異なります。 # # - OpenSearch Service: [カスタムパッケージ][custom-packages]の機能から Sudachi の関連付けを行うことで利用可能 # - OpenSearch Serverless: Sudachi はサポートされていません。 # - OSS OpenSearch: リポジトリからソースコードを入手して[ビルド][elasticsearch-sudachi-build]を実施。作成されたプラグインのバイナリをインストール。 # # Sudachi は以下 3 つの分割モードを提供しています。各モードごとの違いを見ていきます。 # # - A: UniDicショートユニット ([SUW][suw]) に相当する最小ユニットに分割 # - B: 固有表現を中間ユニットに分割して出力 (最小ユニット 2 つまでを結合) # - C: 固有表現を抽出 # # なお、句読点や記号が省略されているのは、**discard_punctuation** オプションに `false` がセットされているためです。 # # [sudachi]: https://github.com/WorksApplications/Sudachi # [custom-packages]: https://docs.aws.amazon.com/opensearch-service/latest/developerguide/custom-packages.html # [elasticsearch-sudachi-build]: https://github.com/WorksApplications/elasticsearch-sudachi?tab=readme-ov-file#build-if-necessary # [suw]: https://clrd.ninjal.ac.jp/unidic/glossary.html#suw # In[60]: payload = { "text": "大阪府の関西国際空港(KIX)から東京都の羽田空港(HND)までのフライト時間はおよそ 70 分です", "tokenizer": { "type": "sudachi_tokenizer", "split_mode": "A" } } response = opensearch_client.indices.analyze( body=payload ) df_sudachi_a = pd.json_normalize(response["tokens"]) df_sudachi_a # In[61]: payload = { "text": "大阪府の関西国際空港(KIX)から東京都の羽田空港(HND)までのフライト時間はおよそ 70 分です", "tokenizer": { "type": "sudachi_tokenizer", "split_mode": "B" } } response = opensearch_client.indices.analyze( body=payload ) df_sudachi_b = pd.json_normalize(response["tokens"]) df_sudachi_b # In[62]: payload = { "text": "大阪府の関西国際空港(KIX)から東京都の羽田空港(HND)までのフライト時間はおよそ 70 分です", "tokenizer": { "type": "sudachi_tokenizer", "split_mode": "C" } } response = opensearch_client.indices.analyze( body=payload ) df_sudachi_c = pd.json_normalize(response["tokens"]) df_sudachi_c # 3 つの分割モードの結果を比較します。 # In[63]: df_sudachi_b_and_a = pd.merge(df_sudachi_a, df_sudachi_b, on=["start_offset"], how="left", suffixes=["_a","_b"]).drop(["type_a","type_b","position_a", "position_b"],axis=1).reindex(["start_offset", "token_a", "token_b"],axis=1).fillna("") df_sudachi_c_and_a = pd.merge(df_sudachi_a, df_sudachi_c, on=["start_offset"], how="left", suffixes=["_a","_c"]).drop(["type_a","type_c","position_a", "position_c"],axis=1).reindex(["start_offset", "token_a", "token_c"],axis=1).fillna("") df_sudachi = pd.merge(df_sudachi_c_and_a, df_sudachi_b_and_a, on=["start_offset"], how="left").drop(["token_a_x"],axis=1).rename(columns={"token_a_y": "token_a"}).reindex(["start_offset", "token_a", "token_b", "token_c"],axis=1).fillna("") df_sudachi # ### Character Filter # Tokenizer に渡す前段での正規化を担当するコンポーネントです。不要な文字の除去や半角・全角を揃えるなどの正規化処理を行うことで、表記ゆれによる検索精度の低下を防ぎます。 # # Character Filter には踊り字の置き換えといった、トークン分割自体の精度向上に寄与するものもあります。 # #### ICU normalization character filter # # ICU normalization character filter は、文字列の正規化処理を行うフィルターです。以下のような表記ゆれを補正可能です。 # # | 変換内容 | 変換例(前) |変換例(後)| # | ---- | ---- | ---- | # | 大文字 -> 小文字 | OpenSearch | opensearch | # | 全角英数字・記号 -> 半角英数字・記号 | open_search | open_search | # | 半角カナ -> 全角カナ | オープンソース | オープンソース | # | 数字記号 -> 半角数字 | ① | 1 | # | 単位記号 -> 全角カナ | ㍍ | メートル | # # 以下の例では、様々な種類の文字が混在する文字列の正規化を行っています。 # # In[64]: payload = { "text": "OpensearChは①⓪⓪㌫オープンソースの検索/分析スイートです", "tokenizer": { "type": "sudachi_tokenizer" } } response = opensearch_client.indices.analyze( body=payload ) df_sudachi = pd.json_normalize(response["tokens"]) payload = { "text": "OpensearChは①⓪⓪㌫オープンソースの検索/分析スイートです", "tokenizer": { "type": "sudachi_tokenizer" }, "char_filter": ["icu_normalizer"] } response = opensearch_client.indices.analyze( body=payload ) df_sudachi_normalized = pd.json_normalize(response["tokens"]) pd.merge(df_sudachi, df_sudachi_normalized, on=["start_offset","end_offset"], how="outer").rename(columns={"token_x": "token", "token_y": "token_normalized"}).reindex(["start_offset", "end_offset", "token", "token_normalized"],axis=1).fillna("") # #### kuromoji_iteration_mark character filter # kuromoji_iteration_mark は、踊り字(々, ゝ, ヽ)を直前の文字で置き換える機能を提供します。 # # 踊り字を変換せずにそのままトークン分割を行った場合、以下のような問題が発生します # # - トークン分割時に踊り字だけがインデクシングされてしまう # - 踊り字を含むキーワードで検索を行った際に、踊り字を含むすべてのキーワードがヒットしてしまう # - 文字列の分割箇所がおかしくなる # # 例えば、**こゝろ** や **つゝむ** をそのまま Kuromoji Tokenizer で処理すると、ゝ が一つのトークンとして抽出されます。このままの状態でインデックスにデータが格納された場合、`こゝろ` で検索を行うと、**つゝむ** もヒットしてしまいます。 # # また、学問のすゝめ については、学問/の/すゝ/め と不自然な位置で区切られてしまいます。 # In[65]: payload = { "text": "こゝろ", "tokenizer": { "type": "kuromoji_tokenizer" } } response = opensearch_client.indices.analyze( body=payload ) pd.json_normalize(response["tokens"]) # In[66]: payload = { "text": "つゝむ", "tokenizer": { "type": "kuromoji_tokenizer" } } response = opensearch_client.indices.analyze( body=payload ) pd.json_normalize(response["tokens"]) # In[67]: payload = { "text": "学問のすゝめ", "tokenizer": { "type": "kuromoji_tokenizer" } } response = opensearch_client.indices.analyze( body=payload ) pd.json_normalize(response["tokens"]) # kuromoji_iteration_mark を利用することで、踊り字がひとつ前の文字に置き換えられ、トークンが正しく抽出されるようになります # In[68]: payload = { "text": "学問のすゝめ", "tokenizer": { "type": "kuromoji_tokenizer" }, "char_filter": ["kuromoji_iteration_mark"] } response = opensearch_client.indices.analyze( body=payload ) pd.json_normalize(response["tokens"]) # なお、Sudachi Tokenizer を使用する場合は基本的に踊り字でトークンが不自然に区切られることがないため、本フィルタの利用は必須ではありません。 # In[69]: payload = { "text": "学問のすゝめ", "tokenizer": { "type": "sudachi_tokenizer" } } response = opensearch_client.indices.analyze( body=payload ) pd.json_normalize(response["tokens"]) # ### Token Filter # Token Filter は Tokenizer によって分割・抽出されたトークンに対する処理を行います。検索ノイズの増加に影響するストップワードや特定の品詞の除去、ステミングや表記ゆれの補正など、検索精度を向上するうえで欠かせない処理が提供されています。 # 以降、主要な Token Filter について解説していきます。 # # なお、Token Filter の中には、品詞分類などを手掛かりとして処理を行うものが存在します。こうした処理は、同じプラグイン(Kuromoji、Sudachi)でトークナイズされていることが前提となるため、Kuromoji で生成されたトークンを Sudachi のトークンフィルタで処理できない場合があります。そうした制限についても以降のセクションで解説していきます。 # #### 原形への置き換え # 変化形を原形に置き換えてインデックスへの格納・検索を行うことで、食べる と 食べた といった形の違いによる検索ヒット率の低下を防ぎます。 # Kuromoji でトークン分割を行った場合は **kuromoji_baseform** Token Filter を、Sudachi でトークン分割を行った場合は **sudachi_baseform** を使用します。 # In[70]: payload = { "tokenizer": "kuromoji_tokenizer", "text": "寿司を食べた。美味しかったな" } response = opensearch_client.indices.analyze( body=payload ) df_kuromoji = pd.json_normalize(response["tokens"]) payload = { "tokenizer": "kuromoji_tokenizer", "filter": ["kuromoji_baseform"], "text": "寿司を食べた。美味しかったな" } response = opensearch_client.indices.analyze( body=payload ) df_kuromoji_baseform = pd.json_normalize(response["tokens"]) pd.merge(df_kuromoji_baseform, df_kuromoji, on=["start_offset","end_offset"], how="outer").rename(columns={"token_x": "token_baseform", "token_y": "token"}).reindex(["start_offset", "end_offset", "token", "token_baseform"],axis=1).fillna("") # In[71]: payload = { "tokenizer": "sudachi_tokenizer", "text": "寿司を食べた。美味しかったな" } response = opensearch_client.indices.analyze( body=payload ) df_sudachi = pd.json_normalize(response["tokens"]) payload = { "tokenizer": "sudachi_tokenizer", "filter": ["sudachi_baseform"], "text": "寿司を食べた。美味しかったな" } response = opensearch_client.indices.analyze( body=payload ) df_sudachi_baseform = pd.json_normalize(response["tokens"]) pd.merge(df_sudachi_baseform, df_sudachi, on=["start_offset","end_offset"], how="outer").rename(columns={"token_x": "token_baseform", "token_y": "token"}).reindex(["start_offset", "end_offset", "token", "token_baseform"],axis=1).fillna("") # sudachi_tokenizer と kuromoji_baseform、kuromoji_tokenizer と sudachi_baseform といった組み合わせは成立しません。 # In[72]: payload = { "tokenizer": "kuromoji_tokenizer", "filter": ["sudachi_baseform"], "text": "寿司を食べた。美味しかったな" } try: response = opensearch_client.indices.analyze( body=payload ) pd.json_normalize(response["tokens"]) except Exception as e: print(e) # In[73]: payload = { "tokenizer": "sudachi_tokenizer", "filter": ["kuromoji_baseform"], "text": "寿司を食べた。美味しかったな" } response = opensearch_client.indices.analyze( body=payload ) pd.json_normalize(response["tokens"]) # #### 品詞分類によるトークン除去 # トークナイザーにより抽出されたトークンには品詞の情報が付与されています。品詞分類を元に、助詞や接続詞などの検索ノイズになりうるトークンを削除します。 # In[74]: payload = { "tokenizer": "kuromoji_tokenizer", "filter": ["kuromoji_baseform"], "text": "寿司を食べた。美味しかったな" } response = opensearch_client.indices.analyze( body=payload ) df_kuromoji_baseform = pd.json_normalize(response["tokens"]) payload = { "tokenizer": "kuromoji_tokenizer", "filter": [ "kuromoji_baseform", { "type": "kuromoji_part_of_speech", "stoptags": [ "助詞-格助詞-一般", "助動詞", "助詞-終助詞" ] } ], "text": "寿司を食べた。美味しかったな" } response = opensearch_client.indices.analyze( body=payload ) df_kuromoji_baseform_part_of_speech = pd.json_normalize(response["tokens"]) pd.merge(df_kuromoji_baseform, df_kuromoji_baseform_part_of_speech, on=["start_offset","end_offset"], how="outer").rename(columns={"token_x": "token_baseform", "token_y": "token_baseform_part_of_speech"}).reindex(["start_offset", "end_offset", "token_baseform", "token_baseform_part_of_speech"],axis=1).fillna("") # In[75]: payload = { "tokenizer": "sudachi_tokenizer", "filter": ["sudachi_baseform"], "text": "寿司を食べた。美味しかったな" } response = opensearch_client.indices.analyze( body=payload ) df_sudachi_baseform = pd.json_normalize(response["tokens"]) payload = { "tokenizer": "sudachi_tokenizer", "filter": [ "sudachi_baseform", { "type": "sudachi_part_of_speech", "stoptags": [ "助詞,終助詞", "助詞,格助詞", "助動詞", ] } ], "text": "寿司を食べた。美味しかったな" } response = opensearch_client.indices.analyze( body=payload ) df_sudachi_baseform_part_of_speech = pd.json_normalize(response["tokens"]) pd.merge(df_sudachi_baseform, df_sudachi_baseform_part_of_speech, on=["start_offset","end_offset"], how="outer").rename(columns={"token_x": "token_baseform", "token_y": "token_baseform_part_of_speech"}).reindex(["start_offset", "end_offset", "token_baseform", "token_baseform_part_of_speech"],axis=1).fillna("") # #### ストップワードの除去 # 日本語における "てにをは" など、検索において重要ではない語句をストップワードと呼びます。ストップワードがインデックスに格納されると検索性が低下するため、一般的にはインデックスに格納されないよう除去します。品詞単位の除去に似ていますが、ストップワードの除去は品詞の分類による判断ではなく、ストップワードリストを元に判断します。 # In[76]: payload = { "tokenizer": "sudachi_tokenizer", "filter": [ "sudachi_baseform" ], "text": "寿司を食べた。美味しかったな" } response = opensearch_client.indices.analyze( body=payload ) df_sudachi = pd.json_normalize(response["tokens"]) payload = { "tokenizer": "sudachi_tokenizer", "filter": [ "sudachi_baseform", { "type": "ja_stop", "stopwords": ["_japanese_","寿司"] } ], "text": "寿司を食べた。美味しかったな" } response = opensearch_client.indices.analyze( body=payload ) df_sudachi_ja_stop = pd.json_normalize(response["tokens"]) pd.merge(df_sudachi, df_sudachi_ja_stop, on=["start_offset","end_offset"], how="outer").rename(columns={"token_x": "token", "token_y": "token_ja_stop"}).reindex(["start_offset", "end_offset", "token", "token_ja_stop"],axis=1).fillna("") # In[77]: payload = { "tokenizer": "sudachi_tokenizer", "filter": [ "sudachi_baseform" ], "text": "寿司を食べた。美味しかったな" } response = opensearch_client.indices.analyze( body=payload ) df_sudachi = pd.json_normalize(response["tokens"]) payload = { "tokenizer": "sudachi_tokenizer", "filter": [ "sudachi_baseform", { "type": "ja_stop", "stopwords": ["_japanese_","寿司"] } ], "text": "寿司を食べた。美味しかったな" } response = opensearch_client.indices.analyze( body=payload ) df_sudachi_ja_stop = pd.json_normalize(response["tokens"]) pd.merge(df_sudachi, df_sudachi_ja_stop, on=["start_offset","end_offset"], how="outer").rename(columns={"token_x": "token", "token_y": "token_ja_stop"}).reindex(["start_offset", "end_offset", "token", "token_ja_stop"],axis=1).fillna("") # In[78]: payload = { "tokenizer": "kuromoji_tokenizer", "filter": [ "kuromoji_baseform" ], "text": "寿司を食べた。美味しかったな" } response = opensearch_client.indices.analyze( body=payload ) df_kuromoji = pd.json_normalize(response["tokens"]) payload = { "tokenizer": "kuromoji_tokenizer", "filter": [ "kuromoji_baseform", { "type": "sudachi_ja_stop", "stopwords": ["_japanese_","寿司"] } ], "text": "寿司を食べた。美味しかったな" } response = opensearch_client.indices.analyze( body=payload ) df_kuromoji_sudachi_ja_stop = pd.json_normalize(response["tokens"]) pd.merge(df_kuromoji, df_kuromoji_sudachi_ja_stop, on=["start_offset","end_offset"], how="outer").rename(columns={"token_x": "token", "token_y": "token_ja_stop"}).reindex(["start_offset", "end_offset", "token", "token_ja_stop"],axis=1).fillna("") # In[79]: payload = { "tokenizer": "sudachi_tokenizer", "filter": [ "sudachi_baseform" ], "text": "寿司を食べた。美味しかったな" } response = opensearch_client.indices.analyze( body=payload ) df_sudachi = pd.json_normalize(response["tokens"]) payload = { "tokenizer": "sudachi_tokenizer", "filter": [ "sudachi_baseform", { "type": "stop", "stopwords": ["_japanese_","寿司"] } ], "text": "寿司を食べた。美味しかったな" } response = opensearch_client.indices.analyze( body=payload ) df_sudachi_stop = pd.json_normalize(response["tokens"]) pd.merge(df_sudachi, df_sudachi_stop, on=["start_offset","end_offset"], how="outer").rename(columns={"token_x": "token", "token_y": "token_ja_stop"}).reindex(["start_offset", "end_offset", "token", "token_ja_stop"],axis=1).fillna("") # #### 類義語 # OpenSearch では類義語を同じ語句として取り扱うことで検索精度を向上させます。 # # 例えば、"パイン"、"パイナップル" など、同じものを指していても、表記が異なれば異なるキーワードとして扱われます。以下は実際の動作例です。 # #
# 以下のワードについては Sudachi と Kuromoji で動作が同じであるため Kuromoji でのみ動作を確認しています。tokenizer を sudachi_tokenizer にセットすることで Sudachi に切り替えることが可能です。 #
# In[80]: payload = { "tokenizer": "kuromoji_tokenizer", #"tokenizer": "sudachi_tokenizer", "text": ["パイン", "パイナップル"] } response = opensearch_client.indices.analyze( body=payload ) pd.json_normalize(response["tokens"]) # シノニムを設定することで、インデクシング時および検索時にテキストの類義語を展開することができます。 # In[81]: payload = { "tokenizer": "kuromoji_tokenizer", "filter": [ { "type": "synonym", "lenient": False, "synonyms": [ "パイン=> パイナップル" ] } ], "text": ["パインゼリー", "パイナップルアイス"] } response = opensearch_client.indices.analyze( body=payload ) pd.json_normalize(response["tokens"]) # _analyze API の実行結果で type が SYNONYM となっているものは、シノニムの定義により展開・出力されたトークンであることを表します。上記の例でパインがパイナップルに変化したのは、シノニム設定時に、矢印 (=>) で展開方向を抑制しているためです。 矢印 (=>) で展開方向を抑制したことで、パイン は パイナップルに変換されてからインデックスに格納されます # # 一方、矢印を記載せずにカンマで区切った場合、シノニムは相互展開されます。以下は展開例です。 # In[82]: payload = { "tokenizer": "kuromoji_tokenizer", "filter": [ { "type": "synonym", "lenient": False, "synonyms": [ "パイン,パイナップル" ] } ], "text": ["パインゼリー", "パイナップルアイス"] } response = opensearch_client.indices.analyze( body=payload ) pd.json_normalize(response["tokens"]) # #### カナおよびローマ字読みへの変換 # トークンをカナ表記、ローマ字表記に変換することで検索ワードの揺らぎを補正することが可能です。 # # Sudachi と Kuromoji それぞれで固有の readingform filter を使用する必要があります。kuromoji_tokenizer に対しては kuromoji_readingform を、sudachi_tokenizer については sudachi_readingform を使用します。 # # use_romaji オプションを true にするとローマ字に、false にするとカタカナに変換されます。 # In[83]: payload = { "tokenizer": "kuromoji_tokenizer", "filter": [ { "type": "kuromoji_readingform", "use_romaji": True }, ], "text": ["いか", "烏賊", "イカ"] } response = opensearch_client.indices.analyze( body=payload ) pd.json_normalize(response["tokens"]) # In[84]: payload = { "tokenizer": "sudachi_tokenizer", "filter": [ { "type": "sudachi_readingform", "use_romaji": False }, ], "text": ["いか", "烏賊", "イカ"] } response = opensearch_client.indices.analyze( body=payload ) pd.json_normalize(response["tokens"]) # 変換の精度は辞書に依存します。例えば、"紅まどんな(べにまどんな)" は Sudachi のデフォルトシステム辞書に登録されていないため、トークン分割された上に "べに" ではなく "くれない" と読まれてしまいます。 # カスタム辞書に読み仮名を含めて登録することで対処可能です。 # In[85]: payload = { "tokenizer": "sudachi_tokenizer", "filter": [ { "type": "sudachi_readingform", "use_romaji": False }, ], "text": ["紅まどんな"] } response = opensearch_client.indices.analyze( body=payload ) pd.json_normalize(response["tokens"]) # もう一つの注意点として、同音異字も同じ文字に変換されます。これは検索ノイズの増加につながる可能性があります # In[86]: payload = { "tokenizer": "sudachi_tokenizer", "filter": [ { "type": "sudachi_readingform", "use_romaji": False }, ], "text": ["感情", "勘定", "環状"] } response = opensearch_client.indices.analyze( body=payload ) pd.json_normalize(response["tokens"]) # #### その他の正規化機能 # その他、各形態素解析器固有の機能について解説していきます。 # ##### 総合的な正規化機能 (Sudachi) # Sudachi プラグインは sudachi_normalizedform トークンフィルターを提供しています。以下のような正規化を行うことが可能です。 # # - Okurigana: e.g. 打込む → 打ち込む # - Script: e.g. かつ丼 → カツ丼 # - Variant: e.g. 附属 → 付属 # - Misspelling: e.g. シュミレーション → シミュレーション # - Contracted form: e.g. ちゃあ → ては # - Long sign: e.g. コンピュータ → コンピューター # In[87]: payload = { "tokenizer": "sudachi_tokenizer", "filter": ["sudachi_normalizedform"], "text": ["コンピュータ", "ユーザ", "プリンタ","シュミレーション", "コーラ", "ちゃあ", "附属", "打込み", "かつ丼"], } response = opensearch_client.indices.analyze( body=payload ) pd.json_normalize(response["tokens"]) # ##### 長音記号のステミング (Kuromoji) # Kuromoji kuromoji_stemmer と呼ばれるトークン末尾の長音記号(ー)を削除する機能を提供します。minimum_length オプションで、長音記号を削除するトークンの最小文字数を指定することが可能です。 # # * minimum_length オプションで指定した文字長未満のトークンは末尾の長音記号削除は行われません。デフォルト値は 4 です。この数値は以前の JISZ8301 にて、3音以上の言葉については語尾に長音符号を付けない、2音以下の言葉については語尾に調音符号を付与するというものに由来していると考えられます。2024 年現在の JISZ8301 ではこの基準は削除されています。 # * 本 Token Filter は全角カナのみが対象となるため、半角カナや全角かなに適用するためには、icu_normalizer による半角カナ->全角カナの置き換えや、kuromoji_readingform による全角かな->全角カナへの置き換えが必要です。 # # In[88]: payload = { "tokenizer": "kuromoji_tokenizer", "filter": [ { "type": "kuromoji_stemmer", "minimum_length": 4 #default } ], "text": ["コピー", "サーバー"] } response = opensearch_client.indices.analyze( body=payload ) pd.json_normalize(response["tokens"]) # Sudachi は sudachi_normalizedform で末尾の調音記号の正規化を行うことが可能であるため、Sudachi では本機能は必須ではありません。sudachi_tokenizer と組み合わせて利用することはできますが、sudachi_normalizedform は "コンピュータ" は "コンピューター" に変換するなど、kuromoji_stemmer とは逆に現在の主流である長音記号の付与を行っています。Sudachi に kuromoji_stemmer を組み合わせるメリットは無いと考えます。 # # 詳細については、[内閣告示・内閣訓令 「外来語の表記 留意事項その2(細則的な事項)」][gairai]や、JTCA の[「TC 関連ガイドライン」][tc_guide]をご覧ください。 # # [gairai]: https://www.bunka.go.jp/kokugo_nihongo/sisaku/joho/joho/kijun/naikaku/gairai/honbun06.html # [tc_guide]: https://jtca.org/useful/tc_guide/ # 語末の長音記号の有無による表記ゆれを解消できる本 Token Filter ですが、長音記号を削除することで元々の単語の意味が変わってしまう副作用には注意が必要です。 # # 例えば、コーラー(caller) 末尾の長音記号を削除した場合、生成されるトークンは コーラ(Cola) となり語句の意味自体が変わってしまいます。 # # このような問題を抑制するために minimum_length 設定があります。デフォルト値の 4 を使用した場合、以下のようなケースを防止可能です。 # # - エコー(echo) -> エコ(eco) # - エラー(error) -> エラ(era) # - カバー(cover) -> カバ # # ##### アラビア数字への置き換え (Kuromoji) # Kuromoji は kuromoji_number と呼ばれる、漢数字をアラビア数字に置換する機能を提供します。置換対象の漢数字は Lucene の [JapaneseNumberFilter.java](https://github.com/apache/lucene/blob/main/lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/JapaneseNumberFilter.java) より確認可能です。 # # 対応している単位は垓(10 の 20 乗) までです。 # # アラビア数字への置き換えは、Tokenizer により分割されたトークンが漢数字で構成された文字列のみが対象となります。 # In[89]: payload = { "tokenizer": "kuromoji_tokenizer", "filter": [ { "type": "kuromoji_number" } ], "text": ["千垓千一", "二千,五百十円です", "千載一遇"] } response = opensearch_client.indices.analyze( body=payload ) pd.json_normalize(response["tokens"]) # #### トークンの再分割 # Tokenizer により分割されたトークンを、Token Filter を使って再分割することが可能です。mode によって挙動が異なります。 # # * "search": Additional segmentation useful for search. (Use C and A mode) # # Ex)関西国際空港, 関西, 国際, 空港 / アバラカダブラ # # * "extended": Similar to search mode, but also unigram unknown words. # # Ex)関西国際空港, 関西, 国際, 空港 / アバラカダブラ, ア, バ, ラ, カ, ダ, # **extended mode** # In[90]: payload = { "tokenizer": { "type": "sudachi_tokenizer", "split_mode": "C" }, "filter": [ { "type": "sudachi_split", "mode": "extended" }, ], "text": ["アバラカダブラ","関西国際空港"] } response = opensearch_client.indices.analyze( body=payload ) pd.json_normalize(response["tokens"]) # **search mode** # In[91]: payload = { "tokenizer": { "type": "sudachi_tokenizer", "split_mode": "C" }, "filter": [ { "type": "sudachi_split", "mode": "search" }, ], "text": ["アバラカダブラ","関西国際空港"] } response = opensearch_client.indices.analyze( body=payload ) pd.json_normalize(response["tokens"]) # ## 日本語検索の実行 # サンプルインデックスにデータをロードし、いくつかの日本語検索を実行していきます。 # ### サンプルデータの準備 # In[98]: get_ipython().run_cell_magic('time', '', 'dataset_dir = "./dataset/jsquad"\n%mkdir -p $dataset_dir\n!curl -L -s -o $dataset_dir/valid.json https://github.com/yahoojapan/JGLUE/raw/main/datasets/jsquad-v1.3/valid-v1.3.json \n!curl -L -s -o $dataset_dir/train.json https://github.com/yahoojapan/JGLUE/raw/main/datasets/jsquad-v1.3/train-v1.3.json \n') # In[99]: get_ipython().run_cell_magic('time', '', 'import pandas as pd\nimport json\n\ndef squad_json_to_dataframe(input_file_path, record_path=["data", "paragraphs", "qas", "answers"]):\n file = json.loads(open(input_file_path).read())\n m = pd.json_normalize(file, record_path[:-1])\n r = pd.json_normalize(file, record_path[:-2])\n\n idx = np.repeat(r["context"].values, r.qas.str.len())\n m["context"] = idx\n m["answers"] = m["answers"]\n m["answers"] = m["answers"].apply(lambda x: np.unique(pd.json_normalize(x)["text"].to_list()))\n return m[["id", "question", "context", "answers"]]\n\nvalid_filename = f"{dataset_dir}/valid.json"\nvalid_df = squad_json_to_dataframe(valid_filename)\n\ntrain_filename = f"{dataset_dir}/train.json"\ntrain_df = squad_json_to_dataframe(train_filename)\n\n') # ### サンプルデータの確認 # サンプルデータは日本語の FAQ データセットです。質問文フィールドの question、回答の answers、説明文の context フィールド、問題 ID である id フィールドから構成されています。 # In[100]: valid_df # In[101]: train_df # ### インデックス作成 # In[102]: index_name = "jsquad-sudachi" payload = { "mappings": { "properties": { "id": {"type": "keyword"}, "question": {"type": "text", "analyzer": "custom_sudachi_analyzer"}, "context": {"type": "text", "analyzer": "custom_sudachi_analyzer"}, "answers": {"type": "text", "analyzer": "custom_sudachi_analyzer"} } }, "settings": { "index.number_of_shards": 1, "index.number_of_replicas": 0, "index.refresh_interval": -1, "analysis": { "analyzer": { "custom_sudachi_analyzer": { "char_filter": ["icu_normalizer"], "filter": [ "sudachi_normalizedform", "custom_sudachi_part_of_speech" ], "tokenizer": "sudachi_tokenizer", "type": "custom" } }, "filter": { "custom_sudachi_part_of_speech": { "type": "sudachi_part_of_speech", "stoptags": ["感動詞,フィラー","接頭辞","代名詞","副詞","助詞","助動詞","動詞,一般,*,*,*,終止形-一般","名詞,普通名詞,副詞可能"] } } } } } 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)) # ### ドキュメントのロード # ドキュメントのロードを行います。ドキュメントのロードは "OpenSearch の基本概念・基本操作の理解" でも解説した通り bulk API を使用することで効率よく進められますが、データ処理フレームワークを利用することでより簡単にデータを取り込むことも可能です。本ワークショップでは、[AWS SDK for Pandas][aws-sdk-pandas] を使用したデータ取り込みを行います。 # # [aws-sdk-pandas]: https://github.com/aws/aws-sdk-pandas # # In[103]: get_ipython().run_cell_magic('time', '', 'index_name = "jsquad-sudachi"\n\nresponse = wr.opensearch.index_df(\n client=opensearch_client,\n df=pd.concat([train_df, valid_df]),\n use_threads=True,\n id_keys=["id"],\n index=index_name,\n bulk_size=1000,\n refresh=False\n)\n') # response["success"] の値が DataFrame の件数と一致しているかを確認します。True が表示される場合は全件登録に成功していると判断できます。 # In[104]: response["success"] == pd.concat([train_df, valid_df]).id.count() # 本ラボではデータ登録時に意図的に Refresh オプションを無効化しているため、念のため Refresh API を実行し、登録されたドキュメントが確実に検索可能となるようにします # In[105]: index_name = "jsquad-sudachi" response = opensearch_client.indices.refresh(index_name) response = opensearch_client.indices.forcemerge(index_name) # ### ドキュメントの検索 # シミュレーションの誤字であるシュミレーションで検索を行い、表記ゆれが補正された検索結果が返されることを確認します。 # In[106]: index_name = "jsquad-sudachi" query = "シュミレーション 言語" payload = { "query": { "match": { "question": { "query": query, "operator": "and" } } } } response = opensearch_client.search( index=index_name, body=payload ) pd.json_normalize(response["hits"]["hits"]) # ### インタラクティブな検索 # 以降は時間の許す限り、自由に検索クエリを実行してみましょう # # - query テキストボックスの内容を書き換えることで、検索クエリを変更することが可能です # - question、context、answers のチェックボックスを ON/OFF で切り替えることで、フィールド単位で検索可否を調整可能です。 # - 具体的にどの個所にヒットしたかは、highlight. のカラムから確認可能です。 # In[108]: def search(index_name, query, question, context, answers): fields = [] if question: fields.append("question") if context: fields.append("context") if answers: fields.append("answers") payload = { "query": { "multi_match": { "query": query, "fields": fields, "operator": "and" } }, "highlight": { "fields": { "*" : {} } }, "_source": False, "fields": fields } response = opensearch_client.search( index=index_name, body=payload ) return pd.json_normalize(response["hits"]["hits"]) index_name = "jsquad-sudachi" query = "シュミレーション 言語" # テキストボックス interact(search, index_name=index_name, query=query, question=True, context=True, answers=True) # ## まとめ # 本ラボでは、OpenSearch の日本語検索について学習しました。本ラボで学習した内容を元に、次のステップとして以下のラボを実行してみましょう。 # # ### 日本語検索の精度向上について学びたい方向け # - [Kuromoji ユーザー辞書のカスタマイズによる日本語検索の精度改善](./kuromoji-user-dictionary.ipynb) # # ### ベクトル検索など他の検索手法を学びたい方向け # - [ベクトル検索の実装 (Amazon SageMaker 編)](../vector-search/vector-search-with-sagemaker.ipynb) # ## 後片付け # ### インデックス削除 # 本ワークショップで使用したインデックスを削除します。インデックスの削除は Delete index API で行います。インデックスを削除するとインデックス内のドキュメントも削除されます。 # In[109]: index_name = "jsquad-sudachi" try: response = opensearch_client.indices.delete(index=index_name) print(json.dumps(response, indent=2)) except Exception as e: print(e) # ### データセット削除 # ダウンロードしたデータセットを削除します。./dataset ディレクトリ配下に何もない場合は、./dataset ディレクトリも合わせて削除します。 # In[110]: get_ipython().run_line_magic('rm', '-rf {dataset_dir}') # In[111]: get_ipython().run_line_magic('rmdir', './dataset')