エンジニアリング

優れたReact検索エクスペリエンスを迅速に構築する方法

2019年11月15日更新:本記事中のコード記述を更新し、不足していたブラケットを追加しました。またランタイムエラーを回避する目的で、フローの修正を実施しました。

検索エクスペリエンスの構築は大変な作業です。検索バーを作成し、データベースにデータを入力し、ユーザーがデータベースに対するクエリを入力する。これだけを見ると簡単そうに思えるかもしれません。しかし、データモデリング、基盤となるロジック、そしてもちろん全体のデザインとユーザーエクスペリエンスに関して、多くのことを考慮する必要があります。

ここでは、ElasticのオープンソースのSearch UIライブラリを使用してReactベースの優れた検索エクスペリエンスを構築する方法を説明します。これには約30分必要です。それが完了すると、検索が必要な任意のアプリケーションに検索機能を追加する準備が整います。

まずは、なぜ検索機能の構築が大変なのかについて説明します。

検索機能の構築は大変

数週間前に、「Falsehoods Programmers Believe About Search(プログラマーが検索について信じている嘘)」というタイトルの記事が話題になりました。そこには、開発者が検索機能の開発に取り入れている誤った思い込みが多数掲載されています。

多くの人が信じている嘘には次のようなものがあります。

  • 「何を探しているかを知っているユーザーは、開発者が想定する方法で検索を行う」
  • 「常にクエリの解析に成功するクエリパーサーを書くことができる」
  • 「検索は、一旦セットアップすると、次の一週間は同じように機能する」
  • 「同義語は簡単」
  • … 他にも興味深いものがたくさんあります。ぜひご一読ください。

結論としては、検索には多くの課題があるということです。そして、そのすべてが予想可能なものとは限りません。状態の管理方法や、フィルター、ファセット、ソート、ページネーション、同義語、言語処理などのコンポーネントを構築する方法について考える必要があります。まとめると、

優れた検索機能を構築するには、次の2つの洗練された部分が必要です。(1)検索エンジン(検索を強化するAPIを提供)、および(2)検索ライブラリ(検索エクスペリエンスを形成)。

検索エンジンについては、Elastic App Searchを見ていきます。

検索エクスペリエンスについては、OS検索ライブラリであるSearch UIを紹介します。

完了すると、次のようになります

image2.png

検索エンジン:Elastic App Search

App Searchは、有料のマネージドサービスまたは無料のセルフマネージドのディストリビューションとして利用できます。このチュートリアルではマネージドサービスを使用しますが、 自社でホストすることで、基本ライセンスを使用してSearch UIおよびApp Searchを無料で使用することもできます。

プラン:これまでのベストビデオゲームが記載されたドキュメントを検索エンジンにインデックスします。そして、それらを検索するための検索エクスペリエンスをデザインし、最適化します。

まず14日間のトライアルに登録します。クレジットカードは必要ありません。

エンジンを作成します。13言語から選択できます。

名前をvideo-gamesとして、言語を英語に設定します。

image4.png

ベストビデオゲームのデータセットをダウンロードし、インポーターを使用してApp Searchにアップロードします。

次に、エンジンをクリックし、[Credentials]タブを選択します。

Limited Engine Accessvideo-gamesエンジンのみにして、新しいパブリック検索キーを作成します。

新しいパブリック検索キーホスト識別子を取得します。

見た目はかなり違いますが、洗練された検索APIを使用してビデオゲームデータを検索することが可能な完全に機能する検索エンジンができました。

ここまでの作業をまとめると次のようになります。

  • 検索エンジンを作成
  • ドキュメントを投入
  • デフォルトのスキーマを作成
  • スコープが指定された使い捨て可能な認証情報を取得(ブラウザで使用可能)

とりあえず、App Searchについてはここまでです。

では、Search UIを使用して検索エクスペリエンスの構築を開始しましょう。

検索ライブラリ:Search UI

create-react-appスキャフォールディングユーティリティを使用してReactアプリを作成します。

npm install -g create-react-app
create-react-app video-game-search --use-npm
cd video-game-search

この基盤内にSearch UIとApp Searchコネクターをインストールします。

npm install --save @elastic/react-search-ui @elastic/search-ui-app-search-connector

そして、開発モードでアプリを起動します。

npm start

お好みのテキストエディターでsrc/App.jsを開きます。

ボイラープレートコードから開始し、展開していきます。

コメントに注意してください。

