スコープ付き検索サジェストと検索クエリ修正の構築方法


適切な検索結果で買い物客をあなたのeコマース・ウェブサイトに引き留めるには、一度しかありません。Harris Poll によると、オンラインショッピング利用者の76%が検索に失敗した後、小売店のウェブサイトを放棄しています。
そのため、購入者が必要なものをすばやく見つけられるように、検索体験を最適化することが重要です。これが、最新の検索エクスペリエンスの背後にある理論です。現在では、単に検索バーがあり、一致する商品が表示されるだけでは十分ではありません。ECサイトでは、ユーザーを目的の商品へ導くための検索ツールを完備する必要があります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33599/81ac3869-1dc5-217c-f066-3b824d435e7e.png) 

このブログでは、スコープ付きサジェストとクエリ修正（"Did you mean?"、「もしかして？」とも呼ばれる）という2つの共通機能を追加することによって、eコマース検索の改善を開始する方法を学びます。
なお、以下の例は Elastic 8.5 でテストされています。

## スコープ付きの提案
eコマースの小売業者の共通の課題は、多くの異なるカテゴリーを持つ大規模な製品カタログです。このような場合、単純なクエリを解析して、ユーザーがどのドメインやカテゴリーに興味があるのかを理解することは困難です。例えば、ある家電量販店でユーザーが「フラットスクリーン」と検索した場合、テレビ、テレビ台、コンピューターモニターなどの項目が表示されることがあります。ユーザーがテレビを探しているときに薄型テレビを表示しても、バイヤージャーニーのこの段階では適切ではないかもしれません。
スコープ付きサジェストは、特定のカテゴリーやブランド内のクエリを提案することで、ユーザーが最も関心のあるトピックを絞り込んで検索できるようにします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33599/2139a399-86dd-2dac-3cb3-f7c979579af5.png)
 
これは、ユーザーが洗練された結果セットを素早く確認するための強力な機能です。Search UI と Elastic を使ってスコープ付き検索サジェストを実装するのも簡単です。次のセクションでは、その構築方法について説明します。

