ファセット検索

このセクションでは、ファセット検索と呼ばれる、検索実装で広く使用されているフィルターから派生したパターンについて学習します。アイデアは、ユーザーにクエリを実行させ、その結果とともに推奨フィルターのリストを提示することです。

次のスクリーンショットは、アプリケーションに現在実装されている 2 つのフィルターのファセットを含む左側のサイドバーを示しています。

ファセット検索

ファセット検索結果の詳細は次のとおりです。各エントリがクリック可能なリンクとしてレンダリングされ、現在の検索にフィルターが追加されることに注意してください。各顔には、含まれる結果の数も報告されます。

ファセット検索の詳細

用語の集約

Elasticsearch では、ファセット検索は集計機能を使用して実装されます。サポートされている集約の 1 つは、いくつかの基準に基づいて、検索結果をバケットに分割します。各バケットに含まれるドキュメントの数を含むバケットのリストは、ファセット サイドバーをレンダリングするために使用されます。

最も単純なタイプのバケット集約は、キーワードごとにバケットが定義されるものです。用語集約と呼ばれるこのタイプは、 categoryフィールドのバケットを作成するのに最適です。以下は、カテゴリの集約を要求するように拡張された、アプリケーションからの検索リクエストです。

results = es.search(
    query={
        'bool': {
            **search_query,
            **filters
        }
    },
    aggs={
        'category-agg': {
            'terms': {
                'field': 'category.keyword',
            }
        },
    },
    size=5,
    from_=from_
)