// 手順1、ステートメントのインポート
import React from "react";
import AppSearchAPIConnector from "@elastic/search-ui-app-search-connector";
import { SearchProvider, Results, SearchBox } from "@elastic/react-search-ui";
import { Layout } from "@elastic/react-search-ui-views";
import "@elastic/react-search-ui-views/lib/styles/styles.css";
// 手順2、コネクター
const connector = new AppSearchAPIConnector({
  searchKey: "[YOUR_SEARCH_KEY]",
  engineName: "video-games",
  hostIdentifier: "[YOUR_HOST_IDENTIFIER]"
});
// 手順3:構成オプション
const configurationOptions = {
  apiConnector: connector
  // これを一緒に入力します。
};
// 手順4、SearchProvider:仕上げ
export default function App() {
  return (
    <SearchProvider config={configurationOptions}>
      <div className="App">
        <Layout
        // これを一緒に入力します。
        />
      </div>
    </SearchProvider>
  );
}

手順1:ステートメントのインポート

Search UIの依存関係とReactをインポートする必要があります。

コアコンポーネント、コネクター、ビューコンポーネントが、3つの異なるパッケージに含まれています。

  • @elastic/search-ui-app-search-connector
  • @elastic/react-search-ui
  • @elastic/react-search-ui-views

それぞれの詳細については後で説明します。

import React from "react";
import AppSearchAPIConnector from "@elastic/search-ui-app-search-connector";
import { SearchProvider, Results, SearchBox } from "@elastic/react-search-ui";
import { Layout } from "@elastic/react-search-ui-views";

このプロジェクトには既定のスタイルシートをインポートする必要もあります。スタイルシートにより、独自のCSSを記述する必要なく、優れた外観が実現します。

import "@elastic/react-search-ui-views/lib/styles/styles.css";

手順2:コネクター

App Searchにはパブリック検索キーとホスト識別子があります。

ここでこれらを使用します。

Search UI内のコネクターのオブジェクトは、認証情報を使用してApp Searchに接続し、検索を強化します。

const connector = new AppSearchAPIConnector({
  searchKey: "[YOUR_SEARCH_KEY]",
  engineName: "video-games",
  hostIdentifier: "[YOUR_HOST_IDENTIFIER]"
});

Search UIはどの検索APIでも機能します。ただし、詳細に設定する必要なく検索APIが機能するのはコネクターのおかげです。

手順3:configurationOptions

configurationOptionsの詳細を見る前に、ここまでを振り返ってみましょう。

データのセットを検索エンジンにインポートしました。しかし、それはどのような種類のデータでしょうか。

データについて深く知っていれば知っているほど、そのデータを検索者に提示する方法をより的確に把握できます。この情報は検索エクスペリエンスの構成方法に役立ちます。

ここで、このデータセット内で最も重要な例となるオブジェクトを見てみましょう。

{ 
  "id":"final-fantasy-vii-ps-1997",
  "name":"Final Fantasy VII",
  "year":1997,
  "platform":"PS",
  "genre":"Role-Playing",
  "publisher":"Sony Computer Entertainment",
  "global_sales":9.72,
  "critic_score":92,
  "user_score":9,
  "developer":"SquareSoft",
  "image_url":"https://r.hswstatic.com/w_907/gif/finalfantasyvii-MAIN.jpg"
}

ここにはnameyearplatformなど、いくつかのテキストフィールドがあり、critic_scoreglobal_sales、およびuser_scoreといった数値フィールドがいくつかあります。

次の3つの重要な質問について考えることで、良好な検索エクスペリエンスを構築するための十分な情報が得られます。

  • ほとんどのユーザーはどのように検索しますか?ビデオゲームの名前で検索します。
  • ほとんどのユーザーは結果として何を得たいですか?ビデオゲームの名前と、そのジャンル、パブリッシャー、評価、プラットフォームです。
  • ほとんどのユーザーはどのようなフィルター、ソート、ファセットを利用しますか?評価、ジャンル、パブリッシャー、およびプラットフォームです。

そして、これらの回答をconfigurationOptionsに変換します。

