ハイブリッド検索: 全文検索とELSER検索結果の統合

前のセクションのベクトル検索と同様に、このセクションでは、相互ランク融合アルゴリズムを使用して、フルテキスト クエリとセマンティック クエリからの最適な検索結果を組み合わせる方法を学習します。

サブ検索の紹介

ハイブリッドの全文検索と高密度ベクトル検索を実装するための解決策は、2 つの検索を要求するためのqueryknn引数と、それらを 1 つの結果リストに結合するためのrrf引数を含む検索要求を送信することでした。

同じことをしてフルテキスト検索リクエストとスパースベクター検索リクエストを結合しようとすると、両方ともquery引数を使用するという複雑な問題が生じます。RRF アルゴリズムと組み合わせる必要がある 2 つのクエリを提供できるようにするには、2 つのquery引数を含める必要があります。これを実現するには、サブ検索を使用します。

サブ検索は現在テクニカルプレビュー段階の機能です。このため、Python Elasticsearch クライアントはこれをネイティブにサポートしていません。この制限を回避するには、 Searchクラスのsearch()メソッドを変更して、 body引数を使用して検索要求を送信します。以下に、クライアントのbody引数を使用して検索リクエストを送信する、新しいが類似した実装を示します。

class Search:
    # ...

    def search(self, **query_args):
        # sub_searches is not currently supported in the client, so we send
        # search requests using the body argument
        if 'from_' in query_args:
            query_args['from'] = query_args['from_']
            del query_args['from_']
        return self.es.search(
            index='my_documents',
            body=json.dumps(query_args),
        )

この実装は機能的に同等であるため、アプリケーションを変更する必要はありません。唯一の違いは、 search()メソッドはリクエストを送信する前にすべての引数を検証し、 bodyが唯一の例外となることです。サーバーは、クライアントがどのようにリクエストを送信するかに関係なく、常にリクエストを検証します。

このバージョンでは、 sub_searches引数をSearch.search()で使用して、次のように複数の検索クエリを送信できます。

results = es.search(
    sub_searches=[
        {
            'query': { ... },  # full-text search
        },
        {
            'query': { ... },  # semantic search
        },
    ],
    rank={
        'rrf': {},  # combine sub-search results
    },
    aggs={ ... },
    size=5,
    from_=from_,
)

ハイブリッド検索の実装

このセクションを完了するには、フルテキスト ロジックを戻して、この章の前半で示したセマンティック検索クエリと組み合わせてみましょう。

以下に更新された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 = {
            'sub_searches': [
                {
                    'query': {
                        'bool': {
                            'must': {
                                'multi_match': {
                                    'query': parsed_query,
                                    'fields': ['name', 'summary', 'content'],
                                }
                            },
                            **filters
                        }
                    }
                },
                {
                    'query': {
                        'bool': {
                            'must': [
                                {
                                    'text_expansion': {
                                        'elser_embedding': {
                                            'model_id': '.elser_model_2',
                                            'model_text': parsed_query,
                                        }
                                    },
                                }
                            ],
                            **filters,
                        }
                    },
                },
            ],
            'rank': {
                'rrf': {}
            },
        }
    else:
        search_query = {
            'query': {
                'bool': {
                    'must': {
                        'match_all': {}
                    },
                    **filters
                }
            }
        }

    results = es.search(
        **search_query,
        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)

ご存知のとおり、 extract_filters()関数は、検索プロンプトでユーザーが入力したカテゴリ フィルターを探し、残りの部分をparsed_queryとして返しました。parsed_queryが空の場合、ユーザーはカテゴリ フィルターのみを入力することを意味し、その場合、クエリは、選択したカテゴリをフィルターとして指定した単純なmatch_allになります。これは大きな条件のelse部分に実装されています。

検索クエリがある場合は、前のセクションで示したようにsub_searchesオプションを使用してmulti_matchクエリとtext_expansionクエリを含めます。また、 rankオプションは、2 つのサブ検索の結果をランク付けされた結果の 1 つのリストに結合することを要求します。クエリを完了するために、ページ区切りのサポートを維持するためにsizeおよびfrom_引数が提供されます。

このバージョンのアプリケーションを確認するには、ここをクリックしてください。

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

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

はじめましょう