## 提案のためのデータの収集
上記の例で説明したように、ユーザーが入力すると、提案されたクエリと関連するスコープが表示されます。これらの提案を作成するために、[分析データ](https://www.elastic.co/jp/enterprise-search/search-analytics)を使用して、顧客が最もよく関連付けるクエリとスコープを収集することは一般的な方法です。
たとえば、当社のeコマースサイトの分析データを確認したところ、[上位のクエリ](https://www.elastic.co/guide/en/app-search/current/queries.html#queries-top-queries)は「LCD TV」で、ユーザーが最も頻繁に[クリック](https://www.elastic.co/guide/en/app-search/current/clicks.html#clicks-top-clicks) する製品は「TV & Home Theater」カテゴリにあることがわかったとします。
このデータを使って、次のように、対応するスコープ付きの提案を表すドキュメントを作成することができます。
```
{
   "name": "lcd tv",
   "weight": 112,
   "category": {
       "name": "TV & Home Theater",
       "value": 111
   }
}
```
このドキュメントには、クエリと関連するカテゴリが含まれています。これは必要に応じてより複雑にすることができます。例えば、カテゴリのリストを用意したり、"ブランド "のような別のスコープを追加したりすることが可能です。
次に、Elasticsearch に専用のインデックスを作成します。私たちの場合は 「suggest」 と名付け、分析データを使って構築したスコープ付きの提案のリストを保存するようにします。これは[データ変換](https://www.elastic.co/guide/en/elasticsearch/reference/current/transforms.html)や、Python スクリプトなどのカスタムコードで行うことができます。私たちのインデックス "suggest "の[インデックスマッピング](https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping.html)の例は、[こちら](https://github.com/lio-p/search-techniques-example/blob/main/data/config/mapping.json)で見ることができます。
## オートコンプリート候補
さて、サジェストリスト（候補の一覧）を使用する準備ができたので、それを検索エクスペリエンスに追加する必要があります。
[Search UI](https://www.elastic.co/jp/enterprise-search/search-ui) を使えば、[Elastic の上に検索機能を構築するのに数分しかかかりません](https://docs.elastic.co/search-ui/tutorials/elasticsearch)。そして新しく構築したインターフェースを、スコープ付きサジェストのベースとして使用することができます。
Search UI は、[構成オブジェクト](https://docs.elastic.co/search-ui/api/core/configuration#autocomplete-query)を使って、お客様のニーズに合わせて検索をカスタマイズします。以下は、サジェスト設定に対応する構成オブジェクトのスニペットです。この場合、クエリを実行するインデックスとフィールド、および結果の一部として返すフィールドを指定します。
```
suggestions: {
     types: {
       popularQueries: {
         search_fields: {
           "name.suggest": {} // fields used to query
         },
         result_fields: {
           name: {
             raw: {}
           },
           "category.name": {
             raw: {}
           }
         },
         index: "suggest",
         queryType: "results"
       }
     },
     size: 5
   }
```
次に、検索結果ページに移動するときにクエリの一部としてカテゴリを渡すように [SearchBox コンポーネント](https://docs.elastic.co/search-ui/api/react/components/search-box)を構成します。
```
// Search bar component
<SearchBox
   onSelectAutocomplete={(suggestion, config, defaultHandler) => {
       // User selects a scoped suggestion - Category
       if (suggestion.name && suggestion.category) {
         const params = { q: suggestion.name.raw, category: suggestion.category.raw.name };  
           // Navigate to search result page with category passed as parameters
           navigate({
               pathname: '/search',
               search: `?${createSearchParams(params)}`,
           });
       // User selects normal suggestion
       } else if (suggestion) {
           // Navigate to search result page
           window.location.href = "/search?q=" + suggestion.suggestion;
       }
       defaultHandler(suggestion);
   }}
   autocompleteSuggestions={{
       popularQueries: {
           sectionTitle: "Popular queries",
           queryType: "results",
           displayField: "name",
           categoryField: "category"
       }
   }}
   autocompleteView={AutocompleteView}
/>
```
オートコンプリートビュー関数を渡してオートコンプリートビューをカスタマイズし、提案されたクエリと一緒にカテゴリを表示できるようにすることに注意してください。 
以下は、関連付けられたスコープを持つクエリを表示する方法を示すオートコンプリートビュー関数のコードスニペットです。
```
{suggestions.slice(0,1).map((suggestion) => {
   index++;
   const suggestionValue = getDisplayField(suggestion)
   const suggestionScope = getCategoryField(suggestion)
   return (
     <li
       {...getItemProps({
         key: suggestionValue,
         index: index - 1,
         item: {
           suggestion: suggestionValue,
           ...suggestion.result
         }
       })}
     >
       <span>{suggestionValue}</span>
       <ul><span style={{marginLeft: "20px"}}>in {suggestionScope}</span></ul>
     </li>
   );
})}
```
そして、検索結果のページでは、クエリーパラメータを処理して、結果セットにフィルターをかけるだけでよいです。
```
const [searchParams] = useSearchParams();
   useEffect(() => {
       if (searchParams.get('category')) addFilter("department", [searchParams.get('category')], "all")
   }, [searchParams]);
```
すべてをまとめると、結果は次のようになります。
![image.png]( https://media.giphy.com/media/QMcmL6FYpqUSSjURjC/giphy.gif) 

## クエリの修正、別名「もしかして?」
クエリ用語の選択ミスやタイプミスのために関連する検索結果が表示されないと、買い物客はイライラすることがあります。検索結果が全く表示されない、または非常に少ないことを避けるために、より良いクエリを提案することがベストプラクティスです。一般的に、"Did you mean?" 「もしかして？」と呼ばれる機能です。
この機能を実装するには様々な方法がありますが、Search UI と Elastic を使って構築するのが最もわかりやすい方法です。
## データの分析
「もしかして?」機能を構築するために使用するデータセットは、スコープ付きサジェストに使用したものと似ているので、ユーザーのクエリが結果を返さなかった場合に備えて、人気のあるクエリを提案することができます。 
「もしかして?」にも同様の文書構造を使用します。
```
{
   "name": "lcd tv",
   "weight": 112,
   "category": {
       "name": "TV & Home Theater",
       "value": 111
   }
}
```
このステップの鍵は、Elasticsearch でデータがインデックス化される方法です。適切なサジェストを提供するためには、[カスタムアナライザー](https://www.elastic.co/guide/en/elasticsearch/reference/current/analysis-custom-analyzer.html)など、Elasticsearch が提供する特定の機能を使用する必要があります。
以下は、この例で使用したインデックスの設定とマッピングです。
```
{
    "settings":
    {
        "index":
        {
            "number_of_shards": 1,
            "analysis":
            {
                "analyzer":
                {
                    "trigram":
                    {
                        "type": "custom",
                        "tokenizer": "standard",
                        "filter":
                        [
                            "lowercase",
                            "shingle"
                        ]
                    },
                    "reverse":
                    {
                        "type": "custom",
                        "tokenizer": "standard",
                        "filter":
                        [
                            "lowercase",
                            "reverse"
                        ]
                    }
                },
                "filter":
                {
                    "shingle":
                    {
                        "type": "shingle",
                        "min_shingle_size": 2,
                        "max_shingle_size": 3
                    }
                }
            }
        }
    },
    "mappings":
    {
        "properties":
        {
            "category":
            {
                "properties":
                {
                    "name":
                    {
                        "type": "text",
                        "fields":
                        {
                            "keyword":
                            {
                                "type": "keyword",
                                "ignore_above": 256
                            }
                        }
                    },
                    "value":
                    {
                        "type": "long"
                    }
                }
            },
            "name":
            {
                "type": "search_as_you_type",
                "doc_values": "false",
                "max_shingle_size": 3,
                "fields":
                {
                    "reverse":
                    {
                        "type": "text",
                        "analyzer": "reverse"
                    },
                    "suggest":
                    {
                        "type": "text",
                        "analyzer": "trigram"
                    }
                }
            },
            "weight":
            {
                "type": "rank_feature",
                "fields":
                {
                    "numeric":
                    {
                        "type": "integer"
                    }
                }
            }
        }
    }
}
```
関連するサジェストを取得するために特定の設定やマッピングを使用する理由についての詳細は、[suggester のドキュメント](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-suggesters.html#phrase-suggester)を参照してください。
## 検索の準備
インデックスの準備ができたので、検索エクスペリエンスに取り組むことができます。クエリに基づいたサジェストを取得するには、 Elasticsearch API を活用します。クライアント側をあまり複雑にしたくない場合は、Elasticsearch でクエリを含む[検索テンプレート](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-template.html) を作成します。そして、クライアント側からは、その検索テンプレートを実行すればよいのです。
```
PUT _scripts/did-you-mean-template
{
   "script": {
       "lang": "mustache",
       "source": {
           "suggest": {
               "text": "{{query_string}}",
               "simple_phrase": {
                   "phrase": {
                       "field": "name.suggest",
                       "size": 1,
                       "direct_generator": [
                           {
                               "field": "name.suggest",
                               "suggest_mode": "always"
                           },
                           {
                               "field": "name.reverse",
                               "suggest_mode": "always",
                               "pre_filter": "reverse",
                               "post_filter": "reverse"
                           }
                       ]
                   }
               }
           }
       }
   }
}
```
## アプリケーションからの提案の取得
Elasticsearch からサジェストを得るために、クライアントが /api/suggest で消費できるバックエンド API を追加しています。この新しい API は、Elasticsearch を呼び出し、検索テンプレートを実行し、サジェストが欲しいクエリを渡し、クエリのサジェストを返します。このバックエンド APIにより、フロントエンド部分の複雑さを軽減することができます。
```
client.searchTemplate({
       index: "suggest",
       id: 'did-you-mean-template',
       params: {
           query_string: req.body.query
       }
   })
```
例えば、クエリが "spakers" である場合、API は "speakers" という提案を返します。これは、最も人気のあるクエリを含む、以前に取り込まれたデータに基づいています。提案 API は、クエリに構文的に最も近い単語を返します。
## 「もしかして?」を追加する
これで、フロントエンドアプリケーションに「もしかして?」機能を追加できます。そのためには、スコープ付きサジェストの部分で使用したのと同じReact アプリケーションで作業を続けます。
追加した新しい API は React アプリケーションから消費され、現在のクエリの結果がない場合にサジェストを表示することができます。
ここでのアイデアは、ユーザーが入力した各クエリに対するサジェストを取得することです。もし結果がなければ、ユーザはサジェストを見ることになります。別の実装も可能です。結果が少ない場合にサジェストを表示することもできますし、ユーザーのクエリの代わりにサジェストを自動的に実行することもできます。
このアプリケーションでは、検索結果を表示する [SearchResults](https://github.com/lio-p/search-techniques-example/blob/main/app-ui/src/components/SearchResults/index.js) という React コンポーネントを持っています。そこに、バックエンド API である /api/suggest からサジェストを取得する関数を追加することができます。
```
const fetchDidYouMeanSuggestion = async (query) => {
   const response = await fetch('/api/did_you_mean', {
       method: 'POST',
       headers: {
           Accept: 'application/json, text/plain, */*',
           'Content-Type': 'application/json',
       },
       body: JSON.stringify(query)
   })
   const body = await response.json();
   if (response.status !== 200) throw Error(body.message);
   return body;
}
```
そして、ユーザーの絞り込みによってクエリが変化すると、APIを呼び出してサジェストを更新することができます。
```
   // Get search params from dom router
   const [searchParams] = useSearchParams();
 
   useEffect(() => {
       // When the searchParams contains a query
       if (searchParams.get('q')) {
           // Set query for Search UI - Run the search
           setSearchTerm(searchParams.get('q'))
           // Fetch suggestion from backend API
           fetchDidYouMeanSuggestion({ query: searchParams.get('q') }).then(res => {
               setSuggestion(res.body?.suggest?.simple_phrase[0]?.options[0]?.text)
           })
               .catch(err => console.log(err));
       }
  }, [searchParams]); 
```
最後に、ユーザーのクエリに対して結果がない場合に、サジェストを表示する。
```
{wasSearched && totalResults == 0 && <span>No results to show{suggestion ? <>, did you mean <span style={{ cursor: "pointer", color: "blue" }} onClick={() => navigateSuggest(suggestion)}>{suggestion}</span>?</> : "."}</span>}
```
最終的にはこのようになります。
![image.png](https://media.giphy.com/media/TuFTzadP3OdRlzt1JL/giphy.gif) 

## 結論
このブログポストでは、[Elastic エンタープライズ検索](https://www.elastic.co/jp/enterprise-search)を使って 「もしかして？（Did you mean?）」とスコープ付きサジェスト機能を簡単に構築する方法について説明しました。これらの機能は、既存の検索エクスペリエンスに簡単に統合でき、ユーザが探しているものをより早く見つけることができます。これらの例を構築するために使用されたコードを確認したい場合は、こちらの [GitHub リポジトリ](https://github.com/lio-p/search-techniques-example)を参照してください。

最新の検索エクスペリエンスを提供することは、[e コマースサイト](https://www.elastic.co/jp/enterprise-search/ecommerce)や、[カスタマーサポート](https://www.elastic.co/jp/enterprise-search/customer-support)、[Web サイト検索](https://www.elastic.co/jp/enterprise-search/site-search)、[社内職場検索](https://www.elastic.co/jp/enterprise-search/workplace-search)、[カスタム検索アプリケーション](https://www.elastic.co/jp/enterprise-search/search-applications)など、他の多くのユースケースにとって重要です。このブログ記事のステップに従うことで、すべてのエンドユーザーにとってより良い検索体験の構築を加速させることができます。ぜひやってみてください！

https://www.elastic.co/jp/blog/how-to-build-scoped-search-suggestions-and-search-query-corrections 

Elastic テクニカルプロダクトマーケティングマネージャー/エバンジェリスト
鈴木章太郎