const configurationOptions = {
  apiConnector: connector,
  searchQuery: {
    search_fields: {
      // 1.ビデオゲームの名前で検索します。
      name: {}
    },
    // 2.結果:名前、ジャンル、パブリッシャー、評価、およびプラットフォーム。
    result_fields: {
      name: {
        // snippet(スニペット)とは、一致した検索用語が<em>タグで囲まれることを意味します。
        snippet: {
          size:75, // スニペットを75文字に制限します。
          fallback: true // 「raw」(生の)結果にフォールバックします。
        }
      },
      genre: {
        snippet: {
          size:50,
          fallback: true
        }
      },
      publisher: {
        snippet: {
          size:50,
          fallback: true
        }
      },
      critic_score: {
        // score(評価)は数値のため、スニペット(強調)は使用しません。
        raw: {}
      },
      user_score: {
        raw: {}
      },
      platform: {
        snippet: {
          size:50,
          fallback: true
        }
      },
      image_url: {
        raw: {}
      }
    },
    // 3.ファセットをscore(評価)、genre(ジャンル)、publisher(パブリッシャー)、およびplatform(プラットフォーム)にします。これらは後でフィルターの作成に使用します。
    facets: {
      user_score: {
        type: "range",
        ranges: [
          { from:0, to:5, name:"Not good" },
          { from:5, to:7, name:"Not bad" },
          { from:7, to:9, name:"Pretty good" },
          { from:9, to:10, name:"Must play!" }
        ]
      },
      critic_score: {
        type: "range",
        ranges: [
          { from:0, to:50, name:"Not good" },
          { from:50, to:70, name:"Not bad" },
          { from:70, to:90, name:"Pretty good" },
          { from:90, to:100, name:"Must play!" }
        ]
      },
      genre: { type: "value", size:100 },
      publisher: { type: "value", size:100 },
      platform: { type: "value", size:100 }
    }
  }
};

Search UIを検索エンジンに接続しました。これで、データの検索方法の管理、結果の表示、その結果のさらなる調査に関するオプションを構築できました。しかし、すべてをSearch UIの動的なフロントエンドのコンポーネントに結び付けるものが必要です。

手順4:// 手順4、SearchProvider:

それらのすべてを規定するのがこのオブジェクトです。SearchProviderで、他のすべてのコンポーネントをネストします。

Search UIには、典型的な検索レイアウトの構築に使用されるLayoutコンポーネントがあります。詳細なカスタマイズオプションもありますが、このチュートリアルではそこまで説明しません。

ここでは次の2つを実行します。

  1. configurationOptionsSearchProviderに渡します。
  2. Layoutに構造の要素を入力し、次の2つの基本コンポーネントを追加します:SearchBoxおよびResults
export default function App() {
  return (
    <SearchProvider config={configurationOptions}>
      <div className="App">
        <Layout
          header={<SearchBox />}
          // titleFieldは、結果内の最も目立つフィールドである結果ヘッダーです。
          bodyContent={<Results titleField="name" urlField="image_url" />}
        />
      </div>
    </SearchProvider>
  );
}

この時点で、フロントエンドの基本的な設定は完了しています。実行する前に、バックエンドでもう少し細かい作業を実施しておきましょう。また、プロジェクト固有のニーズに合わせて細かく調整された検索を実現するために、関連性モデルに関する作業が必要になります。

App Searchの出番です。

ラボに戻る

App Searchには、強力で洗練された検索エンジン機能があります。これにより、今までは複雑だった調整作業がさらに楽しくできるようになります。関連性の細かい調整やシームレスなスキーマ変更が数回のクリックで実行できます。 

まずはスキーマを調整して、どのようになるかを見てみます。

App Searchにログインしてvideo-gamesエンジンに入り、[Manage]セクションの下の[Schema]をクリックします。

スキーマが表示されます。デフォルトで、11フィールドがtextとして認識されます。

configurationOptionsオブジェクトで、数値の検索に役立つ2つの範囲ファセット、 user_scorecritic_scoreをすでに定義しています。想定どおりに範囲ファセットを機能させるには、フィールドタイプをnumberにする必要があります。

各フィールドの横にあるドロップダウンメニューをクリックし、[number]に変更して[Update Types]をクリックします。

image1.png

エンジンによって即座に再インデックスされます。後のプロセス、つまりファセットコンポーネントをレイアウトに追加するときに、これらの範囲フィルターが想定どおりに機能するようになります。では、さらに高度な機能について見てみましょう。

高い関連性を実現

関連性に関する機能は3つあります。同義語、キュレーション、関連付けの調整です。

サイドバーの[Search Settings]セクションの下で各機能を選択します。

image8.png

同義語

「車を運転する」と言う場合、「自動車」と言う人もいれば、車によっては「ぽんこつ車」などと呼ぶ人もいるでしょう。インターネットは世界中で使用されており、物を説明するために世界中の人々がさまざまな語句を使用しています。同義語は、同じであると考えられるものに関して使用されるいくつかの用語をまとめるのに役立ちます。

ビデオゲーム検索エンジンの場合、Final Fantasy(ファイナルファンタジー)を探そうとする人が、FFと入力することが考えられます。