唯一の変更点は、 aggsオプションが追加されたことです。各集計には名前が付けられます (この場合はcategory-aggterms集約は、キーワードによるフィルタリングを実行する必要があることを示します。フィルターと同様に、フィールドに関連付けられたキーワードのサブタイプが使用されるように、 categoryフィールドをcategory.keywordとして指定する必要があります。

集計を含むリクエストへの応答には、集計された結果を含むaggregationsフィールドが含まれます。上記のサンプルリクエストに対する応答は次のようになります。

{
    "aggregations": {
        "category-agg": {
            "buckets": [
                { "key": "sharepoint", "doc_count": 7 },
                { "key": "teams", "doc_count": 3 },
                { "key": "github", "doc_count": 2 },
            ],
            // other fields not used in this tutorial are omitted
        }
    }
}

チュートリアル アプリケーションに含まれているindex.htmlテンプレートは、この時点では空だった左側のサイドバーに集計をレンダリングするように既に設計されています。テンプレートのロジックをシンプルに保つには、上記の応答のデータを次の構造の辞書に変換する必要があります。

{
    "Category": {
        "sharepoint": 7,
        "teams": 3,
        "github": 2
    }
}

次のリストは、Elasticsearch の集計形式を上記の簡略化された辞書に変換する方法と、変換された辞書をテンプレートに送信してレンダリングする方法を示しています。

results = es.search(
    # ...
)
aggs = {
    'Category': {
        bucket['key']: bucket['doc_count']
        for bucket in results['aggregations']['category-agg']['buckets']
    },
}
return render_template('index.html', results=results['hits']['hits'],
                        query=query, from_=from_,
                        total=results['hits']['total']['value'],
                        aggs=aggs)

ご興味があれば、 index.html にaggs辞書をレンダリングするための次のロジックが含まれています。

{% for agg in aggs %}
    <h6 class="mt-3">{{ agg }}</h6>
    {% for key, count in aggs[agg].items() %}
        <form method="POST">
            <input type="hidden" name="query" value="{{ agg|lower }}:{{key}} {{ query }}">
            <button type="submit" class="btn btn-link btn-sm"{% if aggs[agg]|length == 1 %} disabled{% endif %}>{{ key }} ({{ count }})</button>
        </form>
    {% endfor %}
{% endfor %}

この実装では、次のページ区切りボタンと前のページ区切りボタンをレンダリングするために使用されるものと同様のアイデアが使用されます。各ファセットは、対応する追加フィルターを使用してクエリを定義する隠しフィールドを持つフォームとしてレンダリングされます。たとえば、 sharepointカテゴリ ファセットは、現在のクエリにcategory:sharepointを追加します。

単なる装飾的な詳細として、各ファセットの送信ボタンはリンクのスタイルでレンダリングされます。

年集計

前のセクションで作成した年フィルターでは、カテゴリで使用される用語の集計は機能しません。これは、インデックスが年をキーワードとして個別に保存しないためです。代わりに、各記事が更新された年は完全な日付を格納するupdated_atフィールドによって定義されます。

利用可能なバケット集計の長いリストから、日付ヒストグラムがこのユースケースに最も適したものになります。更新された集計リクエストは次のとおりです。

    results = es.search(
        query={
            'bool': {
                **search_query,
                **filters
            }
        },
        aggs={
            'category-agg': {
                'terms': {
                    'field': 'category.keyword',
                }
            },
            'year-agg': {
                'date_histogram': {
                    'field': 'updated_at',
                    'calendar_interval': 'year',
                    'format': 'yyyy',
                },
            },
        },
        size=5,
        from_=from_
    )

ここで、2 番目の集計がaggsフィールドに追加されたことがわかります。この集計のタイプはdate_histogramで、間隔はyearに設定されているため、作成されるバケットはそれぞれ 1 年を表します。formatオプションは、各バケットの名前に使用する形式を設定します。この場合は、年だけが含まれます。

レスポンスのaggregationsフィールドには次の 2 つのセクションが含まれるようになります。

{
    "aggregations": {
        "category-agg": {
            "buckets": [
                { "key": "sharepoint", "doc_count": 7 },
                { "key": "teams", "doc_count": 3 },
                { "key": "github", "doc_count": 2 },
            ],
            // fields not used in this tutorial are omitted
        },
        "year-agg": {
            "buckets": [
                { "key_as_string": "2018", "doc_count": 6 },
                { "key_as_string": "2019", "doc_count": 1 },
                { "key_as_string": "2020", "doc_count": 1 },
                { "key_as_string": "2021", "doc_count": 1 },
                { "key_as_string": "2022", "doc_count": 2 },
                { "key_as_string": "2023", "doc_count": 1 },
            ],
            // fields not used in this tutorial are omitted
        }
    }
}

この 2 番目の集約には、もう 1 つの小さな複雑さがあります。各バケットに含まれるkeyフィールドは、日付間隔の集計ではミリ秒単位であるため、役に立ちません。しかし幸運なことに、集計のformatオプションで指定された形式でレンダリングされた日付がkey_as_stringフィールドに提供されます。

すべてのファセットを含むaggs辞書の計算方法は次のとおりです。

aggs = {
    'Category': {
        bucket['key']: bucket['doc_count']
        for bucket in results['aggregations']['category-agg']['buckets']
    },
    'Year': {
        bucket['key_as_string']: bucket['doc_count']
        for bucket in results['aggregations']['year-agg']['buckets']
        if bucket['doc_count'] > 0
    },
}

keyの代わりにkey_as_string使用するだけでなく、年のファセットについては、ドキュメントが 1 つも含まれていないバケットを除外するための条件が追加されます。これは、それらをフィルターとして使用しても意味がないことは明らかであるためです。

これで、ファセット検索の実装は完了です。以下はhandle_search()関数の完全な実装です。

@app.post('/')
def handle_search():
    query = request.form.get('query', '')
    filters, parsed_query = extract_filters(query)
    from_ = request.form.get('from_', type=int, default=0)

    if parsed_query:
        search_query = {
            'must': {
                'multi_match': {
                    'query': parsed_query,
                    'fields': ['name', 'summary', 'content'],
                }
            }
        }
    else:
        search_query = {
            'must': {
                'match_all': {}
            }
        }

    results = es.search(
        query={
            'bool': {
                **search_query,
                **filters
            }
        },
        aggs={
            'category-agg': {
                'terms': {
                    'field': 'category.keyword',
                }
            },
            'year-agg': {
                'date_histogram': {
                    'field': 'updated_at',
                    'calendar_interval': 'year',
                    'format': 'yyyy',
                },
            },
        },
        size=5,
        from_=from_
    )
    aggs = {
        'Category': {
            bucket['key']: bucket['doc_count']
            for bucket in results['aggregations']['category-agg']['buckets']
        },
        'Year': {
            bucket['key_as_string']: bucket['doc_count']
            for bucket in results['aggregations']['year-agg']['buckets']
            if bucket['doc_count'] > 0
        },
    }
    return render_template('index.html', results=results['hits']['hits'],
                           query=query, from_=from_,
                           total=results['hits']['total']['value'], aggs=aggs)

このチュートリアルに含まれるファセット検索の実装は、シンプルさを念頭に置いて設計されています。Elasticsearch の集計には、ここで取り上げていない多くの可能性があります。この機能が提供するすべての機能について知るには、必ずドキュメントを確認してください。

おめでとうございます。このチュートリアルの全文検索セクションはこれで終了です。チュートリアル検索アプリケーションのこれまでの状態を確認するには、ここをクリックしてください。

最先端の検索体験を構築する準備はできましたか?

十分に高度な検索は 1 人の努力だけでは実現できません。Elasticsearch は、データ サイエンティスト、ML オペレーター、エンジニアなど、あなたと同じように検索に情熱を傾ける多くの人々によって支えられています。ぜひつながり、協力して、希望する結果が得られる魔法の検索エクスペリエンスを構築しましょう。

はじめましょう