見出し画像

Elastic App Searchによるキャンプ場検索システムの構築🏕️

こんにちは!おぎーです。キャンプシーズンがやってきましたね。
キャンプアプリ「Campsyte (キャンプサイト)」にてキャンプ場検索機能をリリースしたのでシステムについて紹介していきます🏕️

アプリは以下からダウンロードできます。
iOS キャンプをもっと楽しく Campsyte(キャンプサイト)


1. 作ったもの

キャンプ場をキーワードで検索する機能を開発しました。キャンプサイト名だけでなく地名やタグ名による検索が行えます。シンプルな機能ですがアプリとしてあるべき必須機能として遅ればせながら実装をしました。

v1.0.14から利用できます。

ホームのフォームからキャンプ場を検索できます🏕️

2. Elastic App Searchによる検索機能の実現

この検索機能は「Elastic App Search」を用いて実現しています。Elastic App Search はElastic Enterprise Search サービスの一部で、簡単に検索機能を実現できます。ツールも充実しており例えば以下のような機能をGUI上から簡単に確認・設定が行えます。

  • 登録されているデータの可視化

  • スキーマ設定

  • Web Crawlerの設定

  • シノニムの登録

  • 簡易的な検索UIの作成 など

今回のキャンプ場検索機能を実現するためには以下の三つの機能を実装する必要があります。

  1. App Search Engineの作成とデータ同期

  2. 検索キーワードの提案APIと検索APIの実装

  3. アプリ検索画面の実装

今回は1, 2のElastic App Searchについて紹介していきます。

App Search Engineの作成とデータ同期

App Search Engineの作成を行います。このEngineは「Campsite」「Spot」「News」のような検索リソースごとに作成します。作成はAppSearchのGUIから簡単に行うことができます。

Engineの作成画面。極力楽をしたいので「App Search managed docs」を選択
選択後Engine名と言語を選択します。Japaneseを選択

この2ステップのみでEngineの作成が完了します。とっても簡単ですね!
次にデータの同期を設定していきます。

Campsyteではデータベースに「Cloud Firestore」を採用しています。そのためFirestoreのデータとElastic App Searchのデータを同期させる必要があります。幸いElastic社が公式で同期させるFirebase Extension 「Search with Elastic App Search」を提供しているのでそちらを用います。

こちらもFirebase ExtensionのGUIから作成していきます。拡張機能の構成については以下のように設定します

  • Cloud Function Location: Tokyo (asia-northeast1)

  • Collection Path: Firestore上の任意のコレクション名

    • Firestore上の「Campsite」というコレクションを同期させてたい場合はそのまま「Campsite」と記載します

  • Elastic App Search engine name: 前手順で作成したエンジン名

  • Elastic App Search private API key: プライベートキー

    • App SearchのGUIページの左タブ「Credintials」に記載されています

  • Elastic Enterprise Search URL: URL

    • App SearchのGUIページの左タブ「Credintials」に記載されています

  • Indexed fields: インデックしたいフィールド名のカンマ区切り

    • 「name」「address」「tags」フィールドを検索対象にしたい場合はここで「name,address,tags」と記載します

Firebase Extensionの設定例。項目は少なく簡単ですね!

作成することで、対象のコレクションのドキュメントが変更された時に自動で同期されるようになります。

初回作成時は既存のドキュメントを手動で同期させる必要があります。このExtensionはインポート機能もありそちらを用いて行います。

以下のようなコマンドを実行することでインポートすることができます。

# 予めNode.jsをインストールしnpxコマンドを実行できるようにしておきます 
$ GOOGLE_APPLICATION_CREDENTIALS=<GCPサービスアカウントのPath> \
  COLLECTION_PATH=<コレクション名> \
  INDEXED_FIELDS=<インデックスフィールド名のカンマ区切り> \
  ENTERPRISE_SEARCH_URL=<App SearchエンジンのURL> \
  APP_SEARCH_API_KEY=<プライベートキー> \
  APP_SEARCH_ENGINE_NAME=<エンジン名> \
  npx @elastic/app-search-firestore-extension import
# 以下は例
$ GOOGLE_APPLICATION_CREDENTIALS=/Users/test/service_account.json \
  COLLECTION_PATH=Campsite \
  INDEXED_FIELDS=name,address,tags \
  ENTERPRISE_SEARCH_URL=https://campsyte-hogehoge.ent.asia-northeast1.gcp.cloud.es.io \
  APP_SEARCH_API_KEY=<private key> \
  APP_SEARCH_ENGINE_NAME=campsite-search-engine \
  npx @elastic/app-search-firestore-extension import