Synonyms]をクリックして、[Create a Synonym Set]を選択し、用語を入力します。

image6.png

Save]をクリックします。同義語のセットは必要なだけ追加できます。

これで、FFでの検索は、Final Fantasyでの検索と同じ重み付けとなります。

キュレーション

キュレーションは優れた機能です。Final FantasyまたはFFで検索した場合、どうなるでしょうか。このシリーズにはたくさんのゲームがあります。どれが結果として表示されるのでしょうか。

デフォルトでは、上位5つは次のようになります。

1.Final Fantasy VIII

2.Final Fantasy X

3.Final Fantasy Tactics

4.Final Fantasy IX

5.Final Fantasy XIII

適切だとは思えません。このシリーズのベストゲームはFinal Fantasy VIIです。Final Fantasy XIIIはあまり良くありませんでした。 😜

Final Fantasyを検索した場合に、Final Fantasy VIIが検索結果の最上位になるようにできるでしょうか。また、Final Fantasy XIIIを検索結果から削除できるでしょうか。

もちろんできます。

Curations]をクリックして次のクエリを入力します:Final Fantasy

次に、テーブルの一番左側にあるハンドルバーをクリックしてFinal Fantasy VIIドキュメントを[Promoted Documents]セクションにドラッグします。そして、Final Fantasy XIIIドキュメントの[Hide Result]ボタンをクリックします。 すると、目に1本のラインが入ったアイコンが表示されます。

image7.png

これで、Final FantasyまたはFFを検索すると、Final Fantasy VIIがまず表示されるようになり、

Final Fantasy XIIIはまったく表示されなくなります。(笑)

多くのドキュメントを上位に表示、および非表示にできます。[Promoted Documents]セクションにドラッグしたドキュメントをソートすることもできます。これにより、各クエリに対して最上位に表示されるものを完全に制御できます。

関連付けの調整

サイドバーの[Relevance Tuning]をクリックします。

テキストフィールド(ここではnameフィールド)が検索されますが、検索するテキストフィールドが複数ある場合はどうなるでしょうか。たとえばnameフィールドdescriptionフィールドがあるとします。ここで使用しているビデオゲームのデータセットにはdescriptionフィールドがないため、次のような例を想定してみましょう。

このようなドキュメントがあるとします。

{ 
  "name":"Magical Quest",
  "description":"A dangerous journey through caves and such." 
},
{ 
  "name":"Dangerous Quest",
  "description":"A magical journey filled with magical magic.Highly magic." 
}

Magical Questというゲームを検索する場合、「Magical」と入力することになりますが、上記のファイルの場合、まずは「Dangeroud Quest」が表示されることになります。

image3.png

なぜでしょうか。それは、「magical」という語句がDangerous Questの説明に3回も含まれており、この検索エンジンではどのフィールドがその他のフィールドより重要であるかを認識しないからです。そのため、Dangerous Questが上位にランク付けされます。このような難題を解決してくれるのが、関連付けの調整です。

フィールドを選択して、そのフィールドの重み付けを他のフィールドよりも上げます。

image5.gif

重要なフィールドとしてnameフィールドが重み付けされることで、ここでの適切な検索結果であるMagical Questが結果の上位に表示されます。必要な操作は、スライダーをドラッグして重み付けの値を上げ、[Save]をクリックするだけです。

ここでは、次のことをするためにApp Searchを使いました。

  • スキーマを調整し、user_scorecritic_scorenumberフィールドに変更。
  • 関連性モデルの微調整。

以上が、優れた「ダッシュボード」機能の説明となります。それぞれの機能にAPIエンドポイントがあり、GUIが専門ではない方でも、プログラム的に機能させるために使用できます。

では、UIの仕上げに取りかかりましょう。

仕上げ

この時点で、UIは機能する状態になっているはずです。いくつかクエリを入力して試してみてください。最初に気づくのは、フィルター、ファセット、ソートなど、結果をさらに調査するツールがないことです。ただし検索は機能します。UIを具体化する必要があります。

最初のsrc/App.jsファイルで、3つの基本コンポーネントをインポートしました。

import { SearchProvider, Results, SearchBox } from "@elastic/react-search-ui";

構成オプションとして定義した内容を考慮して、さらに追加しましょう。

次のコンポーネントをインポートすると、UIで不足している機能を有効化できます。

  • PagingInfo:現在のページの情報を表示します。
  • ResultsPerPage:各ページに表示する結果数を設定できます。
  • Paging:異なるページにナビゲートできます。
  • Facet:データタイプに固有の方法で、データをフィルターおよび調査できます。
  • Sorting:結果を特定のフィールドで並べ替えることができます。
