Hybrid Search: Combined Full-Text and kNN Results

You have now seen two different approaches to search a collection of documents, each with its own particular benefits. If one of these methods matches your needs then you don't need anything else, but in many cases each method of searching returns valuable results that the other method would miss, so the best option is to offer a combined result set.

For these cases, Elasticsearch offers Reciprocal Rank Fusion, an algorithm that combines results from two or more lists into a single list.

How RRF Works

Elasticsearch integrates the RRF algorithm into the search query. Consider the following example, which has query and knn sections to request full-text and vector searches respectively, and a rrf section that combines them into a single result list.

self.es.search(
    query={
        # full-text search query here
    },
    knn={
        # vector search query here
    },
    rank={
        "rrf": {}
    }
)

While RRF works fairly well for short lists of results without any configuration, there are some parameters that can be tuned to provide the best results. Consult the documentation to learn about these in detail.

RRF Implementation

To enable a combined search that returns results from both full-text and vector search methods, the full-text search logic used earlier in the handle_search() function has to be brought back. To implement a hybrid search strategy the search() method must receive both the query and knn arguments, each requesting a separate query. The rank section as shown above is added as well to combine the results into a single ranked list.

Here is the version of handle_search() that implements the hybrid search strategy:

@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
            }
        },
        knn={
            'field': 'embedding',
            'query_vector': es.get_embedding(parsed_query),
            'k': 10,
            'num_candidates': 50,
            **filters,
        },
        rank={
            'rrf': {}
        },
        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)

With this version the best results from each search method are combined. Click here to review the complete application with these changes.

Share this article