データをインポートすることで自動でスキーマ定義まで行ってくれます。
間違っている場合はGUIから変更することができます。

GUIから型定義を確認・変更することができます。配列文字列でも「text」型を設定することで
自動で配列であることを認識してくれます

インデックスするフィールドが増えた時はFirebase Extensionの「拡張機能の構成」->「Indexed fields」にフィールド名を追記後データをインポートし直します。型定義が間違えている場合があるためAppSearchのGUIからスキーマ定義を確認するのも忘れずにです。

検索キーワードの提案APIと検索APIの実装

campsyteのAPIはgolangで実装されています。golangのElasticsearchライブラリはelastic社公式の「go-elasticsearch」を用いることが多いと思います。(過去は「olivere/elastic」を使っていましたがこちらはdeprecatedでv7までのサポートとなっています)

ElasticsearchとElastic Enterprise Searchは別物なため上記のライブラリを使うことができません。Elastic Enterprise Search専用のライブラリは現状

とgolangのものはないです。そのため自前で実装する必要があります。
ただAPIにリクエストするだけなので簡単に実装することができます!

 検索キーワードの提案は「Query Suggestion API」を用います。
サンプルコードは以下のような感じになります。

type ElasticSearchQuerySuggestionRequest struct {
	Query string                             `json:"query"`
	Types *ElasticSearchQuerySuggestionTypes `json:"types"`
	Size  int                                `json:"size"`
}

type ElasticSearchQuerySuggestionTypes struct {
	Documents *ElasticSearchQuerySuggestionDocuments `json:"documents"`
}

type ElasticSearchQuerySuggestionDocuments struct {
	Fields []string `json:"fields"`
}

type ElasticSearchQuerySuggestionResponse struct {
	Results struct {
		Documents []struct {
			Suggestion string `json:"suggestion"`
		} `json:"documents"`
	} `json:"results"`
	Meta struct {
		RequestID string `json:"request_id"`
	} `json:"meta"`
}

type ElasticError struct {
	Errors []string `json:"errors"`

	HTTPStatus int `json:"-"`
}

func (err *ElasticError) Error() string {
	return fmt.Sprintf("%d %s", err.HTTPStatus, strings.Join(err.Errors, ", "))
}

	
// QuerySuggestion https://www.elastic.co/guide/en/app-search/current/query-suggestion.html
func (c *elasticClient) QuerySuggestion(ctx context.Context, engineName string, req *ElasticSearchQuerySuggestionRequest) (*ElasticSearchQuerySuggestionResponse, error) {
	var res ElasticSearchQuerySuggestionResponse
	url := fmt.Sprintf("%s/api/as/v1/engines/%s/query_suggestion", c.url, engineName)
	return &res, c.do(ctx, http.MethodPost, url, nil, req, &res)
}

func (c *elasticClient) do(ctx context.Context, method, url string, queryParams, body, res interface{}) error {
	httpReq, err := c.request(ctx, method, url, queryParams, body)
	if err != nil {
		return err
	}
	httpRes, err := c.client.Do(httpReq)
	if err != nil {
		return err
	}
	defer func(Body io.ReadCloser) {
		err := Body.Close()
		if err != nil {
		}
	}(httpRes.Body)
	if httpRes.StatusCode != http.StatusOK {
		var apiErr ElasticError
		err := json.NewDecoder(httpRes.Body).Decode(&apiErr)
		if err != nil {
			return err
		}
		apiErr.HTTPStatus = httpRes.StatusCode
		return &apiErr
	}
	if res == nil {
		return nil
	}
	return json.NewDecoder(httpRes.Body).Decode(res)
}

func (c *elasticClient) request(ctx context.Context, method, url string, queryParam, body interface{}) (*http.Request, error) {
	var (
		jsonBody []byte
		err      error
	)
	if queryParam != nil {
		queryParameter, err := query.Values(queryParam)
		if err != nil {
			return nil, err
		}
		url += "?" + queryParameter.Encode()
	}
	if body != nil {
		jsonBody, err = json.Marshal(body)
		if err != nil {
			return nil, err
		}
	}
	req, err := http.NewRequestWithContext(ctx, method, url, bytes.NewReader(jsonBody))
	if err != nil {
		return nil, err
	}
	req.Header.Set("content-type", "application/json")
	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.apiKey))
	return req, nil
}

queryに検索クエリに文字列、typesフィールドに検索対象の設定してPOSTリクエストすることで取得することができます。Query Sugesstion専用にエンジン(インデックス)を管理する必要がなくとても簡単です ☺️

