Busca híbrida: resultados combinados de texto completo e ELSER

Assim como na busca vetorial da seção anterior, nesta seção você aprenderá como combinar os melhores resultados de busca de consultas de texto completo e semânticas usando o algoritmo Reciprocal Rank Fusion .

Introdução às Subbuscas

A solução para implementar uma pesquisa híbrida de texto completo e vetor denso foi enviar uma solicitação de pesquisa que incluía os argumentos query, knn para solicitar as duas pesquisas e o argumento rrf para combiná-las em uma única lista de resultados.

A complicação que surge ao tentar fazer o mesmo para combinar pedidos de pesquisa de texto completo e de vetor esparso é que ambos usam o argumento query . Para poder fornecer as duas consultas que precisam ser combinadas com o algoritmo RRF, é necessário incluir dois argumentos query , e a solução para fazer isso é fazê-lo com Sub-Buscas.

A funcionalidade de subbuscas encontra-se atualmente em fase de pré-visualização técnica. Por esse motivo, o cliente Elasticsearch em Python não oferece suporte nativo a isso. Para contornar essa limitação, o método search() da classe Search pode ser alterado para enviar a solicitação de pesquisa usando o argumento body . Abaixo você pode ver uma implementação nova, porém similar, que usa o argumento body do cliente para enviar uma solicitação de pesquisa:

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

Essa implementação não requer nenhuma alteração no aplicativo, pois é funcionalmente equivalente. A única diferença é que o método search() valida todos os argumentos antes de enviar a solicitação, sendo body a única exceção. O servidor sempre valida as solicitações, independentemente de como o cliente as envia.

Nesta versão, o argumento sub_searches pode ser usado em Search.search() para enviar várias consultas de pesquisa da seguinte forma:

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

Implementação de Busca Híbrida

Para concluir esta seção, vamos retomar a lógica de texto completo e combiná-la com a consulta de busca semântica apresentada anteriormente neste capítulo.

Abaixo você pode ver o endpoint handle_search() atualizado:

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

Como você deve se lembrar, a função extract_filters() procurava filtros de categoria inseridos pelo usuário no prompt de pesquisa e retornava a parte restante como parsed_query. Se parsed_query estiver vazio, significa que o usuário inseriu apenas um filtro de categoria e, nesse caso, a consulta deve ser um simples match_all com a categoria selecionada como filtro. Isso é implementado na parte else da grande condicional.

Quando há uma consulta de pesquisa, a opção sub_searches é usada conforme mostrado na seção anterior para incluir as consultas multi_match e text_expansion , com a opção rank solicitando que os resultados das duas subpesquisas sejam combinados em uma única lista de resultados classificados. Para completar a consulta, os argumentos size e from_ são fornecidos para manter o suporte à paginação.

Clique aqui para consultar esta versão do aplicativo.

Pronto para criar buscas de última geração?

Uma pesquisa suficientemente avançada não se consegue apenas com o esforço de uma só pessoa. O Elasticsearch é impulsionado por cientistas de dados, especialistas em operações de aprendizado de máquina, engenheiros e muitos outros que são tão apaixonados por buscas quanto você. Vamos nos conectar e trabalhar juntos para construir a experiência de busca mágica que lhe trará os resultados desejados.

Experimente você mesmo(a)