Busca facetada

Nesta seção, você aprenderá sobre um padrão derivado de filtros que é amplamente utilizado em implementações de pesquisa, chamado pesquisa facetada. A ideia é permitir que o usuário execute uma consulta e, em seguida, apresentar a ele uma lista de filtros sugeridos juntamente com os resultados.

A captura de tela a seguir mostra uma barra lateral esquerda com facetas para os dois filtros atualmente implementados no aplicativo.

Segue abaixo um detalhamento dos resultados da busca facetada. Observe como cada entrada é exibida como um link clicável que adiciona o filtro à pesquisa atual. Cada rosto também indica quantos resultados ele inclui.

Agregações de termos

No Elasticsearch, a pesquisa facetada é implementada usando o recurso de agregações . Uma das agregações suportadas divide os resultados da pesquisa em grupos, com base em alguns critérios. A lista de buckets, cada um incluindo o número de documentos que contém, será usada para renderizar a barra lateral de facetas.

O tipo mais simples de agregação de buckets é aquele em que os buckets são definidos para cada palavra-chave. Este tipo, que é chamado de agregação de termos, é perfeito para criar os buckets para o campo category . Aqui está a solicitação de pesquisa do aplicativo, expandida para solicitar agregações de categorias:

results = es.search(
    query={
        'bool': {
            **search_query,
            **filters
        }
    },
    aggs={
        'category-agg': {
            'terms': {
                'field': 'category.keyword',
            }
        },
    },
    size=5,
    from_=from_
)

A única alteração é a adição da opção aggs . Cada agregação recebe um nome, neste caso category-agg. A agregação terms indica que a filtragem deve ser feita por palavra-chave. Assim como nos filtros, o campo category deve ser fornecido como category.keyword, para que o subtipo de palavra-chave associado ao campo seja usado.

A resposta a uma solicitação com agregações tem um campo aggregations com os resultados agregados. Eis como a resposta para a solicitação de exemplo acima poderia ser:

{
    "aggregations": {
        "category-agg": {
            "buckets": [
                { "key": "sharepoint", "doc_count": 7 },
                { "key": "teams", "doc_count": 3 },
                { "key": "github", "doc_count": 2 },
            ],
            // other fields not used in this tutorial are omitted
        }
    }
}

O modelo index.html incluído no aplicativo tutorial já está configurado para renderizar agregações na barra lateral esquerda, que até então estava vazia. Para manter a lógica do modelo simples, os dados da resposta acima devem ser transformados em um dicionário com a seguinte estrutura:

{
    "Category": {
        "sharepoint": 7,
        "teams": 3,
        "github": 2
    }
}

A próxima listagem mostra como converter o formato de agregação do Elasticsearch para o dicionário simplificado acima e também como enviar o dicionário convertido para o modelo para renderização:

results = es.search(
    # ...
)
aggs = {
    'Category': {
        bucket['key']: bucket['doc_count']
        for bucket in results['aggregations']['category-agg']['buckets']
    },
}
return render_template('index.html', results=results['hits']['hits'],
                        query=query, from_=from_,
                        total=results['hits']['total']['value'],
                        aggs=aggs)

Caso você esteja curioso, o arquivo index.html inclui a seguinte lógica para renderizar o dicionário aggs :

{% for agg in aggs %}
    <h6 class="mt-3">{{ agg }}</h6>
    {% for key, count in aggs[agg].items() %}
        <form method="POST">
            <input type="hidden" name="query" value="{{ agg|lower }}:{{key}} {{ query }}">
            <button type="submit" class="btn btn-link btn-sm"{% if aggs[agg]|length == 1 %} disabled{% endif %}>{{ key }} ({{ count }})</button>
        </form>
    {% endfor %}
{% endfor %}

Esta implementação utiliza ideias semelhantes às usadas para renderizar os botões de paginação "próximo" e "anterior". Cada faceta é apresentada como um formulário com um campo oculto que define a consulta com o filtro adicional correspondente. Por exemplo, uma faceta de categoria sharepoint adicionaria category:sharepoint à consulta atual.

Como um mero detalhe estético, o botão de envio em cada seção é renderizado no formato de um link.

Agregações Anuais

O termo "agregações" usado com as categorias não funciona para o filtro de ano criado na seção anterior, porque, como você deve se lembrar, o índice não armazena os anos individualmente como palavras-chave. Em vez disso, o ano em que cada artigo foi atualizado é definido pelo campo updated_at , que armazena uma data completa.

Dentre a longa lista de agregações de buckets disponíveis, o histograma de datas é o que melhor se adapta a este caso de uso. Segue abaixo a solicitação de agregações atualizada:

    results = es.search(
        query={
            'bool': {
                **search_query,
                **filters
            }
        },
        aggs={
            'category-agg': {
                'terms': {
                    'field': 'category.keyword',
                }
            },
            'year-agg': {
                'date_histogram': {
                    'field': 'updated_at',
                    'calendar_interval': 'year',
                    'format': 'yyyy',
                },
            },
        },
        size=5,
        from_=from_
    )

Aqui você pode ver que uma segunda agregação foi adicionada ao campo aggs . O tipo desta agregação é date_histogram e o intervalo é definido como year para que os buckets criados representem cada um um ano. A opção format configura o formato a ser usado para o nome de cada bucket, que neste caso deve incluir apenas o ano.

O campo aggregations na resposta agora incluirá duas seções:

{
    "aggregations": {
        "category-agg": {
            "buckets": [
                { "key": "sharepoint", "doc_count": 7 },
                { "key": "teams", "doc_count": 3 },
                { "key": "github", "doc_count": 2 },
            ],
            // fields not used in this tutorial are omitted
        },
        "year-agg": {
            "buckets": [
                { "key_as_string": "2018", "doc_count": 6 },
                { "key_as_string": "2019", "doc_count": 1 },
                { "key_as_string": "2020", "doc_count": 1 },
                { "key_as_string": "2021", "doc_count": 1 },
                { "key_as_string": "2022", "doc_count": 2 },
                { "key_as_string": "2023", "doc_count": 1 },
            ],
            // fields not used in this tutorial are omitted
        }
    }
}

Existe outra pequena complicação com essa segunda agregação. O campo key que está incluído em cada bucket não é útil, porque para agregações de intervalo de data ele está em unidades de milissegundos. Mas felizmente, a data renderizada no formato fornecido na opção format na agregação é fornecida em um campo key_as_string .

Eis como o dicionário aggs , incluindo todas as facetas, é calculado:

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

Além de usar key_as_string em vez de key, para as facetas de ano, uma condição é adicionada para eliminar quaisquer buckets que tenham zero documentos, já que obviamente não há motivo para usá-los como filtros.

E com isso, a implementação da busca facetada está completa. Aqui está a implementação completa da função 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 = {
            'must': {
                'multi_match': {
                    'query': parsed_query,
                    'fields': ['name', 'summary', 'content'],
                }
            }
        }
    else:
        search_query = {
            'must': {
                'match_all': {}
            }
        }

    results = es.search(
        query={
            'bool': {
                **search_query,
                **filters
            }
        },
        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)

A implementação da pesquisa facetada incluída neste tutorial foi projetada com a simplicidade em mente. As agregações no Elasticsearch oferecem muitas possibilidades que ainda não foram abordadas, portanto, certifique-se de consultar a documentação para aprender tudo o que esse recurso tem a oferecer.

Parabéns, você chegou ao final da seção de Busca de Texto Completo deste tutorial! Clique aqui para rever o estado atual do aplicativo de busca de tutoriais.

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)