検索APIは「Search API」を用います。
サンプルコードは以下のような感じになります。

type ElasticSearchRequest struct {
	// String or number to match.
	// The value '' (empty string) matches all documents.
	Query string              `json:"query"`
	Page  *ElasticPageRequest `json:"page"`
	// https://www.elastic.co/guide/en/app-search/current/sort.html
	Sort []map[string]interface{} `json:"sort"`
	// https://www.elastic.co/guide/en/app-search/current/filters.html
	Filters map[string]interface{} `json:"filters"`
	// https://www.elastic.co/guide/en/app-search/current/search-fields-weights.html
	SearchFields map[string]interface{} `json:"search_fields,omitempty"`
}

// ElasticSearchResponse is the response from the ElasticSearch API. https://www.elastic.co/guide/en/app-search/current/search.html
type ElasticSearchResponse struct {
	Meta struct {
		RequestID string `json:"request_id"`
		Page      struct {
			Current      int `json:"current"`
			TotalPages   int `json:"total_pages"`
			TotalResults int `json:"total_results"`
		} `json:"page"`
		Warnings []string `json:"warnings"`
		Alerts   []string `json:"alerts"`
	} `json:"meta"`
	Results []interface{} `json:"results"`
}

type ElasticPageRequest struct {
	// Number of results per page.
	Size int `json:"size" url:"size,omitempty"` // defaults 10
	// Page number of results to return.
	Current int `json:"current" url:"current,omitempty"` // defaults 1
}


// Search https://www.elastic.co/guide/en/app-search/current/search.html
func (c *elasticClient) Search(ctx context.Context, engineName string, req *ElasticSearchRequest) (*ElasticSearchResponse, error) {
	var res ElasticSearchResponse
	url := fmt.Sprintf("%s/api/as/v1/engines/%s/search", c.url, engineName)
	return &res, c.do(ctx, http.MethodPost, url, nil, req, &res)
}

検索時はキーワードの他にフィルターやスコアリング、ソートロジックを指定することができます。Campsyteでは主に以下の4つを設定し検索リクエストをしています。

golang上の型定義としては map[string]interface{} として定義し汎用的な形にしています。以下のように検索フィルター専用の型定義を行い、検索に必要な条件をセットするような実装を行っています。

type Filters map[string]interface{}

func NewFilters() *Filters {
	return &Filters{}
}

// SetGeoFilter 緯度経度によるフィルタリング
func (fs *Filters) SetGeoFilter(location *latlng.LatLng, distanceKm float64) {
	if location == nil {
		return
	}
	(*fs)["lat_lng"] = map[string]interface{}{
		"center":   fmt.Sprintf("%v, %v", location.Latitude, location.Longitude),
		"distance": distanceKm,
		"unit":     "km",
	}
}

// SetBusinessStatusOperationalFilter 営業しているキャンプ場にフィルタリング
func (fs *Filters) SetBusinessStatusOperationalFilter() {
	(*fs)["business_status"] = "OPERATIONAL"
}
	
// SetTagsFilter タグ情報によるフィルタリング. ここで渡されたタグ文字列はOR条件となり、別のタグ文字列で複数回呼ばれた時はAND条件となる
// https://www.elastic.co/guide/en/app-search/current/filters.html#filters-arrays-as-or
func (fs *Filters) SetTagsFilter(tags []string) {
	if len(tags) == 0 {
		return
	}
	if _, ok := (*fs)["all"]; ok {
		(*fs)["all"] = append((*fs)["all"].([]map[string]interface{}), map[string]interface{}{
			"any": []map[string]interface{}{
				{
					"tags": tags,
				},
			},
		})
		return
	}
	(*fs)["all"] = []map[string]interface{}{
		{
			"any": []map[string]interface{}{
				{
					"tags": tags,
				},
			},
		},
	}
}


// SetPrefectureFilter 都道府県名によるフィルタリング
func (fs *Filters) SetPrefectureFilter(prefecture string) {
	(*fs)["prefecture"] = prefecture
}

// Request APIリクエスト用に変換する
func (fs *Filters) Request() map[string]interface{} {
	return multipleFiltersAll(*fs)
}

// multipleFiltersAll 複数のフィルターが設定された時に全ての条件に合致する(all)形でリクエストするためのhelper
func multipleFiltersAll(filters map[string]interface{}) map[string]interface{} {
	if len(filters) > 1 {
		fs := make([]map[string]interface{}, 0, len(filters))
		for k, v := range filters {
			fs = append(fs, map[string]interface{}{
				k: v,
			})
		}
		filters = map[string]interface{}{
			"all": fs,
		}
	}
	return filters
}

