Hybrid Search: Combined Full-Text and ELSER Results

As with vector search in the previous section, in this section you will learn how to combine the best search results from full-text and semantic queries using the Reciprocal Rank Fusion algorithm.

Introduction to Sub-Searches

The solution to implementing a hybrid full-text and dense vector search was to send a search request that included the query, knn arguments to request the two searches, and the rrf argument to combine them into a single results list.

The complication that is presented when trying to do the same to combine full-text and sparse vector search requests is that both use the query argument. To be able to provide the two queries that need to be combined with the RRF algorithm, it is necessary to include two query arguments, and the solution to do this is to do it with Sub-Searches.

Sub-searches is a feature that is currently in technical preview. For this reason the Python Elasticsearch client does n ot natively support it. To work around this limitation, the search() method of the Search class can be changed to send the search request as a raw request. Below you can see a new, yet similar implementation that uses the perform_request() method of the client to send raw requests:

class Search:
    # ...

    def search(self, **query_args):
        # sub_searches is not currently supported in the client, so we send
        # search requests as raw requests
        if 'from_' in query_args:
            query_args['from'] = query_args['from_']
            del query_args['from_']
        return self.es.perform_request(
            'GET',
            f'/my_documents/_search',
            body=json.dumps(query_args),
            headers={'Content-Type': 'application/json',
                     'Accept': 'application/json'},
        )

This implementation does not require any changes to the application, as it is functionally equivalent. The only difference is that the search() method validates all arguments before sending the request, while perform_request() is a lower-level method that does not do any validation. The server always validates requests regardless of how the client sends them.

With this version, the sub_searches argument can be used to send multiple search queries as follows:

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

Hybrid Search Implementation

To complete this section, let's bring back the full-text logic and combine it with the semantic search query presented earlier in this chapter.

Below you can see the updated handle_search() endpoint:

@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)

As you recall, the extract_filters() function looked for category filters entered by the user on the search prompt, and returned the left over portion as parsed_query. If parsed_query is empty, it means that the user only enter a category filter, and in that case the query should be a simple match_all with the selected category as a filter. This is implemented in the else portion of the big conditional.

When there is a search query, the sub_searches option is used as shown in the previous section to include the multi_match and text_expansion queries, with the rank option requesting that the results from the two sub-searches are combined into a single list of ranked results. To complete the query, the size and from_ argument are provided to maintain the support for pagination.

Click here to review this version of the application.

Share this article