How to get per term statistics in Elasticsearch

Viewed 576

I need to implement the following (on the backend): a user types a query and gets back hits as well as statistics for the hits. Below is a simplified example.

Suppose the query is Grif, then the user gets back (random words just for example)

  • Griffith
  • Griffin
  • Grif
  • Grift
  • Griffins

And frequency + number of documents a certain term occurs in, for example:

  • Griffith (freq 10, 3 docs)
  • Griffin (freq 17, 9 docs)
  • Grif (freq 6, 3 docs)
  • Grift (freq 9, 5 docs)
  • Griffins (freq 11, 4 docs)

I'm relatively new to Elasticsearch, so I'm not sure where to start to implement something like this. What type of query is the most suitable for this? What can I use to get that kind of statistics? Any other advice will be appreciated too.

1 Answers

There are multiple layers to this. You'd need:

  • n-gram / partial / search-as-you-type matching
  • a way to group the matched keywords by their original form
  • a mechanism to reversely look up the document & term frequencies.

I'm not aware of any way to achieve this in one go, but here's my take on it.

  1. You could start off with a special, n-gram-powered analyzer, as explained in my other answer. There's the original content field, plus a multi-field mapping for the said analyzer, plus a keyword field to aggregate on down the line:
PUT my-index
{
  "settings": {
    "index": {
      "max_ngram_diff": 20
    },
    "analysis": {
      "tokenizer": {
        "my_ngrams": {
          "type": "ngram",
          "min_gram": 3,
          "max_gram": 20,
          "token_chars": [
            "letter",
            "digit"
          ]
        }
      },
      "analyzer": {
        "my_ngrams_analyzer": {
          "tokenizer": "my_ngrams",
          "filter": [
            "lowercase"
          ]
        }
      }
    }
  },
  "mappings": {
    "properties": {
      "content": {
        "type": "text",
        "fields": {
          "analyzed": {
            "type": "text",
            "analyzer": "my_ngrams_analyzer"
          },
          "keyword": {
            "type": "keyword"
          }
        }
      }
    }
  }
}
  1. Next, bulk-insert some sample docs containing text inside the content field. Note that each doc has an _id too — you'll need those later on.
POST _bulk
{"index":{"_index":"my-index", "_id":1}}
{"content":"Griffith"}
{"index":{"_index":"my-index", "_id":2}}
{"content":"Griffin"}
{"index":{"_index":"my-index", "_id":3}}
{"content":"Grif"}
{"index":{"_index":"my-index", "_id":4}}
{"content":"Grift"}
{"index":{"_index":"my-index", "_id":5}}
{"content":"Griffins"}
{"index":{"_index":"my-index", "_id":6}}
{"content":"Griffith"}
{"index":{"_index":"my-index", "_id":7}}
{"content":"Griffins"}
  1. Search for n-grams in the .analyzed field and group the matched documents by the original terms through the terms aggregation. At the same time, retrieve the _id of one of the bucketed documents through the top_hits aggregation. BTW — it doesn't matter which _id is returned in a given bucket — all will have contained the same bucketed term.
POST my-index/_search?filter_path=aggregations.*.buckets.key,aggregations.*.buckets.doc_count,aggregations.*.buckets.*.hits.hits._id
{
  "size": 0, 
  "query": {
    "term": {
      "content.analyzed": "grif"
    }
  },
  "aggs": {
    "full_terms": {
      "terms": {
        "field": "content.keyword",
        "size": 10
      },
      "aggs": {
        "top_doc": {
          "top_hits": {
            "size": 1,
            "_source": false
          }
        }
      }
    }
  }
}
  1. Observe the response. The filter_path URL parameter from the previous request reduces the response to just those attributes that we need — the untouched, original full_terms plus one of the underlying IDs:
{
  "aggregations" : {
    "full_terms" : {
      "buckets" : [
        {
          "key" : "Griffins",
          "doc_count" : 2,
          "top_doc" : {
            "hits" : {
              "hits" : [
                {
                  "_id" : "5"
                }
              ]
            }
          }
        },
        {
          "key" : "Griffith",
          "doc_count" : 2,
          "top_doc" : {
            "hits" : {
              "hits" : [
                {
                  "_id" : "1"
                }
              ]
            }
          }
        },
        {
          "key" : "Grif",
          "doc_count" : 1,
          "top_doc" : {
            "hits" : {
              "hits" : [
                {
                  "_id" : "3"
                }
              ]
            }
          }
        },
        {
          "key" : "Griffin",
          "doc_count" : 1,
          "top_doc" : {
            "hits" : {
              "hits" : [
                {
                  "_id" : "2"
                }
              ]
            }
          }
        },
        {
          "key" : "Grift",
          "doc_count" : 1,
          "top_doc" : {
            "hits" : {
              "hits" : [
                {
                  "_id" : "4"
                }
              ]
            }
          }
        }
      ]
    }
  }
}

