Ahogrammer

Deep Dive Into NLP, ML and Cloud

Azure AI Searchを使った同義語によるクエリ拡張とその効果

PythonからAzure AI Searchのシノニムマップを作成し、クエリ拡張をして、その効果を日本語の質問応答データセットで確認してみました。昔からある機能で、とくに何か新しいことをしているわけでもないのですが、使いそうな機会があったので試してみました。

本記事の構成は以下のとおりです。

シノニムマップ

記法は、Apache SolrのSynonymFilterの仕様に準拠しています[1]。Solrのドキュメントを読む限り、現在ではSynonymFilterは非推奨で、代わりにSynonymGraphFilterを使うことが推奨されていますが、為す術もないのでそのまま使います。2つの違いについては[2]がわかりやすいです。SynonymFilterでは、以下の2種類の規則をサポートしています。

  • 同等性規則(Equivalency rules)
  • 明示的なマッピング(Explicit mapping)

同等性規則の場合は、以下に示すように、同じ規則内でカンマで区切って記述します。規則内の用語は等しく置き換えられます。

{
"format": "solr",
"synonyms": "
    USA, United States, United States of America\n
    dog, puppy, canine\n
    coffee, latte, cup of joe, java\n"
}

明示的なマッピングの場合は、以下のように矢印で置き換える方向を示します。場合によっては、展開の方向を指定したいことがあるため、そのような場合はこちらで指定します。

{
"format": "solr",
"synonyms": "
    Washington, Wash., WA => WA\n
    California, Calif., CA => CA\n"
}

現在の制限では、シノニムマップあたりの規則数は最大で20000件に制限されています(Freeティアのみ5000)[3]。また、展開先に指定できる用語数は最大20までという制限があります。その制限を超えて作成しようとするとエラーが発生します。

シノニムマップの作成

実際にシノニムマップを作成してみましょう。まずは同義語辞書を用意します。今回はSudachi 同義語辞書を使うので、以下のようにしてダウンロードします。

!wget https://raw.githubusercontent.com/WorksApplications/SudachiDict/develop/src/main/text/synonyms.txt

中身を確認すると以下の形式になっていることがわかります。各フィールドが意味するところについては、Sudachi 同義語辞書を確認してください。

000001,1,0,1,0,0,0,(),曖昧,,
000001,1,0,1,0,0,2,(),あいまい,,
000001,1,0,2,0,0,0,(),不明確,,
000001,1,0,3,0,0,0,(),あやふや,,
000001,1,0,4,0,0,0,(),不明瞭,,
000001,1,0,5,0,0,0,(),不確か,,

次に、ダウンロードした同義語辞書を読み込んで、Solr形式に変換します。コードは、ほぼ[4]の記事のとおりです。

def load_synonym_groups(filename, output_predicate=False):
    synonym_groups = {}
    with open(filename, encoding="utf-8") as input:
        for line in input:
            line = line.strip()
            if line == "":
                continue
            entry = line.split(",")[0:9]
            if entry[2] == "2" or (not output_predicate and entry[1] == "2"):
                continue
            group = synonym_groups.setdefault(entry[0], [[], []])
            group[1 if entry[2] == "1" else 0].append(entry[8])
    return synonym_groups


def convert_synonym_groups_to_solr_format(synonym_groups):
    synonyms = []
    for groupid in sorted(synonym_groups):
        group = synonym_groups[groupid]
        if not group[1]:
            if len(group[0]) > 1:
                synonyms.append(",".join(group[0][:20]))
        else:
            if len(group[0]) > 0 and len(group[1]) > 0:
                g = group[0] + group[1]
                synonyms.append(",".join(group[0]) + "=>" + ",".join(g[:20]))
    return synonyms


synonym_groups = load_synonym_groups("synonyms.txt")
synonyms = convert_synonym_groups_to_solr_format(synonym_groups)

Solr形式で用意できたので、シノニムマップを作成しましょう。まずは、SearchIndexClientを用意します。

from azure.core.credentials import AzureKeyCredential
from azure.search.documents.indexes import SearchIndexClient 

service_name = ""
admin_key = ""
index_name = ""

endpoint = f"https://{service_name}.search.windows.net/"
admin_client = SearchIndexClient(
    endpoint=endpoint,
    index_name=index_name,
    credential=AzureKeyCredential(admin_key)
)

次に、シノニムマップを用意します。今回はtest-syn-mapという名前で作成しています。また、1つのシノニムマップに登録できる規則の制限から、用意した同義語を20000件まで刈り込んでいます。PythonSDKを用いて作成するぶんには意識する必要はありませんが、リクエスト的にはシノニムマップ名、形式、同義語等を送信しています[5]

from azure.search.documents.indexes.models import SynonymMap

synonym_map = SynonymMap(name="test-syn-map", synonyms=synonyms[:20000])
result = admin_client.create_synonym_map(synonym_map)

正しく作成できていれば、「Found 1 Synonym Maps in the service: test-syn-map」と表示されるはずです。

result = admin_client.get_synonym_maps()
names = [x.name for x in result]
print("Found {} Synonym Maps in the service: {}".format(len(result), ", ".join(names)))

インデックスの作成

あとは、インデックスを作成するだけです。SearchableFieldsynonym_map_namesに作成したシノニムマップの名前であるtest-syn-mapを指定します。リストの中に入れて指定できますが、現時点ではフィールドごとに1つのシノニムマップだけを指定できることに注意する必要があります[6]

fields = [
    SimpleField(name="AnswerId", type=SearchFieldDataType.String, key=True),
    SearchableField(
        name="Answer",
        type=SearchFieldDataType.String,
        analyzer_name="ja.lucene",
        synonym_map_names=["test-syn-map"]
    ),
 ]
cors_options = CorsOptions(allowed_origins=["*"], max_age_in_seconds=60)
index = SearchIndex(
    name=index_name,
    fields=fields,
    scoring_profiles=[],
    suggesters=[],
    cors_options=cors_options
)
result = admin_client.create_index(index)

あとは検索するだけです。

実験設定

今回の実験では、日本語のQAデータセットを利用して、シノニムマップを使った場合と使っていない場合の検索性能を評価します。

評価用のデータセットとしては、尼崎市のQAデータ[7]を使用します。このデータセットには、784の質問に対して対応する回答がAからCの3つのカテゴリでラベル付けされています。Aの場合は正しい情報を含み、Bであれば関連する情報を含み、Cであればトピックが同じであることを意味します。今回はこれらのカテゴリを関連文書として扱うことにします。

評価については上位10件のヒット率とMRRおよび再現率(Hit Rate@10MRR@10Recall@100)でします。

実験結果

実験結果を以下に示します。結果を見ると、シノニムマップを使った場合の性能が向上していることがわかります。一般的な同義語であれば、OpenAIの埋め込みモデル等を用いたベクトル検索でカバーできるでしょうが、ドメイン固有の用語の扱いを改善したいときに試してみたいと思います。

モデル Hit Rate@10 MRR@10 Recall@100
BM25(ja.lucene 0.6862 0.4649 0.7374
BM25(ja.lucene)+ シノニムマップ 0.7130 0.4874 0.7861

なお、実装にあたり、Azure AI SearchのPythonサンプル[8]Python SDKのドキュメント[9]を参考にしました。ありがとうございます。

参考資料