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.

Busca facetada

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.

Detalhes da Busca Facetada

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)