Mixing exact search with stemming

edit

When building a search application, stemming is often a must as it is desirable for a query on skiing to match documents that contain ski or skis. But what if a user wants to search for skiing specifically? The typical way to do this would be to use a multi-field in order to have the same content indexed in two different ways:

resp = client.indices.create(
    index="index",
    settings={
        "analysis": {
            "analyzer": {
                "english_exact": {
                    "tokenizer": "standard",
                    "filter": [
                        "lowercase"
                    ]
                }
            }
        }
    },
    mappings={
        "properties": {
            "body": {
                "type": "text",
                "analyzer": "english",
                "fields": {
                    "exact": {
                        "type": "text",
                        "analyzer": "english_exact"
                    }
                }
            }
        }
    },
)
print(resp)

resp1 = client.index(
    index="index",
    id="1",
    document={
        "body": "Ski resort"
    },
)
print(resp1)

resp2 = client.index(
    index="index",
    id="2",
    document={
        "body": "A pair of skis"
    },
)
print(resp2)

resp3 = client.indices.refresh(
    index="index",
)
print(resp3)
response = client.indices.create(
  index: 'index',
  body: {
    settings: {
      analysis: {
        analyzer: {
          english_exact: {
            tokenizer: 'standard',
            filter: [
              'lowercase'
            ]
          }
        }
      }
    },
    mappings: {
      properties: {
        body: {
          type: 'text',
          analyzer: 'english',
          fields: {
            exact: {
              type: 'text',
              analyzer: 'english_exact'
            }
          }
        }
      }
    }
  }
)
puts response

response = client.index(
  index: 'index',
  id: 1,
  body: {
    body: 'Ski resort'
  }
)
puts response

response = client.index(
  index: 'index',
  id: 2,
  body: {
    body: 'A pair of skis'
  }
)
puts response

response = client.indices.refresh(
  index: 'index'
)
puts response
const response = await client.indices.create({
  index: "index",
  settings: {
    analysis: {
      analyzer: {
        english_exact: {
          tokenizer: "standard",
          filter: ["lowercase"],
        },
      },
    },
  },
  mappings: {
    properties: {
      body: {
        type: "text",
        analyzer: "english",
        fields: {
          exact: {
            type: "text",
            analyzer: "english_exact",
          },
        },
      },
    },
  },
});
console.log(response);

const response1 = await client.index({
  index: "index",
  id: 1,
  document: {
    body: "Ski resort",
  },
});
console.log(response1);

const response2 = await client.index({
  index: "index",
  id: 2,
  document: {
    body: "A pair of skis",
  },
});
console.log(response2);

const response3 = await client.indices.refresh({
  index: "index",
});
console.log(response3);
PUT index
{
  "settings": {
    "analysis": {
      "analyzer": {
        "english_exact": {
          "tokenizer": "standard",
          "filter": [
            "lowercase"
          ]
        }
      }
    }
  },
  "mappings": {
    "properties": {
      "body": {
        "type": "text",
        "analyzer": "english",
        "fields": {
          "exact": {
            "type": "text",
            "analyzer": "english_exact"
          }
        }
      }
    }
  }
}

PUT index/_doc/1
{
  "body": "Ski resort"
}

PUT index/_doc/2
{
  "body": "A pair of skis"
}

POST index/_refresh

With such a setup, searching for ski on body would return both documents:

resp = client.search(
    index="index",
    query={
        "simple_query_string": {
            "fields": [
                "body"
            ],
            "query": "ski"
        }
    },
)
print(resp)
response = client.search(
  index: 'index',
  body: {
    query: {
      simple_query_string: {
        fields: [
          'body'
        ],
        query: 'ski'
      }
    }
  }
)
puts response
const response = await client.search({
  index: "index",
  query: {
    simple_query_string: {
      fields: ["body"],
      query: "ski",
    },
  },
});
console.log(response);
GET index/_search
{
  "query": {
    "simple_query_string": {
      "fields": [ "body" ],
      "query": "ski"
    }
  }
}
{
  "took": 2,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped" : 0,
    "failed": 0
  },
  "hits": {
    "total" : {
        "value": 2,
        "relation": "eq"
    },
    "max_score": 0.18232156,
    "hits": [
      {
        "_index": "index",
        "_id": "1",
        "_score": 0.18232156,
        "_source": {
          "body": "Ski resort"
        }
      },
      {
        "_index": "index",
        "_id": "2",
        "_score": 0.18232156,
        "_source": {
          "body": "A pair of skis"
        }
      }
    ]
  }
}

On the other hand, searching for ski on body.exact would only return document 1 since the analysis chain of body.exact does not perform stemming.

resp = client.search(
    index="index",
    query={
        "simple_query_string": {
            "fields": [
                "body.exact"
            ],
            "query": "ski"
        }
    },
)
print(resp)
response = client.search(
  index: 'index',
  body: {
    query: {
      simple_query_string: {
        fields: [
          'body.exact'
        ],
        query: 'ski'
      }
    }
  }
)
puts response
const response = await client.search({
  index: "index",
  query: {
    simple_query_string: {
      fields: ["body.exact"],
      query: "ski",
    },
  },
});
console.log(response);
GET index/_search
{
  "query": {
    "simple_query_string": {
      "fields": [ "body.exact" ],
      "query": "ski"
    }
  }
}
{
  "took": 1,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped" : 0,
    "failed": 0
  },
  "hits": {
    "total" : {
        "value": 1,
        "relation": "eq"
    },
    "max_score": 0.8025915,
    "hits": [
      {
        "_index": "index",
        "_id": "1",
        "_score": 0.8025915,
        "_source": {
          "body": "Ski resort"
        }
      }
    ]
  }
}

This is not something that is easy to expose to end users, as we would need to have a way to figure out whether they are looking for an exact match or not and redirect to the appropriate field accordingly. Also what to do if only parts of the query need to be matched exactly while other parts should still take stemming into account?

Fortunately, the query_string and simple_query_string queries have a feature that solves this exact problem: quote_field_suffix. This tells Elasticsearch that the words that appear in between quotes are to be redirected to a different field, see below:

resp = client.search(
    index="index",
    query={
        "simple_query_string": {
            "fields": [
                "body"
            ],
            "quote_field_suffix": ".exact",
            "query": "\"ski\""
        }
    },
)
print(resp)
response = client.search(
  index: 'index',
  body: {
    query: {
      simple_query_string: {
        fields: [
          'body'
        ],
        quote_field_suffix: '.exact',
        query: '"ski"'
      }
    }
  }
)
puts response
const response = await client.search({
  index: "index",
  query: {
    simple_query_string: {
      fields: ["body"],
      quote_field_suffix: ".exact",
      query: '"ski"',
    },
  },
});
console.log(response);
GET index/_search
{
  "query": {
    "simple_query_string": {
      "fields": [ "body" ],
      "quote_field_suffix": ".exact",
      "query": "\"ski\""
    }
  }
}
{
  "took": 2,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped" : 0,
    "failed": 0
  },
  "hits": {
    "total" : {
        "value": 1,
        "relation": "eq"
    },
    "max_score": 0.8025915,
    "hits": [
      {
        "_index": "index",
        "_id": "1",
        "_score": 0.8025915,
        "_source": {
          "body": "Ski resort"
        }
      }
    ]
  }
}

In the above case, since ski was in-between quotes, it was searched on the body.exact field due to the quote_field_suffix parameter, so only document 1 matched. This allows users to mix exact search with stemmed search as they like.

If the choice of field passed in quote_field_suffix does not exist the search will fall back to using the default field for the query string.