// ElasticSearchFilters 検索用のフィルターを生成する。ここでは営業しているかつ指定された緯度経度から120km以内のキャンプ場を検索する例
func ElasticSearchFilters(location *latlng.LatLng) *elastic.Filters {
	filters := elastic.NewFilters()
	filters.SetBusinessStatusOperationalFilter()

	if location != nil {
	  filters.SetGeoFilter(location, 120)
	}
	return filters
}

詳細な説明は割愛させて頂きますが、特にAND条件になるのかOR条件になるかをしっかりと確認するのが大切です。例えば複数タグによる検索について複数のタグ文字列をまとめて渡すとOR条件(Any)になります。

そのため例えば「水辺」の「ペットOK」で営業しているキャンプ場を検索したい場合は上記の検索フィルターの実装だと以下のようになります。

func ElasticSearchFilters() map[string]interface{} {
	filters := elastic.NewFilters()
	filters.SetBusinessStatusOperationalFilter()
	filters.SetTagsFilter([]string{"海", "川", "湖"}) // OR条件
	filters.SetTagsFilter([]string{"ペットOK"})

    // リクエスト時は複数フィルター条件をAND条件で検索する
	return filters.Request()
}

以下のドキュメントをしっかりと読み込むことが大切です。
(僕自身もしばらくの期間間違えた状態でリリースしていました🙏)

検索精度の改善

App Search Engineでは日本語検索に必要な分かち書きなどの処理も自動で行ってくれます。しかし

  • 「ふもとっぱら」と検索すると「ふもと」を含むキャンプ場がヒットする

  • 「北見市」と検索すると「北」を含むキャンプ場がヒットする

など精度があまり良くないです。そのため精度改善を行う必要があります。
方法としてはAppSearchの「Relevance Tuning」の仕組みを用いて調整する方法とアプリケーション側で検索クエリを調整する方法などが考えられます。

今回はアプリケーション側で検索クエリを調整を実施しました。App SearchではGoogle検索などと同様に以下のような検索に対応しています。

  • "" で囲むことでキーワードの完全一致での検索が行える

  • + または AND を含めることでAND検索が行える

  • - または NOT を含めることでNOT検索が行える

そのためアプリケーション側でクエリ文字列を以下のように調整しました。

  • キーワードを""で囲み基本完全一致検索とする

  • スペースをANDに変換しAND検索となるようにする

// AppSearchQueryAdjustment is a query adjustment for App Search.
// https://www.elastic.co/guide/en/app-search/current/search.html#search-api-request-body
func AppSearchQueryAdjustment(query string) string {
	query = strings.ReplaceAll(query, " ", " ")
	query = strings.ReplaceAll(query, "\"", "")
	keywords := strings.Split(query, " ")

	ret := make([]string, len(keywords))
	for i, keyword := range keywords {
		// "" で囲むことで完全一致検索にする
		ret[i] = fmt.Sprintf(`"%s"`, keyword)
	}
	// + でAND検索にする
	return strings.Join(ret, " AND ")
}

このようにすることで違和感のない検索を行えるようになりました。上記のコードだと逆にNOT検索のキーワードを入力しても適用されないようになってしまいます。実際のアプリケーションでは考慮するようロジックを追加しています。

Elasticsearchではベクトル検索やRAG, LLMによる検索にも対応しています。次はこちらを用いた機能開発にもチャレンジしていきたいと思っています!

3. 開発進捗と今後の展望

ベータ版をリリースした当初は9月末に本リリースを予定していましたが遅延している状況です。期待していた皆さまには申し訳なく思っています🙇
本業や起業に関するコンテスト/講義参加などの課外活動、料理などで時間が取れなく悩ましく思っています。どうすれば開発にもっと時間を割けれるか色々考え中です。

今後は以下の開発を進めていく予定です。Campsyteのコア機能であるキャンプの準備・管理の部分に注力していきます!

  • キャンプ場近くのスポット検索機能のリニューアル(実際に僕自身キャンプで使ってみましたが全然使い物になりませんでした🫠)

  • 予約空きを考慮したチャット形式でのキャンプ場のレコメンド機能の実装

  • 複数キャンプ場候補を1スケジュールでまとめて管理できる機能

無理のない範囲で良いものを開発できたらと思っています。
温かく応援して頂けると嬉しいです😊

この記事が気に入ったらサポートをしてみませんか?