[Amazon Athena] SQLのFilter句を使ってスペース区切りで入力された複数検索キーワードによる検索を実現する

比較的複雑な検索をSQLクエリ(Amazon Athena, Presto)で頑張る例。パッと見わかりにくいので分解して解説してみるやつです。
2023.12.08

データアナリティクス事業本部 インテグレーション部 機械学習チームの貞松です。

今回はSQLのTipsというか、ちょっと込み入った検索を実現するための一例を紹介します。
最終的なSQLクエリを掲載して終わり、でも良いのですが、パッと見少々理解しづらいところもあるので、処理を分解して一つずつ解説します。

実行環境とユースケース

実行環境は以下の通りです。

  • Amazon Athena
    • Athena engine version 3
    • AWSマネジメントコンソールのAthena Query Editor上で実行

ユースケースとしてはBIダッシュボードの検索フォームで入力されたスペース区切りの複数検索キーワードをSQLクエリに渡して、検索結果を返すことを想定しています。

実現したいロジックは以下の通りです。

  • スペース区切り(半角でも全角でも可)で複数の検索キーワードの入力を受け付ける
  • 入力されたキーワード全てが、検索対象の文字列内に含まれる (AND検索)
  • 検索キーワードの出現順序は、入力された順序になっていなくても良い
    • 検索キーワードが3つ入力されていたとして、検索対象の文字列内での出現順序が「3番目の検索キーワード」「1番目の検索キーワード」「2番目の検索キーワード」のようになっていても抽出されること
  • 検索キーワードが検索対象内で複数回出現しても正しく抽出されること
  • 検索キーワード自体が重複していても正しく抽出されること

最終的なSQLクエリ

最終的に作成したSQLクエリは以下の通りです。
実際のユースケースでは <ここにスペース区切りで複数検索キーワードを入れる想定> の部分にBIダッシュボードの検索フォームから渡された文字列が埋め込まれる記述になる想定です。

with search_word AS (
    SELECT replace('<ここにスペース区切りで複数検索キーワードを入れる想定>', ' ', ' ') AS search_word
),
split_search_word as (
    select
        word
    from
        search_word
    cross join
        unnest(split(search_word, ' ')) as t(word)
),
search_result as (
    select
        q10,
        case 
            when count(distinct(word)) filter(where q10 LIKE '%' || word || '%') = (select count(distinct(word)) from split_search_word)
            then 0 -- 検索ワードが全てヒットした場合
            else 1 -- 検索ワードが一つでもヒットしなかった場合
        end as match_result
    from
        enquete
    cross join
        split_search_word
    group by
        q10
)
select
    q10
from
    search_result
where
    match_result = 0
;

以下、実行結果の例です。アンケートのフリーテキスト回答欄のデータ列(q10)に対して、検索キーワードとしては「ありがとう 説明 部署 説明」を入力して実行しています(半角・全角スペースを混ぜつつ、キーワード重複もある状態にしました)

検索キーワードの出現順序や出現回数に関わらず、意図通りに抽出できています。

これだけパッと見ても、処理の内容(具体的なロジック)がわかりにくいので、各処理を分解して順番に解説していきます。

各処理の解説

入力された検索キーワードに含まれる全角スペースを半角スペースに変換する処理

これはもうそのままですね。replace関数で全角スペースを半角スペースに変換しています。

これにより、区切り文字のスペースを半角・全角両方に対応させています。

with search_word AS (
    SELECT replace('<ここにスペース区切りで複数検索キーワードを入れる想定>', ' ', ' ') AS search_word
),

入力された検索キーワードを分割する処理

split関数で半角スペースを区切り文字として、検索キーワードを分割した後、unnestすることで複数検索キーワードを縦持ちしています。
わざわざ縦持ちにしている理由は後述します。

split_search_word as (
    select
        word
    from
        search_word
    cross join
        unnest(split(search_word, ' ')) as t(word)
),

前述の実行例のように検索キーワードとして「ありがとう 説明 部署 説明」を入力とした場合は以下のような持ち方になります。

検索対象文字列に各検索キーワードが含まれていた件数をカウントすることで全てのキーワードが含まれているか否かを判定する処理

ここが一番の肝であり、複雑な処理になっています。

search_result as (
    select
        q10,
        case 
            when count(distinct(word)) filter(where q10 LIKE '%' || word || '%') = (select count(distinct(word)) from split_search_word)
            then 0 -- 検索ワードが全てヒットした場合
            else 1 -- 検索ワードが一つでもヒットしなかった場合
        end as match_result
    from
        enquete
    cross join
        split_search_word
    group by
        q10
)

具体的なロジックとしては以下のとおりです。

一つ前の処理で検索キーワードを縦持ちにしたテーブルと検索対象列が含まれるテーブルをCROSS JOINすることで、検索対象文字列×検索キーワードのレコードを持つデータセットになります。

これに対して、以下のよう集計、比較を行うことで、検索対象文字列に検索キーワードが全て含まれるか否かを判定して0 or 1のラベル付けをすることができます。

  • filter句を使用することで、検索対象文字列に対して、検索キーワードが含まれているレコードだけをカウント
    • count(distinct(word)) filter(where q10 LIKE '%' || word || '%')
  • 縦持ちにした検索キーワードの件数をカウントする
    • (select count(distinct(word)) from split_search_word)
  • 上記2つのカウントを突き合わせることで、一致すれば全ての検索キーワードが、検索対象文字列のどこかに含まれていることになるので0(検索キーワードが全てヒットした場合のラベル)とし、一致しなければ1(検索キーワードが一つでもヒットしなかった場合のラベル)とする

filter関数を使用することで、条件に一致するレコードだけを対象として集計することができるので、これを活用しているわけです(もちろんcountだけでなく、sumなど他の集計関数でもfilterを使用することができます)

count(distinct(word)) の部分で、 dicstinct(word) しているのは、検索キーワードが重複している場合の対策です。

また、注意事項として、 group by q10 でグループ化のキーとして、検索対象文字列の列のみ指定していますが、検索対象文字列に重複があり一意にならない場合は、カウントの集計が意図しないものになる(重複カウントしてしまう)ので、一意になるような複合キーで指定してください。

検索キーワードが全て含まれる検索対象文字列を抽出する

最後にmatch_resultが0のレコードを抽出して完了です。

select
    q10
from
    search_result
where
    match_result = 0
;

最後に

Amazon Athena上で、SQLのFilter句を使用してスペース区切りで入力された複数検索キーワードによる検索を実現する為のSQLクエリを解説しました。 用途が限定的な内容ですが、ニーズはあると思いますので、何処かで誰かの参考になれば幸いです。