イケてる全文検索サービス「Algolia」を触ってみよう

## はじめに
この記事は、[株式会社メディアドゥのアドベントカレンダー](https://qiita.com/advent-calendar/2019/mediado)19日目の記事です。

みなさん、「Algolia」って知っていますか？

Algoliaなんて知らないよー、聞いたこともないよー、というそこのあなた。
そんなあなたのためにこの記事を書いたといっても過言ではありません。
拙い記事ではありますが、ぜひ読んでみてください。

## Algoliaとは？
以下のような特徴をもつ検索APIサービスです。

- https://www.algolia.com/
- 全文検索サービスをSaaSとして提供
- GUIから検索ロジックを柔軟に設定可能
- レコード数/APIアクセス数による従量課金
- 世界各地にデータセンターがあり、どのロケーションでも高速な検索が可能
- 様々なプログラミング言語向けにAPIを提供
- 様々なフロントエンドフレームワーク向けに便利なライブラリを提供

## 本記事で書くこと
すでに知ってる・使ったことがある方には眠たい内容ですが、「Algoliaを使った全文検索の実装の流れ」みたいなものを紹介します。

個人的に、全文検索の実装って大変そうなイメージがあったんですが、Algoliaを使えばラクに動くものを実装できるので、その流れとイケてる感をふんわり掴んでもらえたらと思います。

なお、今回はAngular(フロントエンド)から直接クエリを投げて、全文検索結果を画面に表示するものを想定しています。おまけで、Golang(バックエンド)からAPI経由でデータを入れる、検索する、ぐらいの例も紹介します。

## 1.Algoliaへサインアップ
まずは[こちら](https://www.algolia.com/users/sign_up)からアカウントを作って、サインアップします。
結構しっかりしたフリープランがあるのでお試しで使うだけならこれで十分。

登録を進めていくと、以下のように各地のデータセンターとのレイテンシーを見ながら、場所を選択できる画面がでてくるんですが、色々比べながら見てるとおもしろいです。当然Japanが圧倒的に爆速なので、ここではJapanを選択しました。
<img width="770" alt="スクリーンショット 2019-12-17 11.12.57.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/205027/79e73b83-1001-2525-c247-3f97644ece86.png">

## 2.Indexの作成
Algoliaのダッシュボードへアクセスすると、Indexの作成を促されるのでそのまま作成します。
Indexはなんでも良いので好きなように作成してOKです。ここでは`test_index`としました。
<img width="637" alt="スクリーンショット 2019-12-17 11.39.22.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/205027/631c3980-5804-7bba-a8c0-822c76ccbe41.png">

## 3.検索対象となるデータを入れる
AlgoliaにIndexを作ると、そのIndexに紐づけてレコード(実際に検索対象になるデータ)を追加できるようになります。レコードの追加方法は、以下のとおりで様々あります。

- ダッシュボードからJSONデータを直接入力して追加する
- ダッシュボードからJSONファイルをインポートして追加する
- API経由でレコードを追加する

実際にシステムとして運用する場合はAPI経由一択だと思いますが、今回は一番手っ取り早い「**ダッシュボードからJSONデータを直接入力して追加する**」方法でデータを入れることにします。

追加したJSONデータは以下のとおり。`objectID`というフィールドがありますが、これはAlgolia側で必要になるユニークなIDです。このフィールドを事前に持たせていないと、Algolia側で勝手にランダム生成されるため、追加するレコードが持つPrimaryKeyを`objectID`としても持たせておくのが良さそうです。

```json:input_json_data

[
	{
		"objectID": "1",
		"Name": "山田一郎",
		"FavoriteFoods": [
			"寿司",
			"すき焼き"
		]
	},
	{
		"objectID": "2",
		"Name": "鈴木二郎",
		"FavoriteFoods": [
			"オムライス",
			"ビーフシチュー"
		]
	},
	{
		"objectID": "3",
		"Name": "山本三郎",
		"FavoriteFoods": [
			"寿司",
			"ビーフカレー"
		]
	}
]
```

ちなみに、レコードを入れた後のダッシュボードの画面はこんな感じ。
<img width="886" alt="スクリーンショット 2019-12-17 12.40.43.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/205027/03882d72-f47a-48c8-ac9c-d91b6e914b25.png">


## 4.検索可能な属性を設定
Algoliaでは、検索可能にする属性を決めてそれぞれに重み付けをすることで、全文検索を柔軟に設定することができます。これらの設定はGUIでもAPIでもできますが、今回はGUIから行います。

設定画面は以下のとおり。
<img width="1038" alt="スクリーンショット 2019-12-17 15.44.14.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/205027/ae11a571-5512-c882-7f29-6558095c378b.png">

今回は、`Name`および`FavoriteFoods`のどちらの属性でも検索できるようにしました。
また、どちらも`ordered`と設定しているため、上にある属性のほうが優先度が高くなります。

つまり、ある検索クエリに対して`Name`と`FavoriteFoods`のどちらもヒットすることがあった場合、より上位に表示されるのは`Name`属性でヒットしたレコードということになります。

## 5.APIキーの確認
アプリケーションからAlgoliaのAPIを叩くときに必要なAPIキーですが、ダッシュボードから確認できます。下記の画像ではグレーで隠していますが、ここにAPIキーがあります。

この後のフロントエンド編で必要になるので、`Application ID`と`Search-Only API Key`を控えておきます。

<img width="1080" alt="スクリーンショット 2019-12-17 15.58.59.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/205027/a9592736-4f16-7303-0f52-228cb11b7eef.png">

## 6.フロントエンド
ここまでにAlgoliaの最低限の設定はひと通り終わったので、残すはフロントエンドの実装です。

冒頭のAlgoliaの特徴でも書きましたが、フロントエンドフレームワークのためのライブラリが充実しており、それらを使うととても簡単に検索UIが実装できるようになっています。

本記事では、Angularを使うことを前提としているため、 **InstantSearch for Angular** を使って実装を進めることとします。

<img width="1228" alt="スクリーンショット 2019-12-17 13.23.56.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/205027/b53885e3-4c46-1a83-0832-b666a6279fc9.png">

より詳細を知りたい方は、[InstantSearchの公式ページ](https://www.algolia.com/products/instantsearch/)をチェックしてみてください。

### インストール
以下コマンドにて、AngularプロジェクトにInstantSearchをインストールします。

```bash:Terminal

$ npm install algoliasearch angular-instantsearch@beta instantsearch.js instantsearch.css
$ npm install --save-dev @types/algoliasearch
```

### NgAisModuleをインポート
Angularプロジェクトのルートモジュールに以下を追加してインポートします。
これでアプリ内でInstantSearchが使えるようになりました。

```typescript:app.module.ts

import { NgAisModule } from 'angular-instantsearch'; //この行を追加

@NgModule({
  imports: [
    ******,
    ******,
    NgAisModule //この行を追加
  ]
```

### polyfills.tsの更新
polyfills.tsの下部へ以下のとおりに追記を行います。

```typescript:polyfills.ts
(window as any).process = {
  env: { DEBUG: undefined },
};
```

### config設定
見てのとおりなのですが、AlgoliaのAPIキーやIndex名を与えてconfigオブジェクトを生成します。
`Application ID`や`Search-Only API Key`は各々が控えたものを入れてください。

```typescript:app.component.ts
import { Component } from '@angular/core';
import * as algoliasearch from 'algoliasearch/lite';

const searchClient = algoliasearch(
  '##########', // Application IDを入れる
  '################################' // Search-Only API Keyを入れる
);

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss']
})
export class AppComponent {
  title = 'algolia-search';

  config = {
    indexName: 'test_index', // Index名を入れる
    searchClient
  };
}
```

### 検索UIの実装
最後に、InstantSearchへconfigオブジェクトを渡して、検索ボックス(ais-search-box)および検索結果（ais-hits）のためのディレクティブを呼び出すだけでOKです。とりあえず全文検索が動いていることがわかればいいや、ぐらいで以下のようにしました。

```html:app.component.html
<h2>Angular InstantSearch demo</h2>
<ais-instantsearch [config]="config">
  <ais-search-box></ais-search-box>
  <ais-hits>
    <ng-template let-hits="hits">
      <ol class="ais-Hits-list">
        <li *ngFor="let hit of hits" class="ais-Hits-item">
          <div>氏名：{{hit.Name}}</div>
          <div>好きな食べ物：{{hit.FavoriteFoods}}</div>
          <div>---------------------------------</div>
        </li>
      </ol>
    </ng-template>
  </ais-hits>
</ais-instantsearch>
```

## できたもの
今までの工程を経て、でできたものを動かしたgif動画がこちらになります。
レコード数や属性が少なすぎる点は置いといて、一応全文検索されていることは分かるかなーと思います。

![AlgoliaSearch.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/205027/e7c55b39-4d87-9aa5-6cdd-cf9f762e0b83.gif)

よく見ると、何も入力されていないと検索候補が表示されてしまっていますが、Algoliaのデフォルトの動きがこうなっています。もちろんきちんと実装すればこの辺はいい感じにできます。

## 紹介できなかった便利機能
InstantSearchを使うと、とても簡単に検索UIの実装ができるということはわかってもらえたかと思いますが、さらにより便利に使えそうな機能があったのでそれらの一部も紹介します。

- **ais-pagination**
    - これを書くだけでいい感じのページネーションを実装できる
    - https://www.algolia.com/doc/api-reference/widgets/pagination/angular/
- **ais-refinement-list**
    - これに属性を渡すだけでいい感じのファセットを実装できる
    - https://www.algolia.com/doc/api-reference/widgets/refinement-list/angular/
- **自由に使えるライブデモ**
    - 上２つとは毛色が異なるが、すでに構築されたライブデモ環境があり、コード変更による画面チェックなどもできて便利
    - https://codesandbox.io/embed/github/algolia/doc-code-samples/tree/master/Angular+InstantSearch/getting-started

## おまけ1. Go API経由で検索対象データを入れる
ここからおまけです。
まずは、検索対象データをAlgoliaへ入れるときは以下の感じでいけます。
バッチ処理などで複数レコードを入れるときはレコード数だけループを回すより、一括で以下みたいにオブジェクトにまとめて入れたほうが効率よいみたいです。
https://www.algolia.com/doc/api-reference/api-methods/save-objects/

```go:main.go
package main

import (
	"github.com/algolia/algoliasearch-client-go/v3/algolia/search"
)

type Person struct {
	ObjectID      string   `json:"objectID"`
	Name          string   `json:"Name"`
	FavoriteFoods []string `json:"FavoriteFoods"`
}

func main() {
	persons := []Person{
		{
			ObjectID: "1",
			Name:     "山田一郎",
			FavoriteFoods: []string{
				"寿司",
				"すき焼き",
			},
		},
		{
			ObjectID: "2",
			Name:     "鈴木二郎",
			FavoriteFoods: []string{
				"オムライス",
				"ビーフシチュー",
			},
		},
		{
			ObjectID: "3",
			Name:     "山本三郎",
			FavoriteFoods: []string{
				"寿司",
				"ビーフカレー",
			},
		},
	}

	applicationID := "ALGOLIA_APPLICATION_ID" //Application IDを入れる
	adminAPIKey := "ALGOLIA_ADMIN_API_KEY" //Admin API Keyを入れる
	indexName := "test_index"

	client := search.NewClient(applicationID, adminAPIKey)
	index := client.InitIndex(indexName)

	_, err := index.SaveObjects(persons)
	if err != nil {
		panic(err)
	}
}
```

## おまけ2. Go API経由で検索結果を取得する
次にAlgoliaに検索をかけて、結果を取得する場合は以下の感じです。
シンプルに検索するだけであればSearchメソッドにクエリキーワードをいれるだけでOKです。
返り値には、ヒットしたレコードのObjectIDだったり、matchLevelだったり、ヒットした部分だけが強調された文字列などが返ってきます。
https://www.algolia.com/doc/api-reference/api-methods/search/

```go:main.go
package main

import (
	"fmt"

	"github.com/algolia/algoliasearch-client-go/v3/algolia/search"
)

func main() {
	applicationID := "ALGOLIA_APPLICATION_ID" //Application IDを入れる
	adminAPIKey := "ALGOLIA_ADMIN_API_KEY" //Admin API Keyを入れる
	indexName := "test_index"

	client := search.NewClient(applicationID, adminAPIKey)
	index := client.InitIndex(indexName)


	res1, err1 := index.Search("ビーフ")
	if err1 != nil {
		panic(err1)
	}
	fmt.Println("検索ワード: ビーフ")
	fmt.Println("マッチしたレコード数:", res1.NbHits)
	fmt.Println("マッチした内容:", res1.Hits)


	res2, err2 := index.Search("山田")
	if err2 != nil {
		panic(err2)
	}
	fmt.Println("検索ワード: 山田")
	fmt.Println("マッチしたレコード数:", res2.NbHits)
	fmt.Println("マッチした内容:", res2.Hits)
}
```

```plaintext:実行結果
$ go run main.go
検索ワード: ビーフ
マッチしたレコード数: 2
マッチした内容: [map[FavoriteFoods:[寿司 ビーフカレー] Name:山本三郎 _highlightResult:map[FavoriteFoods:[map[matchLevel:none matchedWords:[] value:寿司] map[fullyHighlighted:false matchLevel:full matchedWords:[ビ ー フ] value:<em>ビーフ</em>カレー]] Name:map[matchLevel:none matchedWords:[] value:山本三郎]] objectID:3] map[FavoriteFoods:[オムライス ビーフシチュー] Name:鈴木二郎 _highlightResult:map[FavoriteFoods:[map[matchLevel:none matchedWords:[] value:オムライス] map[fullyHighlighted:false matchLevel:full matchedWords:[ビ ー フ] value:<em>ビーフ</em>シチュー]] Name:map[matchLevel:none matchedWords:[] value:鈴木二郎]] objectID:2]]

検索ワード: 山田
マッチしたレコード数: 1
マッチした内容: [map[FavoriteFoods:[寿司 すき焼き] Name:山田一郎 _highlightResult:map[FavoriteFoods:[map[matchLevel:none matchedWords:[] value:寿司] map[matchLevel:none matchedWords:[] value:すき焼き]] Name:map[fullyHighlighted:false matchLevel:full matchedWords:[山 田] value:<em>山田</em>一郎]] objectID:1]]
```

## 終わりに
Algoliaの回し者みたいな記事になりましたが、サクッと使えてとても便利だなーと個人的には思ってます。もし興味をもっていただけたならぜひ触ってみてください！

## 参考
[Algolia公式ドキュメント](https://www.algolia.com/doc/)