GameWith Developer Blog

GameWith のエンジニア、デザイナーが技術について日々発信していきます。

Amazon OpenSearch Service にシノニム検索を導入する

はじめに

こんにちは。サービス開発部のmです。
GameWith アドベントカレンダー2024 6日目の記事です。

GameWithには、キーワードやジャンルなどを指定してゲームを検索できる機能があります。 今回はこの機能で利用しているシノニム(類義語)検索についてご紹介します。

gamewith.jp

本記事では下記のような架空のゲームタイトルを使用します。

おうどんファンタジー
漬物UFOアカデミア

シノニムの必要性

ゲーム検索において、ユーザーは様々な表記でゲームタイトルを検索します。
例えば『おうどんファンタジー』というゲームは、「うどファン」「UF」「Udon Fantasy」など、複数の呼び方が存在します。また、漢字とひらがなの表記揺れや、日本語と英語の併用など、一つのゲームタイトルに対して数多くの検索バリエーションが考えられます。

検索機能の品質を高めるためには、これらの表記揺れに適切に対応することが重要です。ユーザーが意図したゲームにたどり着けないことは、サービスからの離脱につながる可能性があるためです。そのため、シノニム(類義語)の適切な管理と運用が、検索品質の向上に重要な役割を果たします。

本記事のゴール

本記事では、Amazon OpenSearch Serviceを使用したシノニム検索について、どんな実装の選択肢があるかを解説し、効率的な導入方法を紹介します。

検索時シノニムの基礎知識

検索時シノニムの仕組み

"analysis": {
  "tokenizer": {
    "ja_kuromoji_tokenizer": {
      "mode": "search",
      "type": "kuromoji_tokenizer",
      "discard_compound_token": true
    }
  },
  "filter": {
    "ja_search_synonym": {
      "type": "synonym_graph",
      "expand": true,
      "lenient": true,
      "synonyms_path": "%s",
      "updateable": true
    }
  },
  "analyzer": {
    "ja_kuromoji_index_analyzer": {
      "type": "custom",
      "char_filter": [
        "normalize"
      ],
      "tokenizer": "ja_kuromoji_tokenizer",
      "filter": [
        "kuromoji_readingform"
      ]
    },
    "ja_kuromoji_search_analyzer": {
      "type": "custom",
      "char_filter": [
        "normalize"
      ],
      "tokenizer": "ja_kuromoji_tokenizer",
      "filter": [
        "kuromoji_readingform",
        "ja_search_synonym"
      ]
    }
  }
}

上記の設定は、インデックスのマッピング定義の一部で、日本語テキストの解析とシノニム展開を行うためのものです。
kuromoji_tokenizer により日本語テキストを適切に分割し、検索時にシノニム展開を行うことで、様々な表記のクエリに対応できます。
「検索時シノニム」とは検索用アナライザー( ja_kuromoji_search_analyzer )にだけシノニムのフィルターを設定する方法です。

ja_search_synonymフィルターの設定で updateable: true とすることで、シノニムファイルの更新を動的に反映できるようになります。これにより、新しいゲームがリリースされたり略称が生まれたりした際にも、柔軟に対応することが可能です。

インデックス時シノニムとの比較

OpenSearchでシノニムを実装する方式には、検索時シノニムとインデックス時シノニムの2つがあります。それぞれの特徴を以下の表で比較します。

観点 検索時シノニム インデックス時シノニム
更新の容易さ シノニムファイルの更新のみで反映可能 インデックスの再作成が必要
検索パフォーマンス 検索時に展開処理が必要なため低下する傾向あり 事前に展開済みのため高速
インデックスサイズ 元の単語のみを保持するため小さい 展開後の単語も保持するため大きい
メモリ使用量 検索時の展開処理でメモリを使用 インデックス読み込み時のメモリ使用が大きい
運用コスト 更新が容易で運用負荷が低い 再インデックスの計画と実行が必要
適用範囲 新規検索から即時適用 再インデックス後の検索のみに適用
エラー対応 シノニム設定のミスを即時修正可能 再インデックスが必要なため修正に時間がかかる

今回のゲーム検索システムでは、以下の理由から検索時シノニムを採用しています。

  • ゲームタイトルの略称や通称は、リリース後にユーザーの間で自然発生的に生まれることが多く、随時更新が必要
  • インデックスサイズを抑えることで、システム全体のコストを最適化できる
  • エラー発生時の修正を素早く行えることで、ユーザー体験への影響を最小限に抑えられる

導入時におすすめの構成

シノニムファイルの形式

OpenSearchのシノニムファイルには、明示的マッピングと等価マッピングの2つの記述形式があります。

明示的マッピング(=> 形式)

うどファン,UF,どんファン => おうどんファンタジー
つけアカ,TUA => 漬物UFOアカデミア