Time for the fun part.

There's a specialized Elasticsearch API called Term Vectors which does exactly what you're after — it retrieves field & term stats from the whole index. In order for it to hand these stats over to you, it needs the document IDs — which you'll have obtained from the above aggregation!

  1. Finally, since you've got multiple term vectors to work with, you can use the Multi term vectors API like so — again condensing the response thru filter_path:
POST /my-index/_mtermvectors?filter_path=docs.term_vectors.*.*.*.doc_freq,docs.term_vectors.*.*.*.term_freq
{
  "docs": [
    {
      "_id": "5",                 <--- guaranteeing
      "fields": [
        "content.keyword"
      ],
      "payloads": false,
      "positions": false,
      "offsets": false,
      "field_statistics": false,
      "term_statistics": true
    },
    {
      "_id": "1",                 <--- the response
      "fields": [
        "content.keyword"
      ],
      "payloads": false,
      "positions": false,
      "offsets": false,
      "field_statistics": false,
      "term_statistics": true
    },
    {
      "_id": "3",                 <--- order
      "fields": [
        "content.keyword"
      ],
      "payloads": false,
      "positions": false,
      "offsets": false,
      "field_statistics": false,
      "term_statistics": true
    },
    {
      "_id": "2",
      "fields": [
        "content.keyword"
      ],
      "payloads": false,
      "positions": false,
      "offsets": false,
      "field_statistics": false,
      "term_statistics": true
    },
    {
      "_id": "4",
      "fields": [
        "content.keyword"
      ],
      "payloads": false,
      "positions": false,
      "offsets": false,
      "field_statistics": false,
      "term_statistics": true
    }
  ]
}
  1. The result can be post-processed in your backend to form your autocomplete response. You've got A) the full terms, B) the number of matching documents (doc_freq), and C), the term frequency:
{
  "docs" : [
    {
      "term_vectors" : {
        "content.keyword" : {
          "terms" : {
            "Griffins" : {      |      term
              "doc_freq" : 2,   | <--  # of docs
              "term_freq" : 1   |      term frequency
            }
          }
        }
      }
    },
    {
      "term_vectors" : {
        "content.keyword" : {
          "terms" : {
            "Griffith" : {
              "doc_freq" : 2,
              "term_freq" : 1
            }
          }
        }
      }
    },
    {
      "term_vectors" : {
        "content.keyword" : {
          "terms" : {
            "Grif" : {
              "doc_freq" : 1,
              "term_freq" : 1
            }
          }
        }
      }
    },
    {
      "term_vectors" : {
        "content.keyword" : {
          "terms" : {
            "Griffin" : {
              "doc_freq" : 1,
              "term_freq" : 1
            }
          }
        }
      }
    },
    {
      "term_vectors" : {
        "content.keyword" : {
          "terms" : {
            "Grift" : {
              "doc_freq" : 1,
              "term_freq" : 1
            }
          }
        }
      }
    }
  ]
}

Shameless plug: if you're new to Elasticsearch and, just like me, learn best from real-world examples, consider buying my Elasticsearch Handbook.

Related