import {
  PagingInfo,
  ResultsPerPage,
  Paging,
  Facet,
  SearchProvider,
  Results,
  SearchBox,
  Sorting
} from "@elastic/react-search-ui";

コンポーネントをインポートすると、Layoutに配置できるようになります。

Layoutコンポーネントにより、ページがセクションに分割されます。各コンポーネントは、プロパティを使用することでこれらのセクションに配置できます。

次のようなセクションを使用できます。

  • header:検索ボックス/バー
  • bodyContent:結果のコンテナー
  • sideContent:ファセットおよびソートオプションがあるサイドバー
  • bodyHeader:現在のページや、ページごとの結果数など、結果に関するコンテキスト情報を提示する「ラッパー」
  • bodyFooter:ページ間を素早くナビゲーションできるページングオプション

コンポーネントによってデータがレンダリングされます。データは、configurationOptionsで提示した検索設定に基づいて取得されます。では、各コンポーネントを適切なLayoutセクションに配置しましょう。

configurationOptionsの5つのファセットディメンションを説明したので、ここでは5つのFacetコンポーネントを作成します。各Facetコンポーネントは、 データへのキーとして「field」プロパティを使用します。

それらをsideContentセクションに配置します。Sortingコンポーネントを配置し、そしてPagingPagingInfo、およびResultsPerPageコンポーネントを適切なセクションに配置します。

<Layout
  header={<SearchBox />}
  bodyContent={<Results titleField="name" urlField="image_url" />}
  sideContent={
    <div>
      <Sorting
        label={"Sort by"}
        sortOptions={[
          {
            name:"Relevance",
            value: "",
            direction: ""
          },
          {
            name:"Name",
            value: "name",
            direction: "asc"
          }
        ]}
      />
      <Facet field="user_score" label="User Score" />
      <Facet field="critic_score" label="Critic Score" />
      <Facet field="genre" label="Genre" />
      <Facet field="publisher" label="Publisher" isFilterable={true} />
      <Facet field="platform" label="Platform" />
    </div>
  }
  bodyHeader={
    <>
      <PagingInfo />
      <ResultsPerPage />
    </>
  }
  bodyFooter={<Paging />}
/>

では、ローカルの開発者環境の検索エクスペリエンスを見てみましょう。

さらに良くなっています。検索結果をさらに調査する豊富なオプションがあります。

複数のソートオプションなど、いくつかの優れた機能を追加しました。また、1つのフラグを追加することでフィルター可能となるパブリッシャーファセットを作成しました。空のクエリで検索を試し、すべてのオプションを確認してください。

最後に、検索エクスペリエンスのもう1つの機能を見ておきましょう。有名な機能です。

オートコンプリート(自動入力)です。

自動的に入力を補助

検索ではオートコンプリート機能がよく使用されます。これは即時に提供されるフィードバックであり、結果クエリに関する提案として提示されます。結果に関するオートコンプリートでは、関連する結果を受け取ることができ、クエリに関するオートコンプリートの場合は、求めている結果が得られる可能性のあるクエリが提示されます。

ここでは、クエリに関する提案としてのオートコンプリートに焦点を当てます。

このためには、2つの簡単な変更が必要です。

まずは、configurationOptionsオブジェクトにオートコンプリートを追加する必要があります。

const configurationOptions = {
  autocompleteQuery: {
    suggestions: {
      types: {
        documents: {
          // 提案の対象フィールド
          fields: ["name"]
        }
      },
      // 表示される提案数
      size:5
    }
  },
  ...
};

そして、SearchBoxの機能としてオートコンプリートを有効化する必要があります。

...
        <Layout
          ...
          header={<SearchBox autocompleteSuggestions={true} />}
/>
...

これで終了です。

検索を試してみてください。入力を開始すると、クエリに対する提案が自動的に表示されます。

まとめ

これで、豊富な機能を備えた優れた外観の検索エクスペリエンスを構築できました。検索機能の実装時に陥りやすい落とし穴によって煩雑な作業になってしまうことを回避できました。30分にしては上出来だと思いませんか?

Search UIは、検索エクスペリエンスを迅速に開発できる、最新の柔軟なReactフレームワークです。Elastic App Searchは、Elasticsearch上に構築された堅牢な検索エンジンです。有料のマネージドサービスとして利用、または十分な機能を提供する無料の基本ライセンスを使用して自社で実行することができます。

Search UIで構築されたものを、ぜひご紹介ください。Gitterにアクセスし、プロジェクトに貢献していただければと思います。