この形式では、検索時に左側のワードを右側のワードに正規化して検索が行われます。検索結果の一貫性を保ちやすい一方で、正規化によってngramなどの部分一致が効かなくなるケースがあります。例えば、UFで検索したときに漬物UFOアカデミアがヒットしなくなるといった制限があります。

等価マッピング(, 形式)

うどファン,UF,どんファン,おうどんファンタジー
つけアカ,TUA,漬物UFOアカデミア

カンマ区切りで記述された全てのワードが同じ重みで扱われ、どのワードでも検索が可能です。シンプルで直感的な記述が可能で、ヒットする範囲も広がります。ただし、検索時に利用するトークン数が増えるため、実行時の負荷に注意が必要です。

GameWithのゲーム検索では、ユーザーの多様な検索パターンに対応するため、部分一致によるヒットも重視し、等価マッピングを採用しています。

同義語辞書の管理方法

同義語辞書の管理にはGoogleスプレッドシートの使用をおすすめします。スプレッドシートを選択した理由は、主に以下の特徴があるためです。

まず、チームでの共同編集が非常に容易です。複数人が同時に編集でき、変更履歴の追跡も簡単です。また、コメント機能を使ってディスカッションを行うこともできるため、チーム内でのコミュニケーションがスムーズです。

次に、データの構造化が簡単です。ゲームのケースでは「ゲーム名」「シリーズ名」「パブリッシャー名」のように、異なる要素に対して効率的に類義語を設定する必要があります。一般的なデータベースで管理すると複雑になりがちなこの構造も、スプレッドシートならシートを分けて最後にマージするだけで済みます。

正式名称 類義語(カンマ区切り)
おうどんファンタジー(シリーズ名) うどファン,UF,どんファン
おうどんファンタジーV(ゲーム名) UFV

さらに、Google Apps Scriptやスプレッドシート関数を利用して整形が容易でCSVエクスポート機能もあるので、自動化も簡単に実現できます。

スプレッドシートを使用する際の注意点

スプレッドシートでの管理には便利な面が多い一方で、いくつかの技術的な制約があります。特にOpenSearchのカスタムパッケージ用テキストファイルを作成する際は、特殊文字の取り扱いに注意が必要です。

#\t" といった文字がスプレッドシートに含まれる場合、出力されるテキストファイルがOpenSearchの要求する形式を満たさない可能性があります。そのため、当初は手動でパッケージの更新を行い、問題が発生しやすい箇所を特定することをおすすめします。

現在の実装では、スプレッドシートAPIを使って値を取得する際に、以下のようなバリデーションを行っています。

func (s *serviceDef) convertValueRangeToString(vr *googlesheets.ValueRange) (string, error) {
    const maxInvalidRowsPercentage = 0.05 // 無効な類義語の行数が全体の5%を超えたらエラーにする
    var sb strings.Builder
    invalidRowsCount := 0
    totalRowsCount := len(vr.Values)
    for _, row := range vr.Values {
        strRow := row[0].(string)
        // #, \t, " が入っている場合はスキップしてログ出力
        if strings.Contains(strRow, "#") || strings.Contains(strRow, "\t") || strings.Contains(strRow, "\"") {
            logger.Batch().Warning("invalid synonym strRow: " + strRow)
            invalidRowsCount++
            continue
        }
        sb.WriteString(strRow + "\n")
    }

    logger.Batch().Info(fmt.Sprintf("totalRowsCount: %d, invalidRowsCount: %d", totalRowsCount, invalidRowsCount))
    // 無効な行数のチェック
    if float64(totalRowsCount)*maxInvalidRowsPercentage < float64(invalidRowsCount) {
        return "", fmt.Errorf("invalidRowsCount is over %f", maxInvalidRowsPercentage)
    }
    return sb.String(), nil
}

このコードでは、特殊文字を含む行をスキップしながら、エラー行数が一定の閾値(全体の5%)を超えた場合にはエラーを返すようにしています。これにより、大規模なデータの破損を防ぎながら、個別の問題のある行に対して適切に対処することができます。

まとめ

本記事では、Amazon OpenSearch Serviceにおけるシノニム検索の実装について、特にゲーム検索における活用方法を中心に解説しました。

また、効率的な運用のために、Googleスプレッドシートを活用した管理方法を採用しました。 これにより、チーム内での共同編集や変更履歴の管理が容易になり、さらにデータの構造化や自動化も実現できました。

今後はユーザーの検索ログを分析し、実際の検索クエリからシノニムの候補を自動的に抽出する仕組みの検討も進めていきたいと考えています。
より多くのユーザーが目的のゲームに辿り着けるよう、検索機能の改善を継続していきます。


こんなGameWithではエンジニアを絶賛募集中です!

サーバーエンジニアやフロントエンジニアの方、AIに興味がある方や、Unityでの開発に興味がある方もお気軽にカジュアル面談をお申し込みください!

github.com