混合搜索:全文和 ELSER 结果的综合

与上一节中的矢量搜索一样,在本节中,您将学习如何使用互易排名融合算法将全文查询和语义查询的最佳搜索结果结合起来。

次级搜索简介

实施全文和密集矢量混合搜索的解决方案是发送一个搜索请求,其中包括queryknn 参数,用于请求两种搜索,以及rrf 参数,用于将两种搜索合并为一个结果列表。

在尝试将全文检索请求和稀疏矢量搜索请求结合起来时,会出现一个复杂的问题,那就是两者都使用query 参数。为了能够提供需要与 RRF 算法相结合的两个查询,有必要包含两个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_matchtext_expansion 查询,而rank 选项则要求将两个子查询的结果合并为一个排序结果列表。为完成查询,提供了sizefrom_ 参数,以保持对分页的支持。

单击此处查看此版本的申请表。

准备好打造最先进的搜索体验了吗?

足够先进的搜索不是一个人的努力就能实现的。Elasticsearch 由数据科学家、ML 操作员、工程师以及更多和您一样对搜索充满热情的人提供支持。让我们联系起来,共同打造神奇的搜索体验,让您获得想要的结果。

亲自试用