Repository: cisco/mindmeld (language: python)

Issue:
QuestionAnswerer interface could be tightened up
I was trying out the QuestionAnswerer class and noticed that the interface can be difficult to use.  For example
```
from mindmeld.components import QuestionAnswerer
qa = QuestionAnswerer('.')
qa.load_kb('my_app', 'stores', './data/stores.json')
qa.get(index='stores')
```
The call to `load_kb()` allows the `app_namespace` parameter to be different than the `app_path` parameter for the construction of a `QuestionAnswerer`.  However, the `get()` function does not have a corresponding `app_namespace` parameter and instead relies on resolved namespace from the `app_path` used in the constructor.  This requires the user to have internal knowledge of how the `app_path` parameter is resolved in order to use the QuestionAnswerer functionality.
`TypeError` when using `get_classifier_config` with  no `app_path` argument
Running the following throws an error
```python
from mindmeld.components._config import get_classifier_config
print(get_classifier_config("question_answering"))
```
```bash
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/Users/XXX/Projects/mindmeld/mindmeld/components/_config.py", line 660, in get_classifier_config
    module_conf = _get_config_module(app_path)
  File "/Users/XXX/Projects/mindmeld/mindmeld/components/_config.py", line 913, in _get_config_module
    "config_module_" + os.path.basename(app_path), module_path
  File "/Library/Frameworks/Python.framework/Versions/3.7/lib/python3.7/posixpath.py", line 146, in basename
    p = os.fspath(p)
TypeError: expected str, bytes or os.PathLike object, not NoneType
```

This is happening although `app_path` is an optional argument. 

Possible solution: 
try-catch; add `TypeError` to existing `(OSError, IOError)` for deciding to use defaults in case of exceptions
`TypeError` when using `get_classifier_config` with  no `app_path` argument
Running the following throws an error
```python
from mindmeld.components._config import get_classifier_config
print(get_classifier_config("question_answering"))
```
```bash
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/Users/XXX/Projects/mindmeld/mindmeld/components/_config.py", line 660, in get_classifier_config
    module_conf = _get_config_module(app_path)
  File "/Users/XXX/Projects/mindmeld/mindmeld/components/_config.py", line 913, in _get_config_module
    "config_module_" + os.path.basename(app_path), module_path
  File "/Library/Frameworks/Python.framework/Versions/3.7/lib/python3.7/posixpath.py", line 146, in basename
    p = os.fspath(p)
TypeError: expected str, bytes or os.PathLike object, not NoneType
```

This is happening although `app_path` is an optional argument. 

Possible solution: 
try-catch; add `TypeError` to existing `(OSError, IOError)` for deciding to use defaults in case of exceptions
Training optimizations for memory and time reduction
# What does this PR do?
Reworks the storage and flow of data through the MindMeld nlp building process.  This was instigated by a new build restriction that limits the amount of RAM available to the build process in our pipeline.  The end result of this work was tested for memory use and processing time and the results are below.
___
# What are the results?
Memory usage would peak at around 4GB previously.  With these changes, it grows to about 1GB.
Training time has also been positively impacted. 
Previous training times:
- Full build - 22:12
- Incremental build with no changes - 1:40
- Incremental build with a minor change - 6:06

Training times with this PR:
- Full build - 19:21
- Incremental build with no changes - 1:02
- Incremental build with a minor change - 2:45

The decrease in training time can be mostly attributed to more efficient loading and unloading of data from disk to memory.

# What are the major changes?
1. The `QueryCache` has been moved from an in-memory dictionary that is saved to disk to a sqlite3 backed database.
  a. Keys for the queries are now a sha256 hash of the domain, intent, and query string.
  b. ProcessedQuery and all of the classes it contains now implement a `to_cache()` and `from_cache()` function.  These must be updated if new properties are added.
  c. There is no `load()` and `dump()` function for the `QueryCache`.  Every write to the cache is immediately committed to disk and is available for lookup.
2. Accessing a list of queries and labels is done through `resource_loader.ProcessedQueryList`.  This is done in order to minimize the number of queries loaded into memory at any point in time.
  a. `ResourceLoader.file_to_query_info` is now a list of ids rather than the actual `ProcessedQuery` objects.
  b. `ProcessedQueryList` provides a set of iterators for efficient access to various commonly used properties of the `ProcessedQuery` object.  These iterators load the required data from the `QueryCache` as needed.
  c. Some labels are still being resolved into in-memory lists if the data is small and they are iterated over more than once.
  d. Resource extraction has been reworked to only iterate over the `Query` objects in the set once.
  e. Raw file loading no longer exists.  Instead, there is a `ProcessedQueryList.RawQueryIterator` provided.
3. Some particularly memory-hungry features used by the CRF model now use a `FileBackedList` to aggregate and iterate over these features.
  a. A temporary file is created and the `append()` function writes a JSON serialization of the data to the file.
  b. A `FileBackedList.Iterator` is provided to iterate over the contents of the list.  It loads the data from the temporary file as requested.  
  c. The temporary file is removed when the `FileBackedList` is garbage collected.
4. Models are no longer kept in memory during training.   
  a. An `unload()` method has been added to models which is responsible for clearing any references to memory.
  b. The `fit()` method now returns `True` if the model was loaded into memory, `False` if a cached model is available.  The cached model is no longer loaded into memory by the `fit()` method.
  c.  Models are built, then written to disk and unloaded during training so only the model that is currently training is in memory.
  d. All model are loaded into memory at the end of `nlp.build()` so the `NaturalLanguageProcessor` is ready for inference.
5. I removed the `queries` parameter from the classifiers.  This parameter is not documented externally and was leading to confusion in the data flow.
6. Unit tests have been updated to match the changes.

Feature/allowed entities
This PR implements the allowed entities RFC: https://github.com/cisco/mindmeld/pull/279. The main feature here is to reliably detect entities based allowed entities that are passed into the system. 

## Examples:

Previously, even if `chkings` word is in the gazetteer, we dont recognize it as an `account_type` entity:
```python
nlp.process("chkings", allowed_intents=["accounts_creditcards.transfer_money"])

returns
{'text': 'chkings',
 'domain': 'accounts_creditcards',
 'intent': 'transfer_money',
 'entities': []}
```

With this change, adding the entity to detect into the query will help identify such queries:
```python
nlp.process("chkings", allowed_intents=["accounts_creditcards.transfer_money.account_type"])

returns
{'text': 'chkings',
 'domain': 'accounts_creditcards',
 'intent': 'transfer_money',
 'entities': [{'text': 'chkings',
   'type': 'account_type',
   'role': 'origin',
   'value': [{'cname': 'checking',
     'score': 19.06074,
     'top_synonym': 'chkings'},
    {'cname': 'savings', 'score': 1.0138474, 'top_synonym': 'savings'}],
   'span': {'start': 0, 'end': 6}}]}
```

If a word is not present at all in the gazetteer files, the developer can do the following:

```python
nlp.process("xyz", 
            allowed_intents=["accounts_creditcards.transfer_money.account_type.dest"],
            dynamic_resource={'gazetteers': {'account_type': {'xyz': 1.0}}})

returns
{'text': 'xyz',
 'domain': 'accounts_creditcards',
 'intent': 'transfer_money',
 'entities': [{'text': 'xyz',
   'type': 'account_type',
   'role': 'dest',
   'value': [],
   'span': {'start': 0, 'end': 2}}]}
```

Notice that the role identification worked seamlessly in the above example.

## Research

We wanted to find a solution to the no-context word problem by feature engineering, compared to adding complexity on top of the model predictions.

Observations:
- During testing, we found that in most apps, no-context custom entities in the gazetteer were detected using intent-level allowed_intents
- However, some no-context system entities were not being detected

Conditioned on this training data: 
For example, "$20" with context gets detected while "$20" without context does not:
```
nlp.process("transfer $20", allowed_intents=["accounts_creditcards.transfer_money"])
{'text': 'transfer $20',
 'domain': 'accounts_creditcards',
 'intent': 'transfer_money',
 'entities': [{'text': '$20',
   'type': 'sys_amount-of-money',
   'role': None,
   'value': [{'value': 20, 'unit': '$'}],
   'span': {'start': 9, 'end': 11}}]}


nlp.process("$20", allowed_intents=["accounts_creditcards.transfer_money"])
{'text': '$20',
 'domain': 'accounts_creditcards',
 'intent': 'transfer_money',
 'entities': []}
```

Inspecting the features of the two queries, we see this:
```
pprint.pprint(er.view_extracted_features('transfer $20'))
[{'bag_of_words|length:1|word_pos:-1': '<$>',
  'bag_of_words|length:1|word_pos:-2': '<$>',
  'bag_of_words|length:1|word_pos:0': 'transfer',
  'bag_of_words|length:1|word_pos:1': '$00',
  'bag_of_words|length:1|word_pos:2': '<$>',
  'bag_of_words|length:2|word_pos:-1': '<$> transfer',
  'bag_of_words|length:2|word_pos:-2': '<$> <$>',
  'bag_of_words|length:2|word_pos:0': 'transfer $00',
  'bag_of_words|length:2|word_pos:1': '$00 <$>',
  'sys_candidate|type:sys_amount-of-money|granularity:None|pos:1': 1,
  'sys_candidate|type:sys_amount-of-money|granularity:None|pos:1|log_len': 1.0986122886681098,
   'prev_tag': 'START'},
 {'bag_of_words|length:1|word_pos:-1': 'transfer',
  'bag_of_words|length:1|word_pos:-2': '<$>',
  'bag_of_words|length:1|word_pos:0': '$00',
  'bag_of_words|length:1|word_pos:1': '<$>',
  'bag_of_words|length:1|word_pos:2': '<$>',
  'bag_of_words|length:2|word_pos:-1': 'transfer $00',
  'bag_of_words|length:2|word_pos:-2': '<$> transfer',
  'bag_of_words|length:2|word_pos:0': '$00 <$>',
  'bag_of_words|length:2|word_pos:1': '<$> <$>',
  'sys_candidate|type:sys_amount-of-money|granularity:None|pos:0': 1,
  'sys_candidate|type:sys_amount-of-money|granularity:None|pos:0|log_len': 1.0986122886681098,
  'sys_candidate|type:sys_amount-of-money|granularity:None|pos:1': 1,
  'sys_candidate|type:sys_amount-of-money|granularity:None|pos:1|log_len': 0.6931471805599453}]

pprint.pprint(er.view_extracted_features('$20'))
[{'bag_of_words|length:1|word_pos:-1': '<$>',
  'bag_of_words|length:1|word_pos:-2': '<$>',
  'bag_of_words|length:1|word_pos:0': '$00',
  'bag_of_words|length:1|word_pos:1': '<$>',
  'bag_of_words|length:1|word_pos:2': '<$>',
  'bag_of_words|length:2|word_pos:-1': '<$> $00',
  'bag_of_words|length:2|word_pos:-2': '<$> <$>',
  'bag_of_words|length:2|word_pos:0': '$00 <$>',
  'bag_of_words|length:2|word_pos:1': '<$> <$>',
  'sys_candidate|type:sys_amount-of-money|granularity:None|pos:0': 1,
  'sys_candidate|type:sys_amount-of-money|granularity:None|pos:0|log_len': 1.0986122886681098,
  'sys_candidate|type:sys_amount-of-money|granularity:None|pos:1': 1,
  'sys_candidate|type:sys_amount-of-money|granularity:None|pos:1|log_len': 0.6931471805599453},
  'prev_tag': 'START']
```

### Observations:
- One observation based on the training data (https://github.com/CiscoDevNet/mindmeld-blueprints/blob/develop/blueprints/banking_assistant/domains/accounts_creditcards/transfer_money/train.txt) is that most queries didnt have currency tokens in the 2 digits, they have either 3 digits or more. 
- If we remove the `prev_tag` for no-context queries, it will make these queries look similar to contextual queries.

Doing the two above changes, we get this result:
```
nlp.process("$20", allowed_intents=["accounts_creditcards.transfer_money"])
{'text': '$20',
 'domain': 'accounts_creditcards',
 'intent': 'transfer_money',
 'entities': [{'text': '$20',
   'type': 'sys_amount-of-money',
   'role': None,
   'value': [{'value': 20, 'unit': '$'}],
   'span': {'start': 0, 'end': 2}}]}

nlp.process("$2", allowed_intents=["accounts_creditcards.transfer_money"])
{'text': '$2',
 'domain': 'accounts_creditcards',
 'intent': 'transfer_money',
 'entities': []}
```

We see that it works for some non-context words but not others.

Investigating the features, we see this:
```
pprint.pprint(er.view_extracted_features('$20'))
[{'bag_of_words|length:1|word_pos:-1': '<$>',
  'bag_of_words|length:1|word_pos:-2': '<$>',
  'bag_of_words|length:1|word_pos:0': '$0',
  'bag_of_words|length:1|word_pos:1': '<$>',
  'bag_of_words|length:1|word_pos:2': '<$>',
  'bag_of_words|length:2|word_pos:-1': '<$> $0',
  'bag_of_words|length:2|word_pos:-2': '<$> <$>',
  'bag_of_words|length:2|word_pos:0': '$0 <$>',
  'bag_of_words|length:2|word_pos:1': '<$> <$>',
  'sys_candidate|type:sys_amount-of-money|granularity:None|pos:0': 1,
  'sys_candidate|type:sys_amount-of-money|granularity:None|pos:0|log_len': 1.0986122886681098,
  'sys_candidate|type:sys_amount-of-money|granularity:None|pos:1': 1,
  'sys_candidate|type:sys_amount-of-money|granularity:None|pos:1|log_len': 0.6931471805599453}]

pprint.pprint(er.view_extracted_features('$2'))
[{'bag_of_words|length:1|word_pos:-1': '<$>',
  'bag_of_words|length:1|word_pos:-2': '<$>',
  'bag_of_words|length:1|word_pos:0': '$0',
  'bag_of_words|length:1|word_pos:1': '<$>',
  'bag_of_words|length:1|word_pos:2': '<$>',
  'bag_of_words|length:2|word_pos:-1': '<$> $0',
  'bag_of_words|length:2|word_pos:-2': '<$> <$>',
  'bag_of_words|length:2|word_pos:0': '$0 <$>',
  'bag_of_words|length:2|word_pos:1': '<$> <$>',
  'sys_candidate|type:sys_amount-of-money|granularity:None|pos:0': 1,
  'sys_candidate|type:sys_amount-of-money|granularity:None|pos:0|log_len': 0.6931471805599453,
  'sys_candidate|type:sys_amount-of-money|granularity:None|pos:1': 1,
  'sys_candidate|type:sys_amount-of-money|granularity:None|pos:1|log_len': 0.0}]
```

The main feature difference between the two queries are the `'sys_candidate|type:sys_amount-of-money|granularity:None|pos:{}|log_len'` features. These values are derived from the sys candidates we get from duckling. 

These are the duckling candidates between the queries:
```
`$20`
[<QueryEntity '$20' ('sys_amount-of-money') char: [0-2], tok: [0-0]>, <QueryEntity '20' ('sys_amount-of-money') char: [1-2], tok: [1-1]>]

`$2`
[<QueryEntity '$2' ('sys_amount-of-money') char: [0-1], tok: [0-0]>, <QueryEntity '2' ('sys_amount-of-money') char: [1-1], tok: [1-1]>]
```

## Idea: Filter out "redundant" system entity candidates when they are surrounded by other system entities of the same type

One idea I explored was filtering this set of candidates that are incorporated as features to only non-redundant ones, ie choosing only $20 and not 20, $2 and not 2 since the former encompasses the latter. 

However, edge cases showed up:
```
Query: "$20 5"

System entity candidates:
sys_amount-of-money '$20' 0-2
sys_amount-of-money '$20 5' 0-4
sys_amount-of-money '$20 5' 0-4
sys_amount-of-money '20' 1-2
sys_amount-of-money '20 5' 1-4
sys_amount-of-money '20 5' 1-4
sys_amount-of-money '5' 4-4
```

For the above query, the canonical value would be `$20.5`

## Revisting the features....

```
pprint.pprint(er.view_extracted_features('$2'))
[{'bag_of_words|length:1|word_pos:-1': '<$>',
  'bag_of_words|length:1|word_pos:-2': '<$>',
  'bag_of_words|length:1|word_pos:0': '$0',
  'bag_of_words|length:1|word_pos:1': '<$>',
  'bag_of_words|length:1|word_pos:2': '<$>',
  'bag_of_words|length:2|word_pos:-1': '<$> $0',
  'bag_of_words|length:2|word_pos:-2': '<$> <$>',
  'bag_of_words|length:2|word_pos:0': '$0 <$>',
  'bag_of_words|length:2|word_pos:1': '<$> <$>',
  'sys_candidate|type:sys_amount-of-money|granularity:None|pos:0': 1,
  'sys_candidate|type:sys_amount-of-money|granularity:None|pos:0|log_len': 0.6931471805599453,
  'sys_candidate|type:sys_amount-of-money|granularity:None|pos:1': 1,
  'sys_candidate|type:sys_amount-of-money|granularity:None|pos:1|log_len': 0.0}]
```

Fishing line hooked! <insert better fishing lingo>
```
(Pdb) system_entities[0]
<QueryEntity '$2' ('sys_amount-of-money') char: [0-1], tok: [0-0]>
(Pdb) system_entities[0].normalized_token_span
Span(start=0, end=0)
(Pdb) system_entities[1]
<QueryEntity '2' ('sys_amount-of-money') char: [1-1], tok: [1-1]>
(Pdb) system_entities[1].normalized_token_span
Span(start=1, end=1)
```

## Solution
1. Filter out system entity candidates that have the same normalized_token_span but subsumed by larger entities of the same type

```
(Pdb) system_entities[0]
<QueryEntity '$2' ('sys_amount-of-money') char: [0-1], tok: [0-0]>
(Pdb) system_entities[0].normalized_token_span
Span(start=0, end=0)
(Pdb) system_entities[1]
<QueryEntity '2' ('sys_amount-of-money') char: [1-1], tok: [0-0]>
(Pdb) system_entities[1].normalized_token_span
Span(start=0, end=0)
```



Discussion / hints:

Fix patch:
diff --git a/mindmeld/_util.py b/mindmeld/_util.py
index 733b501ef..5b591235d 100644
--- a/mindmeld/_util.py
+++ b/mindmeld/_util.py
@@ -31,7 +31,7 @@
 from .components import QuestionAnswerer
 from .components._config import get_app_namespace
 from .constants import BLUEPRINTS_URL
-from .exceptions import KnowledgeBaseConnectionError
+from .exceptions import ElasticsearchKnowledgeBaseConnectionError
 
 logger = logging.getLogger(__name__)
 
@@ -175,7 +175,7 @@ def setup_kb(cls, name, app_path=None, es_host=None):
 
             try:
                 QuestionAnswerer.load_kb(app_namespace, index_name, data_file, es_host)
-            except KnowledgeBaseConnectionError as ex:
+            except ElasticsearchKnowledgeBaseConnectionError as ex:
                 logger.error(
                     "Cannot set up knowledge base. Unable to connect to Elasticsearch "
                     "instance at %r. Confirm it is running or specify an alternate "
diff --git a/mindmeld/cli.py b/mindmeld/cli.py
index 007c2def3..7180d7208 100644
--- a/mindmeld/cli.py
+++ b/mindmeld/cli.py
@@ -50,7 +50,7 @@
 )
 from .constants import BINARIES_URL, DUCKLING_VERSION, UNANNOTATE_ALL_RULE
 from .converter import DialogflowConverter, RasaConverter
-from .exceptions import KnowledgeBaseConnectionError, KnowledgeBaseError, MindMeldError
+from .exceptions import ElasticsearchKnowledgeBaseConnectionError, KnowledgeBaseError, MindMeldError
 from .models.helpers import create_annotator
 from .path import (
     MODEL_CACHE_PATH,
@@ -631,7 +631,7 @@ def load_index(ctx, es_host, app_namespace, index_name, data_file, app_path):
             es_host,
             app_path=app_path,
         )
-    except (KnowledgeBaseConnectionError, KnowledgeBaseError) as ex:
+    except (ElasticsearchKnowledgeBaseConnectionError, KnowledgeBaseError) as ex:
         logger.error(ex.message)
         ctx.exit(1)
 
@@ -946,7 +946,7 @@ def setup_blueprint(ctx, es_host, skip_kb, blueprint_name, app_path):
     except ValueError as ex:
         logger.error(ex)
         ctx.exit(1)
-    except (KnowledgeBaseConnectionError, KnowledgeBaseError) as ex:
+    except (ElasticsearchKnowledgeBaseConnectionError, KnowledgeBaseError) as ex:
         logger.error(ex.message)
         ctx.exit(1)
 
diff --git a/mindmeld/components/_config.py b/mindmeld/components/_config.py
index 1b7d35a65..f7c468b89 100644
--- a/mindmeld/components/_config.py
+++ b/mindmeld/components/_config.py
@@ -20,8 +20,8 @@
 import os
 import warnings
 
-from .. import path
 from .schemas import validate_language_code, validate_locale_code
+from .. import path
 from ..constants import CURRENCY_SYMBOLS
 
 logger = logging.getLogger(__name__)
@@ -104,10 +104,30 @@
 
 DEFAULT_ENTITY_RESOLVER_CONFIG = {
     "model_type": "resolver",
-    "model_settings": {"resolver_type": "text_relevance",},
+    "model_settings": {"resolver_type": "text_relevance", },
 }
 
-DEFAULT_QUESTION_ANSWERER_CONFIG = {"model_type": "keyword"}
+DEFAULT_ROLE_CLASSIFIER_CONFIG = {
+    "model_type": "text",
+    "model_settings": {"classifier_type": "logreg"},
+    "params": {"C": 100, "penalty": "l1"},
+    "features": {
+        "bag-of-words-before": {
+            "ngram_lengths_to_start_positions": {1: [-2, -1], 2: [-2, -1]}
+        },
+        "bag-of-words-after": {
+            "ngram_lengths_to_start_positions": {1: [0, 1], 2: [0, 1]}
+        },
+        "other-entities": {},
+    },
+}
+
+DEFAULT_QUESTION_ANSWERER_CONFIG = {
+    "model_type": "elasticsearch",
+    "model_settings": {
+        "query_type": "keyword"
+    }
+}
 
 ENGLISH_LANGUAGE_CODE = "en"
 ENGLISH_US_LOCALE = "en_US"
@@ -116,7 +136,6 @@
     "locale": ENGLISH_US_LOCALE,
 }
 
-
 # ElasticSearch mapping to define text analysis settings for text fields.
 # It defines specific index configuration for synonym indices. The common index configuration
 # is in default index template.
@@ -185,7 +204,7 @@
                         "type": "text",
                         "analyzer": "keyword_match_analyzer",
                     },
-                    "char_ngram": {"type": "text", "analyzer": "char_ngram_analyzer",},
+                    "char_ngram": {"type": "text", "analyzer": "char_ngram_analyzer", },
                     "double_metaphone": {
                         "type": "text",
                         "analyzer": "phonetic_analyzer",
@@ -232,21 +251,6 @@
     },
 }
 
-DEFAULT_ROLE_CLASSIFIER_CONFIG = {
-    "model_type": "text",
-    "model_settings": {"classifier_type": "logreg"},
-    "params": {"C": 100, "penalty": "l1"},
-    "features": {
-        "bag-of-words-before": {
-            "ngram_lengths_to_start_positions": {1: [-2, -1], 2: [-2, -1]}
-        },
-        "bag-of-words-after": {
-            "ngram_lengths_to_start_positions": {1: [0, 1], 2: [0, 1]}
-        },
-        "other-entities": {},
-    },
-}
-
 DEFAULT_ES_INDEX_TEMPLATE_NAME = "mindmeld_default"
 
 # Default ES index template that contains the base index configuration shared across different
@@ -271,7 +275,7 @@
                                 "type": "text",
                                 "analyzer": "keyword_match_analyzer",
                             },
-                            "processed_text": {"type": "text", "analyzer": "english",},
+                            "processed_text": {"type": "text", "analyzer": "english", },
                             "char_ngram": {
                                 "type": "text",
                                 "analyzer": "char_ngram_analyzer",
@@ -400,7 +404,6 @@
     },
 }
 
-
 # Elasticsearch mapping to define knowledge base index specific configuration:
 # - dynamic field mapping to index all synonym whitelist in fields with "$whitelist" suffix.
 # - location field
@@ -462,7 +465,7 @@
 DEFAULT_AUGMENTATION_CONFIG = {
     "augmentor_class": "EnglishParaphraser",
     "batch_size": 8,
-    "paths": [{"domains": ".*", "intents": ".*", "files": ".*",}],
+    "paths": [{"domains": ".*", "intents": ".*", "files": ".*", }],
     "path_suffix": "-augment.txt",
 }
 
@@ -470,7 +473,7 @@
     "annotator_class": "MultiLingualAnnotator",
     "overwrite": False,
     "annotation_rules": [
-        {"domains": ".*", "intents": ".*", "files": ".*", "entities": ".*",}
+        {"domains": ".*", "intents": ".*", "files": ".*", "entities": ".*", }
     ],
     "unannotate_supported_entities_only": True,
     "unannotation_rules": None,
@@ -484,7 +487,6 @@
     "normalizer": "ASCIIFold",
 }
 
-
 DEFAULT_ACTIVE_LEARNING_CONFIG = {
     "output_folder": None,
     "pre_tuning": {
@@ -681,7 +683,7 @@ def get_classifier_config(
     try:
         module_conf = _get_config_module(app_path)
 
-    except (OSError, IOError):
+    except (TypeError, OSError, IOError):
         logger.info(
             "No app configuration file found. Using default %s model configuration",
             clf_type,
@@ -960,7 +962,7 @@ def get_nlp_config(app_path=None, config=None):
     try:
         module_conf = _get_config_module(app_path)
     except (OSError, IOError):
-        logger.info("No app configuration file found.")
+        logger.info("No app configuration file found. Using default nlp config.")
         return _get_default_nlp_config()
 
     # Try provider first
@@ -1132,7 +1134,7 @@ def get_tokenizer_config(app_path=None, exclude_from_norm=None):
             )
         return tokenizer_config
     except (OSError, IOError, AttributeError):
-        logger.info("No app configuration file found.")
+        logger.info("No app configuration file found. Using default tokenizer config.")
         return DEFAULT_TOKENIZER_CONFIG
 
 
diff --git a/mindmeld/components/_elasticsearch_helpers.py b/mindmeld/components/_elasticsearch_helpers.py
index f58bc0e6a..6e2a5831f 100644
--- a/mindmeld/components/_elasticsearch_helpers.py
+++ b/mindmeld/components/_elasticsearch_helpers.py
@@ -15,19 +15,10 @@
 import logging
 import os
 
-from elasticsearch import ConnectionError as EsConnectionError
-from elasticsearch import (
-    Elasticsearch,
-    ElasticsearchException,
-    ImproperlyConfigured,
-    TransportError,
-)
-from elasticsearch.helpers import streaming_bulk
+from tqdm.auto import tqdm
 
-from tqdm import tqdm
-
-from ..exceptions import KnowledgeBaseConnectionError, KnowledgeBaseError
-from ._config import DEFAULT_ES_INDEX_TEMPLATE, DEFAULT_ES_INDEX_TEMPLATE_NAME
+from ._util import _get_module_or_attr as _getattr
+from ..exceptions import ElasticsearchKnowledgeBaseConnectionError, KnowledgeBaseError
 
 logger = logging.getLogger(__name__)
 
@@ -35,6 +26,321 @@
 INDEX_TYPE_KB = "kb"
 DOC_TYPE = "document"
 
+# ElasticSearch mapping to define text analysis settings for text fields.
+# It defines specific index configuration for synonym indices. The common index configuration
+# is in default index template.
+DEFAULT_ES_SYNONYM_MAPPING = {
+    "mappings": {
+        "properties": {
+            "sort_factor": {"type": "double"},
+            "whitelist": {
+                "type": "nested",
+                "properties": {
+                    "name": {
+                        "type": "text",
+                        "fields": {
+                            "raw": {"type": "keyword", "ignore_above": 256},
+                            "normalized_keyword": {
+                                "type": "text",
+                                "analyzer": "keyword_match_analyzer",
+                            },
+                            "char_ngram": {
+                                "type": "text",
+                                "analyzer": "char_ngram_analyzer",
+                            },
+                        },
+                        "analyzer": "default_analyzer",
+                    }
+                },
+            },
+        }
+    }
+}
+
+PHONETIC_ES_SYNONYM_MAPPING = {
+    "mappings": {
+        "properties": {
+            "sort_factor": {"type": "double"},
+            "whitelist": {
+                "type": "nested",
+                "properties": {
+                    "name": {
+                        "type": "text",
+                        "fields": {
+                            "raw": {"type": "keyword", "ignore_above": 256},
+                            "normalized_keyword": {
+                                "type": "text",
+                                "analyzer": "keyword_match_analyzer",
+                            },
+                            "char_ngram": {
+                                "type": "text",
+                                "analyzer": "char_ngram_analyzer",
+                            },
+                            "double_metaphone": {
+                                "type": "text",
+                                "analyzer": "phonetic_analyzer",
+                            },
+                        },
+                        "analyzer": "default_analyzer",
+                    }
+                },
+            },
+            "cname": {
+                "type": "text",
+                "analyzer": "default_analyzer",
+                "fields": {
+                    "raw": {"type": "keyword", "ignore_above": 256},
+                    "normalized_keyword": {
+                        "type": "text",
+                        "analyzer": "keyword_match_analyzer",
+                    },
+                    "char_ngram": {
+                        "type": "text",
+                        "analyzer": "char_ngram_analyzer",
+                    },
+                    "double_metaphone": {
+                        "type": "text",
+                        "analyzer": "phonetic_analyzer",
+                    },
+                },
+            },
+        }
+    },
+    "settings": {
+        "analysis": {
+            "filter": {
+                "phonetic_filter": {
+                    "type": "phonetic",
+                    "encoder": "doublemetaphone",
+                    "replace": True,
+                    "max_code_len": 7,
+                }
+            },
+            "analyzer": {
+                "phonetic_analyzer": {
+                    "filter": [
+                        "lowercase",
+                        "asciifolding",
+                        "token_shingle",
+                        "phonetic_filter",
+                    ],
+                    "char_filter": [
+                        "remove_comma",
+                        "remove_tm_and_r",
+                        "remove_loose_apostrophes",
+                        "space_possessive_apostrophes",
+                        "remove_special_beginning",
+                        "remove_special_end",
+                        "remove_special1",
+                        "remove_special2",
+                        "remove_special3",
+                        "remove_dot",
+                    ],
+                    "type": "custom",
+                    "tokenizer": "whitespace",
+                }
+            },
+        }
+    },
+}
+
+DEFAULT_ES_INDEX_TEMPLATE_NAME = "mindmeld_default"
+
+# Default ES index template that contains the base index configuration shared across different
+# types of indices. Currently all ES indices will be created using this template.
+# - custom text analysis settings such as custom analyzers, token filters and character filters.
+# - dynamic field mapping template for text fields
+# - common fields, e.g. id.
+DEFAULT_ES_INDEX_TEMPLATE = {
+    "template": "*",
+    "mappings": {
+        "dynamic_templates": [
+            {
+                "default_text": {
+                    "match": "*",
+                    "match_mapping_type": "string",
+                    "mapping": {
+                        "type": "text",
+                        "analyzer": "default_analyzer",
+                        "fields": {
+                            "raw": {"type": "keyword", "ignore_above": 256},
+                            "normalized_keyword": {
+                                "type": "text",
+                                "analyzer": "keyword_match_analyzer",
+                            },
+                            "processed_text": {
+                                "type": "text",
+                                "analyzer": "english",
+                            },
+                            "char_ngram": {
+                                "type": "text",
+                                "analyzer": "char_ngram_analyzer",
+                            },
+                        },
+                    },
+                }
+            }
+        ],
+        "properties": {"id": {"type": "keyword"}},
+    },
+    "settings": {
+        "analysis": {
+            "char_filter": {
+                "remove_loose_apostrophes": {
+                    "pattern": " '|' ",
+                    "type": "pattern_replace",
+                    "replacement": "",
+                },
+                "space_possessive_apostrophes": {
+                    "pattern": "([^\\p{N}\\s]+)'s ",
+                    "type": "pattern_replace",
+                    "replacement": "$1 's ",
+                },
+                "remove_special_beginning": {
+                    "pattern": "^[^\\p{L}\\p{N}\\p{Sc}&']+",
+                    "type": "pattern_replace",
+                    "replacement": "",
+                },
+                "remove_special_end": {
+                    "pattern": "[^\\p{L}\\p{N}&']+$",
+                    "type": "pattern_replace",
+                    "replacement": "",
+                },
+                "remove_special1": {
+                    "pattern": "([\\p{L}]+)[^\\p{L}\\p{N}&']+(?=[\\p{N}\\s]+)",
+                    "type": "pattern_replace",
+                    "replacement": "$1 ",
+                },
+                "remove_special2": {
+                    "pattern": "([\\p{N}]+)[^\\p{L}\\p{N}&']+(?=[\\p{L}\\s]+)",
+                    "type": "pattern_replace",
+                    "replacement": "$1 ",
+                },
+                "remove_special3": {
+                    "pattern": "([\\p{L}]+)[^\\p{L}\\p{N}&']+(?=[\\p{L}]+)",
+                    "type": "pattern_replace",
+                    "replacement": "$1 ",
+                },
+                "remove_comma": {
+                    "pattern": ",",
+                    "type": "pattern_replace",
+                    "replacement": "",
+                },
+                "remove_tm_and_r": {
+                    "pattern": "™|®",
+                    "type": "pattern_replace",
+                    "replacement": "",
+                },
+                "remove_dot": {
+                    "pattern": "([\\p{L}]+)[.]+(?=[\\p{L}\\s]+)",
+                    "type": "pattern_replace",
+                    "replacement": "$1",
+                },
+            },
+            "filter": {
+                "token_shingle": {
+                    "max_shingle_size": "4",
+                    "min_shingle_size": "2",
+                    "output_unigrams": "true",
+                    "type": "shingle",
+                },
+                "ngram_filter": {"type": "ngram", "min_gram": "3", "max_gram": "3"},
+            },
+            "analyzer": {
+                "default_analyzer": {
+                    "filter": ["lowercase", "asciifolding", "token_shingle"],
+                    "char_filter": [
+                        "remove_comma",
+                        "remove_tm_and_r",
+                        "remove_loose_apostrophes",
+                        "space_possessive_apostrophes",
+                        "remove_special_beginning",
+                        "remove_special_end",
+                        "remove_special1",
+                        "remove_special2",
+                        "remove_special3",
+                    ],
+                    "type": "custom",
+                    "tokenizer": "whitespace",
+                },
+                "keyword_match_analyzer": {
+                    "filter": ["lowercase", "asciifolding"],
+                    "char_filter": [
+                        "remove_comma",
+                        "remove_tm_and_r",
+                        "remove_loose_apostrophes",
+                        "space_possessive_apostrophes",
+                        "remove_special_beginning",
+                        "remove_special_end",
+                        "remove_special1",
+                        "remove_special2",
+                        "remove_special3",
+                    ],
+                    "type": "custom",
+                    "tokenizer": "keyword",
+                },
+                "char_ngram_analyzer": {
+                    "filter": ["lowercase", "asciifolding", "ngram_filter"],
+                    "char_filter": [
+                        "remove_comma",
+                        "remove_tm_and_r",
+                        "remove_loose_apostrophes",
+                        "space_possessive_apostrophes",
+                        "remove_special_beginning",
+                        "remove_special_end",
+                        "remove_special1",
+                        "remove_special2",
+                        "remove_special3",
+                    ],
+                    "type": "custom",
+                    "tokenizer": "whitespace",
+                },
+            },
+        }
+    },
+}
+
+# Elasticsearch mapping to define knowledge base index specific configuration:
+# - dynamic field mapping to index all synonym whitelist in fields with "$whitelist" suffix.
+# - location field
+#
+# The common configuration is defined in default index template
+DEFAULT_ES_QA_MAPPING = {
+    "mappings": {
+        "dynamic_templates": [
+            {
+                "synonym_whitelist_text": {
+                    "match": "*$whitelist",
+                    "match_mapping_type": "object",
+                    "mapping": {
+                        "type": "nested",
+                        "properties": {
+                            "name": {
+                                "type": "text",
+                                "fields": {
+                                    "raw": {"type": "keyword", "ignore_above": 256},
+                                    "normalized_keyword": {
+                                        "type": "text",
+                                        "analyzer": "keyword_match_analyzer",
+                                    },
+                                    "char_ngram": {
+                                        "type": "text",
+                                        "analyzer": "char_ngram_analyzer",
+                                    },
+                                },
+                                "analyzer": "default_analyzer",
+                            }
+                        },
+                    },
+                }
+            }
+        ],
+        "properties": {"location": {"type": "geo_point"}},
+    }
+}
+
+DEFAULT_ES_RANKING_CONFIG = {"query_clauses_operator": "or"}
+
 
 def get_scoped_index_name(app_namespace, index_name):
     return "{}${}".format(app_namespace, index_name)
@@ -54,11 +360,11 @@ def create_es_client(es_host=None, es_user=None, es_pass=None):
 
     try:
         http_auth = (es_user, es_pass) if es_user and es_pass else None
-        es_client = Elasticsearch(es_host, http_auth=http_auth)
+        es_client = _getattr("elasticsearch", "Elasticsearch")(es_host, http_auth=http_auth)
         return es_client
-    except ElasticsearchException as e:
+    except _getattr("elasticsearch", "ElasticsearchException") as e:
         raise KnowledgeBaseError from e
-    except ImproperlyConfigured as e:
+    except _getattr("elasticsearch", "ImproperlyConfigured") as e:
         raise KnowledgeBaseError from e
 
 
@@ -96,12 +402,12 @@ def does_index_exist(
         # Confirm ES connection with a shorter timeout
         es_client.cluster.health(request_timeout=connect_timeout)
         return es_client.indices.exists(index=scoped_index_name)
-    except EsConnectionError as e:
+    except _getattr("elasticsearch", "ConnectionError") as e:
         logger.debug(
             "Unable to connect to Elasticsearch: %s details: %s", e.error, e.info
         )
-        raise KnowledgeBaseConnectionError(es_host=es_client.transport.hosts) from e
-    except TransportError as e:
+        raise ElasticsearchKnowledgeBaseConnectionError(es_host=es_client.transport.hosts) from e
+    except _getattr("elasticsearch", "TransportError") as e:
         logger.error(
             "Unexpected error occurred when sending requests to Elasticsearch: %s "
             "Status code: %s details: %s",
@@ -110,7 +416,7 @@ def does_index_exist(
             e.info,
         )
         raise KnowledgeBaseError from e
-    except ElasticsearchException as e:
+    except _getattr("elasticsearch", "ElasticsearchException") as e:
         raise KnowledgeBaseError from e
 
 
@@ -137,12 +443,12 @@ def get_field_names(
         else:
             all_field_info = res[scoped_index_name]["mappings"][DOC_TYPE]["properties"]
         return all_field_info.keys()
-    except EsConnectionError as e:
+    except _getattr("elasticsearch", "ConnectionError") as e:
         logger.debug(
             "Unable to connect to Elasticsearch: %s details: %s", e.error, e.info
         )
-        raise KnowledgeBaseConnectionError(es_host=es_client.transport.hosts) from e
-    except TransportError as e:
+        raise ElasticsearchKnowledgeBaseConnectionError(es_host=es_client.transport.hosts) from e
+    except _getattr("elasticsearch", "TransportError") as e:
         logger.error(
             "Unexpected error occurred when sending requests to Elasticsearch: %s "
             "Status code: %s details: %s",
@@ -151,7 +457,7 @@ def get_field_names(
             e.info,
         )
         raise KnowledgeBaseError from e
-    except ElasticsearchException as e:
+    except _getattr("elasticsearch", "ElasticsearchException") as e:
         raise KnowledgeBaseError from e
 
 
@@ -176,6 +482,7 @@ def create_index(
         if not does_index_exist(
             app_namespace, index_name, es_host, es_client, connect_timeout
         ):
+            # TODO: add support for non-english texts by allowing configurable `langauge` as input
             template = resolve_es_config_for_version(
                 DEFAULT_ES_INDEX_TEMPLATE, es_client
             )
@@ -186,12 +493,12 @@ def create_index(
             es_client.indices.create(scoped_index_name, body=mapping)
         else:
             logger.error("Index %r already exists.", index_name)
-    except EsConnectionError as e:
+    except _getattr("elasticsearch", "ConnectionError") as e:
         logger.debug(
             "Unable to connect to Elasticsearch: %202s details: %s", e.error, e.info
         )
-        raise KnowledgeBaseConnectionError(es_host=es_client.transport.hosts) from e
-    except TransportError as e:
+        raise ElasticsearchKnowledgeBaseConnectionError(es_host=es_client.transport.hosts) from e
+    except _getattr("elasticsearch", "TransportError") as e:
         logger.error(
             "Unexpected error occurred when sending requests to Elasticsearch: %s "
             "Status code: %s details: %s",
@@ -204,7 +511,7 @@ def create_index(
             "Elasticsearch: {} Status code: {} details: "
             "{}".format(e.error, e.status_code, e.info)
         ) from e
-    except ElasticsearchException as e:
+    except _getattr("elasticsearch", "ElasticsearchException") as e:
         raise KnowledgeBaseError from e
 
 
@@ -236,12 +543,12 @@ def delete_index(
                     index_name, app_namespace
                 )
             )
-    except EsConnectionError as e:
+    except _getattr("elasticsearch", "ConnectionError") as e:
         logger.debug(
             "Unable to connect to Elasticsearch: %s details: %s", e.error, e.info
         )
-        raise KnowledgeBaseConnectionError(es_host=es_client.transport.hosts) from e
-    except TransportError as e:
+        raise ElasticsearchKnowledgeBaseConnectionError(es_host=es_client.transport.hosts) from e
+    except _getattr("elasticsearch", "TransportError") as e:
         logger.error(
             "Unexpected error occurred when sending requests to Elasticsearch: %s "
             "Status code: %s details: %s",
@@ -250,7 +557,7 @@ def delete_index(
             e.info,
         )
         raise KnowledgeBaseError from e
-    except ElasticsearchException as e:
+    except _getattr("elasticsearch", "ElasticsearchException") as e:
         raise KnowledgeBaseError from e
 
 
@@ -274,9 +581,8 @@ def create_index_mapping(base_mapping, mapping_data):
 def version_compatible_streaming_bulk(
     es_client, docs, index, chunk_size, raise_on_error, doc_type
 ):
-
     if is_es_version_7(es_client):
-        return streaming_bulk(
+        return _getattr("elasticsearch.helpers", "streaming_bulk")(
             es_client,
             docs,
             index=index,
@@ -284,7 +590,7 @@ def version_compatible_streaming_bulk(
             raise_on_error=raise_on_error,
         )
     else:
-        return streaming_bulk(
+        return _getattr("elasticsearch.helpers", "streaming_bulk")(
             es_client,
             docs,
             index=index,
@@ -368,12 +674,12 @@ def load_index(
         # Refresh to make sure all data stored is available for search.
         es_client.indices.refresh(index=scoped_index_name)
         logger.info("Loaded %s document%s", count, "" if count == 1 else "s")
-    except EsConnectionError as e:
+    except _getattr("elasticsearch", "ConnectionError") as e:
         logger.debug(
             "Unable to connect to Elasticsearch: %s details: %s", e.error, e.info
         )
-        raise KnowledgeBaseConnectionError(es_host=es_client.transport.hosts) from e
-    except TransportError as e:
+        raise ElasticsearchKnowledgeBaseConnectionError(es_host=es_client.transport.hosts) from e
+    except _getattr("elasticsearch", "TransportError") as e:
         logger.error(
             "Unexpected error occurred when sending requests to Elasticsearch: %s "
             "Status code: %s details: %s",
@@ -382,5 +688,5 @@ def load_index(
             e.info,
         )
         raise KnowledgeBaseError from e
-    except ElasticsearchException as e:
+    except _getattr("elasticsearch", "ElasticsearchException") as e:
         raise KnowledgeBaseError from e
diff --git a/mindmeld/components/entity_resolver.py b/mindmeld/components/entity_resolver.py
index 3d148f52a..ac1bd993a 100644
--- a/mindmeld/components/entity_resolver.py
+++ b/mindmeld/components/entity_resolver.py
@@ -19,426 +19,110 @@
 import json
 import logging
 import os
-import pickle
 import re
+import uuid
+import warnings
 from abc import ABC, abstractmethod
 from collections import OrderedDict
 from string import punctuation
 
 import numpy as np
 import scipy
-from elasticsearch.exceptions import ConnectionError as EsConnectionError
-from elasticsearch.exceptions import ElasticsearchException, TransportError
 from sklearn.feature_extraction.text import TfidfVectorizer
-from tqdm.autonotebook import trange
+from tqdm.auto import trange
 
 from ._config import (
-    DEFAULT_ES_SYNONYM_MAPPING,
-    PHONETIC_ES_SYNONYM_MAPPING,
     get_app_namespace,
     get_classifier_config,
 )
-from ._elasticsearch_helpers import (
-    INDEX_TYPE_KB,
-    INDEX_TYPE_SYNONYM,
-    DOC_TYPE,
-    create_es_client,
-    delete_index,
-    does_index_exist,
-    get_field_names,
-    get_scoped_index_name,
-    load_index,
-    resolve_es_config_for_version,
-)
 from ._util import _is_module_available, _get_module_or_attr as _getattr
-from .. import path
-from ..core import Entity, Bunch
-from ..exceptions import EntityResolverConnectionError, EntityResolverError
+from ..core import Entity
+from ..exceptions import (
+    ElasticsearchConnectionError,
+    EntityResolverError
+)
+from ..models import create_embedder_model
+from ..path import get_entity_resolver_cache_file_path
 from ..resource_loader import ResourceLoader, Hasher
 
-logger = logging.getLogger(__name__)
-
-
-def _correct_deprecated_er_config(er_config):
-    """
-    for backwards compatability
-      if `er_config` is supplied in deprecated format, its format is corrected and returned,
-      else it is not modified and returned as-is
-
-    deprecated usage
-        >>> er_config = {
-                "model_type": "text_relevance",
-                "model_settings": {
-                    ...
-                }
-            }
-
-    new usage
-        >>> er_config = {
-                "model_type": "resolver",
-                "model_settings": {
-                    "resolver_type": "text_relevance"
-                    ...
-                }
-            }
-    """
-
-    if not er_config.get("model_settings", {}).get("resolver_type", None):
-        model_type = er_config.get("model_type")
-        if model_type == "resolver":
-            raise Exception("Could not find `resolver_type` in `model_settings` of entity resolver")
-        else:
-            logger.warning("DeprecationWarning: Use latest format of configs for entity resolver. "
-                           "See https://www.mindmeld.com/docs/userguide/entity_resolver.html "
-                           "for more details.")
-            er_config = copy.deepcopy(er_config)
-            model_settings = er_config.get("model_settings", {})
-            model_settings.update({"resolver_type": model_type})
-            er_config["model_settings"] = model_settings
-            er_config["model_type"] = "resolver"
-
-    return er_config
-
-
-def _torch(op, *args, sub="", **kwargs):
-    return _getattr(f"torch{'.' + sub if sub else ''}", op)(*args, **kwargs)
-
-
-class BertEmbedder:
-    """
-    Encoder class for bert models based on https://github.com/UKPLab/sentence-transformers
-    """
-
-    # class variable to cache bert model(s);
-    #   helps to mitigate keeping duplicate sets of large weight matrices
-    CACHE_MODELS = {}
-
-    @staticmethod
-    def _batch_to_device(batch, target_device):
-        """
-        send a pytorch batch to a device (CPU/GPU)
-        """
-        tensor = _getattr("torch", "Tensor")
-        for key in batch:
-            if isinstance(batch[key], tensor):
-                batch[key] = batch[key].to(target_device)
-        return batch
-
-    @staticmethod
-    def _num_layers(model):
-        """
-        Finds the number of layers in a given transformers model
-        """
-
-        if hasattr(model, "n_layers"):  # eg. xlm
-            num_layers = model.n_layers
-        elif hasattr(model, "layer"):  # eg. xlnet
-            num_layers = len(model.layer)
-        elif hasattr(model, "encoder"):  # eg. bert
-            num_layers = len(model.encoder.layer)
-        elif hasattr(model, "transformer"):  # eg. sentence_transformers models
-            num_layers = len(model.transformer.layer)
-        else:
-            raise ValueError(f"Not supported model {model} to obtain number of layers")
+if _is_module_available("elasticsearch"):
+    from ._elasticsearch_helpers import (
+        INDEX_TYPE_KB,
+        INDEX_TYPE_SYNONYM,
+        DOC_TYPE,
+        DEFAULT_ES_SYNONYM_MAPPING,
+        PHONETIC_ES_SYNONYM_MAPPING,
+        create_es_client,
+        delete_index,
+        does_index_exist,
+        get_field_names,
+        get_scoped_index_name,
+        load_index,
+        resolve_es_config_for_version,
+    )
 
-        return num_layers
+logger = logging.getLogger(__name__)
 
-    @property
-    def device(self):
-        return "cuda" if _torch("is_available", sub="cuda") else "cpu"
 
-    @staticmethod
-    def get_hashid(config):
-        string = json.dumps(config, sort_keys=True)
-        return Hasher(algorithm="sha1").hash(string=string)
+class EntityResolverFactory:
 
     @staticmethod
-    def get_sentence_transformers_encoder(name_or_path,
-                                          output_type="mean",
-                                          quantize=True,
-                                          return_components=False):
-        """
-        Retrieves a sentence-transformer model and returns it along with its transformer and
-        pooling components.
-
-        Args:
-            name_or_path: name or path to load a huggingface model
-            output_type: type of pooling required
-            quantize: if the model needs to be qunatized or not
-            return_components: if True, returns the Transformer and Poooling components of the
-                                sentence-bert model in a Bunch data type,
-                                else just returns the sentence-bert model
-
-        Returns:
-            Union[
-                sentence_transformers.SentenceTransformer,
-                Bunch(sentence_transformers.Transformer,
-                      sentence_transformers.Pooling,
-                      sentence_transformers.SentenceTransformer)
-            ]
-        """
-
-        strans_models = _getattr("sentence_transformers.models")
-        strans = _getattr("sentence_transformers", "SentenceTransformer")
-
-        transformer_model = strans_models.Transformer(name_or_path,
-                                                      model_args={"output_hidden_states": True})
-        pooling_model = strans_models.Pooling(transformer_model.get_word_embedding_dimension(),
-                                              pooling_mode_cls_token=output_type == "cls",
-                                              pooling_mode_max_tokens=False,
-                                              pooling_mode_mean_tokens=output_type == "mean",
-                                              pooling_mode_mean_sqrt_len_tokens=False)
-        sbert_model = strans(modules=[transformer_model, pooling_model])
-
-        if quantize:
-            if not _is_module_available("torch"):
-                raise ImportError("`torch` library required to quantize models") from None
-
-            torch_qint8 = _getattr("torch", "qint8")
-            torch_nn_linear = _getattr("torch.nn", "Linear")
-            torch_quantize_dynamic = _getattr("torch.quantization", "quantize_dynamic")
-
-            transformer_model = torch_quantize_dynamic(
-                transformer_model, {torch_nn_linear}, dtype=torch_qint8
-            ) if transformer_model else None
-            pooling_model = torch_quantize_dynamic(
-                pooling_model, {torch_nn_linear}, dtype=torch_qint8
-            ) if pooling_model else None
-            sbert_model = torch_quantize_dynamic(
-                sbert_model, {torch_nn_linear}, dtype=torch_qint8
-            ) if sbert_model else None
-
-        if return_components:
-            return Bunch(
-                transformer_model=transformer_model,
-                pooling_model=pooling_model,
-                sbert_model=sbert_model
-            )
-
-        return sbert_model
-
-    def _init_sentence_transformers_encoder(self, model_configs):
-
-        sbert_model = None
-        sbert_model_hashid = self.get_hashid(model_configs)
-        sbert_model_name = model_configs["pretrained_name_or_abspath"]
-        sbert_output_type = model_configs["bert_output_type"]
-        sbert_quantize_model = model_configs["quantize_model"]
-
-        if sbert_model_hashid not in BertEmbedder.CACHE_MODELS:
-
-            info_msg = ""
-            for name in [f"sentence-transformers/{sbert_model_name}", sbert_model_name]:
-                try:
-                    sbert_model = (
-                        self.get_sentence_transformers_encoder(name,
-                                                               output_type=sbert_output_type,
-                                                               quantize=sbert_quantize_model,
-                                                               return_components=True)
-                    )
-                    info_msg += f"Successfully initialized name/path `{name}` directly through " \
-                                f"huggingface-transformers. "
-                except OSError:
-                    info_msg += f"Could not initialize name/path `{name}` directly through " \
-                                f"huggingface-transformers. "
-
-                if sbert_model:
-                    break
-
-            logger.info(info_msg)
-
-            if not sbert_model:
-                msg = f"Could not resolve the name/path `{sbert_model_name}`. " \
-                      f"Please check the model name and retry."
-                raise Exception(msg)
-
-            BertEmbedder.CACHE_MODELS.update({sbert_model_hashid: sbert_model})
-
-        sbert_model = BertEmbedder.CACHE_MODELS.get(sbert_model_hashid)
-        self.transformer_model = sbert_model.transformer_model
-        self.pooling_model = sbert_model.pooling_model
-        self.sbert_model = sbert_model.sbert_model
-
-    def _encode_local(self,
-                      sentences,
-                      batch_size,
-                      show_progress_bar,
-                      output_value,
-                      convert_to_numpy,
-                      convert_to_tensor,
-                      device,
-                      concat_last_n_layers,
-                      normalize_token_embs):
+    def _correct_deprecated_er_config(er_config):
         """
-        Computes sentence embeddings (Note: Method largely derived from Sentence Transformers
-            library to improve flexibility in encoding and pooling. Notably, `is_pretokenized` and
-            `num_workers` are ignored due to deprecation in their library, retrieved 23-Feb-2021)
-        """
-
-        if concat_last_n_layers != 1:
-            assert 1 <= concat_last_n_layers <= self._num_layers(self.transformer_model.auto_model)
-
-        self.transformer_model.eval()
-        if show_progress_bar is None:
-            show_progress_bar = (
-                logger.getEffectiveLevel() == logging.INFO or
-                logger.getEffectiveLevel() == logging.DEBUG
-            )
-
-        if convert_to_tensor:
-            convert_to_numpy = False
-
-        input_is_string = isinstance(sentences, str)
-        if input_is_string:  # Cast an individual sentence to a list with length 1
-            sentences = [sentences]
-
-        self.transformer_model.to(device)
-        self.pooling_model.to(device)
-
-        all_embeddings = []
-        length_sorted_idx = np.argsort([len(sen) for sen in sentences])
-        sentences_sorted = [sentences[idx] for idx in length_sorted_idx]
-
-        for start_index in trange(0, len(sentences), batch_size, desc="Batches",
-                                  disable=not show_progress_bar):
-            sentences_batch = sentences_sorted[start_index:start_index + batch_size]
-            features = self.transformer_model.tokenize(sentences_batch)
-            features = self._batch_to_device(features, device)
-
-            with _torch("no_grad"):
-                out_features_transformer = self.transformer_model.forward(features)
-                token_embeddings = out_features_transformer["token_embeddings"]
-                if concat_last_n_layers > 1:
-                    _all_layer_embs = out_features_transformer["all_layer_embeddings"]
-                    token_embeddings = _torch(
-                        "cat", _all_layer_embs[-concat_last_n_layers:], dim=-1)
-                if normalize_token_embs:
-                    _norm_token_embeddings = _torch(
-                        "norm", token_embeddings, sub="linalg", dim=2, keepdim=True)
-                    token_embeddings = token_embeddings.div(_norm_token_embeddings)
-                out_features_transformer.update({"token_embeddings": token_embeddings})
-                out_features = self.pooling_model.forward(out_features_transformer)
-
-                embeddings = out_features[output_value]
-
-                if output_value == 'token_embeddings':
-                    # Set token embeddings to 0 for padding tokens
-                    input_mask = out_features['attention_mask']
-                    input_mask_expanded = input_mask.unsqueeze(-1).expand(embeddings.size()).float()
-                    embeddings = embeddings * input_mask_expanded
-
-                embeddings = embeddings.detach()
-
-                if convert_to_numpy:
-                    embeddings = embeddings.cpu()
-
-                all_embeddings.extend(embeddings)
-
-        all_embeddings = [all_embeddings[idx] for idx in np.argsort(length_sorted_idx)]
-
-        if convert_to_tensor:
-            all_embeddings = _torch("stack", all_embeddings)
-        elif convert_to_numpy:
-            all_embeddings = np.asarray([emb.numpy() for emb in all_embeddings])
-
-        if input_is_string:
-            all_embeddings = all_embeddings[0]
-
-        return all_embeddings
-
-    def encode(self, phrases, **kwargs):
-        """Encodes input text(s) into embeddings, one vector for each phrase
-
-        Args:
-            phrases (str, list[str]): textual inputs that are to be encoded using sentence \
-                                        transformers' model
-            batch_size (int): the batch size used for the computation
-            show_progress_bar (bool): Output a progress bar when encode sentences
-            output_value (str): Default sentence_embedding, to get sentence embeddings.
-                Can be set to token_embeddings to get wordpiece token embeddings.
-            convert_to_numpy (bool): If true, the output is a list of numpy vectors. Else, it is a
-                list of pytorch tensors.
-            convert_to_tensor (bool): If true, you get one large tensor as return. Overwrites any
-                setting from convert_to_numpy
-            device: Which torch.device to use for the computation
-            concat_last_n_layers (int): number of hidden outputs to concat starting from last layer
-            normalize_token_embs (bool): if the (sub-)token embs are to be individually normalized
+        for backwards compatability
+          if `er_config` is supplied in deprecated format, its format is corrected and returned,
+          else it is not modified and returned as-is
+
+        deprecated usage
+            >>> er_config = {
+                    "model_type": "text_relevance",
+                    "model_settings": {
+                        ...
+                    }
+                }
 
-        Returns:
-            (Union[List[Tensor], ndarray, Tensor]): By default, a list of tensors is returned.
-                If convert_to_tensor, a stacked tensor is returned. If convert_to_numpy, a numpy
-                matrix is returned.
+        new usage
+            >>> er_config = {
+                    "model_type": "resolver",
+                    "model_settings": {
+                        "resolver_type": "text_relevance"
+                        ...
+                    }
+                }
         """
 
-        if not phrases:
-            return []
-
-        if not isinstance(phrases, (str, list)):
-            raise TypeError(f"argument phrases must be of type str or list, not {type(phrases)}")
-
-        batch_size = kwargs.get("batch_size", 16)
-        _len_phrases = len(phrases) if isinstance(phrases, list) else 1
-        show_progress_bar = kwargs.get("show_progress_bar", False) and _len_phrases > 1
-        output_value = kwargs.get("output_value", 'sentence_embedding')
-        convert_to_numpy = kwargs.get("convert_to_numpy", True)
-        convert_to_tensor = kwargs.get("convert_to_tensor", False)
-        device = kwargs.get("device", self.device)
-        concat_last_n_layers = kwargs.get("concat_last_n_layers", 1)
-        normalize_token_embs = kwargs.get("normalize_token_embs", False)
-
-        # `False` for first call but might not for the subsequent calls
-        _use_sbert_model = getattr(self, "_use_sbert_model", False)
-
-        if not _use_sbert_model:
-            try:
-                # this snippet is to reduce dependency on sentence-transformers library
-                #   note that currently, the dependency is not fully eliminated due to backwards
-                #   compatability issues in huggingface-transformers between older (python 3.6)
-                #   and newer (python >=3.7) versions which needs more conditions to be implemented
-                #   in `_encode_local` and hence will be addressed in future work
-                # TODO: eliminate depedency on sentence-transformers library
-                results = self._encode_local(phrases,
-                                             batch_size=batch_size,
-                                             show_progress_bar=show_progress_bar,
-                                             output_value=output_value,
-                                             convert_to_numpy=convert_to_numpy,
-                                             convert_to_tensor=convert_to_tensor,
-                                             device=device,
-                                             concat_last_n_layers=concat_last_n_layers,
-                                             normalize_token_embs=normalize_token_embs)
-                setattr(self, "_use_sbert_model", False)
-            except TypeError as e:
-                logger.error(e)
-                if concat_last_n_layers != 1 or normalize_token_embs:
-                    msg = f"{'concat_last_n_layers,' if concat_last_n_layers != 1 else ''} " \
-                          f"{'normalize_token_embs' if normalize_token_embs else ''} " \
-                          f"ignored as resorting to using encode methods from sentence-transformers"
-                    logger.warning(msg)
-                setattr(self, "_use_sbert_model", True)
-
-        if getattr(self, "_use_sbert_model"):
-            results = self.sbert_model.encode(phrases,
-                                              batch_size=batch_size,
-                                              show_progress_bar=show_progress_bar,
-                                              output_value=output_value,
-                                              convert_to_numpy=convert_to_numpy,
-                                              convert_to_tensor=convert_to_tensor,
-                                              device=device)
-
-        return results
-
-
-class EntityResolverFactory:
+        if not er_config.get("model_settings", {}).get("resolver_type"):
+            model_type = er_config.get("model_type")
+            if model_type == "resolver":
+                raise ValueError(
+                    "Could not find `resolver_type` in `model_settings` of entity resolver")
+            else:
+                msg = "Using deprecated config format for Entity Resolver. " \
+                      "See https://www.mindmeld.com/docs/userguide/entity_resolver.html " \
+                      "for more details."
+                warnings.warn(msg, DeprecationWarning)
+                er_config = copy.deepcopy(er_config)
+                model_settings = er_config.get("model_settings", {})
+                model_settings.update({"resolver_type": model_type})
+                er_config["model_settings"] = model_settings
+                er_config["model_type"] = "resolver"
+
+        return er_config
 
     @staticmethod
     def _validate_resolver_type(name):
-        if name not in ENTITY_RESOLVER_MODEL_TYPES:
-            raise Exception(f"Expected 'resolver_type' in ENTITY_RESOLVER_CONFIG "
-                            f"among {ENTITY_RESOLVER_MODEL_TYPES}")
+        if name not in ENTITY_RESOLVER_MODEL_MAPPINGS:
+            raise ValueError(f"Expected 'resolver_type' in config of Entity Resolver "
+                             f"among {[*ENTITY_RESOLVER_MODEL_MAPPINGS]} but found {name}")
         if name == "sbert_cosine_similarity" and not _is_module_available("sentence_transformers"):
             raise ImportError(
                 "Must install the extra [bert] by running `pip install mindmeld[bert]` "
-                "to use the built in embbedder for entity resolution.")
+                "to use the built in embedder for entity resolution.")
+        if name == "text_relevance" and not _is_module_available("elasticsearch"):
+            raise ImportError(
+                "Must install the extra [elasticsearch] by running "
+                "`pip install mindmeld[elasticsearch]` "
+                "to use Elasticsearch based entity resolution.")
 
     @classmethod
     def create_resolver(cls, app_path, entity_type, **kwargs):
@@ -456,29 +140,34 @@ def create_resolver(cls, app_path, entity_type, **kwargs):
         """
 
         er_config = (
-            kwargs.pop("er_config", None) or
+            kwargs.pop("config", {}) or
             get_classifier_config("entity_resolution", app_path=app_path)
         )
-        er_config = _correct_deprecated_er_config(er_config)
+        er_config = cls._correct_deprecated_er_config(er_config)
 
         resolver_type = er_config["model_settings"]["resolver_type"]
         cls._validate_resolver_type(resolver_type)
 
         resource_loader = kwargs.pop(
             "resource_loader",
-            ResourceLoader.create_resource_loader(app_path=app_path))
-
-        return ENTITY_RESOLVER_MODEL_MAPPINGS.get(resolver_type)(
-            app_path, entity_type, er_config, resource_loader, **kwargs
+            ResourceLoader.create_resource_loader(app_path=app_path)
         )
 
+        kwargs.update({
+            "entity_type": entity_type,
+            "config": er_config,
+            "resource_loader": resource_loader,
+        })
 
-class EntityResolverBase(ABC):
+        return ENTITY_RESOLVER_MODEL_MAPPINGS.get(resolver_type)(app_path, **kwargs)
+
+
+class BaseEntityResolver(ABC):
     """
     Base class for Entity Resolvers
     """
 
-    def __init__(self, app_path, entity_type, resource_loader=None):
+    def __init__(self, app_path, entity_type, resource_loader=None, **_kwargs):
         """Initializes an entity resolver"""
         self.app_path = app_path
         self.type = entity_type
@@ -486,26 +175,124 @@ def __init__(self, app_path, entity_type, resource_loader=None):
             resource_loader or ResourceLoader.create_resource_loader(app_path=self.app_path)
         )
 
+        self._processed_entity_map = None
         self._is_system_entity = Entity.is_system_entity(self.type)
         self._no_trainable_canonical_entity_map = False
         self.dirty = False  # bool, True if exists any unsaved generated data that can be saved
         self.ready = False  # bool, True if the model is fit by calling .fit()
 
+    def __repr__(self):
+        msg = "<{} ready: {!r}, dirty: {!r}, app_path: {!r}, entity_type: {!r}>"
+        return msg.format(self.__class__.__name__, self.ready, self.dirty, self.app_path, self.type)
+
+    def _load_and_get_entity_map(self, force_reload=False):
+        try:
+            return self._resource_loader.get_entity_map(self.type, force_reload=force_reload)
+        except Exception as e:
+            msg = f"Unable to load entity mapping data for " \
+                  f"entity type: {self.type} in app_path: {self.app_path}"
+            raise Exception(msg) from e
+
     @staticmethod
-    def _process_entity_map(entity_type,
-                            entity_map,
-                            normalizer=None,
-                            augment_lower_case=False,
-                            augment_title_case=False,
-                            augment_normalized=False,
-                            normalize_aliases=False):
+    def _format_entity_map(entity_map):
+        if not entity_map:
+            return {}
+
+        if not entity_map.get("entities", []):
+            return entity_map
+
+        # format (if required) entities key-field
+        new_entities = []
+        all_ids = {}
+        for ent in entity_map.get("entities", []):
+            cname, _id = ent.get("cname"), ent.get("id")
+            whitelist = list(dict.fromkeys(ent.get("whitelist", [])))
+            if not cname and not whitelist:
+                continue
+            if not cname:
+                cname = whitelist[0]
+                whitelist = whitelist[1:]
+            if _id in all_ids:
+                msg = f"Found a duplicate id {_id} while formatting data for entity resolution. "
+                _id = uuid.uuid4()
+                msg += f"Replacing it with a new id: {_id}"
+                logger.warning(msg)
+            if not _id:
+                _id = uuid.uuid4()
+                msg = f"Found an entry in entity_map without a corresponding id. " \
+                      f"Creating a random new id ({_id}) for this object."
+                logger.warning(msg)
+            _id = str(_id)
+            all_ids.update({_id: None})
+            new_entities.append({"id": _id, "cname": cname, "whitelist": whitelist})
+
+        entity_map["entities"] = new_entities
+        return entity_map
+
+    def _trim_results(self, results, top_n):
+        """
+        Trims down the results generated by any ER class, finally populating at max top_n documents
+
+        Args:
+            results (list[dict]): Each element in this list is a result dictions with keys such as
+                `id` (optional), `cname`, `score` and any others
+            top_n (int): Number of top documents required to be populated
+
+        Returns:
+            list[dict]: if trimmed, a list similar to `results` but with fewer elements,
+                        else, the `results` list as-is is returned
+        """
+
+        if not results:
+            return None
+
+        if not isinstance(top_n, int) or top_n <= 0:
+            msg = f"The value of 'top_n' set to '{top_n}' during predictions in " \
+                  f"{self.__class__.__name__}. This will result in an unsorted list of documents. "
+            logger.info(msg)
+            return results
+
+        # Obtain top scored result for each doc id (only if scores field exist in results)
+        best_results = {}
+        for result in results:
+            if "score" not in result:
+                return results
+            # use cname as id if no `id` field exist in results
+            _id = result["id"] if "id" in result else result["cname"]
+            if _id not in best_results or result["score"] > best_results[_id]["score"]:
+                best_results[_id] = result
+
+        # sort as per top_n requirement using the score field
+        results = [*best_results.values()]
+        n_scores = len(results)
+        if n_scores < top_n:
+            if top_n != 20:
+                # log only if a value different from default value is specified
+                msg = f"Retrieved only {len(results)} entity resolutions instead of asked " \
+                      f"number {top_n} for entity type {self.type}"
+                logger.info(msg)
+            return results
+        # else, n_scores >= top_n
+        _sim_scores = np.asarray([val["score"] for val in results])
+        _top_inds = _sim_scores.argpartition(n_scores - top_n)[-top_n:]
+        results = [results[ind] for ind in _top_inds]  # narrowed list of top_n docs
+        results = sorted(results, key=lambda x: x["score"], reverse=True)
+
+        return results
+
+    def process_entities(self,
+                         entities,
+                         normalizer=None,
+                         augment_lower_case=False,
+                         augment_title_case=False,
+                         augment_normalized=False,
+                         normalize_aliases=False):
         """
         Loads in the mapping.json file and stores the synonym mappings in a item_map
             and a synonym_map
 
         Args:
-            entity_type (str): The entity type associated with this entity resolver
-            entity_map (dict): The loaded mapping.json file for the given entity type
+            entities (list[dict]): List of dictionaries with keys `id`, `cname` and `whitelist`
             normalizer (callable): The normalizer to use, if provided, used to normalize synonyms
             augment_lower_case (bool): If to extend the synonyms list with their lower-cased values
             augment_title_case (bool): If to extend the synonyms list with their title-cased values
@@ -521,15 +308,15 @@ def _process_entity_map(entity_type,
         item_map = {}
         syn_map = {}
         seen_ids = []
-        for item in entity_map.get("entities", []):
-            cname = item["cname"]
+        for item in entities:
             item_id = item.get("id")
+            cname = item["cname"]
             if cname in item_map:
                 msg = "Canonical name %s specified in %s entity map multiple times"
-                logger.debug(msg, cname, entity_type)
+                logger.debug(msg, cname, self.type)
             if item_id and item_id in seen_ids:
-                msg = "Item id {!r} specified in {!r} entity map multiple times"
-                raise ValueError(msg.format(item_id, entity_type))
+                msg = "Id %s specified in %s entity map multiple times"
+                raise ValueError(msg.format(item_id, self.type))
             seen_ids.append(item_id)
 
             aliases = [cname] + item.pop("whitelist", [])
@@ -542,9 +329,8 @@ def _process_entity_map(entity_type,
                 if augment_normalized and normalizer:
                     new_aliases.extend([normalizer(string) for string in aliases])
                 aliases = set([*aliases, *new_aliases])
-            if normalize_aliases:
-                alias_normalizer = normalizer
-                aliases = [alias_normalizer(alias) for alias in aliases]
+            if normalize_aliases and normalizer:
+                aliases = [normalizer(alias) for alias in aliases]
 
             items_for_cname = item_map.get(cname, [])
             items_for_cname.append(item)
@@ -552,33 +338,36 @@ def _process_entity_map(entity_type,
             for alias in aliases:
                 if alias in syn_map:
                     msg = "Synonym %s specified in %s entity map multiple times"
-                    logger.debug(msg, cname, entity_type)
+                    logger.debug(msg, cname, self.type)
                 cnames_for_syn = syn_map.get(alias, [])
                 cnames_for_syn.append(cname)
                 syn_map[alias] = list(set(cnames_for_syn))
 
         return {"items": item_map, "synonyms": syn_map}
 
-    def _load_entity_map(self, force_reload=False):
-        return self._resource_loader.get_entity_map(self.type, force_reload=force_reload)
-
-    @abstractmethod
-    def _fit(self, clean, entity_map):
-        """Fits the entity resolver model
-
-        Args:
-            clean (bool): If ``True``, deletes and recreates the index from scratch instead of
-                            updating the existing index with synonyms in the mapping.json.
-            entity_map (json): json data loaded from `mapping.json` file for the entity type
-        """
-        raise NotImplementedError
-
-    def fit(self, clean=False):
+    def fit(self, clean=False, entity_map=None):
         """Fits the resolver model, if required
 
         Args:
             clean (bool, optional): If ``True``, deletes and recreates the index from scratch
                                     with synonyms in the mapping.json.
+            entity_map (Dict[str, Union[str, List]]): Entity map if passed in directly instead of
+                                                        loading from a file path
+
+        Example for entity_map.json:
+        ---------------------------
+        entity_map = {
+            "some_optional_key": "value",
+            "entities": [
+                {
+                    "id": "B01MTUORTQ",
+                    "cname": "Seaweed Salad",
+                    "whitelist": [...],
+                },
+                ...
+            ],
+        }
+
         """
 
         msg = f"Fitting {self.__class__.__name__} entity resolver for entity_type {self.type}"
@@ -593,7 +382,8 @@ def fit(self, clean=False):
             return
 
         # load data: list of canonical entities and their synonyms
-        entity_map = self._load_entity_map()
+        entity_map = entity_map or self._load_and_get_entity_map()
+        entity_map = self._format_entity_map(entity_map)
         if not entity_map.get("entities", []):
             self._no_trainable_canonical_entity_map = True
             self.ready = True
@@ -603,18 +393,20 @@ def fit(self, clean=False):
         self.ready = True
         return
 
-    @abstractmethod
-    def _predict(self, nbest_entities, top_n):
-        raise NotImplementedError
-
-    def predict(self, entity, top_n: int = 20):
+    def predict(self, entity_or_list_of_entities, top_n=20, allowed_cnames=None):
         """Predicts the resolved value(s) for the given entity using the loaded entity map or the
         trained entity resolution model.
 
         Args:
-            entity (Entity, tuple[Entity], str, tuple[str]): An entity found in an input query,
-                                                                or a list of n-best entity objects.
-            top_n (int): maximum number of results to populate
+            entity_or_list_of_entities (Entity, tuple[Entity], str, tuple[str]): One or more
+                entity query strings or Entity objects that needs to be resolved.
+            top_n (int, optional): maximum number of results to populate. If specifically inputted
+                as 0 or `None`, results in an unsorted list of results in case of embedder and tfidf
+                entity resolvers. This is sometimes helpful when a developer wishes to do some
+                wrapper operations on top of unsorted results, such as combining scores from
+                multiple resolvers and then sorting, etc.
+            allowed_cnames (Iterable, optional): if inputted, predictions will only include objects
+                related to these canonical names
 
         Returns:
             (list): The top n resolved values for the provided entity.
@@ -624,52 +416,134 @@ def predict(self, entity, top_n: int = 20):
             msg = "Resolver not ready, model must be built (.fit()) or loaded (.load()) first."
             logger.error(msg)
 
-        nbest_entities = entity
+        nbest_entities = entity_or_list_of_entities
         if not isinstance(nbest_entities, (list, tuple)):
             nbest_entities = tuple([nbest_entities])
 
         nbest_entities = tuple(
             [Entity(e, self.type) if isinstance(e, str) else e for e in nbest_entities]
         )
-        top_entity = nbest_entities[0]
 
         if self._is_system_entity:
             # system entities are already resolved
+            top_entity = nbest_entities[0]
             return [top_entity.value]
 
         if self._no_trainable_canonical_entity_map:
             return []
 
-        results = self._predict(nbest_entities, top_n)
+        if allowed_cnames:
+            allowed_cnames = set(allowed_cnames)  # order doesn't matter
 
-        if not results:
-            return None
+        # unsorted list in case of tfidf and embedder models; sorted in case of Elasticsearch
+        results = self._predict(nbest_entities, allowed_cnames)
 
-        results = results[:top_n]
-        if len(results) < top_n:
-            logger.info(
-                "Retrieved only %d entity resolutions instead of asked number %d for "
-                "entity %r of type %r",
-                len(results), top_n, nbest_entities[0].text, self.type,
-            )
+        return self._trim_results(results, top_n)
 
-        return results
+    def load(self):
+        """If available, loads embeddings of synonyms that are previously dumped
+        """
+        self._load()
+
+    @abstractmethod
+    def _fit(self, clean, entity_map):
+        """Fits the entity resolver model
+
+        Args:
+            clean (bool): If ``True``, deletes and recreates the index from scratch instead of
+                            updating the existing index with synonyms in the mapping.json.
+            entity_map (json): json data loaded from `mapping.json` file for the entity type
+        """
+        raise NotImplementedError
+
+    @abstractmethod
+    def _predict(self, nbest_entities, allowed_cnames=None):
+        """Predicts the resolved value(s) for the given entity using cosine similarity.
+
+        Args:
+            nbest_entities (tuple): List of one entity object found in an input query, or a list  \
+                of n-best entity objects.
+            allowed_cnames (set, optional): if inputted, predictions will only include objects
+                related to these canonical names
+
+        Returns:
+            (list): The resolved values for the provided entity.
+        """
+        raise NotImplementedError
 
     @abstractmethod
     def _load(self):
         raise NotImplementedError
 
-    def load(self):
-        """If available, loads embeddings of synonyms that are previously dumped
+
+class ExactMatchEntityResolver(BaseEntityResolver):
+    """
+    Resolver class based on exact matching
+    """
+
+    def __init__(self, app_path, **kwargs):
+        super().__init__(app_path, **kwargs)
+
+        settings = kwargs.pop("config", {}).get("model_settings", {})
+        self._aug_lower_case = settings.get("augment_lower_case", False)
+        self._aug_title_case = settings.get("augment_title_case", False)
+        self._aug_normalized = settings.get("augment_normalized", False)
+
+    def _fit(self, clean, entity_map):
+
+        if clean:
+            logger.info(
+                "clean=True ignored while fitting ExactMatchEntityResolver"
+            )
+
+        entities = entity_map.get("entities", [])
+        self._processed_entity_map = self.process_entities(
+            entities,
+            normalizer=self._resource_loader.query_factory.normalize,
+            augment_lower_case=self._aug_lower_case,
+            augment_title_case=self._aug_title_case,
+            augment_normalized=self._aug_normalized,
+            normalize_aliases=True
+        )
+
+    def _predict(self, nbest_entities, allowed_cnames=None):
+        """Looks for exact name in the synonyms data
         """
-        self._load()
 
-    def __repr__(self):
-        msg = "<{} ready: {!r}, dirty: {!r}>"
-        return msg.format(self.__class__.__name__, self.ready, self.dirty)
+        entity = nbest_entities[0]  # top_entity
+
+        normed = self._resource_loader.query_factory.normalize(entity.text)
+        try:
+            cnames = self._processed_entity_map["synonyms"][normed]
+        except (KeyError, TypeError):
+            logger.warning(
+                "Failed to resolve entity %r for type %r", entity.text, entity.type
+            )
+            return []
+
+        if len(cnames) > 1:
+            logger.info(
+                "Multiple possible canonical names for %r entity for type %r",
+                entity.text,
+                entity.type,
+            )
+
+        values = []
+        for cname in cnames:
+            if allowed_cnames and cname not in allowed_cnames:
+                continue
+            for item in self._processed_entity_map["items"][cname]:
+                item_value = copy.copy(item)
+                item_value.pop("whitelist", None)
+                values.append(item_value)
+
+        return values
+
+    def _load(self):
+        self.fit()
 
 
-class ElasticsearchEntityResolver(EntityResolverBase):
+class ElasticsearchEntityResolver(BaseEntityResolver):
     """
     Resolver class based on Elastic Search
     """
@@ -678,13 +552,13 @@ class ElasticsearchEntityResolver(EntityResolverBase):
     ES_SYNONYM_INDEX_PREFIX = "synonym"
     """The prefix of the ES index."""
 
-    def __init__(self, app_path, entity_type, er_config, resource_loader, **kwargs):
-        super().__init__(app_path, entity_type, resource_loader=resource_loader)
+    def __init__(self, app_path, **kwargs):
+        super().__init__(app_path, **kwargs)
 
-        self._es_host = kwargs.get("es_host", None)
-        self._es_config = {"client": kwargs.get("es_client", None), "pid": os.getpid()}
+        self._es_host = kwargs.get("es_host")
+        self._es_config = {"client": kwargs.get("es_client"), "pid": os.getpid()}
         self._use_double_metaphone = "double_metaphone" in (
-            er_config.get("model_settings", {}).get("phonetic_match_types", [])
+            kwargs.pop("config", {}).get("model_settings", {}).get("phonetic_match_types", [])
         )
 
         self._app_namespace = get_app_namespace(self.app_path)
@@ -857,7 +731,7 @@ def _fit(self, clean, entity_map):
                 use_double_metaphone=self._use_double_metaphone,
             )
 
-    def _predict(self, nbest_entities, top_n):
+    def _predict(self, nbest_entities, allowed_cnames=None):
         """Predicts the resolved value(s) for the given entity using the loaded entity map or the
         trained entity resolution model.
 
@@ -869,6 +743,10 @@ def _predict(self, nbest_entities, top_n):
             (list): The resolved values for the provided entity.
         """
 
+        if allowed_cnames:
+            msg = f"Cannot set 'allowed_cnames' param for {self.__class__.__name__}."
+            raise NotImplementedError(msg)
+
         top_entity = nbest_entities[0]
 
         weight_factors = [1 - float(i) / len(nbest_entities) for i in range(len(nbest_entities))]
@@ -1008,12 +886,12 @@ def _construct_whitelist_query(entity, weight=1, use_phons=False):
         try:
             index = get_scoped_index_name(self._app_namespace, self._es_index_name)
             response = self._es_client.search(index=index, body=text_relevance_query)
-        except EsConnectionError as ex:
+        except _getattr("elasticsearch", "ConnectionError") as ex:
             logger.error(
                 "Unable to connect to Elasticsearch: %s details: %s", ex.error, ex.info
             )
-            raise EntityResolverConnectionError(es_host=self._es_client.transport.hosts) from ex
-        except TransportError as ex:
+            raise ElasticsearchConnectionError(es_host=self._es_client.transport.hosts) from ex
+        except _getattr("elasticsearch", "TransportError") as ex:
             logger.error(
                 "Unexpected error occurred when sending requests to Elasticsearch: %s "
                 "Status code: %s details: %s",
@@ -1026,7 +904,7 @@ def _construct_whitelist_query(entity, weight=1, use_phons=False):
                 "Elasticsearch: {} Status code: {} details: "
                 "{}".format(ex.error, ex.status_code, ex.info)
             ) from ex
-        except ElasticsearchException as ex:
+        except _getattr("elasticsearch", "ElasticsearchException") as ex:
             raise EntityResolverError from ex
         else:
             hits = response["hits"]["hits"]
@@ -1065,12 +943,12 @@ def _load(self):
             )
             if not self._es_client.indices.exists(index=scoped_index_name):
                 self.fit()
-        except EsConnectionError as e:
+        except _getattr("elasticsearch", "ConnectionError") as e:
             logger.error(
                 "Unable to connect to Elasticsearch: %s details: %s", e.error, e.info
             )
-            raise EntityResolverConnectionError(es_host=self._es_client.transport.hosts) from e
-        except TransportError as e:
+            raise ElasticsearchConnectionError(es_host=self._es_client.transport.hosts) from e
+        except _getattr("elasticsearch", "TransportError") as e:
             logger.error(
                 "Unexpected error occurred when sending requests to Elasticsearch: %s "
                 "Status code: %s details: %s",
@@ -1079,334 +957,361 @@ def _load(self):
                 e.info,
             )
             raise EntityResolverError from e
-        except ElasticsearchException as e:
+        except _getattr("elasticsearch", "ElasticsearchException") as e:
             raise EntityResolverError from e
 
 
-class ExactMatchEntityResolver(EntityResolverBase):
+class TfIdfSparseCosSimEntityResolver(BaseEntityResolver):
     """
-    Resolver class based on exact matching
+    a tf-idf based entity resolver using sparse matrices. ref:
+    scikit-learn.org/stable/modules/generated/sklearn.feature_extraction.text.TfidfVectorizer.html
     """
 
-    def __init__(self, app_path, entity_type, er_config, resource_loader, **_kwargs):
-        super().__init__(app_path, entity_type, resource_loader=resource_loader)
+    def __init__(self, app_path, **kwargs):
+        super().__init__(app_path, **kwargs)
 
-        self._augment_lower_case = er_config.get(
-            "model_settings", {}
-        ).get("augment_lower_case", False)
-        self._processed_entity_map = None
-
-    def _fit(self, clean, entity_map):
-
-        if clean:
-            logger.info(
-                "clean=True ignored while fitting ExactMatchEntityResolver"
-            )
+        settings = kwargs.pop("config", {}).get("model_settings", {})
+        self._aug_lower_case = settings.get("augment_lower_case", True)
+        self._aug_title_case = settings.get("augment_title_case", False)
+        self._aug_normalized = settings.get("augment_normalized", False)
+        self._aug_max_syn_embs = settings.get("augment_max_synonyms_embeddings", True)
 
-        self._processed_entity_map = self._process_entity_map(
-            self.type,
-            entity_map,
-            normalizer=self._resource_loader.query_factory.normalize,
-            augment_lower_case=self._augment_lower_case,
-            normalize_aliases=True
-        )
+        self.ngram_length = 5  # max number of character ngrams to consider; 3 for elasticsearch
+        self._analyzer = kwargs.get("analyzer") or self._char_ngrams_plus_words_analyzer
+        self._vectorizer = TfidfVectorizer(analyzer=self._analyzer, lowercase=False)
+        self._syn_tfidf_matrix = None
+        self._unique_synonyms = []
 
-    def _predict(self, nbest_entities, top_n):
-        """Looks for exact name in the synonyms data
+    def _char_ngrams_plus_words_analyzer(self, string):
         """
+        Analyzer that accounts for character ngrams as well as individual words in the input
+        """
+        # get char ngrams
+        results = self._char_ngrams_analyzer(string)
+        # add individual words
+        words = re.split(r'[\s{}]+'.format(re.escape(punctuation)), string.strip())
+        results.extend(words)
+        return results
 
-        entity = nbest_entities[0]  # top_entity
+    def _char_ngrams_analyzer(self, string):
+        """
+        Analyzer that only accounts for character ngrams from size 1 to self.ngram_length
+        """
+        string = string.strip()
+        if len(string) == 1:
+            return [string]
 
-        normed = self._resource_loader.query_factory.normalize(entity.text)
-        try:
-            cnames = self._processed_entity_map["synonyms"][normed]
-        except (KeyError, TypeError):
-            logger.warning(
-                "Failed to resolve entity %r for type %r", entity.text, entity.type
-            )
-            return None
+        results = []
+        # give importance to starting and ending characters of a word
+        string = f" {string} "
+        for n in range(self.ngram_length + 1):
+            results.extend([''.join(gram) for gram in zip(*[string[i:] for i in range(n)])])
+        results = list(set(results))
+        results.remove(' ')
+        # adding lowercased single characters might add more noise
+        results = [r for r in results if not (len(r) == 1 and r.islower())]
+        # returns empty list of an empty string
+        return results
 
-        if len(cnames) > 1:
-            logger.info(
-                "Multiple possible canonical names for %r entity for type %r",
-                entity.text,
-                entity.type,
-            )
+    def find_similarity(self,
+                        src_texts,
+                        top_n=20,
+                        scores_normalizer=None,
+                        _return_as_dict=False,
+                        _no_sort=False):
+        """Computes sparse cosine similarity
 
-        values = []
-        for cname in cnames:
-            for item in self._processed_entity_map["items"][cname]:
-                item_value = copy.copy(item)
-                item_value.pop("whitelist", None)
-                values.append(item_value)
+        Args:
+            src_texts (Union[str, list]): string or list of strings to obtain matching scores for.
+            top_n (int, optional): maximum number of results to populate. if None, equals length
+                of self._syn_tfidf_matrix
+            scores_normalizer (str, optional): normalizer type to normalize scores. Allowed values
+                are: "min_max_scaler", "standard_scaler"
+           _return_as_dict (bool, optional): if the results should be returned as a dictionary of
+                target_text name as keys and scores as corresponding values
+            _no_sort (bool, optional): If True, results are returned without sorting. This is
+                helpful at times when you wish to do additional wrapper operations on top of raw
+                results and would like to save computational time without sorting.
+        Returns:
+            Union[dict, list[tuple]]: if _return_as_dict, returns a dictionary of tgt_texts and
+                their scores, else a list of sorted synonym names paired with their
+                similarity scores (descending order)
 
-        return values
+        """
 
-    def _load(self):
-        self.fit()
+        is_single = False
+        if isinstance(src_texts, str):
+            is_single = True
+            src_texts = [src_texts]
 
+        top_n = self._syn_tfidf_matrix.shape[0] if not top_n else top_n
 
-class SentenceBertCosSimEntityResolver(EntityResolverBase, BertEmbedder):
-    """
-    Resolver class for bert models as described here:
-    https://github.com/UKPLab/sentence-transformers
-    """
+        results = []
+        for src_text in src_texts:
+            src_text_vector = self._vectorizer.transform([src_text])
+
+            similarity_scores = self._syn_tfidf_matrix.dot(src_text_vector.T).toarray().reshape(-1)
+            # Rounding sometimes helps to bring correct answers on to the
+            # top score as other non-correct resolutions
+            similarity_scores = np.around(similarity_scores, decimals=4)
+
+            if scores_normalizer:
+                if scores_normalizer == "min_max_scaler":
+                    _min = np.min(similarity_scores)
+                    _max = np.max(similarity_scores)
+                    denominator = (_max - _min) if (_max - _min) != 0 else 1.0
+                    similarity_scores = (similarity_scores - _min) / denominator
+                elif scores_normalizer == "standard_scaler":
+                    _mean = np.mean(similarity_scores)
+                    _std = np.std(similarity_scores)
+                    denominator = _std if _std else 1.0
+                    similarity_scores = (similarity_scores - _mean) / denominator
+                else:
+                    msg = f"Allowed values for `scores_normalizer` are only " \
+                          f"{['min_max_scaler', 'standard_scaler']}. Continuing without " \
+                          f"normalizing similarity scores."
+                    logger.error(msg)
 
-    def __init__(self, app_path, entity_type, er_config, resource_loader, **_kwargs):
-        super().__init__(app_path, entity_type, resource_loader=resource_loader)
+            if _return_as_dict:
+                results.append(dict(zip(self._unique_synonyms, similarity_scores)))
+            else:
+                if not _no_sort:  # sort results in descending scores
+                    n_scores = len(similarity_scores)
+                    if n_scores > top_n:
+                        top_inds = similarity_scores.argpartition(n_scores - top_n)[-top_n:]
+                        result = sorted(
+                            [(self._unique_synonyms[ii], similarity_scores[ii])
+                             for ii in top_inds],
+                            key=lambda x: x[1],
+                            reverse=True)
+                    else:
+                        result = sorted(zip(self._unique_synonyms, similarity_scores),
+                                        key=lambda x: x[1],
+                                        reverse=True)
+                    results.append(result)
+                else:
+                    result = list(zip(self._unique_synonyms, similarity_scores))
+                    results.append(result)
 
-        # default configs useful for reusing model's encodings through a cache path
-        for key, value in self.default_er_config.get("model_settings", {}).items():
-            er_config["model_settings"][key] = value
-
-        self.batch_size = er_config["model_settings"]["batch_size"]
-        _model_configs = {
-            "pretrained_name_or_abspath": er_config["model_settings"]["pretrained_name_or_abspath"],
-            "bert_output_type": er_config["model_settings"]["bert_output_type"],
-            "quantize_model": er_config["model_settings"]["quantize_model"],
-        }
-        self._runtime_configs = {
-            "concat_last_n_layers": er_config["model_settings"]["concat_last_n_layers"],
-            "normalize_token_embs": er_config["model_settings"]["normalize_token_embs"],
-            "augment_lower_case": er_config["model_settings"]["augment_lower_case"],
-            "augment_average_synonyms_embeddings":
-                er_config["model_settings"]["augment_average_synonyms_embeddings"],
-        }
-        self.cache_path = self.get_cache_path(
-            app_path=self.app_path,
-            er_config={**_model_configs, **self._runtime_configs},
-            entity_type=self.type
-        )
+        if is_single:
+            return results[0]
 
-        self._processed_entity_map = None
-        self._synonyms = None
-        self._synonyms_embs = None
+        return results
 
-        self._init_sentence_transformers_encoder(_model_configs)
+    def _fit(self, clean, entity_map):
 
-    @property
-    def default_er_config(self):
-        defaults = {
-            "model_settings": {
-                "pretrained_name_or_abspath": "distilbert-base-nli-stsb-mean-tokens",
-                "batch_size": 16,
-                "concat_last_n_layers": 4,
-                "normalize_token_embs": True,
-                "bert_output_type": "mean",
-                "augment_lower_case": False,
-                "quantize_model": True,
-                "augment_average_synonyms_embeddings": True
-            }
-        }
-        return defaults
+        if clean:
+            logger.info(
+                "clean=True ignored while fitting tf-idf algo for entity resolution"
+            )
 
-    @staticmethod
-    def get_cache_path(app_path, er_config, entity_type):
-        """Obtains and return a unique cache path for saving synonyms' embeddings
+        entities = entity_map.get("entities", [])
+        self._processed_entity_map = self.process_entities(
+            entities,
+            normalizer=self._resource_loader.query_factory.normalize,
+            augment_lower_case=self._aug_lower_case,
+            augment_title_case=self._aug_title_case,
+            augment_normalized=self._aug_normalized,
+        )
 
-        Args:
-               er_config: the er_config dictionary of the reolver class
-               entity_type: entity type of the class instance, for unique path identification
+        # obtain sparse matrix
+        synonyms = {v: k for k, v in
+                    dict(enumerate(set(self._processed_entity_map["synonyms"]))).items()}
+        synonyms_embs = self._vectorizer.fit_transform([*synonyms.keys()])
 
-        Return:
-            str: path with a .pkl extension to cache embeddings
-        """
-        string = json.dumps(er_config, sort_keys=True)
-        hashid = Hasher(algorithm="sha1").hash(string=string)
-        hashid = f"{hashid}$synonym_{entity_type}"
+        # encode artificial synonyms if required
+        if self._aug_max_syn_embs:
+            # obtain cnames to synonyms mapping
+            synonym2cnames = self._processed_entity_map["synonyms"]
+            cname2synonyms = {}
+            for syn, cnames in synonym2cnames.items():
+                for cname in cnames:
+                    items = cname2synonyms.get(cname, [])
+                    items.append(syn)
+                    cname2synonyms[cname] = items
+            pooled_cnames, pooled_cnames_encodings = [], []
+            # assert pooled synonyms
+            for cname, syns in cname2synonyms.items():
+                syns = list(set(syns))
+                if len(syns) == 1:
+                    continue
+                pooled_cname = f"{cname} - SYNONYMS AVERAGE"
+                # update synonyms map 'cause such synonyms don't actually exist in mapping.json file
+                pooled_cname_aliases = synonym2cnames.get(pooled_cname, [])
+                pooled_cname_aliases.append(cname)
+                synonym2cnames[pooled_cname] = pooled_cname_aliases
+                # check if needs to be encoded
+                if pooled_cname in synonyms:
+                    continue
+                # if required, obtain pooled encoding and update collections
+                pooled_encoding = scipy.sparse.csr_matrix(
+                    np.max([synonyms_embs[synonyms[syn]].toarray() for syn in syns], axis=0)
+                )
+                pooled_cnames.append(pooled_cname)
+                pooled_cnames_encodings.append(pooled_encoding)
+            if pooled_cnames_encodings:
+                pooled_cnames_encodings = scipy.sparse.vstack(pooled_cnames_encodings)
+            if pooled_cnames:
+                synonyms_embs = (
+                    pooled_cnames_encodings if not synonyms else scipy.sparse.vstack(
+                        [synonyms_embs, pooled_cnames_encodings])
+                )
+                synonyms.update(
+                    OrderedDict(zip(
+                        pooled_cnames,
+                        np.arange(len(synonyms), len(synonyms) + len(pooled_cnames)))
+                    )
+                )
 
-        return path.get_entity_resolver_cache_file_path(app_path, hashid)
+        # returns a sparse matrix
+        self._unique_synonyms = [*synonyms.keys()]
+        self._syn_tfidf_matrix = synonyms_embs
 
-    @staticmethod
-    def _compute_cosine_similarity(synonyms,
-                                   synonyms_encodings,
-                                   entity_emb,
-                                   top_n,
-                                   return_as_dict=False):
-        """Uses cosine similarity metric on synonym embeddings to sort most relevant ones
-            for entity resolution
+    def _predict(self, nbest_entities, allowed_cnames=None):
 
-        Args:
-            synonyms (dict): a dict of synonym and its corresponding embedding's row index
-                                in synonyms_encodings
-            synonyms_encodings (np.array): a 2d array of embedding of the synonyms; an array of
-                                            size equal to number of synonyms
-            entity_emb (np.array): a 2d array of embedding of the input entity text(s)
-            top_n (int): maximum number of results to populate
-        Returns:
-            Union[dict, list[tuple]]: if return_as_dict, returns a dictionary of synonyms and their
-                                        scores, else a list of sorted synonym names, paired with
-                                        their similarity scores (descending)
-        """
+        # encode input entity
+        top_entity = nbest_entities[0]  # top_entity
 
-        n_entities = len(entity_emb)
+        try:
+            scored_items = self.find_similarity(top_entity.text, _no_sort=True)
+            values = []
+            for synonym, score in scored_items:
+                cnames = self._processed_entity_map["synonyms"][synonym]
+                for cname in cnames:
+                    if allowed_cnames and cname not in allowed_cnames:
+                        continue
+                    for item in self._processed_entity_map["items"][cname]:
+                        item_value = copy.copy(item)
+                        item_value.pop("whitelist", None)
+                        item_value.update({"score": score})
+                        item_value.update({"top_synonym": synonym})
+                        values.append(item_value)
+        except KeyError:
+            logger.warning(
+                "Failed to resolve entity %r for type %r; "
+                "set 'clean=True' for computing embeddings of newly added items in mappings.json",
+                top_entity.text, top_entity.type
+            )
+            return None
+        except TypeError:
+            logger.warning(
+                "Failed to resolve entity %r for type %r", top_entity.text, top_entity.type
+            )
+            return None
 
-        is_single = n_entities == 1
+        return values
 
-        # [n_syns, emd_dim] -> [n_entities, n_syns, emd_dim]
-        t_syn_enc = _torch("as_tensor", synonyms_encodings)
-        t_syn_enc = t_syn_enc.expand([n_entities, *t_syn_enc.shape])
+    def _load(self):
+        self.fit()
 
-        # [n_entities, emd_dim] -> [n_entities, n_syns, emd_dim]
-        t_entity_emb = _torch("as_tensor", entity_emb)
-        t_entity_emb = t_entity_emb.unsqueeze(dim=1).expand_as(t_syn_enc)
 
-        # returns -> [n_entities, n_syns]
-        similarity_scores_2d = _torch(
-            "cosine_similarity", t_syn_enc, t_entity_emb, dim=-1).numpy()
+class EmbedderCosSimEntityResolver(BaseEntityResolver):
+    """
+    Resolver class for embedder models that create dense embeddings
+    """
 
-        results = []
-        for similarity_scores in similarity_scores_2d:
-            similarity_scores = similarity_scores.reshape(-1)
-            similarity_scores = np.around(similarity_scores, decimals=2)
+    def __init__(self, app_path, **kwargs):
+        """
+        Args:
+            app_path (str): App's path to cache embeddings
+            er_config (dict): Configurations can be passed in through `model_settings` field
+                `model_settings`:
+                    embedder_type: the type of embedder picked from embedder_models.py class
+                        (eg. 'bert', 'glove', etc. )
+                    augment_lower_case: to augment lowercased synonyms as whitelist
+                    augment_title_case: to augment titlecased synonyms as whitelist
+                    augment_normalized: to augment text normalized synonyms as whitelist
+                    augment_average_synonyms_embeddings: to augment pooled synonyms whose embedding
+                        is average of all whitelist's (including above alterations) encodings.
+                    scores_normalizer: a normalizer that normalizes computed similarity scores
+                        allowed choices are "min_max_scaler", "standard_scaler"
+                    cache_path: a predetermined cache path to dump embeddings
+                    batch_size: can be set based on machine capabilities and RAM
+        """
+        super().__init__(app_path, **kwargs)
 
-            if return_as_dict:
-                results.append(dict(zip(synonyms.keys(), similarity_scores)))
-            else:
-                # results in descending scores
-                n_scores = len(similarity_scores)
-                if n_scores > top_n:
-                    top_inds = similarity_scores.argpartition(n_scores - top_n)[-top_n:]
-                    result = sorted(
-                        zip(np.asarray([*synonyms.keys()])[top_inds], similarity_scores[top_inds]),
-                        key=lambda x: x[1], reverse=True)
-                else:
-                    result = sorted(zip(synonyms.keys(), similarity_scores), key=lambda x: x[1],
-                                    reverse=True)
-                results.append(result)
+        er_config = kwargs.pop("config", {})
+        settings = er_config.get("model_settings", {})
+        self._aug_lower_case = settings.get("augment_lower_case", False)
+        self._aug_title_case = settings.get("augment_title_case", False)
+        self._aug_normalized = settings.get("augment_normalized", False)
+        self._aug_avg_syn_embs = settings.get("augment_average_synonyms_embeddings", True)
 
-        if is_single:
-            return results[0]
+        cache_path = settings.get("cache_path")
+        if not cache_path:
+            hashid = f"{self.__class__.__name__}$synonym_{self.type}"
+            cache_path = get_entity_resolver_cache_file_path(app_path, hashid)
+            settings.update({"cache_path": cache_path})
+            er_config.update({"model_settings": settings})
 
-        return results
+        self._embedder_model = create_embedder_model(self.app_path, er_config)
 
     def _fit(self, clean, entity_map):
 
-        if clean and os.path.exists(self.cache_path):
-            os.remove(self.cache_path)
+        # if clean, clear cache
+        if clean:
+            self._embedder_model.clear_cache()
 
-        # load mapping.json data and process it
-        augment_lower_case = self._runtime_configs["augment_lower_case"]
-        self._processed_entity_map = self._process_entity_map(
-            self.type,
-            entity_map,
-            augment_lower_case=augment_lower_case
+        entities = entity_map.get("entities", [])
+        self._processed_entity_map = self.process_entities(
+            entities,
+            normalizer=self._resource_loader.query_factory.normalize,
+            augment_lower_case=self._aug_lower_case,
+            augment_title_case=self._aug_title_case,
+            augment_normalized=self._aug_normalized,
         )
 
         # load embeddings from cache if exists, encode any other synonyms if required
-        synonyms, synonyms_embs = OrderedDict(), np.empty(0)
-        if os.path.exists(self.cache_path):
-            logger.info("Cached embs exists for entity %s. "
-                        "Loading existing data from: %s",
-                        self.type, self.cache_path)
-            cached_data = self._load_embeddings(self.cache_path)
-            synonyms, synonyms_embs = cached_data["synonyms"], cached_data["synonyms_embs"]
-        new_synonyms_to_encode = [syn for syn in self._processed_entity_map["synonyms"] if
-                                  syn not in synonyms]
-        if new_synonyms_to_encode:
-            new_synonyms_encodings = (
-                self.encode(
-                    new_synonyms_to_encode,
-                    batch_size=self.batch_size,
-                    concat_last_n_layers=self._runtime_configs["concat_last_n_layers"],
-                    normalize_token_embs=self._runtime_configs["normalize_token_embs"],
-                )
-            )
-            synonyms_embs = new_synonyms_encodings if not synonyms else np.concatenate(
-                [synonyms_embs, new_synonyms_encodings])
-            synonyms.update(
-                OrderedDict(zip(
-                    new_synonyms_to_encode,
-                    np.arange(len(synonyms), len(synonyms) + len(new_synonyms_to_encode)))
-                )
-            )
+        self._embedder_model.get_encodings([*self._processed_entity_map["synonyms"].keys()])
 
         # encode artificial synonyms if required
-        if self._runtime_configs["augment_average_synonyms_embeddings"]:
+        if self._aug_avg_syn_embs:
             # obtain cnames to synonyms mapping
-            entity_mapping_synonyms = self._processed_entity_map["synonyms"]
-            cnames2synonyms = {}
-            for syn, cnames in entity_mapping_synonyms.items():
+            cname2synonyms = {}
+            for syn, cnames in self._processed_entity_map["synonyms"].items():
                 for cname in cnames:
-                    items = cnames2synonyms.get(cname, [])
-                    items.append(syn)
-                    cnames2synonyms[cname] = items
-            dummy_new_synonyms_to_encode, dummy_new_synonyms_encodings = [], []
-            # assert dummy synonyms
-            for cname, syns in cnames2synonyms.items():
-                dummy_synonym = f"{cname} - SYNONYMS AVERAGE"
+                    cname2synonyms[cname] = cname2synonyms.get(cname, []) + [syn]
+            # create and add superficial data
+            for cname, syns in cname2synonyms.items():
+                syns = list(set(syns))
+                if len(syns) == 1:
+                    continue
+                pooled_cname = f"{cname} - SYNONYMS AVERAGE"
                 # update synonyms map 'cause such synonyms don't actually exist in mapping.json file
-                dummy_synonym_mappings = entity_mapping_synonyms.get(dummy_synonym, [])
-                dummy_synonym_mappings.append(cname)
-                entity_mapping_synonyms[dummy_synonym] = dummy_synonym_mappings
-                # check if needs to be encoded
-                if dummy_synonym in synonyms:
+                if pooled_cname not in self._processed_entity_map["synonyms"]:
+                    self._processed_entity_map["synonyms"][pooled_cname] = [cname]
+                # obtain encoding and update cache
+                # TODO: asumption that embedding cache has __getitem__ can be addressed
+                if pooled_cname in self._embedder_model.cache:
                     continue
-                # if required, obtain dummy encoding and update collections
-                dummy_encoding = np.mean([synonyms_embs[synonyms[syn]] for syn in syns], axis=0)
-                dummy_new_synonyms_to_encode.append(dummy_synonym)
-                dummy_new_synonyms_encodings.append(dummy_encoding)
-            if dummy_new_synonyms_encodings:
-                dummy_new_synonyms_encodings = np.vstack(dummy_new_synonyms_encodings)
-            if dummy_new_synonyms_to_encode:
-                synonyms_embs = dummy_new_synonyms_encodings if not synonyms else np.concatenate(
-                    [synonyms_embs, dummy_new_synonyms_encodings])
-                synonyms.update(
-                    OrderedDict(zip(
-                        dummy_new_synonyms_to_encode,
-                        np.arange(len(synonyms), len(synonyms) + len(dummy_new_synonyms_to_encode)))
-                    )
-                )
+                pooled_encoding = np.mean(self._embedder_model.get_encodings(syns), axis=0)
+                self._embedder_model.add_to_cache({pooled_cname: pooled_encoding})
 
-        # dump embeddings if required
-        self._synonyms, self._synonyms_embs = synonyms, synonyms_embs
-        do_dump = (
-            new_synonyms_to_encode or
-            dummy_new_synonyms_to_encode or
-            not os.path.exists(self.cache_path)
-        )
-        if do_dump:
-            data_dump = {"synonyms": self._synonyms, "synonyms_embs": self._synonyms_embs}
-            self._dump_embeddings(self.cache_path, data_dump)
+        self._embedder_model.dump_cache()
         self.dirty = False  # never True with the current logic, kept for consistency purpose
 
-    def _predict(self, nbest_entities, top_n):
+    def _predict(self, nbest_entities, allowed_cnames=None):
         """Predicts the resolved value(s) for the given entity using cosine similarity.
-
-        Args:
-            nbest_entities (tuple): List of one entity object found in an input query, or a list  \
-                of n-best entity objects.
-
-        Returns:
-            (list): The resolved values for the provided entity.
         """
 
-        synonyms, synonyms_encodings = self._synonyms, self._synonyms_embs
-
         # encode input entity
-        # TODO: Use all provided entities (i.e all nbest_entities) like elastic search
         top_entity = nbest_entities[0]  # top_entity
-        existing_index = synonyms.get(top_entity.text, None)
-        if existing_index:
-            top_entity_emb = synonyms_encodings[existing_index]
-        else:
-            top_entity_emb = (
-                self.encode(
-                    top_entity.text,
-                    concat_last_n_layers=self._runtime_configs["concat_last_n_layers"],
-                    normalize_token_embs=self._runtime_configs["normalize_token_embs"],
-                )
-            )
-        top_entity_emb = top_entity_emb.reshape(1, -1)
+
+        allowed_syns = None
+        if allowed_cnames:
+            syn2cnames = self._processed_entity_map["synonyms"]
+            allowed_syns = [syn for syn, cnames in syn2cnames.items()
+                            if any([cname in allowed_cnames for cname in cnames])]
 
         try:
-            sorted_items = self._compute_cosine_similarity(
-                synonyms, synonyms_encodings, top_entity_emb, top_n
-            )
+            scored_items = self._embedder_model.find_similarity(
+                top_entity.text, tgt_texts=allowed_syns, _no_sort=True)
             values = []
-            for synonym, score in sorted_items:
+            for synonym, score in scored_items:
                 cnames = self._processed_entity_map["synonyms"][synonym]
                 for cname in cnames:
+                    if allowed_cnames and cname not in allowed_cnames:
+                        continue
                     for item in self._processed_entity_map["items"][cname]:
                         item_value = copy.copy(item)
                         item_value.pop("whitelist", None)
@@ -1431,64 +1336,25 @@ def _predict(self, nbest_entities, top_n):
     def _load(self):
         self.fit()
 
-    @staticmethod
-    def _load_embeddings(cache_path):
-        """Loads embeddings for all synonyms, previously dumped into a .pkl file
-        """
-        with open(cache_path, "rb") as fp:
-            _cached_embs = pickle.load(fp)
-        return _cached_embs
-
-    def _dump_embeddings(self, cache_path, data):
-        """Dumps embeddings of synonyms into a .pkl file when the .fit() method is called
-        """
-        msg = f"bert embeddings are are being cached for entity_type: `{self.type}` " \
-              f"for quicker entity resolution; consumes some disk space"
-        logger.info(msg)
-
-        folder = os.path.split(cache_path)[0]
-        if folder and not os.path.exists(folder):
-            os.makedirs(folder)
-        with open(cache_path, "wb") as fp:
-            pickle.dump(data, fp)
-
-    def _predict_batch(self, nbest_entities_list, batch_size, top_n):
-        synonyms, synonyms_encodings = self._synonyms, self._synonyms_embs
+    def _predict_batch(self, nbest_entities_list, batch_size):
 
         # encode input entity
-        top_entity_list = [i[0] for i in nbest_entities_list]  # top_entity
-        # called a list but observed as a list
-        top_entity_emb_list = []
-        for st_idx in trange(0, len(top_entity_list), batch_size, disable=False):
-            batch = [top_entity.text for top_entity in top_entity_list[st_idx:st_idx + batch_size]]
-            top_entity_emb_list.append(
-                self.encode(
-                    batch,
-                    show_progress_bar=False,
-                    batch_size=self.batch_size,
-                    concat_last_n_layers=self._runtime_configs["concat_last_n_layers"],
-                    normalize_token_embs=self._runtime_configs["normalize_token_embs"],
-                )
-            )
-        top_entity_emb_list = np.vstack(top_entity_emb_list)
+        top_entity_list = [i[0].text for i in nbest_entities_list]  # top_entity
 
         try:
             # w/o batch,  [ nsyms x 768*4 ] x [ 1 x 768*4 ] --> [ nsyms x 1 ]
             # w/  batch,  [ nsyms x 768*4 ] x [ k x 768*4 ] --> [ nsyms x k ]
-            sorted_items_list = []
-            for st_idx in trange(0, len(top_entity_emb_list), batch_size, disable=False):
-                batch = top_entity_emb_list[st_idx:st_idx + batch_size]
-                result = self._compute_cosine_similarity(synonyms, synonyms_encodings, batch, top_n)
-                # due to way compute similarity returns
-                if len(batch) == 1:
-                    result = [result]
-                sorted_items_list.extend(result)
+            scored_items_list = []
+            for st_idx in trange(0, len(top_entity_list), batch_size, disable=False):
+                batch = top_entity_list[st_idx:st_idx + batch_size]
+                result = self._embedder_model.find_similarity(batch, _no_sort=True)
+                scored_items_list.extend(result)
 
             values_list = []
 
-            for sorted_items in sorted_items_list:
+            for scored_items in scored_items_list:
                 values = []
-                for synonym, score in sorted_items:
+                for synonym, score in scored_items:
                     cnames = self._processed_entity_map["synonyms"][synonym]
                     for cname in cnames:
                         for item in self._processed_entity_map["items"][cname]:
@@ -1504,7 +1370,7 @@ def _predict_batch(self, nbest_entities_list, batch_size, top_n):
 
         return values_list
 
-    def predict_batch(self, entity_list, top_n: int = 20, batch_size: int = 16):
+    def predict_batch(self, entity_list, top_n: int = 20, batch_size: int = 8):
 
         if self._no_trainable_canonical_entity_map:
             return [[] for _ in entity_list]
@@ -1529,177 +1395,75 @@ def predict_batch(self, entity_list, top_n: int = 20, batch_size: int = 16):
         if self._is_system_entity:
             return results_list
 
-        results_list = self._predict_batch(nbest_entities_list, batch_size, top_n)
-
-        for i, results in enumerate(results_list):
-            if results:
-                results_list[i] = results[:top_n]
+        results_list = self._predict_batch(nbest_entities_list, batch_size)
 
-        return results_list
+        return [self._trim_results(results, top_n) for results in results_list]
 
 
-class TfIdfSparseCosSimEntityResolver(EntityResolverBase):
+class SentenceBertCosSimEntityResolver(EmbedderCosSimEntityResolver):
     """
-    a tf-idf based entity resolver using sparse matrices. ref:
-    scikit-learn.org/stable/modules/generated/sklearn.feature_extraction.text.TfidfVectorizer.html
+    Resolver class for bert models as described here:
+    https://github.com/UKPLab/sentence-transformers
     """
 
-    def __init__(self, app_path, entity_type, er_config, resource_loader, **_kwargs):
-        super().__init__(app_path, entity_type, resource_loader=resource_loader)
-
-        self._aug_lower_case = er_config.get("model_settings", {}).get("augment_lower_case", True)
-        self._aug_title_case = er_config.get("model_settings", {}).get("augment_title_case", False)
-        self._aug_normalized = er_config.get("model_settings", {}).get("augment_normalized", False)
-        self._aug_max_syn_embs = (
-            er_config.get("model_settings", {}).get("augment_max_synonyms_embeddings", True)
-        )
-
-        self._processed_entity_map = None
-        self.ngram_length = 5  # max number of character ngrams to consider
-        self._vectorizer = \
-            TfidfVectorizer(analyzer=self._char_ngram_and_word_analyzer, lowercase=False)
-        self._syn_tfidf_matrix = None
-        self._unique_synonyms = []
-
-    def _char_ngram_and_word_analyzer(self, string):
-        results = self._char_ngram_analyzer(string)
-        # add words
-        words = re.split(r'[\s{}]+'.format(re.escape(punctuation)), string.strip())
-        results.extend(words)
-        return results
-
-    def _char_ngram_analyzer(self, string):
-        results = []
-        # give more importance to starting and ending characters of a word
-        string = f" {string.strip()} "
-        for n in range(self.ngram_length + 1):
-            results.extend([''.join(gram) for gram in zip(*[string[i:] for i in range(n)])])
-        results = list(set(results))
-        results.remove(' ')
-        # adding lowercased single characters might add more noise
-        results = [r for r in results if not (len(r) == 1 and r.islower())]
-        return results
-
-    def _fit(self, clean, entity_map):
-
-        if clean:
-            logger.info(
-                "clean=True ignored while fitting tf-idf algo for entity resolution"
-            )
-
-        # load mappings.json data
-        self._processed_entity_map = self._process_entity_map(
-            self.type,
-            entity_map,
-            normalizer=self._resource_loader.query_factory.normalize,
-            augment_lower_case=self._aug_lower_case,
-            augment_title_case=self._aug_title_case,
-            augment_normalized=self._aug_normalized,
-        )
-
-        # obtain sparse matrix
-        synonyms = {v: k for k, v in
-                    dict(enumerate(set(self._processed_entity_map["synonyms"]))).items()}
-        synonyms_embs = self._vectorizer.fit_transform([*synonyms.keys()])
-
-        # encode artificial synonyms if required
-        if self._aug_max_syn_embs:
-            # obtain cnames to synonyms mapping
-            entity_mapping_synonyms = self._processed_entity_map["synonyms"]
-            cnames2synonyms = {}
-            for syn, cnames in entity_mapping_synonyms.items():
-                for cname in cnames:
-                    items = cnames2synonyms.get(cname, [])
-                    items.append(syn)
-                    cnames2synonyms[cname] = items
-            dummy_new_synonyms_to_encode, dummy_new_synonyms_encodings = [], []
-            # assert dummy synonyms
-            for cname, syns in cnames2synonyms.items():
-                dummy_synonym = f"{cname} - SYNONYMS AVERAGE"
-                # update synonyms map 'cause such synonyms don't actually exist in mapping.json file
-                dummy_synonym_mappings = entity_mapping_synonyms.get(dummy_synonym, [])
-                dummy_synonym_mappings.append(cname)
-                entity_mapping_synonyms[dummy_synonym] = dummy_synonym_mappings
-                # check if needs to be encoded
-                if dummy_synonym in synonyms:
-                    continue
-                # if required, obtain dummy encoding and update collections
-                dummy_encoding = scipy.sparse.csr_matrix(
-                    np.max([synonyms_embs[synonyms[syn]].toarray() for syn in syns], axis=0)
-                )
-                dummy_new_synonyms_to_encode.append(dummy_synonym)
-                dummy_new_synonyms_encodings.append(dummy_encoding)
-            if dummy_new_synonyms_encodings:
-                dummy_new_synonyms_encodings = scipy.sparse.vstack(dummy_new_synonyms_encodings)
-            if dummy_new_synonyms_to_encode:
-                synonyms_embs = (
-                    dummy_new_synonyms_encodings if not synonyms else scipy.sparse.vstack(
-                        [synonyms_embs, dummy_new_synonyms_encodings])
-                )
-                synonyms.update(
-                    OrderedDict(zip(
-                        dummy_new_synonyms_to_encode,
-                        np.arange(len(synonyms), len(synonyms) + len(dummy_new_synonyms_to_encode)))
-                    )
-                )
-
-        # returns a sparse matrix
-        self._unique_synonyms = [*synonyms.keys()]
-        self._syn_tfidf_matrix = synonyms_embs
+    def __init__(self, app_path, **kwargs):
+        """
+        This wrapper class allows creation of a BERT base embedder class
+        (currently based on sentence-transformers)
 
-    def _predict(self, nbest_entities, top_n):
-        """Predicts the resolved value(s) for the given entity using cosine similarity.
+        Specificall, this wrapper updates er_config in kwargs with
+            - any default settings if unavailable in input
+            - cache path
 
         Args:
-            nbest_entities (tuple): List of one entity object found in an input query, or a list  \
-                of n-best entity objects.
-
-        Returns:
-            (list): The resolved values for the provided entity.
+            app_path (str): App's path to cache embeddings
+            er_config (dict): Configurations can be passed in through `model_settings` field
+                `model_settings`:
+                    embedder_type: the type of embedder picked from embedder_models.py class
+                        (eg. 'bert', 'glove', etc. )
+                    pretrained_name_or_abspath: the pretrained model for 'bert' embedder
+                    bert_output_type: if the output is a sentence mean pool or CLS output
+                    quantize_model: if the model needs to be quantized for faster inference time
+                        but at a possibly reduced accuracy
+                    concat_last_n_layers: if some of the last layers of a BERT model are to be
+                        concatenated for better accuracies
+                    normalize_token_embs: if the obtained sub-token level encodings are to be
+                        normalized
         """
 
-        # encode input entity
-        # TODO: Use all provided entities (i.e all nbest_entities) like elastic search
-        top_entity = nbest_entities[0]  # top_entity
-        top_entity_vector = self._vectorizer.transform([top_entity.text])
-
-        similarity_scores = self._syn_tfidf_matrix.dot(top_entity_vector.T).toarray().reshape(-1)
-        # Rounding sometimes helps to bring correct answers on to the top score as other
-        # non-correct resolutions
-        similarity_scores = np.around(similarity_scores, decimals=4)
-        sorted_items = sorted(list(zip(self._unique_synonyms, similarity_scores)),
-                              key=lambda x: x[1], reverse=True)
-
-        try:
-            values = []
-            for synonym, score in sorted_items:
-                cnames = self._processed_entity_map["synonyms"][synonym]
-                for cname in cnames:
-                    for item in self._processed_entity_map["items"][cname]:
-                        item_value = copy.copy(item)
-                        item_value.pop("whitelist", None)
-                        item_value.update({"score": score})
-                        item_value.update({"top_synonym": synonym})
-                        values.append(item_value)
-        except (TypeError, KeyError):
-            logger.warning(
-                "Failed to resolve entity %r for type %r", top_entity.text, top_entity.type
-            )
-            return None
+        # default configs useful for reusing model's encodings through a cache path
+        defaults = {
+            "embedder_type": "bert",
+            "pretrained_name_or_abspath": "distilbert-base-nli-stsb-mean-tokens",
+            "bert_output_type": "mean",
+            "quantize_model": True,
+            "concat_last_n_layers": 4,
+            "normalize_token_embs": True,
+        }
 
-        return values
+        # determine cache path and pass it in `model_settings`
+        er_config = copy.deepcopy(kwargs.get("config", {}))
+        model_settings = er_config.get("model_settings", {})
+        for key, value in model_settings.items():
+            if key in defaults:
+                defaults.update({key: value})
+        string = json.dumps(defaults, sort_keys=True)
+        hashid = f"{Hasher(algorithm='sha256').hash(string=string)}$synonym_{kwargs['entity_type']}"
+        defaults.update({"cache_path": get_entity_resolver_cache_file_path(app_path, hashid)})
 
-    def _load(self):
-        self.fit()
+        model_settings.update(defaults)
+        er_config.update({"model_settings": defaults})
+        kwargs.update({"config": er_config})
+        super().__init__(app_path, **kwargs)
 
 
 class EntityResolver:
     """
-    for backwards compatability
+    Class for backwards compatability
 
     deprecated usage
         >>> entity_resolver = EntityResolver(
-                app_path, self.resource_loader, entity_type
+                app_path, resource_loader, entity_type
             )
 
     new usage
@@ -1708,14 +1472,14 @@ class EntityResolver:
             )
         # or ...
         >>> entity_resolver = EntityResolverFactory.create_resolver(
-                app_path, entity_type, resource_loader=self.resource_loader
+                app_path, entity_type, resource_loader=resource_loader
             )
     """
 
     def __new__(cls, app_path, resource_loader, entity_type, es_host=None, es_client=None):
-        logger.warning(
-            "DeprecationWarning: Entity Resolver should now be loaded using EntityResolverFactory. "
-            "See https://www.mindmeld.com/docs/userguide/entity_resolver.html for more details.")
+        msg = "Entity Resolver should now be loaded using EntityResolverFactory. " \
+              "See https://www.mindmeld.com/docs/userguide/entity_resolver.html for more details."
+        warnings.warn(msg, DeprecationWarning)
         return EntityResolverFactory.create_resolver(
             app_path, entity_type, resource_loader=resource_loader,
             es_host=es_host, es_client=es_client
@@ -1725,7 +1489,9 @@ def __new__(cls, app_path, resource_loader, entity_type, es_host=None, es_client
 ENTITY_RESOLVER_MODEL_MAPPINGS = {
     "exact_match": ExactMatchEntityResolver,
     "text_relevance": ElasticsearchEntityResolver,
+    # TODO: In the newly added resolvers, to support
+    #   (1) using all provided entities (i.e all nbest_entities) like elastic search
+    #   (2) using kb_index_name and kb_field_name as used by Elasticsearch resolver
     "sbert_cosine_similarity": SentenceBertCosSimEntityResolver,
     "tfidf_cosine_similarity": TfIdfSparseCosSimEntityResolver
 }
-ENTITY_RESOLVER_MODEL_TYPES = [*ENTITY_RESOLVER_MODEL_MAPPINGS]
diff --git a/mindmeld/components/nlp.py b/mindmeld/components/nlp.py
index 6c9cc2061..0ec24d1fa 100644
--- a/mindmeld/components/nlp.py
+++ b/mindmeld/components/nlp.py
@@ -44,7 +44,7 @@
 )
 from .domain_classifier import DomainClassifier
 from .entity_recognizer import EntityRecognizer
-from .entity_resolver import EntityResolverFactory, EntityResolverConnectionError
+from .entity_resolver import EntityResolverFactory, ElasticsearchConnectionError
 from .intent_classifier import IntentClassifier
 from .parser import Parser
 from .role_classifier import RoleClassifier
@@ -1644,7 +1644,7 @@ def _load(self, incremental_timestamp=None):
                 incremental_model_path if incremental_timestamp else model_path
             )
             self.entity_resolver.load()
-        except EntityResolverConnectionError:
+        except ElasticsearchConnectionError:
             logger.warning("Cannot connect to ES, so Entity Resolver is not loaded.")
 
     def _evaluate(self, print_stats, label_set="test"):
diff --git a/mindmeld/components/question_answerer.py b/mindmeld/components/question_answerer.py
index 1eb31e81a..ba73edb2c 100644
--- a/mindmeld/components/question_answerer.py
+++ b/mindmeld/components/question_answerer.py
@@ -17,110 +17,148 @@
 import copy
 import json
 import logging
+import numbers
+import os
+import pickle
 import re
+import unicodedata
+import uuid
+import warnings
 from abc import ABC, abstractmethod
+from datetime import datetime
+from math import sin, cos, sqrt, atan2, radians
+from typing import List, Union
 
-from elasticsearch import ConnectionError as EsConnectionError
-from elasticsearch import ElasticsearchException, TransportError
+import nltk
+import numpy as np
+from nltk.corpus import stopwords as nltk_stopwords
+from nltk.stem import PorterStemmer
+from nltk.tokenize import word_tokenize as nltk_word_tokenize
 
-from ..exceptions import (
-    KnowledgeBaseConnectionError,
-    KnowledgeBaseError,
-    ElasticsearchVersionError,
-)
-from ..resource_loader import ResourceLoader
 from ._config import (
-    DEFAULT_ES_QA_MAPPING,
-    DEFAULT_RANKING_CONFIG,
     get_app_namespace,
     get_classifier_config,
 )
-from ._elasticsearch_helpers import (
-    DOC_TYPE,
-    create_es_client,
-    delete_index,
-    does_index_exist,
-    get_scoped_index_name,
-    load_index,
-    create_index_mapping,
-    is_es_version_7,
-    resolve_es_config_for_version,
+from ._util import _is_module_available, _get_module_or_attr as _getattr
+from .entity_resolver import EmbedderCosSimEntityResolver, TfIdfSparseCosSimEntityResolver
+from ..core import Bunch
+from ..exceptions import (
+    ElasticsearchKnowledgeBaseConnectionError,
+    KnowledgeBaseError,
+    ElasticsearchVersionError,
 )
 from ..models import create_embedder_model
+from ..path import (
+    get_question_answerer_index_cache_file_path,
+    NATIVE_QUESTION_ANSWERER_INDICES_CACHE_PATH
+)
+from ..resource_loader import Hasher, ResourceLoader
+
+if _is_module_available("elasticsearch"):
+    from ._elasticsearch_helpers import (
+        DOC_TYPE,
+        DEFAULT_ES_QA_MAPPING,
+        DEFAULT_ES_RANKING_CONFIG,
+        create_es_client,
+        delete_index,
+        does_index_exist,
+        get_scoped_index_name,
+        load_index,
+        create_index_mapping,
+        is_es_version_7,
+        resolve_es_config_for_version,
+    )
 
 logger = logging.getLogger(__name__)
 
-
 DEFAULT_QUERY_TYPE = "keyword"
-ALL_QUERY_TYPES = ["keyword", "text", "embedder", "embedder_keyword", "embedder_text"]
+ALL_QUERY_TYPES = ["keyword", "text", "embedder",
+                   "embedder_keyword", "keyword_embedder",
+                   "embedder_text", "text_embedder"]
 EMBEDDING_FIELD_STRING = "_embedding"
 
 
-class QuestionAnswerer:
-    """The question answerer is primarily an information retrieval system that provides all the
-    necessary functionality for interacting with the application's knowledge base.
-    """
-
-    def __init__(self, app_path, resource_loader=None, es_host=None, config=None):
-        """Initializes a question answerer
+class BaseQuestionAnswerer(ABC):
 
+    def __init__(self, **kwargs):
+        """
         Args:
-            app_path (str): The path to the directory containing the app's data
-            resource_loader (ResourceLoader): An object which can load resources for the answerer
-            es_host (str): The Elasticsearch host server
+            app_path (str, optional): The path to the directory containing the app's data. If
+                provided, used to obtain default 'app_namespace' and QA configurations
+            app_namespace (str, optional): The namespace of the app. Used to prevent
+                collisions between the indices of this app and those of other apps.
+            config (dict, optional): The QA config if passed directly rather than loaded from the
+                app config
+            resource_loader (ResourceLoader, optional): An object which can load resources for the
+                question answerer.
         """
-        self._resource_loader = (
-            resource_loader or ResourceLoader.create_resource_loader(app_path)
+
+        if not kwargs.get("app_path") and not kwargs.get("app_namespace"):
+            msg = f"At least one of 'app_path' or 'app_namespace' must be inputted as arguments " \
+                  f"while creating an instance of {self.__class__.__name__} in order to " \
+                  f"distinctly identify the Knowledge Base indices being created. Using the " \
+                  f"default 'app_path' as the current working directory path: '{os.getcwd()}'."
+            logger.warning(msg)
+
+        app_path = kwargs.get("app_path") or os.getcwd()  # app_path can be NoneType as well!
+        self.app_path = os.path.abspath(app_path)
+        self.app_namespace = kwargs.get("app_namespace") or get_app_namespace(self.app_path)
+        self.resource_loader = (
+            kwargs.get("resource_loader") or ResourceLoader.create_resource_loader(self.app_path)
+        )
+        self._qa_config = (
+            kwargs.get("config") or
+            get_classifier_config("question_answering", app_path=self.app_path)
         )
-        self._es_host = es_host
-        self.__es_client = None
-        self._app_namespace = get_app_namespace(app_path)
-        self._es_field_info = {}
-        if config:
-            self._qa_config = config
-        else:
-            self._qa_config = get_classifier_config(
-                "question_answering", app_path=app_path
-            )
 
-        self._embedder_model = None
-        if self._qa_config.get("model_type") == "embedder":
-            self._embedder_model = create_embedder_model(app_path, self._qa_config)
+    def __repr__(self):
+        return f"<{self.__class__.__name__} query_type: {self.query_type} " \
+               f"app_path: {self.app_path} app_namespace: {self.app_namespace}>"
 
     @property
-    def _es_client(self):
-        # Lazily connect to Elasticsearch
-        if self.__es_client is None:
-            self.__es_client = create_es_client(self._es_host)
-        return self.__es_client
+    def model_type(self) -> str:
+        return self._qa_config.get("model_type")
 
     @property
-    def _query_type(self):
-        if self._qa_config.get("model_type") in ALL_QUERY_TYPES:
-            return self._qa_config.get("model_type")
+    def query_type(self) -> str:
+        _query_type = self._qa_config.get("model_settings", {}).get("query_type")
+        if _query_type in ALL_QUERY_TYPES:
+            return _query_type
         else:
             return DEFAULT_QUERY_TYPE
 
-    def get(self, index, size=10, query_type=None, **kwargs):
-        """Gets a collection of documents from the knowledge base matching the provided
-        search criteria. This API provides a simple interface for developers to specify a list of
-        knowledge base field and query string pairs to find best matches in a similar way as in
-        common Web search interfaces. The knowledge base fields to be used depend on the mapping
-        between NLU entity types and corresponding knowledge base objects. For example, a “cuisine”
-        entity type can be mapped to either a knowledge base object or an attribute of a knowledge
-        base object. The mapping is often application specific and is dependent on the data model
-        developers choose to use when building the knowledge base.
+    @property
+    def model_settings(self) -> dict:
+        return self._qa_config.get("model_settings", {})
 
-        Examples:
+    @abstractmethod
+    def _get(self, *args, **kwargs):
+        raise NotImplementedError
 
-            >>> question_answerer.get(index='menu_items',
-                                      name='pork and shrimp',
-                                      restaurant_id='B01CGKGQ40',
-                                      _sort='price',
-                                      _sort_type='asc')
+    @abstractmethod
+    def _build_search(self, *args, **kwargs):
+        raise NotImplementedError
+
+    @abstractmethod
+    def _load_kb(self, *args, **kwargs):
+        raise NotImplementedError
 
+    @staticmethod
+    def _resolve_deprecated_index_name(index_name, index):
+        if index:
+            msg = "Input the index name to a question answerer method by using the argument name " \
+                  "'index_name' instead of 'index'."
+            warnings.warn(msg, DeprecationWarning)
+        index_name = index_name or index
+        if not index_name:
+            msg = "Missing one required argument: 'index_name'"
+            raise TypeError(msg)
+        return index_name
+
+    def get(self, index_name=None, size=10, query_type=None, app_namespace=None, **kwargs):
+        """
         Args:
-            index (str): The name of an index.
+            index_name (str): The name of an index.
             size (int): The maximum number of records, default to 10.
             query_type (str): Whether the search is over structured, unstructured and whether to use
                               text signals for ranking, embedder signals, or both.
@@ -133,1121 +171,2769 @@ def get(self, index, size=10, query_type=None, **kwargs):
         Returns:
             list: A list of matching documents.
         """
+
+        index_name = self._resolve_deprecated_index_name(index_name, kwargs.pop("index", None))
+        return self._get(index=index_name, size=size, query_type=query_type,
+                         app_namespace=app_namespace, **kwargs)
+
+    def build_search(self, index_name=None, ranking_config=None, app_namespace=None, **kwargs):
+        """Build a search object for advanced filtered search.
+
+        Args:
+            index_name (str): index name of knowledge base object.
+            ranking_config (dict, optional): overriding ranking configuration parameters.
+            app_namespace (str, optional): The namespace of the app. Used to prevent
+                collisions between the indices of this app and those of other apps.
+        Returns:
+            Search: a Search object for filtered search.
+        """
+
+        index_name = self._resolve_deprecated_index_name(index_name, kwargs.pop("index", None))
+        return self._build_search(index=index_name, ranking_config=ranking_config,
+                                  app_namespace=app_namespace, **kwargs)
+
+    def load_kb(self, index_name, data_file, **kwargs):
+        """Loads documents from disk into the specified index in the knowledge
+        base. If an index with the specified name doesn't exist, a new index
+        with that name will be created in the knowledge base.
+
+        Args:
+            index_name (str): The name of the new index to be created.
+            data_file (str): The path to the data file containing the documents
+                to be imported into the knowledge base index. It could be
+                either json or jsonl file.
+
+        Optional Args (used by all QA classes):
+            app_namespace (str): The namespace of the app. Used to prevent
+                collisions between the indices of this app and those of other apps.
+            clean (bool): Set to true if you want to delete an existing index
+                and reindex it
+            embedding_fields (list): List of embedding fields can be directly passed in
+                instead of adding them to QA config
+
+        Optional Args (Elasticsearch specific):
+            es_host (str): The Elasticsearch host server.
+            es_client (Elasticsearch): The Elasticsearch client.
+            connect_timeout (int): The amount of time for a connection to the Elasticsearch host.
+        """
+
+        if (
+            ("config" in kwargs and kwargs["config"]) or
+            ("app_path" in kwargs and kwargs["app_path"])
+        ):
+            msg = "Passing 'config' or 'app_path' to '.load_kb()' method is no longer " \
+                  "supported. Create a Question Answerer instance with the required " \
+                  "configurations and/or app path before calling '.load_kb()'."
+            raise ValueError(msg)
+        self._load_kb(index_name, data_file, **kwargs)
+
+
+class NativeQuestionAnswerer(BaseQuestionAnswerer):
+    """
+    The question answerer is primarily an information retrieval system that provides all the
+    necessary functionality for interacting with the application's knowledge base.
+
+    This class uses Entity Resolvers in the backend to implement various underlying functionalities
+    of question answerer.
+
+    """
+
+    def _get(self, index, size=10, query_type=None, app_namespace=None, **kwargs):
+
         doc_id = kwargs.get("id")
 
-        query_type = query_type or self._query_type
+        query_type = query_type or self.query_type
+
+        # fix related to Issue 219: https://github.com/cisco/mindmeld/issues/219
+        app_namespace = app_namespace or self.app_namespace
 
         # If an id was passed in, simply retrieve the specified document
         if doc_id:
             logger.info(
                 "Retrieve object from KB: index= '%s', id= '%s'.", index, doc_id
             )
-            s = self.build_search(index)
-            s = s.filter(query_type=query_type, id=doc_id)
-            results = s.execute(size=size)
-            return results
-
-        sort_clause = {}
-        query_clauses = []
+            s = self.build_search(index, app_namespace=app_namespace)
 
-        # iterate through keyword arguments to get KB field and value pairs for search and custom
-        # sort criteria
-        for key, value in kwargs.items():
-            logger.debug("Processing argument: key= %s value= %s.", key, value)
-            if key == "_sort":
-                sort_clause["field"] = value
-            elif key == "_sort_type":
-                sort_clause["type"] = value
-            elif key == "_sort_location":
-                sort_clause["location"] = value
-            elif "embedder" in query_type and self._embedder_model:
-                if "text" in query_type or "keyword" in query_type:
-                    query_clauses.append({key: value})
-                embedded_value = self._embedder_model.get_encodings([value])[0]
-                embedded_key = key + EMBEDDING_FIELD_STRING
-                query_clauses.append({embedded_key: embedded_value})
+            if NativeQuestionAnswerer.FieldResource.is_number(doc_id):
+                # if a number, look for that exact doc_id; no fuzzy match like in Elasticsearch QA
+                s = s.filter(query_type=query_type, field="id", et=doc_id)  # et == equals to
             else:
-                query_clauses.append({key: value})
-                logger.debug("Added query clause: field= %s value= %s.", key, value)
-
-        logger.debug("Custom sort criteria %s.", sort_clause)
-
-        # build Search object with overriding ranking setting to require all query clauses are
-        # matched.
-        s = self.build_search(index, {"query_clauses_operator": "and"})
+                s = s.filter(query_type=query_type, id=doc_id)
+                # TODO: should consider s.query() to do fuzzy match like in Elasticsearch?
+                # s = s.query(query_type=query_type, id=doc_id)
+            results = s.execute(size=size)
+            return results
 
-        # add query clauses to Search object.
-        for clause in query_clauses:
-            s = s.query(query_type=query_type, **clause)
+        field = kwargs.pop("_sort", None)
+        sort_type = kwargs.pop("_sort_type", None)
+        location = kwargs.pop("_sort_location", None)
 
-        # add custom sort clause if specified.
-        if sort_clause:
-            s = s.sort(
-                field=sort_clause.get("field"),
-                sort_type=sort_clause.get("type"),
-                location=sort_clause.get("location"),
-            )
+        s = self.build_search(index, app_namespace=app_namespace).query(
+            query_type=query_type, **kwargs)
+        if field and (sort_type or location):
+            s.sort(field, sort_type=sort_type, location=location)
 
         results = s.execute(size=size)
         return results
 
-    def build_search(self, index, ranking_config=None):
+    def _build_search(self, index, ranking_config=None, app_namespace=None):
         """Build a search object for advanced filtered search.
 
         Args:
             index (str): index name of knowledge base object.
-            ranking_config (dict): overriding ranking configuration parameters.
+            ranking_config (dict, optional): overriding ranking configuration parameters.
+            app_namespace (str, optional): The namespace of the app. Used to prevent
+                collisions between the indices of this app and those of other apps.
         Returns:
             Search: a Search object for filtered search.
         """
 
-        if not does_index_exist(app_namespace=self._app_namespace, index_name=index):
-            raise ValueError("Knowledge base index '{}' does not exist.".format(index))
-
-        # get index name with app scope
-        index = get_scoped_index_name(self._app_namespace, index)
-
-        # load knowledge base field information for the specified index.
-        self._load_field_info(index)
-
-        return Search(
-            client=self._es_client,
-            index=index,
-            ranking_config=ranking_config,
-            field_info=self._es_field_info[index],
-        )
-
-    def _load_field_info(self, index):
-        """load knowledge base field metadata information for the specified index.
-
-        Args:
-            index (str): index name.
-        """
-
-        # load field info from local cache
-        index_info = self._es_field_info.get(index, {})
+        if ranking_config:
+            msg = f"'ranking_config' is currently discarded in {self.__class__.__name__}."
+            logger.warning(msg)
+            ranking_config = None
 
-        if not index_info:
-            try:
-                # TODO: move the ES API call logic to ES helper
-                self._es_field_info[index] = {}
-                res = self._es_client.indices.get(index=index)
-                if is_es_version_7(self._es_client):
-                    all_field_info = res[index]["mappings"]["properties"]
-                else:
-                    all_field_info = res[index]["mappings"][DOC_TYPE]["properties"]
-                for field_name in all_field_info:
-                    field_type = all_field_info[field_name].get("type")
-                    self._es_field_info[index][field_name] = FieldInfo(
-                        field_name, field_type
-                    )
-            except EsConnectionError as e:
-                logger.error(
-                    "Unable to connect to Elasticsearch: %s details: %s",
-                    e.error,
-                    e.info,
-                )
-                raise KnowledgeBaseConnectionError(
-                    es_host=self._es_client.transport.hosts
-                ) from e
-            except TransportError as e:
-                logger.error(
-                    "Unexpected error occurred when sending requests to Elasticsearch: %s "
-                    "Status code: %s details: %s",
-                    e.error,
-                    e.status_code,
-                    e.info,
-                )
-                raise KnowledgeBaseError from e
-            except ElasticsearchException as e:
-                raise KnowledgeBaseError from e
+        # fix related to Issue 219: https://github.com/cisco/mindmeld/issues/219
+        app_namespace = app_namespace or self.app_namespace
 
-    def config(self, config):
-        """Summary
+        # get index name with app scope
+        scoped_index_name = get_scoped_index_name(app_namespace, index)
 
-        Args:
-            config: Description
-        """
-        raise NotImplementedError
+        if scoped_index_name not in NativeQuestionAnswerer.ALL_INDICES:
+            raise ValueError("Knowledge base index '{}' does not exist.".format(index))
 
-    def save_embedder_model(self):
-        self._embedder_model.dump()
+        return NativeQuestionAnswerer.Search(index=scoped_index_name)
 
-    @classmethod
-    def load_kb(
-        cls,
-        app_namespace,
-        index_name,
-        data_file,
-        es_host=None,
-        es_client=None,
-        connect_timeout=2,
-        clean=False,
-        app_path=None,
-        config=None,
-    ):
+    def _load_kb(self,
+                 index_name,
+                 data_file,
+                 app_namespace=None,
+                 clean=False,
+                 embedding_fields=None,
+                 **kwargs):
         """Loads documents from disk into the specified index in the knowledge
         base. If an index with the specified name doesn't exist, a new index
         with that name will be created in the knowledge base.
 
         Args:
-            app_namespace (str): The namespace of the app. Used to prevent
-                collisions between the indices of this app and those of other
-                apps.
             index_name (str): The name of the new index to be created.
             data_file (str): The path to the data file containing the documents
                 to be imported into the knowledge base index. It could be
                 either json or jsonl file.
-            es_host (str): The Elasticsearch host server.
-            es_client (Elasticsearch): The Elasticsearch client.
-            connect_timeout (int, optional): The amount of time for a
-                connection to the Elasticsearch host.
-            clean (bool): Set to true if you want to delete an existing index
+            app_namespace (str, optional): The namespace of the app. Used to prevent
+                collisions between the indices of this app and those of other apps.
+            clean (bool, optional): Set to true if you want to delete an existing index
                 and reindex it
-            app_path (str): The path to the directory containing the app's data
-            config (dict): The QA config if passed directly rather than loaded from the app config
+            embedding_fields (list, optional): List of embedding fields can be directly passed in
+                instead of adding them to QA config
         """
-        embedder_model = None
-        embedding_fields = []
-        if not app_path and not config:
-            logger.warning(
-                "You must provide either the application path to upload embeddings as specified"
-                " in the app config or directly provide the QA config."
-            )
-        else:
-            if config:
-                qa_config = config
-            else:
-                qa_config = get_classifier_config(
-                    "question_answering", app_path=app_path
-                )
-            embedder_model = create_embedder_model(app_path, qa_config)
-            embedding_fields = (
-                qa_config.get("model_settings", {})
-                .get("embedding_fields", {})
-                .get(index_name, [])
-            )
 
-        def _doc_data(data_file):
-            with open(data_file) as data_fp:
-                line = data_fp.readline()
-                data_fp.seek(0)
-                if line.strip() == "[":
-                    docs = json.load(data_fp)
-                    count = len(docs)
-                else:
-                    count = 0
-                    for line in data_fp:
-                        count += 1
-                return count
+        # fix related to Issue 219: https://github.com/cisco/mindmeld/issues/219
+        app_namespace = app_namespace or self.app_namespace
 
-        def _doc_generator(data_file, embedder_model=None, embedding_fields=None):
-            def match_regex(string, pattern_list):
-                for pattern in pattern_list:
-                    if re.match(pattern, string):
-                        return True
+        query_type = self.query_type
+        model_settings = self.model_settings
 
-            def transform(doc, embedder_model, embedding_fields):
-                if embedder_model:
-                    embed_fields = [
-                        (key, str(val))
-                        for key, val in doc.items()
-                        if match_regex(key, embedding_fields)
-                    ]
-                    embed_keys = list(zip(*embed_fields))[0]
-                    embed_vals = embedder_model.get_encodings(
-                        list(zip(*embed_fields))[1]
-                    )
-                    embedded_doc = {
-                        key + EMBEDDING_FIELD_STRING: emb.tolist()
-                        for key, emb in zip(embed_keys, embed_vals)
-                    }
-                    doc.update(embedded_doc)
-                if not doc.get("id"):
-                    return doc
-                base = {"_id": doc["id"]}
-                base.update(doc)
-                return base
+        # obtain a scoped index name using app_namespace and index_name
+        scoped_index_name = get_scoped_index_name(app_namespace, index_name)
 
-            with open(data_file) as data_fp:
+        # clean by deleting
+        if clean:
+            if NativeQuestionAnswerer.ALL_INDICES.is_available(scoped_index_name):
+                msg = f"Index '{index_name}' exists for app '{app_namespace}', deleting index."
+                logger.info(msg)
+                NativeQuestionAnswerer.ALL_INDICES.delete_index(scoped_index_name)
+            else:
+                msg = f"Index '{index_name}' does not exist for app '{app_namespace}', " \
+                      f"creating a new index."
+                logger.warning(msg)
+
+        # determine embedding fields
+        embedding_fields = (
+            embedding_fields or model_settings.get("embedding_fields", {}).get(index_name, [])
+        )
+        if embedding_fields:
+            if "embedder" not in query_type:
+                msg = f"Found KB fields to upload embedding (fields: {embedding_fields}) for " \
+                      f"index '{index_name}' but query_type configured for this QA " \
+                      f"({query_type}) has no 'embedder' phrase in it leading to not setting up " \
+                      f"an embedder model. Ignoring provided 'embedding_fields'."
+                logger.error(msg)
+                embedding_fields = []
+        else:
+            if "embedder" in query_type:
+                logger.warning(
+                    "No embedding fields specified in the app config, continuing without "
+                    "generating embeddings. "
+                )
+
+        def _doc_generator(_data_file):
+            with open(_data_file) as data_fp:
                 line = data_fp.readline()
                 data_fp.seek(0)
-                if line.strip() == "[":
+                # fix related to Issue 220: https://github.com/cisco/mindmeld/issues/220
+                if line.strip().startswith("["):
                     logging.debug("Loading data from a json file.")
                     docs = json.load(data_fp)
                     for doc in docs:
-                        yield transform(doc, embedder_model, embedding_fields)
+                        yield doc
                 else:
                     logging.debug("Loading data from a jsonl file.")
                     for line in data_fp:
                         doc = json.loads(line)
-                        yield transform(doc, embedder_model, embedding_fields)
-
-        docs_count = _doc_data(data_file)
-        if embedder_model and len(embedding_fields) == 0:
-            logger.warning(
-                "No embedding fields specified in the app config, "
-                "continuing without generating embeddings..."
+                        yield doc
+
+        def match_regex(string, pattern_list):
+            return any([re.match(pattern, string) for pattern in pattern_list])
+
+        all_id2value = {}  # a mapping from id to value(s) for each kb field
+        all_ids = {}  # maintained to keep a record of order-preserved-doc-ids of the knowledge base
+        for doc in _doc_generator(data_file):
+            # determine _id; once determined, it remains same for all keys of the doc
+            _id = doc.get("id")
+            if _id in all_ids:
+                msg = f"Found a duplicate id {_id} while processing {data_file}. "
+                _id = uuid.uuid4()
+                msg += f"Replacing it and assigning a new id: {_id}"
+                logger.warning(msg)
+            if not _id:
+                _id = uuid.uuid4()
+                msg = f"Found an entry in {data_file} without a corresponding id. " \
+                      f"Assigning a randomly generated new id ({_id}) for this KB object."
+                logger.warning(msg)
+            _id = str(_id)
+            all_ids.update({_id: None})
+            # update data
+            for key, value in doc.items():
+                if key not in all_id2value:
+                    all_id2value[key] = {}
+                all_id2value[key].update({_id: value})
+
+        # for each key/field in doc, reuse an already existing FieldResource metadata or create one
+        index_resources = \
+            NativeQuestionAnswerer.ALL_INDICES.get_index_metadata(scoped_index_name)
+        for key, id2value in all_id2value.items():
+            field_resource = index_resources.get(key)
+            if not field_resource:
+                field_resource = NativeQuestionAnswerer.FieldResource(
+                    index_name=scoped_index_name, field_name=key)
+            field_resource.update_resource(
+                id2value,
+                has_text_resolver=("text" in query_type or "keyword" in query_type),
+                has_embedding_resolver=match_regex(key, embedding_fields),
+                resolver_settings=model_settings,
+                lazy_clean=clean,
+                processor_type="text" if "text" in query_type else "keyword",
             )
-            embedder_model = None
-        docs = _doc_generator(data_file, embedder_model, embedding_fields)
+            index_resources.update({key: field_resource})
 
-        if clean:
-            try:
-                delete_index(app_namespace, index_name, es_host, es_client)
-            except ValueError:
-                logger.warning(
-                    "Index %s does not exist for app %s, creating a new index",
-                    index_name,
-                    app_namespace,
-                )
+        # update and dump
+        NativeQuestionAnswerer.ALL_INDICES.update_and_dump_index(
+            scoped_index_name, index_resources, [*all_ids.keys()]
+        )
 
-        def _generate_mapping_data(embedder_model, embedding_fields):
-            # generates a dictionary with any metadata needed to create the mapping"
-            if not embedder_model:
-                return {}
-            MAX_ES_VECTOR_LEN = 2048
-            embedding_properties = []
-            mapping_data = {"embedding_properties": embedding_properties}
+    class Indices:
+        """ An object that hold all the indices for an app_path
 
-            dims = len(embedder_model.get_encodings(["encoding"])[0])
-            if dims > MAX_ES_VECTOR_LEN:
-                logger.error(
-                    "Vectors in ElasticSearch must be less than size: %d",
-                    MAX_ES_VECTOR_LEN,
-                )
-            for field in embedding_fields:
-                embedding_properties.append(
-                    {"field": field + EMBEDDING_FIELD_STRING, "dims": dims}
-                )
+        'self._indices' has the following dictionary format, with keys as the index name and
+        the value as the metadata of that index
 
-            return mapping_data
+        '''
+            {
+                index_name1: {key1: FieldResource1, key2: FieldResource2, ...},
+                index_name2: {...},
+                ...
+            }
+        '''
 
-        es_client = es_client or create_es_client(es_host)
-        if is_es_version_7(es_client):
-            mapping_data = _generate_mapping_data(embedder_model, embedding_fields)
-            qa_mapping = create_index_mapping(DEFAULT_ES_QA_MAPPING, mapping_data)
-        else:
-            if embedder_model:
-                logger.error(
-                    "You must upgrade to ElasticSearch 7 to use the embedding features."
-                )
-                raise ElasticsearchVersionError
-            qa_mapping = resolve_es_config_for_version(DEFAULT_ES_QA_MAPPING, es_client)
+        Index metadata includes metadata of each field found in the KB. The metadata for each field
+        is encapsulated in a `FieldResource` object which in-turn constitutes of metadata related
+        to that specific field in the KB (across all ids in the KB) along with information such as
+        what data-type that field belongs to (number, date, etc.), field name, & hash of the stored
+        data. See `FieldResource` class docstrings for more details.
 
-        load_index(
-            app_namespace,
-            index_name,
-            docs,
-            docs_count,
-            qa_mapping,
-            DOC_TYPE,
-            es_host,
-            es_client,
-            connect_timeout=connect_timeout,
-        )
+        """
 
-        # Saves the embedder model cache to disk
-        if embedder_model:
-            embedder_model.dump()
+        def __init__(self, indices_cache_path=None):
+            self._indices = {}
+            self._indices_all_ids = {}  # maintained to keep a record of all doc ids of each KB
+            self.indices_cache_path = (
+                indices_cache_path or NATIVE_QUESTION_ANSWERER_INDICES_CACHE_PATH
+            )
 
+        def __contains__(self, item):
+            return item in self._indices
 
-class FieldInfo:
-    """This class models an information source of a knowledge base field metadata"""
+        def _get_index_cache_path(self, index_name):
+            return get_question_answerer_index_cache_file_path(self.indices_cache_path, index_name)
 
-    NUMBER_TYPES = {
-        "long",
-        "integer",
-        "short",
-        "byte",
-        "double",
-        "float",
-        "half_float",
-        "scaled_float",
-    }
-    TEXT_TYPES = {"text", "keyword"}
-    DATE_TYPES = {"date"}
-    GEO_TYPES = {"geo_point"}
-    VECTOR_TYPES = {"dense_vector"}
+        def _create_metadata_for_dumping(self, index_name):
+            index_resources = self._indices[index_name]
+            dump = {field: resource.to_metadata() for field, resource in index_resources.items()}
+            return dump
 
-    def __init__(self, name, field_type):
-        self.name = name
-        self.type = field_type
+        def get(self, index_name):
+            try:
+                return self._indices[index_name]
+            except KeyError as e:
+                msg = f"Index {index_name} does not exist in scope of {self.indices_cache_path}. " \
+                      f"Consider creating one before calling '.get()'. "
+                raise KeyError(msg) from e
 
-    def get_name(self):
-        """Returns knowledge base field name"""
+        def get_all_ids(self, index_name):
+            try:
+                return self._indices_all_ids[index_name]
+            except KeyError as e:
+                msg = f"Index {index_name} does not exist in scope of {self.indices_cache_path}. " \
+                      f"Consider creating one before calling '.get_all_ids()'. "
+                raise KeyError(msg) from e
 
-        return self.name
+        def get_index_metadata(self, index_name):
+            """
+            Different from '.get()', this method checks all possible ways to retrieve meta data
+            for the chosen index. Notably, to reduce time complexity, if an index is loaded from
+            a cache path, resolvers (if any) are not fit automatically and one must fit them by
+            calling 'update_resource()' in FieldResource.
 
-    def get_type(self):
-        """Returns knowledge base field type"""
+            Use this method only to obtain metadata.
+            """
 
-        return self.type
+            cache_path = self._get_index_cache_path(index_name)
 
-    def is_number_field(self):
-        """Returns True if the knowledge base field is a number field, otherwise returns False"""
+            if index_name in self:
+                metadata_dump = self._create_metadata_for_dumping(index_name)
 
-        return self.type in self.NUMBER_TYPES
+            elif os.path.exists(cache_path):
+                opfile = open(cache_path, "rb")
+                metadata_dump = pickle.load(opfile)
+                metadata_dump.pop("__all_ids", None)
+                opfile.close()
 
-    def is_date_field(self):
-        """Returns True if the knowledge base field is a date field, otherwise returns False"""
+            else:
+                return {}
 
-        return self.type in self.DATE_TYPES
+            # create field resource instance for each of the metadata
+            index_resources = {}
+            for field, cache_object in metadata_dump.items():
+                field_resource = (
+                    NativeQuestionAnswerer.FieldResource.from_metadata(cache_object)
+                )
+                index_resources.update({field: field_resource})
+            return index_resources
+
+        def delete_index(self, index_name):
+
+            # clear field resources
+            if index_name in self:
+                del self._indices[index_name]  # free the pointer
+
+            # clear index dump cache path, if required
+            cache_path = self._get_index_cache_path(index_name)
+            if cache_path and os.path.exists(cache_path):
+                os.remove(cache_path)
+
+        def update_and_dump_index(self, index_name, index_resources, index_all_ids):
+            # a method to be used with load_kb() function
+
+            # update
+            self._indices.update({index_name: index_resources})
+            self._indices_all_ids.update({index_name: index_all_ids})
+
+            # create repo for dumping if required
+            cache_path = self._get_index_cache_path(index_name)
+            dir_name = os.path.dirname(cache_path)
+            if not os.path.exists(dir_name):
+                os.makedirs(dir_name)
+
+            # dump
+            metadata_dump = self._create_metadata_for_dumping(index_name)
+            metadata_dump.update({"__all_ids": index_all_ids})
+            opfile = open(cache_path, "wb")
+            pickle.dump(metadata_dump, opfile)
+            opfile.close()
+
+        def is_available(self, index_name):
+            return index_name in self or os.path.exists(self._get_index_cache_path(index_name))
+
+    class FieldResourceDataHelper:
+        """ A class that holds methods to aid validating, formatting and scoring different data
+        types in a FieldResource object.
+        """
 
-    def is_location_field(self):
-        """Returns True if the knowledge base field is a location field, otherwise returns False"""
+        DATA_TYPES = ["bool", "number", "string", "date", "location", "unknown"]
 
-        return self.type in self.GEO_TYPES
+        DATE_FORMATS = (
+            '%Y', '%d %b', '%d %B', '%b %d', '%B %d', '%b %Y', '%B %Y', '%b, %Y', '%B, %Y',
+            '%b %d, %Y', '%B %d, %Y', '%b %d %Y', '%B %d %Y', '%b %d,%Y', '%B %d,%Y',
+            '%d %b, %Y', '%d %B, %Y', '%d %b %Y', '%d %B %Y', '%d %b,%Y', '%d %B,%Y',
+            '%m/%d/%Y', '%m/%d/%y', '%d/%m/%Y', '%d/%m/%y'
+        )
 
-    def is_text_field(self):
-        """Returns True if the knowledge base field is a text field, otherwise returns False"""
+        @staticmethod
+        def _get_date(value: str):
+            value_date = None
+
+            if not isinstance(value, str):
+                return value_date
+
+            # check if value can be resolved as-is
+            for fmt in NativeQuestionAnswerer.FieldResourceDataHelper.DATE_FORMATS:
+                try:
+                    value_date = datetime.strptime(value, fmt)
+                    return value_date
+                except (ValueError, TypeError):
+                    pass
+
+            # check if value can be resolved with some modifications
+            for fmt in NativeQuestionAnswerer.FieldResourceDataHelper.DATE_FORMATS:
+                for val in [value.replace("/", " "), value.replace("-", " ")]:
+                    if val != value:
+                        try:
+                            value_date = datetime.strptime(val, fmt)
+                            return value_date
+                        except (ValueError, TypeError):
+                            pass
+
+            return value_date
+
+        @staticmethod
+        def _get_location(value: Union[dict, str, List]):
+
+            # convert it into standard format, e.g. "37.77,122.41"
+
+            if isinstance(value, dict) and "lat" in value and "lon" in value:
+                # eg. {"lat": 37.77, "lon": 122.41}
+                return ",".join([str(value["lat"]), str(value["lon"])])
+            elif isinstance(value, str) and "," in value and len(value.split(",")) == 2:
+                # eg. "37.77,122.41"
+                return value.strip()
+            elif (isinstance(value, list) and len(value) == 2 and
+                  isinstance(value[0], numbers.Number) and isinstance(value[1], numbers.Number)):
+                # eg. [37.77, 122.41]
+                return ",".join([str(_value) for _value in value])
+
+            return None
+
+        @staticmethod
+        def is_bool(value):
+            return isinstance(value, bool)
+
+        @staticmethod
+        def is_number(value):
+            return isinstance(value, numbers.Number)
+
+        @staticmethod
+        def is_string(value):
+            return isinstance(value, str)
+
+        @staticmethod
+        def is_list_of_strings(value):
+            return (isinstance(value, (list, set))
+                    and len(value) > 0
+                    and all([isinstance(val, str) for val in value]))
+
+        @staticmethod
+        def is_date(value):
+            if NativeQuestionAnswerer.FieldResourceDataHelper._get_date(value):
+                return True
+            return False
+
+        @staticmethod
+        def is_location(value):
+            if NativeQuestionAnswerer.FieldResourceDataHelper._get_location(value):
+                return True
+            return False
+
+        @staticmethod
+        def number_scorer(some_number):
+            return some_number
+
+        @staticmethod
+        def date_scorer(value):
+            """
+            ascertains a suitable date format for input and
+            returns number of days from origin date as score
+            """
+            origin_date = datetime.now()
+            target_date = NativeQuestionAnswerer.FieldResourceDataHelper._get_date(value)
+            if not target_date:
+                target_date = origin_date
+            # TODO: should this be configurable to days or seconds based on the app?
+            return (target_date - origin_date).days
+
+        @staticmethod
+        def location_scorer(some_location, source_location):
+            """
+            Uses Haversine formula to find distance between two coordinates
+            references: https://en.wikipedia.org/wiki/Haversine_formula and
+                        http://www.movable-type.co.uk/scripts/latlong.html
+
+            Args:
+                some_location (str): latitude and longitude supplied as
+                    comma separated strings, eg. "37.77,122.41"
+                source_location (str): latitude and longitude supplied as
+                    comma separated strings, eg. "37.77,122.41"
+
+            Returns:
+                float: distance between the coordinates in kilometers
+
+            Example 1:
+                >>> point_1 = "37.78953146306901,-122.41160227491551" # SF in CA
+                >>> point_2 = "47.65182346406002, -122.36765696283909" # Seattle in WA
+                >>> location_scorer(point_1, point_2)
+                >>> # 1096.98 kms (approx. points on Google maps says 680.23 miles/1094.72 kms)
+            Example 2:
+                >>> point_3, point_4 = "52.2296756,21.0122287", "52.406374,16.9251681"
+                >>> location_scorer(point_3, point_4)
+                >>> # 278.54 kms
+            """
 
-        return self.type in self.TEXT_TYPES
+            some_location = \
+                NativeQuestionAnswerer.FieldResourceDataHelper._get_location(some_location)
+            source_location = \
+                NativeQuestionAnswerer.FieldResourceDataHelper._get_location(source_location)
 
-    def is_vector_field(self):
-        """Returns True if the knowledge base field is a vector field, otherwise returns False"""
+            R = 6373.0  # constant based on Haversine formula
+            lat1, lon1 = [radians(float(ii.strip())) for ii in some_location.split(",")]
+            lat2, lon2 = [radians(float(ii.strip())) for ii in source_location.split(",")]
+            dlon, dlat = lon2 - lon1, lat2 - lat1
 
-        return self.type in self.VECTOR_TYPES
+            a = sin(dlat / 2) ** 2 + cos(lat1) * cos(lat2) * sin(dlon / 2) ** 2
+            c = 2 * atan2(sqrt(a), sqrt(1 - a))
+            distance_haversine_formula = R * c
 
+            return distance_haversine_formula
 
-class Search:
-    """This class models a generic filtered search in knowledge base. It allows developers to
-    construct more complex knowledge base search criteria based on the application requirements.
+        @staticmethod
+        def min_max_normalizer(list_of_numbers):
+            _min = min(list_of_numbers)
+            list_of_numbers = [num - _min for num in list_of_numbers]
+            _max = max(list_of_numbers)
+            _max = 1.0 if not _max else _max
+            list_of_numbers = [num / _max for num in list_of_numbers]
+            return list_of_numbers
 
-    """
+    class FieldResource(FieldResourceDataHelper):
+        """
+        An object encapsulating all resources necessary for search/filter/sort-ing on any field in
+        the Knowledge Base. This class should only be used as part of `Indices` class and not in
+        isolation.
+
+        This class currently supports:
+        - location strings,
+        - date strings,
+        - boolean,
+        - number,
+        - strings, and
+        - list of strings.
+
+        Any other data type (eg. dictionary type) is currently not supported and is marked as an
+        'unknown' data type. Such unknown data types fields do not have any associated resolvers.
+        """
 
-    SYN_FIELD_SUFFIX = "$whitelist"
+        def __init__(self, index_name, field_name):
 
-    def __init__(self, client, index, ranking_config=None, field_info=None):
-        """Initialize a Search object.
+            # details to establish a scoped field name
+            self.index_name = index_name
+            self.field_name = field_name
 
-        Args:
-            client (Elasticsearch): Elasticsearch client.
-            index (str): index name of knowledge base object.
-            ranking_config (dict): overriding ranking configuration parameters for current search.
-            field_info (dict): dictionary contains knowledge base matadata objects.
-        """
-        self.index = index
-        self.client = client
+            # vars that contain data of the field
+            self.data_type = None
+            self.id2value = {}  # TODO: duplicate data also exist in resolver object if created
+            self.hash = None  # to identify any data changes before build resolver
 
-        self._clauses = {"query": [], "filter": [], "sort": []}
+            # details to create any required resolvers
+            self.processor_type = None
+            self.has_text_resolver = None
+            self.has_embedding_resolver = None
 
-        self._ranking_config = ranking_config
-        if not ranking_config:
-            self._ranking_config = copy.deepcopy(DEFAULT_RANKING_CONFIG)
+            # required resolvers
+            self._text_resolver = None  # an entity resolver if string type data
+            self._embedding_resolver = None  # an embedding based entity resolver
 
-        self._kb_field_info = field_info
+        def __repr__(self):
+            return f"{self.__class__.__name__} " \
+                   f"field_name: {self.field_name} " \
+                   f"data_type: {self.data_type} " \
+                   f"has_text_resolver: {self.has_text_resolver} " \
+                   f"has_embedding_resolver: {self.has_embedding_resolver}"
 
-    def _clone(self):
-        """Clone a Search object.
+        @staticmethod
+        def _auto_string_processor(string_or_strings, query_type, language='english'):
 
-        Returns:
-            Search: cloned copy of the Search object.
-        """
-        s = Search(client=self.client, index=self.index)
-        s._clauses = copy.deepcopy(self._clauses)
-        s._ranking_config = copy.deepcopy(self._ranking_config)
-        s._kb_field_info = copy.deepcopy(self._kb_field_info)
-
-        return s
-
-    def _build_query_clause(self, query_type=DEFAULT_QUERY_TYPE, **kwargs):
-        field, value = next(iter(kwargs.items()))
-        field_info = self._kb_field_info.get(field)
-        if not field_info:
-            raise ValueError("Invalid knowledge base field '{}'".format(field))
-
-        # check whether the synonym field is available. By default the synonyms are
-        # imported to "<field_name>$whitelist" field.
-        synonym_field = (
-            field + self.SYN_FIELD_SUFFIX
-            if self._kb_field_info.get(field + self.SYN_FIELD_SUFFIX)
-            else None
-        )
-        clause = Search.QueryClause(field, field_info, value, query_type, synonym_field)
-        clause.validate()
-        self._clauses[clause.get_type()].append(clause)
+            if language != 'english':
+                # TODO: implement support for non-english texts
+                msg = "Only allowed language for text processing in QA is 'english'. "
+                raise NotImplementedError(msg)
 
-    def _build_filter_clause(self, query_type=DEFAULT_QUERY_TYPE, **kwargs):
-        # set the filter type to be 'range' if any range operator is specified.
-        if (
-            kwargs.get("gt")
-            or kwargs.get("gte")
-            or kwargs.get("lt")
-            or kwargs.get("lte")
-        ):
-            field = kwargs.get("field")
-            gt = kwargs.get("gt")
-            gte = kwargs.get("gte")
-            lt = kwargs.get("lt")
-            lte = kwargs.get("lte")
+            try:
+                english_stop_words = set(nltk_stopwords.words(language))
+            except LookupError:
+                nltk.download('stopwords')
+                english_stop_words = set(nltk_stopwords.words(language))
 
-            if field not in self._kb_field_info:
-                raise ValueError("Invalid knowledge base field '{}'".format(field))
+            english_stemmer = PorterStemmer()
 
-            clause = Search.FilterClause(
-                field=field,
-                field_info=self._kb_field_info.get(field),
-                range_gt=gt,
-                range_gte=gte,
-                range_lt=lt,
-                range_lte=lte,
-            )
-        else:
-            key, value = next(iter(kwargs.items()))
-            if key not in self._kb_field_info:
-                raise ValueError("Invalid knowledge base field '{}'".format(key))
-            clause = Search.FilterClause(field=key, value=value, query_type=query_type)
-        clause.validate()
-        self._clauses[clause.get_type()].append(clause)
-
-    def _build_sort_clause(self, **kwargs):
-        sort_field = kwargs.get("field")
-        sort_type = kwargs.get("sort_type")
-        sort_location = kwargs.get("location")
-
-        field_info = self._kb_field_info.get(sort_field)
-        if not field_info:
-            raise ValueError("Invalid knowledge base field '{}'".format(sort_field))
-
-        # only compute field stats if sort field is number or date type.
-        field_stats = None
-        if field_info.is_number_field() or field_info.is_date_field():
-            field_stats = self._get_field_stats(sort_field)
-
-        clause = Search.SortClause(
-            sort_field, field_info, sort_type, field_stats, sort_location
-        )
-        clause.validate()
-        self._clauses[clause.get_type()].append(clause)
+            try:
+                nltk_word_tokenize(" ")
+            except LookupError:
+                nltk.download('punkt')
+
+            def lowercase(string):
+                return string.lower()
+
+            def strip_accents(string):
+                return ''.join((c for c in unicodedata.normalize('NFD', string) if
+                                unicodedata.category(c) != 'Mn'))
+
+            def keyword_processor(string):
+                # TODO: can add char_filters like Elasticsearch; see 'keyword_match_analyzer' in
+                #       'components/_elasticsearch_hepers.py'
+                return strip_accents(lowercase(str(string)))
+
+            def text_processor(string):
+                string = strip_accents(lowercase(str(string)))
+                word_tokens = nltk_word_tokenize(string)
+                filtered_string = " ".join(
+                    [english_stemmer.stem(w) for w in word_tokens if w not in english_stop_words])
+                return filtered_string
+
+            if "keyword" in query_type:
+                processor = keyword_processor
+            elif "text" in query_type:
+                processor = text_processor
+            else:
+                raise ValueError("query_type in processor must contain 'text' or 'keyword' string")
 
-    def _build_clause(self, clause_type, query_type=DEFAULT_QUERY_TYPE, **kwargs):
-        """Helper method to build query, filter and sort clauses.
+            if isinstance(string_or_strings, str):
+                return processor(string_or_strings)
+            elif isinstance(string_or_strings, (list, set)):
+                return [processor(string) for string in string_or_strings]
+            else:
+                raise ValueError("Input to auto processor must be string or list of strings")
 
-        Args:
-            clause_type (str): type of clause
-        """
-        if clause_type == "query":
-            self._build_query_clause(query_type, **kwargs)
-        elif clause_type == "filter":
-            self._build_filter_clause(query_type, **kwargs)
-        elif clause_type == "sort":
-            self._build_sort_clause(**kwargs)
-        else:
-            raise Exception("Unknown clause type.")
+        def _update_data_type(self, value):
 
-    def query(self, query_type=DEFAULT_QUERY_TYPE, **kwargs):
-        """Specify the query text to match on a knowledge base text field. The query text is
-        normalized and processed (based on query_type) to find matches in knowledge base using
-        several text relevance scoring factors including exact matches, phrase matches and partial
-        matches.
+            if self.data_type is not None:
+                return
 
-        Examples:
+            try:
+                value = value.strip()
+            except AttributeError:
+                pass
+
+            if self.is_location(value):
+                self.data_type = "location"
+            elif self.is_bool(value):
+                self.data_type = "bool"
+            elif self.is_number(value):
+                self.data_type = "number"
+            elif self.is_string(value) or self.is_list_of_strings(value):
+                self.data_type = "string"
+            elif self.is_date(value):
+                self.data_type = "date"
+            else:
+                self.data_type = "unknown"
+
+        def _validate_and_reformat_value(self, value, _id=None):
+
+            def _raise_error():
+                errmsg = f"Formatting error for the field {self.field_name}" \
+                         f"{' in doc id' if _id else ''} {str(_id) if _id else ''} " \
+                         f"in index {self.index_name}. Found an unexpected type {type(value)} " \
+                         f"but expected the field value to have type {self.data_type}"
+                logger.error(errmsg)
+                raise TypeError(errmsg)
+
+            if self.data_type == "location":
+                if not self.is_location(value):
+                    _raise_error()
+            elif self.data_type == "bool":
+                if not self.is_bool(value):
+                    _raise_error()
+            elif self.data_type == "number":
+                if not self.is_number(value):
+                    _raise_error()
+            elif self.data_type == "string":
+                if self.is_string(value):
+                    value = value.strip()
+                elif self.is_list_of_strings(value):
+                    value = [val.strip() for val in value]
+                else:
+                    _raise_error()
+            elif self.data_type == "date":
+                value = value.strip()
+                if not self.is_date(value):
+                    _raise_error()
 
-            >>> s = question_answerer.build_search(index='dish')
-            >>> s.query(name='pad thai')
+            return value
 
-        In the example above the query text "pad thai" will be used to match against document field
-        "name" in knowledge base index "dish".
+        def _do_search_validation(self, query_type):
 
-        Args:
-            a keyword argument to specify the query text and the knowledge base document field along
-            with the query type (keyword/text/embedder/embedder_keyword/embedder_text).
-        Returns:
-            Search: a new Search object with added search criteria.
-        """
-        new_search = self._clone()
-        new_search._build_clause("query", query_type, **kwargs)
+            if self.data_type not in ["string", "date"]:
+                msg = f"Searching is not allowed for data type '{self.data_type}'. "
+                logger.error(msg)
+                raise ValueError(
+                    "Query can only be defined on text and vector fields. If it is,"
+                    " try running load_kb with clean=True and reinitializing your"
+                    " QuestionAnswerer object."
+                )
 
-        return new_search
+            if query_type not in ALL_QUERY_TYPES:
+                msg = f"Unknown query type- {query_type}"
+                logger.error(msg)
+                raise ValueError(msg)
 
-    def filter(self, query_type=DEFAULT_QUERY_TYPE, **kwargs):
-        """Specify filter condition to be applied to specified knowledge base field. In MindMeld
-        two types of filters are supported: text filter and range filters.
+        def _do_filter_validation(self, filter_text, gt, gte, lt, lte, et, boolean):
+            # code inspired and adapted from Elasticsearch QA class (see Sort/Filter/QueryClause)
 
-        Text filters are used to apply hard filters on specified knowledge base text fields.
-        The filter text value is normalized and matched using entire text span against the
-        knowledge base field.
+            if self.data_type in ["number", "date"]:
+                if (
+                    not gt
+                    and not gte
+                    and not lt
+                    and not lte
+                    and not et
+                ):
+                    raise ValueError("No range parameter is specified")
+                elif gte and gt:
+                    raise ValueError(
+                        "Invalid range parameters. Cannot specify both 'gte' and 'gt'."
+                    )
+                elif lte and lt:
+                    raise ValueError(
+                        "Invalid range parameters. Cannot specify both 'lte' and 'lt'."
+                    )
+                elif (gt or gte or lt or lte) and et:
+                    raise ValueError(
+                        "Invalid range parameters. Cannot specify 'et' when specifying one of "
+                        "[gt, gte, lt, lte]"
+                    )
+            elif self.data_type in ["bool"]:
+                if not isinstance(boolean, bool):
+                    raise ValueError(
+                        "Invalid boolean parameter."
+                    )
+            elif self.data_type in ["string"]:
+                if not self.is_string(filter_text):
+                    raise ValueError(
+                        "Invalid textual input parameter."
+                    )
+            else:
+                raise ValueError(
+                    "Custom filter can only be defined for boolean, number, string or date field."
+                )
 
-        It's common to have filter conditions based on other resolved canonical entities.
-        For example, in food ordering domain the resolved restaurant entity can be used as a filter
-        to resolve dish entities. The exact knowledge base field to apply these filters depends on
-        the knowledge base data model of the application.
-        If the entity is not in the canonical form, a fuzzy filter can be applied by setting the
-        query_type to 'text'.
+        def _do_sort_validation(self, sort_type, location):
+            # code inspired and adapted from Elasticsearch QA class (see Sort/Filter/QueryClause)
 
-        Range filters are used to filter with a value range on specified knowledge base number or
-        date fields. Example use cases include price range filters and date range filters.
+            SORT_ORDER_ASC = "asc"
+            SORT_ORDER_DESC = "desc"
+            SORT_DISTANCE = "distance"
+            SORT_TYPES = {SORT_ORDER_ASC, SORT_ORDER_DESC, SORT_DISTANCE}
 
-        Examples:
+            if sort_type not in SORT_TYPES:
+                raise ValueError(
+                    "Invalid value for sort type '{}'".format(sort_type)
+                )
 
-        add text filter:
-            >>> s = question_answerer.build_search(index='menu_items')
-            >>> s.filter(restaurant_id='B01CGKGQ40')
+            if self.data_type == "location" and sort_type != SORT_DISTANCE:
+                raise ValueError(
+                    "Invalid value for sort type '{}'".format(sort_type)
+                )
 
-        add range filter:
-                >>> s = question_answerer.build_search(index='menu_items')
-                >>> s.filter(field='price', gte=1, lt=10)
+            if self.data_type == "location" and not location:
+                raise ValueError(
+                    "No origin location specified for sorting by distance."
+                )
 
-        Args:
-            query_type (str): Whether the filter is over structured or unstructured text.
-            kwargs: A keyword argument to specify the filter text and the knowledge base text field.
-            field (str): knowledge base field name for range filter.
-            gt (number or str): range filter operator for greater than.
-            gte (number or str): range filter operator for greater than or equal to.
-            lt (number or str): range filter operator for less than.
-            lte (number or str): range filter operator for less or equal to.
+            if sort_type == SORT_DISTANCE and self.data_type != "location":
+                raise ValueError(
+                    "Sort by distance is only supported using 'location' field."
+                )
 
-        Returns:
-            Search: A new Search object with added search criteria.
-        """
-        new_search = self._clone()
-        new_search._build_clause("filter", query_type, **kwargs)
+            if self.data_type not in ["number", "date", "location"]:
+                raise ValueError(
+                    "Custom sort criteria can only be defined for"
+                    + " 'number', 'date' or 'location' fields."
+                )
 
-        return new_search
+        @staticmethod
+        def _get_resolvers_cname(value):
+            if isinstance(value, (set, list)):
+                return list(value)[0]
+            return value
 
-    def sort(self, field, sort_type=None, location=None):
-        """Specify custom sort criteria.
+        @staticmethod
+        def _get_resolvers_whitelist(value):
+            if isinstance(value, (set, list)):
+                return list(value)[1:]
+            return []
 
-        Args:
-            field (str): knowledge base field for sort.
-            sort_type (str): sorting type. valid values are 'asc', 'desc' and 'distance'. 'asc' and
-                             'desc' can be used to sort numeric or date fields and 'distance' can
-                             be used to sort by distance on geo_point fields. Default sort type
-                             is 'desc' if not specified.
-            location (str): location (lat, lon) in geo_point format to be used as origin when
-                            sorting by 'distance'
-        """
-        new_search = self._clone()
-        new_search._build_clause(
-            "sort", field=field, sort_type=sort_type, location=location
-        )
-        return new_search
+        def _get_resolvers_entity_map(self, id2value):
+            """
+            converts id2value into an entity map format and returns it
+            """
+            # new: https://github.com/cisco/mindmeld/issues/291
+            #   making 'value' into a list for cname and whitelist conversion.
+            #   If more than one items in 'value', all items after first one go into whitelist
+            entity_map = {
+                "entities": [
+                    {
+                        "id": _id,
+                        "cname": self._get_resolvers_cname(value),
+                        "whitelist": self._get_resolvers_whitelist(value)
+                    } for _id, value in id2value.items()
+                ]
+            }
+            return entity_map
 
-    def _get_field_stats(self, field):
-        """Get knowledge field statistics for custom sort functions. The field statistics is
-        only available for number and date typed fields.
+        def update_resource(self, id2value, has_text_resolver, has_embedding_resolver,
+                            resolver_settings, lazy_clean=False,
+                            app_path=NATIVE_QUESTION_ANSWERER_INDICES_CACHE_PATH,
+                            processor_type="keyword"):
+            """
+            Updates a field resource by augmenting/updating latest data and resolvers
+
+            Args:
+                id2value (dict): a mapping between documnet ids & values of the chosen KB field
+                has_text_resolver (bool): If a tfidf resolver is to be created
+                has_embedding_resolver (bool): If a embedder resolver is to be created
+                resolver_settings (dict): a ER- or QA- config with 'model_settings' keyword;
+                    used while fitting any resolver
+                lazy_clean (bool, optional): if True, resolvers are fit with clean=True; tagged lazy
+                    because the embedder cache, if cleaned, is cleaned later than the index cache
+                app_path (str, optional): a path to create cache for embedder resolver
+                processor_type (str, optional, "text" or "keyword"): processor for tfidf resolver
+            """
 
-        Args:
-            field(str): knowledge base field name
+            if not id2value and not self.data_type:  # else, if required, update resolvers
+                return
+
+            # update id2cname and compute hash
+            for _id, value in id2value.items():
+                # ignore null values
+                if not isinstance(value, bool) and not value:
+                    continue
+                # first non empty value will determine the data type of this field if not already
+                #   determined. will be 'unknown' if all values are empty or if there is a ambiguity
+                #   in deciding the data type.
+                if not self.data_type:
+                    self._update_data_type(value)
+                # validation and re-formatting to update database, no change for unknown data type
+                try:
+                    value = self._validate_and_reformat_value(value, _id)
+                except TypeError:
+                    # implies that this field had different observed data type across different docs
+                    self.data_type = "unknown"
+                self.id2value.update({_id: value})
+
+            # in cases where all docs have null data for this field
+            if not self.id2value:
+                msg = f"Found no data for field {self.field_name}. "
+                logger.warning(msg)
+                self.data_type = "unknown"
+
+            # ascertain resolver(s) requirement
+            new_hash = None
+            if self.data_type in ["bool", "number", "location", "unknown"]:
+                # discard input arguments as resolvers are not applicable to these data types
+                if has_text_resolver or has_embedding_resolver:
+                    msg = f"Unable to create any resolver for the field {self.field_name} due to " \
+                          f"its marked data type {self.data_type}. "
+                    logger.info(msg)
+                self.has_text_resolver = False
+                self.has_embedding_resolver = False
+                return
+            else:  # ["string", "date"]
+                self.has_text_resolver = has_text_resolver
+                self.has_embedding_resolver = has_embedding_resolver
+                if not self.has_text_resolver and not self.has_embedding_resolver:
+                    msg = f"Atleast one of text or embedder resolver needs to be applied " \
+                          f"for string(s) type data field ({self.field_name}). "
+                    logger.error(msg)
+                    return
+                new_hash = Hasher(algorithm="sha256").hash(
+                    string=json.dumps(self.id2value, sort_keys=True)
+                )
 
-        Returns:
-            dict: dictionary that contains knowledge base field statistics.
-        """
+            # tfidf based text resolver
+            if (
+                self.has_text_resolver and
+                (not self._text_resolver or
+                 new_hash != self.hash or
+                 self.processor_type != processor_type)
+            ):
+                # log info
+                msg = f"Creating a text resolver for field '{self.field_name}' in " \
+                      f"index '{self.index_name}'."
+                logger.info(msg)
+                # update processor type
+                if processor_type not in ['text', 'keyword']:
+                    msg = f"Expected 'processor_type' to be among ['text', 'keyword'] but found " \
+                          f"to be '{processor_type}'"
+                    raise ValueError(msg)
+                self.processor_type = processor_type
+                # create a new resolver and fit
+                self._text_resolver = (
+                    TfIdfSparseCosSimEntityResolver(
+                        app_path=app_path,  # not required to supply, can be None!
+                        entity_type=get_scoped_index_name(self.index_name, self.field_name),
+                        config={"model_settings": {**resolver_settings}})
+                )
+                # format id2value data into an 'entity_map' format for resolvers
+                values = self._auto_string_processor([*self.id2value.values()], self.processor_type)
+                processed_id2value = dict(zip(self.id2value.keys(), values))
+                entity_map = self._get_resolvers_entity_map(processed_id2value)
+                self._text_resolver.fit(entity_map=entity_map, clean=lazy_clean)
 
-        stats_query = {"aggs": {}, "size": 0}
-        stats_query["aggs"][field + "_min"] = {"min": {"field": field}}
-        stats_query["aggs"][field + "_max"] = {"max": {"field": field}}
+            # embedder based resolver
+            if (
+                self.has_embedding_resolver and
+                (not self._embedding_resolver or new_hash != self.hash)
+            ):
+                # log info
+                msg = f"Creating an embedder resolver for field '{self.field_name}' in " \
+                      f"index '{self.index_name}'."
+                logger.info(msg)
+                # create a new resolver and fit
+                self._embedding_resolver = (
+                    EmbedderCosSimEntityResolver(
+                        app_path=app_path,
+                        entity_type=get_scoped_index_name(self.index_name, self.field_name),
+                        config={"model_settings": {**resolver_settings}})
+                )
+                # use same data as text resolver but without any processing!
+                entity_map = self._get_resolvers_entity_map(self.id2value)
+                # fit() also includes dumping cache in case of embedding resolver
+                self._embedding_resolver.fit(entity_map=entity_map, clean=lazy_clean)
 
-        res = self.client.search(
-            index=self.index, body=stats_query, search_type="query_then_fetch"
-        )
+            self.hash = new_hash
 
-        return {
-            "min_value": res["aggregations"][field + "_min"]["value"],
-            "max_value": res["aggregations"][field + "_max"]["value"],
-        }
+        def do_search(self, query_type, value, allowed_ids=None):
+            """
+            Retrieves doc ids with corresponding similarity scores for the given query
+
+            Args:
+                query_type (str): one of ALL_QUERY_TYPES
+                value (str): A string to do similarity search
+                allowed_ids (iterable, optional): if not None, only docs containing these ids are
+                    populated in the results
+            Returns:
+                dict: a mapping between _ids recorded in this field and their corresponding scores
+            """
 
-    def _build_es_query(self, size=10):
-        """Build knowledge base search syntax based on provided search criteria.
+            self._do_search_validation(query_type)
 
-        Args:
-            size (int): The maximum number of records to fetch, default to 10.
+            value = self._validate_and_reformat_value(value)
 
-        Returns:
-            str: knowledge base search syntax for the current search object.
-        """
-        es_query = {
-            "query": {
-                "function_score": {
-                    "query": {},
-                    "functions": [],
-                    "score_mode": "sum",
-                    "boost_mode": "sum",
-                }
-            },
-            "_source": {"excludes": ["*" + self.SYN_FIELD_SUFFIX]},
-            "size": size,
-        }
+            def update_scores(resolver, value_string, this_field_scores, n_scores, allowed_cnames):
 
-        if not self._clauses["query"] and not self._clauses["filter"]:
-            # no query/filter clauses - use match_all
-            es_query["query"]["function_score"]["query"] = {"match_all": {}}
-        else:
-            es_query["query"]["function_score"]["query"]["bool"] = {}
-
-            if self._clauses["query"]:
-                es_query_clauses = []
-                es_boost_functions = []
-                for clause in self._clauses["query"]:
-                    query_clause, boost_functions = clause.build_query()
-                    if query_clause:
-                        es_query_clauses.append(query_clause)
-                    es_boost_functions.extend(boost_functions)
-
-                if self._ranking_config["query_clauses_operator"] == "and":
-                    es_query["query"]["function_score"]["query"]["bool"][
-                        "must"
-                    ] = es_query_clauses
+                # obtain synonyms' scores without sorting! (saves compute time)
+                # also, do matching against selected cnames only
+                predictions = resolver.predict(
+                    value_string, top_n=None, allowed_cnames=allowed_cnames
+                )
+                # retain only top scored entries for each id
+                _best_scores = {}
+                for prediction in predictions:
+                    _id, _score = prediction["id"], prediction["score"]
+                    if _id not in _best_scores:
+                        _best_scores[_id] = _score
+                    else:
+                        _best_scores[_id] = max(_best_scores[_id], _score)
+                # for missing doc ids, populate the minimum score
+                # in cases where there was no training data for resolver, all ids are absent
+                #   in the returned predictions! And then the _best_scores will be empty
+                if _best_scores:
+                    min_best_scores = min([*_best_scores.values()])
+                    for _id in self.id2value.keys():
+                        if allowed_ids and _id not in allowed_ids:
+                            continue
+                        if _id not in _best_scores:
+                            _best_scores[_id] = min_best_scores
+                    # retain only top scored entries for each id
+                    this_field_scores = {
+                        _id: (this_field_scores.get(_id, 0.0) + _score) / (n_scores + 1)
+                        for _id, _score in _best_scores.items()
+                    }
+                    n_scores += 1
+
+                # if no predictions are obtained from resolver, these values are same as input
+                return this_field_scores, n_scores
+
+            # maps doc ids with their similarity scores
+            this_field_scores = {}
+            n_scores = 0
+
+            if ("text" in query_type or "keyword" in query_type) and self._text_resolver:
+                # get processor type, process the value and then obtain similarities
+                processor_type = "text" if "text" in query_type else "keyword"
+                if processor_type != self.processor_type:
+                    msg = f"Using different text processing during loading KB " \
+                          f"({self.processor_type}) vs during inference ({processor_type}) " \
+                          f"for the field {self.field_name} in index {self.index_name}"
+                    logger.warning(msg)
+                new_value = self._auto_string_processor(value, processor_type)
+                if allowed_ids:
+                    filtered_cnames = [
+                        self._get_resolvers_cname(self._auto_string_processor(val, processor_type))
+                        for _id, val in self.id2value.items() if _id in allowed_ids
+                    ]
                 else:
-                    es_query["query"]["function_score"]["query"]["bool"][
-                        "should"
-                    ] = es_query_clauses
-
-                # add all boost functions for the query clause
-                # right now the only boost functions supported are exact match boosting for
-                # CNAME and synonym whitelists.
-                es_query["query"]["function_score"]["functions"].extend(
-                    es_boost_functions
+                    filtered_cnames = None
+                this_field_scores, n_scores = update_scores(
+                    self._text_resolver, new_value, this_field_scores, n_scores, filtered_cnames
                 )
+            elif "text" in query_type or "keyword" in query_type:
+                msg = f"No text based resolver configured for field {self.field_name} " \
+                      f"in index {self.index_name}."
+                logger.warning(msg)
+
+            if "embedder" in query_type and self._embedding_resolver:
+                if allowed_ids:
+                    filtered_cnames = [
+                        self._get_resolvers_cname(val)
+                        for _id, val in self.id2value.items() if _id in allowed_ids
+                    ]
+                else:
+                    filtered_cnames = None
+                this_field_scores, n_scores = update_scores(
+                    self._embedding_resolver, value, this_field_scores, n_scores, filtered_cnames
+                )
+            elif "embedder" in query_type:
+                msg = f"No embedder based resolver configured for field {self.field_name} " \
+                      f"in index {self.index_name}."
+                logger.warning(msg)
 
-            if self._clauses["filter"]:
-                es_filter_clauses = {"bool": {"must": []}}
-                for clause in self._clauses["filter"]:
-                    es_filter_clauses["bool"]["must"].append(clause.build_query())
+            # Case where-in no resolver exists (eg. "unknown" data type)
+            if not this_field_scores:
+                this_field_scores = {_id: 0.0 for _id in self.id2value.keys()}
 
-                es_query["query"]["function_score"]["query"]["bool"][
-                    "filter"
-                ] = es_filter_clauses
+            return this_field_scores
 
-        # add scoring function for custom sort criteria
-        for clause in self._clauses["sort"]:
-            sort_function = clause.build_query()
-            es_query["query"]["function_score"]["functions"].append(sort_function)
+        def do_filter(self, allowed_ids, filter_text=None, gt=None, gte=None, lt=None, lte=None,
+                      et=None, boolean=None):
+            """
+            Filters a list of docs to a subset based on some criteria such as a boolean value
+            or {>,<,=} operations or a text snippet.
+            """
 
-        logger.debug("ES query syntax: %s.", es_query)
+            if not allowed_ids:
+                return allowed_ids
 
-        return es_query
+            self._do_filter_validation(filter_text, gt, gte, lt, lte, et, boolean)
 
-    def execute(self, size=10):
-        """Executes the knowledge base search with provided criteria and returns matching documents.
+            def _is_valid(value):
+                if gt and not (value > gt):
+                    return False
+                if gte and not (value >= gte):
+                    return False
+                if lt and not (value < lt):
+                    return False
+                if lte and not (value <= lte):
+                    return False
+                if et and not (value != et):
+                    return False
+                return True
 
-        Args:
-            size (int): The maximum number of records to fetch, default to 10.
+            if self.data_type in ["number"]:
 
-        Returns:
-            a list of matching documents.
-        """
-        try:
-            # TODO: move the ES API call logic to ES helper
-            es_query = self._build_es_query(size=size)
-
-            response = self.client.search(index=self.index, body=es_query)
-
-            # construct results, removing embedding metadata and exposing score
-            results = []
-            for hit in response["hits"]["hits"]:
-                item = {key: val for (key, val) in hit["_source"].items()
-                        if not key.endswith(EMBEDDING_FIELD_STRING)}
-                item['_score'] = hit['_score']
-                results.append(item)
-            return results
-        except EsConnectionError as e:
-            logger.error(
-                "Unable to connect to Elasticsearch: %s details: %s", e.error, e.info
-            )
-            raise KnowledgeBaseConnectionError(es_host=self.client.transport.hosts) from e
-        except TransportError as e:
-            logger.error(
-                "Unexpected error occurred when sending requests to Elasticsearch: %s "
-                "Status code: %s details: %s",
-                e.error,
-                e.status_code,
-                e.info,
-            )
-            raise KnowledgeBaseError from e
-        except ElasticsearchException as e:
-            raise KnowledgeBaseError from e
+                def is_valid(value):
+                    return _is_valid(value)
 
-    class Clause(ABC):
-        """This class models an abstract knowledge base clause."""
+            elif self.data_type in ["date"]:
 
-        def __init__(self):
-            """Initialize a knowledge base clause"""
-            self.clause_type = None
+                def is_valid(value):
+                    return _is_valid(abs(self.date_scorer(value)))  # returns number of days
 
-        @abstractmethod
-        def validate(self):
-            """Validate the clause."""
-            raise NotImplementedError("Must override validate()")
+            elif self.data_type in ["bool"]:
 
-        @abstractmethod
-        def build_query(self):
-            """Build knowledge base query."""
-            raise NotImplementedError("Must override build_query()")
+                def is_valid(value):
+                    return value == boolean
 
-        def get_type(self):
-            """Returns clause type"""
-            return self.clause_type
+            elif self.data_type in ["string"]:
 
-    class QueryClause(Clause):
-        """This class models a knowledge base query clause."""
+                # different from Elasticsearch filtering on strings, this method only allows
+                #   exact presence of that input text and not a fuzzy match!
 
-        DEFAULT_EXACT_MATCH_BOOSTING_WEIGHT = 100
+                filter_text = self._validate_and_reformat_value(filter_text)
+                filter_text_aliases = {filter_text, filter_text.lower()}
 
-        def __init__(
-            self,
-            field,
-            field_info,
-            value,
-            query_type=DEFAULT_QUERY_TYPE,
-            synonym_field=None,
-        ):
-            """Initialize a knowledge base query clause."""
-            self.field = field
-            self.field_info = field_info
-            self.value = value
-            self.query_type = query_type
-            self.syn_field = synonym_field
-
-            self.clause_type = "query"
-
-        def build_query(self):
-            """build knowledge base query for query clause"""
-
-            # ES syntax is generated based on specified knowledge base field
-            # the following ranking factors are considered:
-            # 1. exact matches (with boosted weight)
-            # 2. word N-gram matches
-            # 3. character N-gram matches
-            # 4. matches on synonym if available (exact, word N-gram and character N-gram):
-            # for a knowledge base text field the synonym are indexed in a separate field
-            # "<field name>$whitelist" if available.
-            functions = []
-
-            if "embedder" in self.query_type and self.field_info.is_vector_field():
-                clause = None
-                functions = [
-                    {
-                        "script_score": {
-                            "script": {
-                                "source": "cosineSimilarity(params.field_embedding,"
-                                " doc[params.matching_field]) + 1.0",
-                                "params": {
-                                    "field_embedding": self.value.tolist(),
-                                    "matching_field": self.field,
-                                },
-                            }
-                        },
-                        "weight": 10,
-                    }
-                ]
-            elif "text" in self.query_type:
-                clause = {
-                    "bool": {
-                        "should": [
-                            {"match": {self.field: {"query": self.value}}},
-                            {
-                                "match": {
-                                    self.field
-                                    + ".processed_text": {"query": self.value}
-                                }
-                            },
-                        ]
-                    }
-                }
-            elif "keyword" in self.query_type:
-                clause = {
-                    "bool": {
-                        "should": [
-                            {"match": {self.field: {"query": self.value}}},
-                            {
-                                "match": {
-                                    self.field
-                                    + ".normalized_keyword": {"query": self.value}
-                                }
-                            },
-                            {
-                                "match": {
-                                    self.field + ".char_ngram": {"query": self.value}
-                                }
-                            },
-                        ]
-                    }
-                }
-            else:
-                raise Exception("Unknown query type.")
+                def is_valid(value):
+                    if value in filter_text_aliases:
+                        return True
+                    if value.lower() in filter_text_aliases:
+                        return True
+                    return False
 
-            if self.field_info.is_text_field():
-                # Boost function for boosting conditions, e.g. exact match boosting
-                functions = [
-                    {
-                        "filter": {
-                            "match": {self.field + ".normalized_keyword": self.value}
-                        },
-                        "weight": self.DEFAULT_EXACT_MATCH_BOOSTING_WEIGHT,
-                    }
+            allowed_ids = {_id: None for _id in allowed_ids if
+                           _id in self.id2value and is_valid(self.id2value[_id])}
+
+            return allowed_ids
+
+        def do_sort(self, curated_docs, sort_type, location=None):
+
+            if not curated_docs:
+                return curated_docs
+
+            self._do_sort_validation(sort_type, location)
+
+            validated_curated_docs, field_values = [], []
+            for doc in curated_docs:
+                _id = doc["_id"]
+                value = self.id2value.get(_id)
+                if value is None:
+                    msg = f"Discarding doc with id: {doc['id']} while sorting as no " \
+                          f"{self.field_name} field available for it."
+                    logger.info(msg)
+                    continue
+                validated_curated_docs.append(doc)
+                field_values.append(value)
+            curated_docs = validated_curated_docs
+
+            if self.data_type == "location":
+                sort_type = "asc"
+                field_values = [
+                    self.location_scorer(value, location) for value in field_values
                 ]
+            elif self.data_type == "number":
+                field_values = [self.number_scorer(value) for value in field_values]
+            elif self.data_type == "date":
+                field_values = [self.date_scorer(value) for value in field_values]
+
+            _, results = zip(*sorted(enumerate(curated_docs),
+                                     key=lambda x: field_values[x[0]],
+                                     reverse=sort_type != "asc"))
+            return results
 
-            # generate ES syntax for matching on synonym whitelist if available.
-            if self.syn_field:
-                clause["bool"]["should"].append(
-                    {
-                        "nested": {
-                            "path": self.syn_field,
-                            "score_mode": "max",
-                            "query": {
-                                "bool": {
-                                    "should": [
-                                        {
-                                            "match": {
-                                                self.syn_field
-                                                + ".name.normalized_keyword": {
-                                                    "query": self.value
-                                                }
-                                            }
-                                        },
-                                        {
-                                            "match": {
-                                                self.syn_field
-                                                + ".name": {"query": self.value}
-                                            }
-                                        },
-                                        {
-                                            "match": {
-                                                self.syn_field
-                                                + ".name.char_ngram": {
-                                                    "query": self.value
-                                                }
-                                            }
-                                        },
-                                    ]
-                                }
-                            },
-                            "inner_hits": {},
-                        }
-                    }
-                )
+        @property
+        def doc_ids(self):
+            return [*self.id2value.keys()]
 
-                functions.append(
-                    {
-                        "filter": {
-                            "nested": {
-                                "path": self.syn_field,
-                                "query": {
-                                    "match": {
-                                        self.syn_field
-                                        + ".name.normalized_keyword": self.value
-                                    }
-                                },
-                            }
-                        },
-                        "weight": self.DEFAULT_EXACT_MATCH_BOOSTING_WEIGHT,
-                    }
-                )
+        @staticmethod
+        def curate_docs_to_return(index_resources, _ids, _scores=None):
+            """
+            Collates all field names into docs
+
+            Args:
+                index_resources: a dict of field names and corresponding FieldResource instances
+                _ids (List[str]): if provided as a list of strings, only docs with those ids are
+                    obtained in the same order of the ids, else all ids are used
+                _scores (List[number], optional): if provided as a list of numbers and of same size
+                    as the _ids, they will be attached to the curated results for corresponding _ids
+            Returns:
+                list[dict]: compiled docs
+            """
+            docs = {}
+
+            if _scores:
+                if len(_scores) != len(_ids):
+                    msg = f"Number of ids ({len(_ids)}) supplied did not match " \
+                          f"number of  scores ({len(_scores)}). Discarding inputted scores " \
+                          f"while curating docs for QA results. "
+                    logger.warning(msg)
+                else:
+                    docs = {_id: {"_id": _id, "_score": _scores[i]} for i, _id in enumerate(_ids)}
+
+            if not docs:
+                # when no _scores are inputted or when number of _scores do not match umber of _ids
+                docs = {_id: {"_id": _id} for _id in _ids}
+
+            # populate docs for all collected ids
+            for field_name, field_resource in index_resources.items():
+                for _id in _ids:
+                    try:
+                        docs[_id][field_name] = field_resource.id2value[_id]
+                    except KeyError:
+                        docs[_id][field_name] = None
+
+            return [*docs.values()]
+
+        @classmethod
+        def from_metadata(cls, cache_object: Bunch):
+            field_resource = cls(index_name=cache_object.index_name,
+                                 field_name=cache_object.field_name)
+            field_resource.data_type = cache_object.data_type
+            field_resource.id2value = cache_object.id2value
+            field_resource.hash = cache_object.hash
+            field_resource.processor_type = cache_object.processor_type
+            field_resource.has_text_resolver = cache_object.has_text_resolver
+            field_resource.has_embedding_resolver = cache_object.has_embedding_resolver
+            return field_resource
+
+        def to_metadata(self):
+            cache_object = Bunch(
+                index_name=self.index_name,
+                field_name=self.field_name,
+                data_type=self.data_type,
+                id2value=self.id2value,
+                hash=self.hash,
+                processor_type=self.processor_type,
+                has_text_resolver=self.has_text_resolver,
+                has_embedding_resolver=self.has_embedding_resolver
+            )
+            return cache_object
 
-            return clause, functions
+    class Search:
+        """ Search class enabling functionality to query, filter and sort.
+        Utilizes various methods from Indices and FiledResource to compute results.
 
-        def validate(self):
-            if (
-                not self.field_info.is_text_field()
-                and not self.field_info.is_vector_field()
-            ):
-                raise ValueError(
-                    "Query can only be defined on text and vector fields. If it is,"
-                    " try running load_kb with clean=True and reinitializing your"
-                    " QuestionAnswerer object."
-                )
+        Currently, the following are supported data types for each clause type:
+        Query -> "string", "date"  (assumes that "date" field exists as strings, both are supported
+                    through kwargs)
+        Filter -> "number" and "date" (through range parameters), "bool" (though boolean parameter),
+                    "string" (through kwargs)
+        Sort -> "number" and "date" (by specifying sort_type=asc or sort_type=desc),
+                    "location" (by specifying sort_type=distance and passing origin
+                    'location' parameter)
 
-    class FilterClause(Clause):
-        """This class models a knowledge base filter clause."""
-
-        def __init__(
-            self,
-            field,
-            field_info=None,
-            value=None,
-            query_type=DEFAULT_QUERY_TYPE,
-            range_gt=None,
-            range_gte=None,
-            range_lt=None,
-            range_lte=None,
-        ):
-            """Initialize a knowledge base filter clause. The filter type is determined by whether
-            the range operators or value is passed in.
+        Note: This Search class supports more items than Elasticsearch based QA
+
+        """
+
+        def __init__(self, index):
+            """Initialize a Search object.
             """
+            self.index_name = index
+            self._search_queries = {}
+            self._filter_queries = {}
+            self._sort_queries = {}
+
+        def query(self, query_type=DEFAULT_QUERY_TYPE, **kwargs):
+
+            for field, value in kwargs.items():
+                if field in self._search_queries:
+                    msg = f"Found a duplicate search clause against '{field}' field name. " \
+                          "Utilizing only latest input."
+                    logger.warning(msg)
+                self._search_queries.update({field: {"query_type": query_type, "value": value}})
+
+            return self
+
+        def filter(self, query_type=DEFAULT_QUERY_TYPE, **kwargs):
+
+            # Note: 'query_type' only kept to maintain similar arguments as ES based QA
+            if query_type:
+                query_type = None
+
+            field = kwargs.pop("field", None)
+            gt = kwargs.pop("gt", None)
+            gte = kwargs.pop("gte", None)
+            lt = kwargs.pop("lt", None)
+            lte = kwargs.pop("lte", None)
+            et = kwargs.pop("et", None)  # NEW! support equals-to filter; similar to above
+            boolean = kwargs.pop("boolean", None)  # NEW! support boolean filter; input True/False
+
+            # filter that operates on numeric values or date values or boolean
+            if field:
+                if field in self._filter_queries:
+                    msg = f"Found a duplicate filter clause against '{field}' field name. " \
+                          "Utilizing only latest input."
+                    logger.warning(msg)
+                self._filter_queries.update(
+                    {field: {"gt": gt, "gte": gte, "lt": lt, "lte": lte, "et": et,
+                             "boolean": boolean}}
+                )
 
-            self.field = field
-            self.field_info = field_info
-            self.value = value
-            self.query_type = query_type
-            self.range_gt = range_gt
-            self.range_gte = range_gte
-            self.range_lt = range_lt
-            self.range_lte = range_lte
-
-            if self.value:
-                self.filter_type = "text"
+            # filter that operates on strings; extract field name and query strings then
             else:
-                self.filter_type = "range"
+                for key, filter_text in kwargs.items():
+                    if key in self._filter_queries:
+                        msg = f"Found a duplicate filter clause against '{key}' field name. " \
+                              "Utilizing only latest input."
+                        logger.warning(msg)
+                    self._filter_queries.update({key: {"filter_text": filter_text}})
 
-            self.clause_type = "filter"
+            return self
 
-        def build_query(self):
-            """build knowledge base query for filter clause"""
-            clause = {}
-            if self.filter_type == "text":
-                if self.field == "id":
-                    clause = {"term": {"id": self.value}}
-                else:
-                    if self.query_type == "text":
-                        clause = {
-                            "match": {self.field + ".char_ngram": {"query": self.value}}
-                        }
-                    else:
-                        clause = {
-                            "match": {
-                                self.field
-                                + ".normalized_keyword": {"query": self.value}
-                            }
-                        }
-            elif self.filter_type == "range":
-                lower_bound = None
-                upper_bound = None
-                if self.range_gt:
-                    lower_bound = ("gt", self.range_gt)
-                elif self.range_gte:
-                    lower_bound = ("gte", self.range_gte)
-
-                if self.range_lt:
-                    upper_bound = ("lt", self.range_lt)
-                elif self.range_lte:
-                    upper_bound = ("lte", self.range_lte)
-
-                clause = {"range": {self.field: {}}}
-
-                if lower_bound:
-                    clause["range"][self.field][lower_bound[0]] = lower_bound[1]
-
-                if upper_bound:
-                    clause["range"][self.field][upper_bound[0]] = upper_bound[1]
+        def sort(self, field, sort_type=None, location=None):
+
+            if field in self._sort_queries:
+                msg = f"Found a duplicate sort clause against '{field}' field name. " \
+                      "Utilizing only latest input."
+                logger.warning(msg)
+            self._sort_queries.update(
+                {field: {"sort_type": sort_type, "location": location}})
+
+            return self
+
+        def execute(self, size=10):
+
+            try:
+                # fetch all indexes
+                index_resources = NativeQuestionAnswerer.ALL_INDICES.get(self.index_name)
+                # obtain all doc ids in the order they were present in the KB
+                index_all_ids = (
+                    NativeQuestionAnswerer.ALL_INDICES.get_all_ids(self.index_name)
+                )
+            except KeyError:
+                msg = f"The index '{self.index_name}' looks unavailable. " \
+                      f"Consider running '.load_kb(...)' to create indices " \
+                      f"before creating search/filter/sort queries. "
+                logger.error(msg)
+                return []
+
+            def requires_field_resource(f, i):
+                msg = f"The field '{f}' is not available in index '{i}'."
+                logger.error(msg)
+                raise ValueError("Invalid knowledge base field '{}'".format(f))
+
+            allowed_ids = None
+
+            # if any filter queries exist, filter the necassary ids to do further processing
+            if self._filter_queries:
+                # can't make it set; preserve order
+                allowed_ids = {_id: None for _id in index_all_ids}
+                # get narrowed results for 'filter' clause
+                for field_name, kwargs in self._filter_queries.items():
+                    field_resource = index_resources.get(field_name)
+                    if not field_resource:
+                        requires_field_resource(field_name, self.index_name)
+                    allowed_ids = field_resource.do_filter(allowed_ids, **kwargs)
+                # return if nothing to process in further steps
+                if not allowed_ids:
+                    return []
+
+            # get results (aka. curated_docs) for 'query' clauses, in decreasing order of similarity
+            n_scores = 0
+            scores = {}
+            for field_name, kwargs in self._search_queries.items():
+                query_type = kwargs["query_type"]
+                value = kwargs["value"]
+                field_resource = index_resources.get(field_name)
+                if not field_resource:
+                    requires_field_resource(field_name, self.index_name)
+                this_field_scores = field_resource.do_search(query_type, value, allowed_ids)
+                scores = {_id: (scores.get(_id, 0.0) + _score) / (n_scores + 1)
+                          for _id, _score in this_field_scores.items()}
+                n_scores += 1
+            # pass in all indices if no similarities computed
+            if scores:
+                _ids, _scores = zip(*sorted(scores.items(), key=lambda x: x[1], reverse=True))
             else:
-                raise Exception("Unknown filter type.")
+                _ids, _scores = allowed_ids or index_all_ids, None
+            has_similarity_scores = _scores is not None
+            curated_docs = (
+                NativeQuestionAnswerer.FieldResource.curate_docs_to_return(
+                    index_resources, _ids=_ids, _scores=_scores)
+            )
 
-            return clause
+            # if sim scores are available, get the top_n and then sort only the top_n objects
+            if has_similarity_scores:
+                if len(curated_docs) > size:
+                    docs_scores = np.array([x["_score"] for x in curated_docs])
+                    n_scores = len(docs_scores)
+                    top_inds = docs_scores.argpartition(n_scores - size)[-size:]
+                    curated_docs = [curated_docs[i] for i in top_inds]
+                curated_docs = sorted(curated_docs, key=lambda x: x["_score"], reverse=True)[:size]
 
-        def validate(self):
-            if self.filter_type == "range":
+            # get sorted results for 'sort' clause, only on the resultant curated_docs
+            for field_name, kwargs in self._sort_queries.items():
+                field_resource = index_resources.get(field_name)
+                if not field_resource:
+                    requires_field_resource(field_name, self.index_name)
+                curated_docs = field_resource.do_sort(curated_docs, **kwargs)
+
+            curated_docs = curated_docs[:size]
+            if len(curated_docs) < size:
+                msg = f"Retrieved only {len(curated_docs)} matches instead of asked number " \
+                      f"{size} for index '{self.index_name}'."
+                logger.info(msg)
+
+            # remove '_id' key field, as it meant for internal purposes only!
+            # not removing '_score' key field, as user might need that for further analysis
+            for doc in curated_docs:
+                doc.pop("_id", None)
+
+            return curated_docs
+
+    ALL_INDICES = Indices()
+
+
+class ElasticsearchQuestionAnswerer(BaseQuestionAnswerer):
+    """
+    The question answerer is primarily an information retrieval system that provides all the
+    necessary functionality for interacting with the application's knowledge base.
+
+    This class uses Elasticsearch in the backend to implement various underlying functionalities
+    of question answerer.
+
+    """
+
+    def __init__(self, **kwargs):
+        """Initializes a question answerer
+
+        Args:
+            app_path (str): The path to the directory containing the app's data
+            resource_loader (ResourceLoader): An object which can load resources for the answerer
+            es_host (str): The Elasticsearch host server
+            config (dict): The QA config if passed directly rather than loaded from the app config
+        """
+        super().__init__(**kwargs)
+        self._es_host = kwargs.get("es_host")
+        self.__es_client = kwargs.get("es_client")
+        self._es_field_info = {}
+
+        # bug-fix: previously, '_embedder_model' is created only when 'model_type' is 'embedder'
+        self._embedder_model = None
+        if "embedder" in self.query_type:
+            self._embedder_model = create_embedder_model(self.app_path, self.model_settings)
+
+    @property
+    def _es_client(self):
+        # Lazily connect to Elasticsearch
+        if self.__es_client is None:
+            self.__es_client = create_es_client(self._es_host)
+        return self.__es_client
+
+    def _load_field_info(self, index):
+        """load knowledge base field metadata information for the specified index.
+
+        Args:
+            index (str): index name.
+        """
+
+        # load field info from local cache
+        index_info = self._es_field_info.get(index, {})
+
+        if not index_info:
+            try:
+                # TODO: move the ES API call logic to ES helper
+                self._es_field_info[index] = {}
+                res = self._es_client.indices.get(index=index)
+                if is_es_version_7(self._es_client):
+                    all_field_info = res[index]["mappings"]["properties"]
+                else:
+                    all_field_info = res[index]["mappings"][DOC_TYPE]["properties"]
+                for field_name in all_field_info:
+                    field_type = all_field_info[field_name].get("type")
+                    self._es_field_info[index][field_name] = (
+                        ElasticsearchQuestionAnswerer.FieldInfo(field_name, field_type)
+                    )
+            except _getattr("elasticsearch", "ConnectionError") as e:
+                logger.error(
+                    "Unable to connect to Elasticsearch: %s details: %s",
+                    e.error,
+                    e.info,
+                )
+                raise ElasticsearchKnowledgeBaseConnectionError(
+                    es_host=self._es_client.transport.hosts
+                ) from e
+            except _getattr("elasticsearch", "TransportError") as e:
+                logger.error(
+                    "Unexpected error occurred when sending requests to Elasticsearch: %s "
+                    "Status code: %s details: %s",
+                    e.error,
+                    e.status_code,
+                    e.info,
+                )
+                raise KnowledgeBaseError from e
+            except _getattr("elasticsearch", "ElasticsearchException") as e:
+                raise KnowledgeBaseError from e
+
+    def _get(self, index, size=10, query_type=None, app_namespace=None, **kwargs):
+        """Gets a collection of documents from the knowledge base matching the provided
+        search criteria. This API provides a simple interface for developers to specify a list of
+        knowledge base field and query string pairs to find best matches in a similar way as in
+        common Web search interfaces. The knowledge base fields to be used depend on the mapping
+        between NLU entity types and corresponding knowledge base objects. For example, a “cuisine”
+        entity type can be mapped to either a knowledge base object or an attribute of a knowledge
+        base object. The mapping is often application specific and is dependent on the data model
+        developers choose to use when building the knowledge base.
+
+        Examples:
+
+            >>> question_answerer.get(index='menu_items',
+                                      name='pork and shrimp',
+                                      restaurant_id='B01CGKGQ40',
+                                      _sort='price',
+                                      _sort_type='asc')
+
+        Args:
+            index (str): The name of an index.
+            size (int): The maximum number of records, default to 10.
+            query_type (str): Whether the search is over structured, unstructured and whether to use
+                              text signals for ranking, embedder signals, or both.
+            id (str): The id of a particular document to retrieve.
+            _sort (str): Specify the knowledge base field for custom sort.
+            _sort_type (str): Specify custom sort type. Valid values are 'asc', 'desc' and
+                              'distance'.
+            _sort_location (dict): The origin location to be used when sorting by distance.
+
+        Returns:
+            list: A list of matching documents.
+        """
+        doc_id = kwargs.get("id")
+
+        query_type = query_type or self.query_type
+
+        # fix related to Issue 219: https://github.com/cisco/mindmeld/issues/219
+        app_namespace = app_namespace or self.app_namespace
+
+        # If an id was passed in, simply retrieve the specified document
+        if doc_id:
+            logger.info(
+                "Retrieve object from KB: index= '%s', id= '%s'.", index, doc_id
+            )
+            s = self.build_search(index, app_namespace=app_namespace)
+            s = s.filter(query_type=query_type, id=doc_id)
+            results = s.execute(size=size)
+            return results
+
+        sort_clause = {}
+        query_clauses = []
+
+        # iterate through keyword arguments to get KB field and value pairs for search and custom
+        # sort criteria
+        for key, value in kwargs.items():
+            logger.debug("Processing argument: key= %s value= %s.", key, value)
+            if key == "_sort":
+                sort_clause["field"] = value
+            elif key == "_sort_type":
+                sort_clause["type"] = value
+            elif key == "_sort_location":
+                sort_clause["location"] = value
+            elif "embedder" in query_type and self._embedder_model:
+                if "text" in query_type or "keyword" in query_type:
+                    query_clauses.append({key: value})
+                embedded_value = self._embedder_model.get_encodings([value])[0]
+                embedded_key = key + EMBEDDING_FIELD_STRING
+                query_clauses.append({embedded_key: embedded_value})
+            else:
+                query_clauses.append({key: value})
+                logger.debug("Added query clause: field= %s value= %s.", key, value)
+
+        logger.debug("Custom sort criteria %s.", sort_clause)
+
+        # build Search object with overriding ranking setting to require all query clauses are
+        # matched.
+        s = self.build_search(index, app_namespace=app_namespace,
+                              ranking_config={"query_clauses_operator": "and"})
+
+        # add query clauses to Search object.
+        for clause in query_clauses:
+            s = s.query(query_type=query_type, **clause)
+
+        # add custom sort clause if specified.
+        if sort_clause:
+            s = s.sort(
+                field=sort_clause.get("field"),
+                sort_type=sort_clause.get("type"),
+                location=sort_clause.get("location"),
+            )
+
+        results = s.execute(size=size)
+        return results
+
+    def _build_search(self, index, ranking_config=None, app_namespace=None):
+        """Build a search object for advanced filtered search.
+
+        Args:
+            index (str): index name of knowledge base object.
+            ranking_config (dict, optional): overriding ranking configuration parameters.
+            app_namespace (str, optional): The namespace of the app. Used to prevent
+                collisions between the indices of this app and those of other apps.
+        Returns:
+            Search: a Search object for filtered search.
+        """
+
+        # fix related to Issue 219: https://github.com/cisco/mindmeld/issues/219
+        app_namespace = app_namespace or self.app_namespace
+
+        if not does_index_exist(app_namespace=app_namespace, index_name=index):
+            raise ValueError("Knowledge base index '{}' does not exist.".format(index))
+
+        # get index name with app scope
+        index = get_scoped_index_name(app_namespace, index)
+
+        # load knowledge base field information for the specified index.
+        self._load_field_info(index)
+
+        return ElasticsearchQuestionAnswerer.Search(
+            client=self._es_client,
+            index=index,
+            ranking_config=ranking_config,
+            field_info=self._es_field_info[index],
+        )
+
+    def _load_kb(self,
+                 index_name,
+                 data_file,
+                 app_namespace=None,
+                 clean=False,
+                 embedding_fields=None,
+                 es_host=None,
+                 es_client=None,
+                 connect_timeout=2,
+                 **kwargs):
+        """Loads documents from disk into the specified index in the knowledge
+        base. If an index with the specified name doesn't exist, a new index
+        with that name will be created in the knowledge base.
+
+        Args:
+            index_name (str): The name of the new index to be created.
+            data_file (str): The path to the data file containing the documents
+                to be imported into the knowledge base index. It could be
+                either json or jsonl file.
+            app_namespace (str, optional): The namespace of the app. Used to prevent
+                collisions between the indices of this app and those of other apps.
+            clean (bool, optional): Set to true if you want to delete an existing index
+                and reindex it
+            embedding_fields (list, optional): List of embedding fields can be directly passed in
+                instead of adding them to QA config
+            es_host (str, optional): The Elasticsearch host server.
+            es_client (Elasticsearch, optional): The Elasticsearch client.
+            connect_timeout (int, optional): The amount of time for a connection
+                to the Elasticsearch host.
+        """
+
+        # fix related to Issue 219: https://github.com/cisco/mindmeld/issues/219
+        app_namespace = app_namespace or self.app_namespace
+        es_host = es_host or self._es_host
+        es_client = es_client or self._es_client
+
+        query_type = self.query_type
+        model_settings = self.model_settings
+        embedder_model = self._embedder_model
+
+        # clean by deleting
+        if clean:
+            try:
+                delete_index(app_namespace, index_name, es_host, es_client)
+            except ValueError:
+                msg = f"Index {index_name} does not exist for app {app_namespace}, " \
+                      f"creating a new index"
+                logger.warning(msg)
+
+        # determine embedding fields
+        embedding_fields = (
+            embedding_fields or model_settings.get("embedding_fields", {}).get(index_name, [])
+        )
+        if embedding_fields:
+            if "embedder" not in query_type:
+                msg = f"Found KB fields to upload embedding (fields: {embedding_fields}) for " \
+                      f"index '{index_name}' but query_type configured for this QA " \
+                      f"({query_type}) has no 'embedder' phrase in it leading to not setting up " \
+                      f"an embedder model. Ignoring provided 'embedding_fields'."
+                logger.error(msg)
+                embedding_fields = []
+        else:
+            if "embedder" in query_type:
+                logger.warning(
+                    "No embedding fields specified in the app config, continuing without "
+                    "generating embeddings although an embedder model is loaded. "
+                )
+
+        def _doc_data_count(data_file):
+            with open(data_file) as data_fp:
+                line = data_fp.readline()
+                data_fp.seek(0)
+                # fix related to Issue 220: https://github.com/cisco/mindmeld/issues/220
+                if line.strip().startswith("["):
+                    docs = json.load(data_fp)
+                    count = len(docs)
+                else:
+                    count = 0
+                    for line in data_fp:
+                        count += 1
+                return count
+
+        def _doc_generator(data_file, embedder_model=None, embedding_fields=None):
+            def match_regex(string, pattern_list):
+                return any([re.match(pattern, string) for pattern in pattern_list])
+
+            def transform(doc, embedder_model, embedding_fields):
+                if embedder_model and embedding_fields:
+                    embed_fields = [
+                        (key, str(val))
+                        for key, val in doc.items()
+                        if match_regex(key, embedding_fields)
+                    ]
+                    embed_keys = list(zip(*embed_fields))[0]
+                    embed_vals = embedder_model.get_encodings(
+                        list(zip(*embed_fields))[1]
+                    )
+                    embedded_doc = {
+                        key + EMBEDDING_FIELD_STRING: emb.tolist()
+                        for key, emb in zip(embed_keys, embed_vals)
+                    }
+                    doc.update(embedded_doc)
+                if not doc.get("id"):
+                    return doc
+                base = {"_id": doc["id"]}
+                base.update(doc)
+                return base
+
+            with open(data_file) as data_fp:
+                line = data_fp.readline()
+                data_fp.seek(0)
+                # fix related to Issue 220: https://github.com/cisco/mindmeld/issues/220
+                if line.strip().startswith("["):
+                    logging.debug("Loading data from a json file.")
+                    docs = json.load(data_fp)
+                    for doc in docs:
+                        yield transform(doc, embedder_model, embedding_fields)
+                else:
+                    logging.debug("Loading data from a jsonl file.")
+                    for line in data_fp:
+                        doc = json.loads(line)
+                        yield transform(doc, embedder_model, embedding_fields)
+
+        docs_count = _doc_data_count(data_file)
+        docs = _doc_generator(data_file, embedder_model, embedding_fields)
+
+        def _generate_mapping_data(embedder_model, embedding_fields):
+            # generates a dictionary with any metadata needed to create the mapping"
+            if not embedder_model:
+                return {}
+            MAX_ES_VECTOR_LEN = 2048
+            embedding_properties = []
+            mapping_data = {"embedding_properties": embedding_properties}
+
+            dims = len(embedder_model.get_encodings(["encoding"])[0])
+            if dims > MAX_ES_VECTOR_LEN:
+                logger.error(
+                    "Vectors in ElasticSearch must be less than size: %d",
+                    MAX_ES_VECTOR_LEN,
+                )
+            for field in embedding_fields:
+                embedding_properties.append(
+                    {"field": field + EMBEDDING_FIELD_STRING, "dims": dims}
+                )
+
+            return mapping_data
+
+        es_client = es_client or create_es_client(es_host)
+        if is_es_version_7(es_client):
+            mapping_data = _generate_mapping_data(embedder_model, embedding_fields)
+            qa_mapping = create_index_mapping(DEFAULT_ES_QA_MAPPING, mapping_data)
+        else:
+            if embedder_model:
+                logger.error(
+                    "You must upgrade to ElasticSearch 7 to use the embedding features."
+                )
+                raise ElasticsearchVersionError
+            qa_mapping = resolve_es_config_for_version(DEFAULT_ES_QA_MAPPING, es_client)
+
+        load_index(
+            app_namespace,
+            index_name,
+            docs,
+            docs_count,
+            qa_mapping,
+            DOC_TYPE,
+            es_host,
+            es_client,
+            connect_timeout=connect_timeout,
+        )
+
+        # Saves the embedder model cache to disk
+        if embedder_model:
+            embedder_model.dump_cache()
+
+    class Search:
+        """This class models a generic filtered search in knowledge base. It allows developers to
+        construct more complex knowledge base search criteria based on the application requirements.
+
+        """
+
+        SYN_FIELD_SUFFIX = "$whitelist"
+
+        def __init__(self, client, index, ranking_config=None, field_info=None):
+            """Initialize a Search object.
+
+            Args:
+                client (Elasticsearch): Elasticsearch client.
+                index (str): index name of knowledge base object.
+                ranking_config (dict): overriding ranking configuration parameters for current
+                                        search.
+                field_info (dict): dictionary contains knowledge base matadata objects.
+            """
+            self.index = index
+            self.client = client
+
+            self._clauses = {"query": [], "filter": [], "sort": []}
+
+            self._ranking_config = ranking_config
+            if not ranking_config:
+                self._ranking_config = copy.deepcopy(DEFAULT_ES_RANKING_CONFIG)
+
+            self._kb_field_info = field_info
+
+        def _clone(self):
+            """Clone a Search object.
+
+            Returns:
+                Search: cloned copy of the Search object.
+            """
+            s = ElasticsearchQuestionAnswerer.Search(client=self.client, index=self.index)
+            s._clauses = copy.deepcopy(self._clauses)
+            s._ranking_config = copy.deepcopy(self._ranking_config)
+            s._kb_field_info = copy.deepcopy(self._kb_field_info)
+
+            return s
+
+        def _build_query_clause(self, query_type=DEFAULT_QUERY_TYPE, **kwargs):
+            field, value = next(iter(kwargs.items()))
+            field_info = self._kb_field_info.get(field)
+            if not field_info:
+                raise ValueError("Invalid knowledge base field '{}'".format(field))
+
+            # check whether the synonym field is available. By default the synonyms are
+            # imported to "<field_name>$whitelist" field.
+            synonym_field = (
+                field + self.SYN_FIELD_SUFFIX
+                if self._kb_field_info.get(field + self.SYN_FIELD_SUFFIX)
+                else None
+            )
+            clause = ElasticsearchQuestionAnswerer.Search.QueryClause(
+                field, field_info, value, query_type, synonym_field)
+            clause.validate()
+            self._clauses[clause.get_type()].append(clause)
+
+        def _build_filter_clause(self, query_type=DEFAULT_QUERY_TYPE, **kwargs):
+            # set the filter type to be 'range' if any range operator is specified.
+            if (
+                kwargs.get("gt")
+                or kwargs.get("gte")
+                or kwargs.get("lt")
+                or kwargs.get("lte")
+            ):
+                field = kwargs.get("field")
+                gt = kwargs.get("gt")
+                gte = kwargs.get("gte")
+                lt = kwargs.get("lt")
+                lte = kwargs.get("lte")
+
+                if field not in self._kb_field_info:
+                    raise ValueError("Invalid knowledge base field '{}'".format(field))
+
+                clause = ElasticsearchQuestionAnswerer.Search.FilterClause(
+                    field=field,
+                    field_info=self._kb_field_info.get(field),
+                    range_gt=gt,
+                    range_gte=gte,
+                    range_lt=lt,
+                    range_lte=lte,
+                )
+            else:
+                key, value = next(iter(kwargs.items()))
+                if key not in self._kb_field_info:
+                    raise ValueError("Invalid knowledge base field '{}'".format(key))
+                clause = ElasticsearchQuestionAnswerer.Search.FilterClause(
+                    field=key, value=value, query_type=query_type)
+            clause.validate()
+            self._clauses[clause.get_type()].append(clause)
+
+        def _build_sort_clause(self, **kwargs):
+            sort_field = kwargs.get("field")
+            sort_type = kwargs.get("sort_type")
+            sort_location = kwargs.get("location")
+
+            field_info = self._kb_field_info.get(sort_field)
+            if not field_info:
+                raise ValueError("Invalid knowledge base field '{}'".format(sort_field))
+
+            # only compute field stats if sort field is number or date type.
+            field_stats = None
+            if field_info.is_number_field() or field_info.is_date_field():
+                field_stats = self._get_field_stats(sort_field)
+
+            clause = ElasticsearchQuestionAnswerer.Search.SortClause(
+                sort_field, field_info, sort_type, field_stats, sort_location
+            )
+            clause.validate()
+            self._clauses[clause.get_type()].append(clause)
+
+        def _build_clause(self, clause_type, query_type=DEFAULT_QUERY_TYPE, **kwargs):
+            """Helper method to build query, filter and sort clauses.
+
+            Args:
+                clause_type (str): type of clause
+            """
+            if clause_type == "query":
+                self._build_query_clause(query_type, **kwargs)
+            elif clause_type == "filter":
+                self._build_filter_clause(query_type, **kwargs)
+            elif clause_type == "sort":
+                self._build_sort_clause(**kwargs)
+            else:
+                raise Exception("Unknown clause type.")
+
+        def query(self, query_type=DEFAULT_QUERY_TYPE, **kwargs):
+            """Specify the query text to match on a knowledge base text field. The query text is
+            normalized and processed (based on query_type) to find matches in knowledge base using
+            several text relevance scoring factors including exact matches, phrase matches and
+            partial matches.
+
+            Examples:
+
+                >>> s = question_answerer.build_search(index='dish')
+                >>> s.query(name='pad thai')
+
+            In the example above the query text "pad thai" will be used to match against document
+            field "name" in knowledge base index "dish".
+
+            Args:
+                a keyword argument to specify the query text and the knowledge base document field
+                along with the query type (keyword/text/embedder/embedder_keyword/embedder_text).
+            Returns:
+                Search: a new Search object with added search criteria.
+            """
+            new_search = self._clone()
+            new_search._build_clause("query", query_type, **kwargs)
+
+            return new_search
+
+        def filter(self, query_type=DEFAULT_QUERY_TYPE, **kwargs):
+            """Specify filter condition to be applied to specified knowledge base field. In MindMeld
+            two types of filters are supported: text filter and range filters.
+
+            Text filters are used to apply hard filters on specified knowledge base text fields.
+            The filter text value is normalized and matched using entire text span against the
+            knowledge base field.
+
+            It's common to have filter conditions based on other resolved canonical entities.
+            For example, in food ordering domain the resolved restaurant entity can be used as a
+            filter to resolve dish entities. The exact knowledge base field to apply these filters
+            depends on the knowledge base data model of the application.
+            If the entity is not in the canonical form, a fuzzy filter can be applied by setting the
+            query_type to 'text'.
+
+            Range filters are used to filter with a value range on specified knowledge base number
+            or date fields. Example use cases include price range filters and date range filters.
+
+            Examples:
+
+            add text filter:
+                >>> s = question_answerer.build_search(index='menu_items')
+                >>> s.filter(restaurant_id='B01CGKGQ40')
+
+            add range filter:
+                    >>> s = question_answerer.build_search(index='menu_items')
+                    >>> s.filter(field='price', gte=1, lt=10)
+
+            Args:
+                query_type (str): Whether the filter is over structured or unstructured text.
+                kwargs: A keyword argument to specify the filter text and the knowledge base text
+                        field.
+                field (str): knowledge base field name for range filter.
+                gt (number or str): range filter operator for greater than.
+                gte (number or str): range filter operator for greater than or equal to.
+                lt (number or str): range filter operator for less than.
+                lte (number or str): range filter operator for less or equal to.
+
+            Returns:
+                Search: A new Search object with added search criteria.
+            """
+            new_search = self._clone()
+            new_search._build_clause("filter", query_type, **kwargs)
+
+            return new_search
+
+        def sort(self, field, sort_type=None, location=None):
+            """Specify custom sort criteria.
+
+            Args:
+                field (str): knowledge base field for sort.
+                sort_type (str): sorting type. valid values are 'asc', 'desc' and 'distance'.
+                                 'asc' and 'desc' can be used to sort numeric or date fields and
+                                 'distance' can be used to sort by distance on geo_point fields.
+                                 Default sort type is 'desc' if not specified.
+                location (str): location (lat, lon) in geo_point format to be used as origin when
+                                sorting by 'distance'
+            """
+            new_search = self._clone()
+            new_search._build_clause(
+                "sort", field=field, sort_type=sort_type, location=location
+            )
+            return new_search
+
+        def _get_field_stats(self, field):
+            """Get knowledge field statistics for custom sort functions. The field statistics is
+            only available for number and date typed fields.
+
+            Args:
+                field(str): knowledge base field name
+
+            Returns:
+                dict: dictionary that contains knowledge base field statistics.
+            """
+
+            stats_query = {"aggs": {}, "size": 0}
+            stats_query["aggs"][field + "_min"] = {"min": {"field": field}}
+            stats_query["aggs"][field + "_max"] = {"max": {"field": field}}
+
+            res = self.client.search(
+                index=self.index, body=stats_query, search_type="query_then_fetch"
+            )
+
+            return {
+                "min_value": res["aggregations"][field + "_min"]["value"],
+                "max_value": res["aggregations"][field + "_max"]["value"],
+            }
+
+        def _build_es_query(self, size=10):
+            """Build knowledge base search syntax based on provided search criteria.
+
+            Args:
+                size (int): The maximum number of records to fetch, default to 10.
+
+            Returns:
+                str: knowledge base search syntax for the current search object.
+            """
+            es_query = {
+                "query": {
+                    "function_score": {
+                        "query": {},
+                        "functions": [],
+                        "score_mode": "sum",
+                        "boost_mode": "sum",
+                    }
+                },
+                "_source": {"excludes": ["*" + self.SYN_FIELD_SUFFIX]},
+                "size": size,
+            }
+
+            if not self._clauses["query"] and not self._clauses["filter"]:
+                # no query/filter clauses - use match_all
+                es_query["query"]["function_score"]["query"] = {"match_all": {}}
+            else:
+                es_query["query"]["function_score"]["query"]["bool"] = {}
+
+                if self._clauses["query"]:
+                    es_query_clauses = []
+                    es_boost_functions = []
+                    for clause in self._clauses["query"]:
+                        query_clause, boost_functions = clause.build_query()
+                        if query_clause:
+                            es_query_clauses.append(query_clause)
+                        es_boost_functions.extend(boost_functions)
+
+                    if self._ranking_config["query_clauses_operator"] == "and":
+                        es_query["query"]["function_score"]["query"]["bool"][
+                            "must"
+                        ] = es_query_clauses
+                    else:
+                        es_query["query"]["function_score"]["query"]["bool"][
+                            "should"
+                        ] = es_query_clauses
+
+                    # add all boost functions for the query clause
+                    # right now the only boost functions supported are exact match boosting for
+                    # CNAME and synonym whitelists.
+                    es_query["query"]["function_score"]["functions"].extend(
+                        es_boost_functions
+                    )
+
+                if self._clauses["filter"]:
+                    es_filter_clauses = {"bool": {"must": []}}
+                    for clause in self._clauses["filter"]:
+                        es_filter_clauses["bool"]["must"].append(clause.build_query())
+
+                    es_query["query"]["function_score"]["query"]["bool"][
+                        "filter"
+                    ] = es_filter_clauses
+
+            # add scoring function for custom sort criteria
+            for clause in self._clauses["sort"]:
+                sort_function = clause.build_query()
+                es_query["query"]["function_score"]["functions"].append(sort_function)
+
+            logger.debug("ES query syntax: %s.", es_query)
+
+            return es_query
+
+        def execute(self, size=10):
+            """Executes the knowledge base search with provided criteria and returns matching
+            documents.
+
+            Args:
+                size (int): The maximum number of records to fetch, default to 10.
+
+            Returns:
+                a list of matching documents.
+            """
+            try:
+                # TODO: move the ES API call logic to ES helper
+                es_query = self._build_es_query(size=size)
+
+                response = self.client.search(index=self.index, body=es_query)
+
+                # construct results, removing embedding metadata and exposing score
+                results = []
+                for hit in response["hits"]["hits"]:
+                    item = {key: val for (key, val) in hit["_source"].items()
+                            if not key.endswith(EMBEDDING_FIELD_STRING)}
+                    item['_score'] = hit['_score']
+                    results.append(item)
+                return results
+            except _getattr("elasticsearch", "ConnectionError") as e:
+                logger.error(
+                    "Unable to connect to Elasticsearch: %s details: %s", e.error, e.info
+                )
+                raise ElasticsearchKnowledgeBaseConnectionError(
+                    es_host=self.client.transport.hosts) from e
+            except _getattr("elasticsearch", "TransportError") as e:
+                logger.error(
+                    "Unexpected error occurred when sending requests to Elasticsearch: %s "
+                    "Status code: %s details: %s",
+                    e.error,
+                    e.status_code,
+                    e.info,
+                )
+                raise KnowledgeBaseError from e
+            except _getattr("elasticsearch", "ElasticsearchException") as e:
+                raise KnowledgeBaseError from e
+
+        class Clause(ABC):
+            """This class models an abstract knowledge base clause."""
+
+            def __init__(self):
+                """Initialize a knowledge base clause"""
+                self.clause_type = None
+
+            @abstractmethod
+            def validate(self):
+                """Validate the clause."""
+                raise NotImplementedError("Must override validate()")
+
+            @abstractmethod
+            def build_query(self):
+                """Build knowledge base query."""
+                raise NotImplementedError("Must override build_query()")
+
+            def get_type(self):
+                """Returns clause type"""
+                return self.clause_type
+
+        class QueryClause(Clause):
+            """This class models a knowledge base query clause."""
+
+            DEFAULT_EXACT_MATCH_BOOSTING_WEIGHT = 100
+
+            def __init__(
+                self,
+                field,
+                field_info,
+                value,
+                query_type=DEFAULT_QUERY_TYPE,
+                synonym_field=None,
+            ):
+                """Initialize a knowledge base query clause."""
+                self.field = field
+                self.field_info = field_info
+                self.value = value
+                self.query_type = query_type
+                self.syn_field = synonym_field
+
+                self.clause_type = "query"
+
+            def build_query(self):
+                """build knowledge base query for query clause"""
+
+                # ES syntax is generated based on specified knowledge base field
+                # the following ranking factors are considered:
+                # 1. exact matches (with boosted weight)
+                # 2. word N-gram matches
+                # 3. character N-gram matches
+                # 4. matches on synonym if available (exact, word N-gram and character N-gram):
+                # for a knowledge base text field the synonym are indexed in a separate field
+                # "<field name>$whitelist" if available.
+                functions = []
+
+                if "embedder" in self.query_type and self.field_info.is_vector_field():
+                    clause = None
+                    functions = [
+                        {
+                            "script_score": {
+                                "script": {
+                                    "source": "cosineSimilarity(params.field_embedding,"
+                                              " doc[params.matching_field]) + 1.0",
+                                    "params": {
+                                        "field_embedding": self.value.tolist(),
+                                        "matching_field": self.field,
+                                    },
+                                }
+                            },
+                            "weight": 10,
+                        }
+                    ]
+                elif "text" in self.query_type:
+                    clause = {
+                        "bool": {
+                            "should": [
+                                {"match": {self.field: {"query": self.value}}},
+                                {
+                                    "match": {
+                                        self.field
+                                        + ".processed_text": {"query": self.value}
+                                    }
+                                },
+                            ]
+                        }
+                    }
+                elif "keyword" in self.query_type:
+                    clause = {
+                        "bool": {
+                            "should": [
+                                {"match": {self.field: {"query": self.value}}},
+                                {
+                                    "match": {
+                                        self.field
+                                        + ".normalized_keyword": {"query": self.value}
+                                    }
+                                },
+                                {
+                                    "match": {
+                                        self.field + ".char_ngram": {"query": self.value}
+                                    }
+                                },
+                            ]
+                        }
+                    }
+                else:
+                    raise Exception("Unknown query type.")
+
+                if self.field_info.is_text_field():
+                    # Boost function for boosting conditions, e.g. exact match boosting
+                    functions = [
+                        {
+                            "filter": {
+                                "match": {self.field + ".normalized_keyword": self.value}
+                            },
+                            "weight": self.DEFAULT_EXACT_MATCH_BOOSTING_WEIGHT,
+                        }
+                    ]
+
+                # generate ES syntax for matching on synonym whitelist if available.
+                if self.syn_field:
+                    clause["bool"]["should"].append(
+                        {
+                            "nested": {
+                                "path": self.syn_field,
+                                "score_mode": "max",
+                                "query": {
+                                    "bool": {
+                                        "should": [
+                                            {
+                                                "match": {
+                                                    self.syn_field
+                                                    + ".name.normalized_keyword": {
+                                                        "query": self.value
+                                                    }
+                                                }
+                                            },
+                                            {
+                                                "match": {
+                                                    self.syn_field
+                                                    + ".name": {"query": self.value}
+                                                }
+                                            },
+                                            {
+                                                "match": {
+                                                    self.syn_field
+                                                    + ".name.char_ngram": {
+                                                        "query": self.value
+                                                    }
+                                                }
+                                            },
+                                        ]
+                                    }
+                                },
+                                "inner_hits": {},
+                            }
+                        }
+                    )
+
+                    functions.append(
+                        {
+                            "filter": {
+                                "nested": {
+                                    "path": self.syn_field,
+                                    "query": {
+                                        "match": {
+                                            self.syn_field
+                                            + ".name.normalized_keyword": self.value
+                                        }
+                                    },
+                                }
+                            },
+                            "weight": self.DEFAULT_EXACT_MATCH_BOOSTING_WEIGHT,
+                        }
+                    )
+
+                return clause, functions
+
+            def validate(self):
                 if (
-                    not self.range_gt
-                    and not self.range_gte
-                    and not self.range_lt
-                    and not self.range_lte
+                    not self.field_info.is_text_field()
+                    and not self.field_info.is_vector_field()
                 ):
-                    raise ValueError("No range parameter is specified")
-                elif self.range_gte and self.range_gt:
                     raise ValueError(
-                        "Invalid range parameters. Cannot specify both 'gte' and 'gt'."
+                        "Query can only be defined on text and vector fields. If it is,"
+                        " try running load_kb with clean=True and reinitializing your"
+                        " QuestionAnswerer object."
                     )
-                elif self.range_lte and self.range_lt:
+
+        class FilterClause(Clause):
+            """This class models a knowledge base filter clause."""
+
+            def __init__(
+                self,
+                field,
+                field_info=None,
+                value=None,
+                query_type=DEFAULT_QUERY_TYPE,
+                range_gt=None,
+                range_gte=None,
+                range_lt=None,
+                range_lte=None,
+            ):
+                """Initialize a knowledge base filter clause. The filter type is determined by
+                whether the range operators or value is passed in.
+                """
+
+                self.field = field
+                self.field_info = field_info
+                self.value = value
+                self.query_type = query_type
+                self.range_gt = range_gt
+                self.range_gte = range_gte
+                self.range_lt = range_lt
+                self.range_lte = range_lte
+
+                if self.value:
+                    self.filter_type = "text"
+                else:
+                    self.filter_type = "range"
+
+                self.clause_type = "filter"
+
+            def build_query(self):
+                """build knowledge base query for filter clause"""
+                clause = {}
+                if self.filter_type == "text":
+                    if self.field == "id":
+                        clause = {"term": {"id": self.value}}
+                    else:
+                        if self.query_type == "text":
+                            clause = {
+                                "match": {self.field + ".char_ngram": {"query": self.value}}
+                            }
+                        else:
+                            clause = {
+                                "match": {
+                                    self.field
+                                    + ".normalized_keyword": {"query": self.value}
+                                }
+                            }
+                elif self.filter_type == "range":
+                    lower_bound = None
+                    upper_bound = None
+                    if self.range_gt:
+                        lower_bound = ("gt", self.range_gt)
+                    elif self.range_gte:
+                        lower_bound = ("gte", self.range_gte)
+
+                    if self.range_lt:
+                        upper_bound = ("lt", self.range_lt)
+                    elif self.range_lte:
+                        upper_bound = ("lte", self.range_lte)
+
+                    clause = {"range": {self.field: {}}}
+
+                    if lower_bound:
+                        clause["range"][self.field][lower_bound[0]] = lower_bound[1]
+
+                    if upper_bound:
+                        clause["range"][self.field][upper_bound[0]] = upper_bound[1]
+                else:
+                    raise Exception("Unknown filter type.")
+
+                return clause
+
+            def validate(self):
+                if self.filter_type == "range":
+                    if (
+                        not self.range_gt
+                        and not self.range_gte
+                        and not self.range_lt
+                        and not self.range_lte
+                    ):
+                        raise ValueError("No range parameter is specified")
+                    elif self.range_gte and self.range_gt:
+                        raise ValueError(
+                            "Invalid range parameters. Cannot specify both 'gte' and 'gt'."
+                        )
+                    elif self.range_lte and self.range_lt:
+                        raise ValueError(
+                            "Invalid range parameters. Cannot specify both 'lte' and 'lt'."
+                        )
+                    elif (
+                        not self.field_info.is_number_field()
+                        and not self.field_info.is_date_field()
+                    ):
+                        raise ValueError(
+                            "Range filter can only be defined for number or date field."
+                        )
+
+        class SortClause(Clause):
+            """This class models a knowledge base sort clause."""
+
+            SORT_ORDER_ASC = "asc"
+            SORT_ORDER_DESC = "desc"
+            SORT_DISTANCE = "distance"
+            SORT_TYPES = {SORT_ORDER_ASC, SORT_ORDER_DESC, SORT_DISTANCE}
+
+            # default weight for adjusting sort scores so that they will be on the same scale when
+            # combined with text relevance scores.
+            DEFAULT_SORT_WEIGHT = 30
+
+            def __init__(
+                self,
+                field,
+                field_info=None,
+                sort_type=None,
+                field_stats=None,
+                location=None,
+            ):
+                """Initialize a knowledge base sort clause"""
+                self.field = field
+                self.location = location
+                self.sort_type = sort_type if sort_type else self.SORT_ORDER_DESC
+                self.field_stats = field_stats
+                self.field_info = field_info
+
+                self.clause_type = "sort"
+
+            def build_query(self):
+                """build knowledge base query for sort clause"""
+
+                # sort by distance based on passed in origin
+                if self.sort_type == "distance":
+                    origin = self.location
+                    scale = "5km"
+                else:
+                    max_value = self.field_stats["max_value"]
+                    min_value = self.field_stats["min_value"]
+
+                    if self.field_info.is_date_field():
+                        # ensure the timestamps for date fields are integer values
+                        max_value = int(max_value)
+                        min_value = int(min_value)
+
+                        # add time unit for date field
+                        scale = (
+                            "{}ms".format(int(0.5 * (max_value - min_value)))
+                            if max_value != min_value
+                            else 1
+                        )
+                    else:
+                        scale = (
+                            0.5 * (max_value - min_value) if max_value != min_value else 1
+                        )
+
+                    if self.sort_type == "asc":
+                        origin = min_value
+                    else:
+                        origin = max_value
+
+                sort_clause = {
+                    "linear": {self.field: {"origin": origin, "scale": scale}},
+                    "weight": self.DEFAULT_SORT_WEIGHT,
+                }
+
+                return sort_clause
+
+            def validate(self):
+                # validate the sort type to be valid.
+                if self.sort_type not in self.SORT_TYPES:
                     raise ValueError(
-                        "Invalid range parameters. Cannot specify both 'lte' and 'lt'."
+                        "Invalid value for sort type '{}'".format(self.sort_type)
                     )
-                elif (
-                    not self.field_info.is_number_field()
-                    and not self.field_info.is_date_field()
-                ):
+
+                if self.field == "location" and self.sort_type != self.SORT_DISTANCE:
                     raise ValueError(
-                        "Range filter can only be defined for number or date field."
+                        "Invalid value for sort type '{}'".format(self.sort_type)
                     )
 
-    class SortClause(Clause):
-        """This class models a knowledge base sort clause."""
-
-        SORT_ORDER_ASC = "asc"
-        SORT_ORDER_DESC = "desc"
-        SORT_DISTANCE = "distance"
-        SORT_TYPES = {SORT_ORDER_ASC, SORT_ORDER_DESC, SORT_DISTANCE}
-
-        # default weight for adjusting sort scores so that they will be on the same scale when
-        # combined with text relevance scores.
-        DEFAULT_SORT_WEIGHT = 30
-
-        def __init__(
-            self,
-            field,
-            field_info=None,
-            sort_type=None,
-            field_stats=None,
-            location=None,
-        ):
-            """Initialize a knowledge base sort clause"""
-            self.field = field
-            self.location = location
-            self.sort_type = sort_type if sort_type else self.SORT_ORDER_DESC
-            self.field_stats = field_stats
-            self.field_info = field_info
-
-            self.clause_type = "sort"
-
-        def build_query(self):
-            """build knowledge base query for sort clause"""
-
-            # sort by distance based on passed in origin
-            if self.sort_type == "distance":
-                origin = self.location
-                scale = "5km"
-            else:
-                max_value = self.field_stats["max_value"]
-                min_value = self.field_stats["min_value"]
-
-                if self.field_info.is_date_field():
-                    # ensure the timestamps for date fields are integer values
-                    max_value = int(max_value)
-                    min_value = int(min_value)
-
-                    # add time unit for date field
-                    scale = (
-                        "{}ms".format(int(0.5 * (max_value - min_value)))
-                        if max_value != min_value
-                        else 1
+                if self.field == "location" and not self.location:
+                    raise ValueError(
+                        "No origin location specified for sorting by distance."
                     )
-                else:
-                    scale = (
-                        0.5 * (max_value - min_value) if max_value != min_value else 1
+
+                if self.sort_type == self.SORT_DISTANCE and self.field != "location":
+                    raise ValueError(
+                        "Sort by distance is only supported using 'location' field."
                     )
 
-                if self.sort_type == "asc":
-                    origin = min_value
-                else:
-                    origin = max_value
+                # validate the sort field is number, date or location field
+                if not (
+                    self.field_info.is_number_field()
+                    or self.field_info.is_date_field()
+                    or self.field_info.is_location_field()
+                ):
+                    raise ValueError(
+                        "Custom sort criteria can only be defined for"
+                        + " 'number', 'date' or 'location' fields."
+                    )
 
-            sort_clause = {
-                "linear": {self.field: {"origin": origin, "scale": scale}},
-                "weight": self.DEFAULT_SORT_WEIGHT,
-            }
+    class FieldInfo:
+        """This class models an information source of a knowledge base field metadata"""
+
+        NUMBER_TYPES = {
+            "long",
+            "integer",
+            "short",
+            "byte",
+            "double",
+            "float",
+            "half_float",
+            "scaled_float",
+        }
+        TEXT_TYPES = {"text", "keyword"}
+        DATE_TYPES = {"date"}
+        GEO_TYPES = {"geo_point"}
+        VECTOR_TYPES = {"dense_vector"}
 
-            return sort_clause
+        def __init__(self, name, field_type):
+            self.name = name
+            self.type = field_type
 
-        def validate(self):
-            # validate the sort type to be valid.
-            if self.sort_type not in self.SORT_TYPES:
-                raise ValueError(
-                    "Invalid value for sort type '{}'".format(self.sort_type)
-                )
+        def get_name(self):
+            """Returns knowledge base field name"""
 
-            if self.field == "location" and self.sort_type != self.SORT_DISTANCE:
-                raise ValueError(
-                    "Invalid value for sort type '{}'".format(self.sort_type)
-                )
+            return self.name
 
-            if self.field == "location" and not self.location:
-                raise ValueError(
-                    "No origin location specified for sorting by distance."
-                )
+        def get_type(self):
+            """Returns knowledge base field type"""
 
-            if self.sort_type == self.SORT_DISTANCE and self.field != "location":
-                raise ValueError(
-                    "Sort by distance is only supported using 'location' field."
-                )
+            return self.type
 
-            # validate the sort field is number, date or location field
-            if not (
-                self.field_info.is_number_field()
-                or self.field_info.is_date_field()
-                or self.field_info.is_location_field()
-            ):
+        def is_number_field(self):
+            """Returns True if the knowledge base field is a number field, otherwise returns False
+            """
+
+            return self.type in self.NUMBER_TYPES
+
+        def is_date_field(self):
+            """Returns True if the knowledge base field is a date field, otherwise returns False"""
+
+            return self.type in self.DATE_TYPES
+
+        def is_location_field(self):
+            """Returns True if the knowledge base field is a location field, otherwise returns False
+            """
+
+            return self.type in self.GEO_TYPES
+
+        def is_text_field(self):
+            """Returns True if the knowledge base field is a text field, otherwise returns False"""
+
+            return self.type in self.TEXT_TYPES
+
+        def is_vector_field(self):
+            """Returns True if the knowledge base field is a vector field, otherwise returns False
+            """
+
+            return self.type in self.VECTOR_TYPES
+
+
+class QuestionAnswerer:
+    """
+    Factory class with backwards compatability
+
+    deprecated usages
+        >>> QuestionAnswerer.load_kb(...)
+
+    new usages
+        >>> question_answerer = QuestionAnswerer(app_path, resource_loader, es_host, config)
+        # or ...
+        >>> question_answerer = QuestionAnswerer.create_question_answerer(**kwargs)
+        # And then ...
+        >>> question_answerer.load_kb(...)
+        >>> question_answerer.get(...) # .get(...), .build_search(...)
+    """
+
+    def __new__(cls, app_path=None, resource_loader=None, es_host=None, config=None, **kwargs):
+        """
+        This method is used to initialize a XxxQuestionAnswerer based on the model_type
+
+        To keep the code base backwards compatible, we use a '__new__()' way of creating instances
+        alongside using a factory approach. For cases wherein a question-answerer is instantiated
+        from 'QuestionAnswerer' class instead of  'QuestionAnswerer.create_question_answerer',
+        this method is called before __init__ and returns an instance of a question-answerer.
+
+        See that the input arguments are kept as-is with respect to the '__init__()' of
+        'ElasticsearchQuestionAnswerer' class which was the 'QuestionAnswerer' class in previous
+        version of 'question_answerer.py'
+        """
+
+        kwargs.update({
+            "app_path": app_path,
+            "resource_loader": resource_loader,
+            "es_host": es_host,
+            "config": config,
+        })
+        return cls.create_question_answerer(**kwargs)
+
+    @classmethod
+    def create_question_answerer(cls, **kwargs):
+        """
+        Args:
+            app_path (str, optional): The path to the directory containing the app's data. If
+                provided, used to obtain default 'app_namespace' and QA configurations
+            app_namespace (str, optional): The namespace of the app. Used to prevent
+                collisions between the indices of this app and those of other apps.
+            config (dict, optional): The QA config if passed directly rather than loaded from the
+                app config
+            resource_loader (ResourceLoader, optional): An object which can load resources for the
+                question answerer.
+        """
+
+        config = cls._get_config(kwargs.get("config"), kwargs.get("app_path"))
+        config = cls._correct_deprecated_qa_config(config)
+        kwargs.update({"config": config})
+        return cls._get_question_answerer_class(config.get("model_type"))(**kwargs)
+
+    @classmethod
+    def load_kb(cls,
+                app_namespace,
+                index_name,
+                data_file,
+                es_host=None,
+                es_client=None,
+                connect_timeout=2,
+                clean=False,
+                app_path=None,
+                config=None,
+                **kwargs):
+        """
+        Implemented to maintain backward compatibility. Should be removed in future versions.
+
+        Args:
+            app_namespace (str): The namespace of the app. Used to prevent
+                collisions between the indices of this app and those of other
+                apps.
+            index_name (str): The name of the new index to be created.
+            data_file (str): The path to the data file containing the documents
+                to be imported into the knowledge base index. It could be
+                either json or jsonl file.
+            es_host (str): The Elasticsearch host server.
+            es_client (Elasticsearch): The Elasticsearch client.
+            connect_timeout (int, optional): The amount of time for a
+                connection to the Elasticsearch host.
+            clean (bool): Set to true if you want to delete an existing index
+                and reindex it
+            app_path (str): The path to the directory containing the app's data
+            config (dict): The QA config if passed directly rather than loaded from the app config
+        """
+
+        # As a way to reduce entropy in using 'load_kb()' and it's related inconsistencies of not
+        # exposing 'app_namespace' argument in '.get()' and '.build_search()', this reformatting
+        # recommends that all these methods be used as instance methods and not as class methods
+        msg = "Calling the 'load_kb(...)' method directly from the QuestionAnswerer object " \
+              "like 'QuestionAnswerer.load_kb(...)' will be deprecated. New usage: " \
+              "'qa = QuestionAnswerer(...); qa.load_kb(...)'. Note that this change might also " \
+              "lead to creating different QA instances for different configs. " \
+              "See https://www.mindmeld.com/docs/userguide/kb.html for more details. "
+        warnings.warn(msg, DeprecationWarning)
+
+        # add everything except 'index_name' and 'data_file' to kwargs, and create a QA instance
+        kwargs.update({
+            "app_namespace": app_namespace,
+            "es_host": es_host,
+            "es_client": es_client,
+            "connect_timeout": connect_timeout,
+            "clean": clean,
+            "config": config,
+            "app_path": app_path,
+        })
+        question_answerer = cls.create_question_answerer(**kwargs)
+
+        # only retain 'connection_timeout', 'clean' information as everything else is already
+        #   absorbed during initialization; the recommended way of passing configs to QA is by
+        #   passing those details during initialization, that way there exists no discrepancies
+        #   between loading and inference.
+        kwargs.pop("app_namespace")
+        kwargs.pop("es_host")
+        kwargs.pop("es_client")
+        kwargs.pop("config")
+        kwargs.pop("app_path")
+        question_answerer.load_kb(index_name, data_file, **kwargs)
+
+    @staticmethod
+    def _get_config(config=None, app_path=None):
+        if not config:
+            return get_classifier_config("question_answering", app_path=app_path)
+        return config
+
+    @staticmethod
+    def _correct_deprecated_qa_config(config):
+        """
+        for backwards compatability
+          if the config is supplied in deprecated format, its format is corrected and returned,
+          else it is not modified and returned as-is
+
+        deprecated usage
+            >>> config = {
+                    "model_type": "keyword",  # or "text", "embedder", "embedder_keyword", etc.
+                    "model_settings": {
+                        ...
+                    }
+                }
+
+        new usage
+            >>> config = {
+                    "model_type": "elasticsearch",  # or "native"
+                    "model_settings": {
+                        "query_type": "keyword",  # or "text", "embedder", "embedder_keyword", etc.
+                        ...
+                    }
+                }
+        """
+
+        if not config.get("model_settings", {}).get("query_type"):
+            model_type = config.get("model_type")
+            if not model_type:
+                msg = f"Invalid 'model_type': {model_type} found while creating a QuestionAnswerer"
+                raise ValueError(msg)
+            if model_type in QUESTION_ANSWERER_MODEL_MAPPINGS:
                 raise ValueError(
-                    "Custom sort criteria can only be defined for"
-                    + " 'number', 'date' or 'location' fields."
-                )
+                    "Could not find `query_type` in `model_settings` of question answerer")
+            else:
+                msg = "Using deprecated config format for Question Answerer. " \
+                      "See https://www.mindmeld.com/docs/userguide/kb.html for more details."
+                warnings.warn(msg, DeprecationWarning)
+                config = copy.deepcopy(config)
+                model_settings = config.get("model_settings", {})
+                model_settings.update({"query_type": model_type})
+                config["model_settings"] = model_settings
+                config["model_type"] = "elasticsearch"
+
+        return config
+
+    @staticmethod
+    def _get_question_answerer_class(model_type):
+
+        if model_type not in QUESTION_ANSWERER_MODEL_MAPPINGS:
+            msg = f"Expected 'model_type' in config of Question Answerer among " \
+                  f"{[*QUESTION_ANSWERER_MODEL_MAPPINGS]} but found {model_type}"
+            raise ValueError(msg)
+
+        if model_type == "elasticsearch" and not _is_module_available("elasticsearch"):
+            raise ImportError("Must install the extra [elasticsearch] by running "
+                              "'pip install mindmeld[elasticsearch]' "
+                              "to use Elasticsearch for question answering.")
+
+        return QUESTION_ANSWERER_MODEL_MAPPINGS[model_type]
+
+
+QUESTION_ANSWERER_MODEL_MAPPINGS = {
+    # TODO: Both QA classes assume input text is in English.
+    #  Going forward, this should be configurable and multilingual support should be added!
+    "native": NativeQuestionAnswerer,
+    "elasticsearch": ElasticsearchQuestionAnswerer
+}
diff --git a/mindmeld/exceptions.py b/mindmeld/exceptions.py
index b8425dd70..0f7cb0118 100644
--- a/mindmeld/exceptions.py
+++ b/mindmeld/exceptions.py
@@ -91,7 +91,7 @@ class ElasticsearchVersionError(MindMeldError):
     pass
 
 
-class KnowledgeBaseConnectionError(KnowledgeBaseError):
+class ElasticsearchKnowledgeBaseConnectionError(KnowledgeBaseError):
     """An exception for problem connecting to knowledge base."""
 
     def __init__(self, es_host):
@@ -117,7 +117,7 @@ class EntityResolverError(MindMeldError):
     pass
 
 
-class EntityResolverConnectionError(EntityResolverError):
+class ElasticsearchConnectionError(EntityResolverError):
     """An exception for connection error to Elasticsearch for entity resolver"""
 
     def __init__(self, es_host):
diff --git a/mindmeld/models/_util.py b/mindmeld/models/_util.py
new file mode 100644
index 000000000..a461f3ed2
--- /dev/null
+++ b/mindmeld/models/_util.py
@@ -0,0 +1,40 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (c) 2015 Cisco Systems, Inc. and others.  All rights reserved.
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#     http://www.apache.org/licenses/LICENSE-2.0
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""A module containing various utility functions for creating and using models
+"""
+import importlib
+
+
+def _is_module_available(module_name: str):
+    """
+    checks if a module is available or not (eg. _is_module_available("sentence_transformers"))
+    Args:
+        module_name (str): name of the model to check
+    Returns:
+        bool, if or not the given module exists
+    """
+    return bool(importlib.util.find_spec(module_name) is not None)
+
+
+def _get_module_or_attr(module_name: str, func_name: str = None):
+    """
+    Loads an attribute from a module or a module itself
+    (check if the module exists before calling this function)
+    """
+    m = importlib.import_module(module_name)
+    if not func_name:
+        return m
+    if func_name not in dir(m):
+        raise ImportError(f"Cannot import {func_name} from {module_name}")
+    return getattr(m, func_name)
diff --git a/mindmeld/models/embedder_models.py b/mindmeld/models/embedder_models.py
index d717ab80c..8491ba9c3 100644
--- a/mindmeld/models/embedder_models.py
+++ b/mindmeld/models/embedder_models.py
@@ -14,27 +14,36 @@
 """
 This module contains the embedder model class.
 """
-from abc import ABC, abstractmethod
-import pickle
+import json
 import logging
 import os
+import pickle
+from abc import ABC, abstractmethod
+from collections import OrderedDict
+
 import numpy as np
+from tqdm.autonotebook import trange
 
-from .. import path
+from ._util import _is_module_available, _get_module_or_attr as _getattr
 from .helpers import register_embedder
 from .taggers.embeddings import WordSequenceEmbedding
+from .. import path
+from ..core import Bunch
+from ..resource_loader import Hasher
 from ..tokenizer import Tokenizer
 
-
 logger = logging.getLogger(__name__)
 
-try:
-    from sentence_transformers import SentenceTransformer
 
-    register_bert = True
-except ImportError:
-    logger.info("Must install the extra [bert] to use the built in embbedder.")
-    register_bert = False
+def _torch(op, *args, sub="", **kwargs):
+    """
+    A safe function caller for torch module
+    Usage:
+        call ```_torch("normalize", arg1, arg2, sub="nn.functional", p=2, dim=1)```
+        instead of ```torch.nn.functional.normalize(arg1, arg2, p=2, dim=1)```
+    """
+
+    return _getattr(f"torch{'.' + sub if sub else ''}", op)(*args, **kwargs)
 
 
 class Embedder(ABC):
@@ -42,36 +51,153 @@ class Embedder(ABC):
     Base class for embedder model
     """
 
-    def __init__(self, app_path, **kwargs):
-        """Initializes an embedder."""
-        self.model_name = kwargs.get("model_name", "default")
-        self.cache_path = path.get_embedder_cache_file_path(
-            app_path, kwargs.get("embedder_type", "default"), self.model_name
-        )
+    class EmbeddingsCache:
+
+        def __init__(self, cache_path):
+            self.cache_path = cache_path
+            self.reset_data()
+            self.load()
+
+        def reset_data(self):
+            self.data = OrderedDict()
+
+        def load(self):
+            """Loads the cache file."""
+
+            if os.path.exists(self.cache_path) and os.path.getsize(self.cache_path) > 0:
+                with open(self.cache_path, "rb") as fp:
+                    data = pickle.load(fp)
+
+                if (
+                    "_texts" in data and
+                    "_texts_embeddings" in data and
+                    isinstance(data["_texts"], list)
+                ):  # new format;
+                    self.data = dict(zip(data["_texts"], data["_texts_embeddings"]))
+
+                elif (
+                    "synonyms" in data and
+                    "synonyms_embs" in data and
+                    isinstance(data["synonyms"], dict)
+                ):  # deprecated format; backwards compatible with ER module code
+                    self.data = {key: data["synonyms_embs"][j] for key, j in data["synonyms"]}
+
+                else:  # deprecated format; backwards compatible with QA module code
+                    if not isinstance(data, dict):
+                        msg = "Unknown data format while loading cache embeddings. " \
+                              "Ignoring loading ..."
+                        logger.error(msg)
+                    self.data = data
+
+        def clear(self):
+            """Deletes the cache file."""
+
+            self.reset_data()
+
+            if os.path.exists(self.cache_path):
+                os.remove(self.cache_path)
+                msg = f"Embedder cache cleared at {self.cache_path}"
+                logger.info(msg)
+
+        def dump(self):
+            """Dumps the cache to disk."""
+
+            if self.data:
+                folder = os.path.split(self.cache_path)[0]
+                if folder and not os.path.exists(folder):
+                    os.makedirs(folder)
+                data = {
+                    "_texts": [*self.data.keys()],
+                    "_texts_embeddings": np.array([*self.data.values()])
+                }
+                with open(self.cache_path, "wb") as fp:
+                    pickle.dump(data, fp)
+                    fp.close()
+
+                msg = f"Embedder cache dumped at {self.cache_path}"
+                logger.info(msg)
 
-        folder = os.path.dirname(self.cache_path)
-        if not os.path.isdir(folder):
-            os.makedirs(folder)
+            else:
+                msg = "No embedding data exists to dump. Ignoring dumping."
+                logger.warning(msg)
 
-        if not os.path.exists(self.cache_path) or os.path.getsize(self.cache_path) == 0:
-            self.cache = {}
-        else:
-            with open(self.cache_path, "rb") as fp:
-                self.cache = pickle.load(fp)
+        def get(self, text, default=None):
+            return self.__getitem__(text, default)
 
-        self.model = self.load(**kwargs)
+        def __contains__(self, text):
+            if text in self.data:
+                return True
+            return False
+
+        def __getitem__(self, text, default=None):
+            return self.data.get(text, default)
+
+        def __setitem__(self, text, encoding):
+            self.data[text] = encoding
+
+        def __delitem__(self, text):
+            try:
+                del self.data[text]
+            except KeyError as e:
+                logger.error(e)
+                pass
+
+        def __iter__(self):
+            # zip texts and encodings into a dictionary for iteration
+            if self.data:
+                return iter(self.data)
+
+        def __len__(self):
+            len(self.data)
+
+    def __init__(self, app_path, **kwargs):
+        """
+        Initializes an embedder.
+
+        Args:
+            app_path: Path of the app used to create cache folder to dump encodings
+        """
+        self.model_name = kwargs.get("model_name", "default")
+        self.model_id = str(self.model_name)
+        self.emb_dim = None
+
+        # load model (change `emb_dim` and if required change `model_id`)
+        self.model = self._load(**kwargs)
+
+        # cache path
+        cache_path = kwargs.get("cache_path", None)
+        if not cache_path:
+            # deprecated usage: determine path from `embedder_type` and `model_name` (eg. QA module)
+            cache_path = path.get_embedder_cache_file_path(
+                app_path, kwargs.get("embedder_type", "default"), self.model_name
+            )
+            # new usage: determine cache path for the model using model_id (eg. ER module)
+            if not (os.path.exists(cache_path) and os.path.getsize(cache_path) > 0):
+                # implies a previously used path name has no data and hence, is safe to change
+                #   default cache path for this model (backwards compatability in loading data)
+                cache_path = path.get_embedder_cache_file_path(app_path, self.model_id)
+        self.cache = Embedder.EmbeddingsCache(str(cache_path))
+
+    def __repr__(self):
+        return f"{self.__class__.__name__} cache_path: {self.cache.cache_path}"
 
     @abstractmethod
-    def load(self, **kwargs):
+    def _load(self, **kwargs):
         """Loads the embedder model
 
         Returns:
             The model object.
         """
-        pass
+
+        # backwards compatability
+        if getattr(self, "load", None):
+            # implies `load()` is implemented as an abstract class instead of the new way `_load()`
+            getattr(self, "load")(**kwargs)
+
+        raise NotImplementedError
 
     @abstractmethod
-    def encode(self, text_list):
+    def _encode(self, text_list):
         """
         Args:
             text_list (list): A list of text strings for which to generate the embeddings.
@@ -79,17 +205,26 @@ def encode(self, text_list):
         Returns:
             (list): A list of numpy arrays of the embeddings.
         """
-        pass
 
-    def clear_cache(self):
-        """Deletes the cache file."""
-        if os.path.exists(self.cache_path):
-            os.remove(self.cache_path)
+        # backwards compatability
+        if getattr(self, "encode", None):
+            # implies `encode()` is implemented as an abstract class instead of new way `_encode()`
+            getattr(self, "encode")(text_list)
+
+        raise NotImplementedError
 
-    def get_encodings(self, text_list):
+    # deprecated method
+    def dump(self):
+        msg = f"DeprecationWarning: Use {self.__class__.__name__}.dump_cache() instead of " \
+              f"{self.__class__.__name__}.dump()"
+        logger.warning(msg)
+        self.dump_cache()
+
+    def get_encodings(self, text_list, add_to_cache=True):
         """Fetches the encoded values from the cache, or generates them.
         Args:
             text_list (list): A list of text strings for which to get the embeddings.
+            add_to_cache (bool): If True, adds the encodings to self.cache and returns embeddings
 
         Returns:
             (list): A list of numpy arrays with the embeddings.
@@ -97,36 +232,520 @@ def get_encodings(self, text_list):
         encoded = [self.cache.get(text, None) for text in text_list]
         cache_miss_indices = [i for i, vec in enumerate(encoded) if vec is None]
         text_to_encode = [text_list[i] for i in cache_miss_indices]
-        model_encoded_text = self.encode(text_to_encode)
+        model_encoded_text = self._encode(text_to_encode)
 
         for i, v in enumerate(cache_miss_indices):
             encoded[v] = model_encoded_text[i]
-            self.cache[text_to_encode[i]] = model_encoded_text[i]
+            if add_to_cache:
+                self.cache[text_to_encode[i]] = model_encoded_text[i]
+
         return encoded
 
-    def dump(self):
-        """Dumps the cache to disk."""
-        with open(self.cache_path, "wb") as fp:
-            pickle.dump(self.cache, fp)
+    def add_to_cache(self, mean_or_max_pooled_whitelist_embs):
+        """Manually add some max-pooled or mean-pooled embeddings to cache. This method is created
+        to entertain storing superficial text-encoding pairs (superficial because the encodings are
+        not the encodings of the text itself but a combination of encodings of some list of texts
+        from the same embedder model). For example, to add superficial entity embeddings as average
+        of whitelist embeddings in Entity Resolution.
+
+        Args:
+            mean_or_max_pooled_whitelist_embs (dict): texts and their corresponding superficial
+                embeddings as a 1D numpy array, having same length as self.emb_dim
+        """
+        for key, value in mean_or_max_pooled_whitelist_embs.items():
+            value = np.asarray(value).reshape(-1)
+            if self.emb_dim and not len(value) == self.emb_dim:
+                msg = f"Expected superficial embedding of length {self.emb_dim} but found " \
+                      f"{len(value)}. Not adding the embedding for {key} to cache."
+                logger.error(msg)
+            if key in self.cache:
+                msg = f"Overwriting a superficial embedding for {key}"
+                logger.warning(msg)
+            self.cache[key] = value
+
+    def dump_cache(self):
+        self.cache.dump()
+
+    def clear_cache(self):
+        self.cache.clear()
+
+    @staticmethod
+    def _pytorch_cos_sim(src_vecs, tgt_vecs, as_numpy=False):
+        """Computes the cosine similarity for 2d matrices
+
+        Args:
+            src_vecs: a 2d numpy array or pytorch tensor
+            tgt_vecs: a 2d numpy array or pytorch tensor
+            as_numpy: If true, returns the cosine similarity as a numpy 2d array instead of tensor
+        """
+
+        src_vecs = _torch("as_tensor", src_vecs)
+        tgt_vecs = _torch("as_tensor", tgt_vecs)
+
+        if len(src_vecs.shape) == 1:
+            src_vecs = src_vecs.view(1, -1)
+
+        if len(tgt_vecs.shape) == 1:
+            tgt_vecs = tgt_vecs.view(1, -1)
+
+        if len(src_vecs.shape) != 2 or len(tgt_vecs.shape) != 2:
+            msg = "Only 2-dimensional arrays/tensors are allowed in Embedder._pytorch_cos_sim()"
+            raise ValueError(msg)
+
+        # method specific to 2d tensors
+        # [n_src, emb_dim] * [n_tgt, emb_dim] -> [n_src, n_tgt]
+        a_norm = _torch("normalize", src_vecs, sub="nn.functional", p=2, dim=1)
+        b_norm = _torch("normalize", tgt_vecs, sub="nn.functional", p=2, dim=1)
+        similarity_scores = _torch("mm", a_norm, b_norm.transpose(0, 1))
+
+        if as_numpy:
+            return similarity_scores.numpy()
+
+        return similarity_scores
+
+    def find_similarity(self,
+                        src_texts,
+                        tgt_texts=None,
+                        top_n=20,
+                        scores_normalizer=None,
+                        _sim_func=None,
+                        _return_as_dict=False,
+                        _no_sort=False):
+        """Computes the cosine similarity
+
+        Args:
+            src_texts (Union[str, list]): string or list of strings to obtain matching scores for.
+            tgt_texts (list, optional): list of strings that will be matched to.
+                if None, existing cache is used as target strings
+            top_n (int, optional): maximum number of results to populate. if None, equals length
+                of tgt_texts
+            scores_normalizer (str, optional): normalizer type to normalize scores. Allowed values
+                are: "min_max_scaler", "standard_scaler"
+            _sim_func (function, optional): if None, defaults to `_pytorch_cos_sim`. If
+                specified, must take two numpy-array/pytorch-tensor arguments for similarity
+                computation with an optional argument to return results as numpy or tensor
+            _return_as_dict (bool, optional): if the results should be returned as a dictionary of
+                target_text name as keys and scores as corresponding values
+            _no_sort (bool, optional): If True, results are returned without sorting. This is
+                helpful at times when you wish to do additional wrapper operations on top of raw
+                results and would like to save computational time without sorting.
+        Returns:
+            Union[dict, list[tuple]]: if _return_as_dict, returns a dictionary of tgt_texts and
+                their scores, else a list of sorted synonym names paired with their
+                similarity scores (descending order)
+        """
+
+        is_single = False
+        if isinstance(src_texts, str):
+            is_single = True
+            src_texts = [src_texts]
+
+        tgt_texts = [*self.cache.data.keys()] if not tgt_texts else tgt_texts
+        top_n = len(tgt_texts) if not top_n else top_n
+        _sim_func = _sim_func or self._pytorch_cos_sim
+
+        src_vecs = np.asarray(self.get_encodings(list(src_texts), add_to_cache=False))
+        tgt_vecs = np.asarray(self.get_encodings(list(tgt_texts), add_to_cache=False))
+
+        similarity_scores_2d = _sim_func(src_vecs, tgt_vecs, as_numpy=True)  # a 2d numpy array
+
+        results = []
+        for similarity_scores in similarity_scores_2d:
+            similarity_scores = similarity_scores.reshape(-1)
+            # Rounding sometimes helps to bring correct answers on to the list of top scored results
+            similarity_scores = np.around(similarity_scores, decimals=2)
+
+            if scores_normalizer:
+                if scores_normalizer == "min_max_scaler":
+                    _min = np.min(similarity_scores)
+                    _max = np.max(similarity_scores)
+                    denominator = (_max - _min) if (_max - _min) != 0 else 1.0
+                    similarity_scores = (similarity_scores - _min) / denominator
+                elif scores_normalizer == "standard_scaler":
+                    _mean = np.mean(similarity_scores)
+                    _std = np.std(similarity_scores)
+                    denominator = _std if _std else 1.0
+                    similarity_scores = (similarity_scores - _mean) / denominator
+                else:
+                    msg = f"Allowed values for `scores_normalizer` are only " \
+                          f"{['min_max_scaler', 'standard_scaler']}. Continuing without " \
+                          f"normalizing similarity scores."
+                    logger.error(msg)
+
+            if _return_as_dict:
+                results.append(dict(zip(tgt_texts, similarity_scores)))
+            else:
+                if not _no_sort:  # sort results in descending scores
+                    n_scores = len(similarity_scores)
+                    if n_scores > top_n:
+                        top_inds = similarity_scores.argpartition(n_scores - top_n)[-top_n:]
+                        result = sorted(
+                            [(tgt_texts[ii], similarity_scores[ii]) for ii in top_inds],
+                            key=lambda x: x[1],
+                            reverse=True)
+                    else:
+                        result = sorted(zip(tgt_texts, similarity_scores),
+                                        key=lambda x: x[1],
+                                        reverse=True)
+                    results.append(result)
+                else:
+                    result = list(zip(tgt_texts, similarity_scores))
+                    results.append(result)
+
+        if is_single:
+            return results[0]
+
+        return results
 
 
 class BertEmbedder(Embedder):
     """
-    Encoder class for bert models as described here: https://github.com/UKPLab/sentence-transformers
+    Encoder class for bert models based on https://github.com/UKPLab/sentence-transformers
     """
 
     DEFAULT_BERT = "bert-base-nli-mean-tokens"
 
-    def load(self, **kwargs):
-        if self.model_name == "default":
-            bert_model_name = self.DEFAULT_BERT
-            logger.info("No bert model specifications passed, using default.")
+    # class variable to cache bert model(s);
+    #   helps to mitigate keeping duplicate sets of large weight matrices, especially
+    #   when creating mutliple BERT based Entity Resolvers
+    CACHE_MODELS = {}
+
+    def __init__(self, app_path, **kwargs):
+        """
+        Args:
+            batch_size (int): the batch size used for the computation
+            show_progress_bar (bool): Output a progress bar when encode sentences
+            output_value (str): Default sentence_embedding, to get sentence embeddings.
+                Can be set to token_embeddings to get wordpiece token embeddings.
+                Choices are `sentence_embedding` and `token_embedding`
+            convert_to_numpy (bool): If true, the output is a list of numpy vectors. Else, it is a
+                list of pytorch tensors.
+            convert_to_tensor (bool): If true, you get one large tensor as return. Overwrites any
+                setting from convert_to_numpy
+            device: Which torch.device to use for the computation
+            concat_last_n_layers (int): number of hidden outputs to concat starting from last layer
+            normalize_token_embs (bool): if the (sub-)token embs are to be individually normalized
+        """
+        super().__init__(app_path, **kwargs)
+
+        # init runtime configs for the model
+        self._batch_size = kwargs.get("batch_size", 8)
+        self._show_progress_bar = kwargs.get("show_progress_bar", False)
+        self._output_value = kwargs.get("output_value", 'sentence_embedding')
+        self._convert_to_numpy = kwargs.get("convert_to_numpy", True)
+        self._convert_to_tensor = kwargs.get("convert_to_tensor", False)
+        self.device = kwargs.get("device", "cuda" if _torch("is_available", sub="cuda") else "cpu")
+        self._concat_last_n_layers = kwargs.get("concat_last_n_layers", 1)
+        self._normalize_token_embs = kwargs.get("normalize_token_embs", False)
+
+    @staticmethod
+    def _batch_to_device(batch, target_device):
+        """
+        send a pytorch batch to a device (CPU/GPU)
+        """
+        tensor = _getattr("torch", "Tensor")
+        for key in batch:
+            if isinstance(batch[key], tensor):
+                batch[key] = batch[key].to(target_device)
+        return batch
+
+    @staticmethod
+    def _num_layers(model):
+        """
+        Finds the number of layers in a given transformers model
+        """
+
+        if hasattr(model, "n_layers"):  # eg. xlm
+            num_layers = model.n_layers
+        elif hasattr(model, "layer"):  # eg. xlnet
+            num_layers = len(model.layer)
+        elif hasattr(model, "encoder"):  # eg. bert
+            num_layers = len(model.encoder.layer)
+        elif hasattr(model, "transformer"):  # eg. sentence_transformers models
+            num_layers = len(model.transformer.layer)
         else:
-            bert_model_name = self.model_name
-        return SentenceTransformer(bert_model_name)
+            raise ValueError(f"Not supported model {model} to obtain number of layers")
+
+        return num_layers
+
+    @staticmethod
+    def get_hashid(**kwargs):
+        string = json.dumps(kwargs, sort_keys=True)
+        return Hasher(algorithm="sha1").hash(string=string)
+
+    @staticmethod
+    def _get_sentence_transformers_encoder(name_or_path,
+                                           output_type="mean",
+                                           quantize=True,
+                                           return_components=False):
+        """
+        Retrieves a sentence-transformer model and returns it along with its transformer and
+        pooling components.
+
+        Args:
+            name_or_path: name or path to load a huggingface model
+            output_type: type of pooling required
+            quantize: if the model needs to be qunatized or not
+            return_components: if True, returns the Transformer and Poooling components of the
+                                sentence-bert model in a Bunch data type,
+                                else just returns the sentence-bert model
+
+        Returns:
+            Union[
+                sentence_transformers.SentenceTransformer,
+                Bunch(sentence_transformers.Transformer,
+                      sentence_transformers.Pooling,
+                      sentence_transformers.SentenceTransformer)
+            ]
+        """
+
+        strans_models = _getattr("sentence_transformers.models")
+        strans = _getattr("sentence_transformers", "SentenceTransformer")
+
+        transformer_model = strans_models.Transformer(name_or_path,
+                                                      model_args={"output_hidden_states": True})
+        pooling_model = strans_models.Pooling(transformer_model.get_word_embedding_dimension(),
+                                              pooling_mode_cls_token=output_type == "cls",
+                                              pooling_mode_max_tokens=False,
+                                              pooling_mode_mean_tokens=output_type == "mean",
+                                              pooling_mode_mean_sqrt_len_tokens=False)
+        sbert_model = strans(modules=[transformer_model, pooling_model])
+
+        if quantize:
+            if not _is_module_available("torch"):
+                raise ImportError("`torch` library required to quantize models") from None
+
+            torch_qint8 = _getattr("torch", "qint8")
+            torch_nn_linear = _getattr("torch.nn", "Linear")
+            torch_quantize_dynamic = _getattr("torch.quantization", "quantize_dynamic")
+
+            transformer_model = torch_quantize_dynamic(
+                transformer_model, {torch_nn_linear}, dtype=torch_qint8
+            ) if transformer_model else None
+            pooling_model = torch_quantize_dynamic(
+                pooling_model, {torch_nn_linear}, dtype=torch_qint8
+            ) if pooling_model else None
+            sbert_model = torch_quantize_dynamic(
+                sbert_model, {torch_nn_linear}, dtype=torch_qint8
+            ) if sbert_model else None
+
+        if return_components:
+            return Bunch(
+                transformer_model=transformer_model,
+                pooling_model=pooling_model,
+                sbert_model=sbert_model
+            )
+
+        return sbert_model
+
+    def _encode_local(self,
+                      sentences,
+                      batch_size,
+                      show_progress_bar,
+                      output_value,
+                      convert_to_numpy,
+                      convert_to_tensor,
+                      device,
+                      concat_last_n_layers,
+                      normalize_token_embs):
+        """
+        Computes sentence embeddings (Note: Method largely derived from Sentence Transformers
+            library to improve flexibility in encoding and pooling. Notably, `is_pretokenized` and
+            `num_workers` are ignored due to deprecation in their library, retrieved 23-Feb-2021)
+        """
+
+        self.transformer_model = self.model.transformer_model
+        self.pooling_model = self.model.pooling_model
+
+        if concat_last_n_layers != 1:
+            assert 1 <= concat_last_n_layers <= self._num_layers(self.transformer_model.auto_model)
+
+        self.transformer_model.eval()
+        if show_progress_bar is None:
+            show_progress_bar = (
+                logger.getEffectiveLevel() == logging.INFO or
+                logger.getEffectiveLevel() == logging.DEBUG
+            )
+
+        if convert_to_tensor:
+            convert_to_numpy = False
+
+        input_is_string = isinstance(sentences, str)
+        if input_is_string:  # Cast an individual sentence to a list with length 1
+            sentences = [sentences]
+
+        self.transformer_model.to(device)
+        self.pooling_model.to(device)
+
+        all_embeddings = []
+        length_sorted_idx = np.argsort([len(sen) for sen in sentences])
+        sentences_sorted = [sentences[idx] for idx in length_sorted_idx]
+
+        for start_index in trange(0, len(sentences), batch_size, desc="Batches",
+                                  disable=not show_progress_bar):
+            sentences_batch = sentences_sorted[start_index:start_index + batch_size]
+            features = self.transformer_model.tokenize(sentences_batch)
+            features = self._batch_to_device(features, device)
+
+            with _torch("no_grad"):
+                out_features_transformer = self.transformer_model.forward(features)
+                token_embeddings = out_features_transformer["token_embeddings"]
+                if concat_last_n_layers > 1:
+                    _all_layer_embs = out_features_transformer["all_layer_embeddings"]
+                    token_embeddings = _torch(
+                        "cat", _all_layer_embs[-concat_last_n_layers:], dim=-1)
+                if normalize_token_embs:
+                    _norm_token_embeddings = _torch(
+                        "norm", token_embeddings, sub="linalg", dim=2, keepdim=True)
+                    token_embeddings = token_embeddings.div(_norm_token_embeddings)
+                out_features_transformer.update({"token_embeddings": token_embeddings})
+                out_features = self.pooling_model.forward(out_features_transformer)
+
+                embeddings = out_features[output_value]
+
+                if output_value == 'token_embeddings':
+                    # Set token embeddings to 0 for padding tokens
+                    input_mask = out_features['attention_mask']
+                    input_mask_expanded = input_mask.unsqueeze(-1).expand(embeddings.size()).float()
+                    embeddings = embeddings * input_mask_expanded
+
+                embeddings = embeddings.detach()
+
+                if convert_to_numpy:
+                    embeddings = embeddings.cpu()
+
+                all_embeddings.extend(embeddings)
+
+        all_embeddings = [all_embeddings[idx] for idx in np.argsort(length_sorted_idx)]
+
+        if convert_to_tensor:
+            all_embeddings = _torch("stack", all_embeddings)
+        elif convert_to_numpy:
+            all_embeddings = np.asarray([emb.numpy() for emb in all_embeddings])
+
+        if input_is_string:
+            all_embeddings = all_embeddings[0]
+
+        return all_embeddings
+
+    def _load(self, **kwargs):
+
+        if not _is_module_available("sentence_transformers"):
+            raise ImportError(
+                "Must install the extra [bert] by running `pip install mindmeld[bert]` "
+                "to use the built in bert embedder.")
+
+        pretrained_name_or_abspath = (
+            kwargs.get("pretrained_name_or_abspath", None) or
+            BertEmbedder.DEFAULT_BERT if self.model_name == "default" else self.model_name
+        )
+        bert_output_type = kwargs.get("bert_output_type", "mean")
+        quantize_model = kwargs.get("quantize_model", False)
+
+        model_hashid = self.get_hashid(pretrained_name_or_abspath=pretrained_name_or_abspath,
+                                       bert_output_type=bert_output_type,
+                                       quantize_model=quantize_model)
+        model = BertEmbedder.CACHE_MODELS.get(model_hashid, None)
+
+        if not model:
+
+            info_msg = ""
+            for name in [f"sentence-transformers/{pretrained_name_or_abspath}",
+                         pretrained_name_or_abspath]:
+                try:
+                    model = (
+                        self._get_sentence_transformers_encoder(name,
+                                                                output_type=bert_output_type,
+                                                                quantize=quantize_model,
+                                                                return_components=True)
+                    )
+                    info_msg += f"Successfully initialized name/path `{name}` directly through " \
+                                f"huggingface-transformers. "
+                except OSError:
+                    info_msg += f"Could not initialize name/path `{name}` directly through " \
+                                f"huggingface-transformers. "
+
+                if model:
+                    break
+
+            logger.info(info_msg)
+
+            if not model:
+                msg = f"Could not resolve the name/path `{pretrained_name_or_abspath}`. " \
+                      f"Please check the model name and retry."
+                raise Exception(msg)
+
+            BertEmbedder.CACHE_MODELS.update({model_hashid: model})
+
+        self.model_id = str(model_hashid)
+
+        return model
+
+    def _encode(self, phrases):
+        """Encodes input text(s) into embeddings, one vector for each phrase
+
+        Args:
+            phrases (str, list[str]): textual inputs that are to be encoded using sentence \
+                                        transformers' model
+
+
+        Returns:
+            (Union[List[Tensor], ndarray, Tensor]): By default, a numpy array is returned.
+                If convert_to_tensor, a stacked tensor is returned. If convert_to_numpy, a numpy
+                matrix is returned.
+        """
+
+        if not phrases:
+            return []
+
+        if not isinstance(phrases, (str, list)):
+            raise TypeError(f"argument phrases must be of type str or list, not {type(phrases)}")
+
+        show_progress_bar = (
+            self._show_progress_bar and
+            (len(phrases) if isinstance(phrases, list) else 1) > 1
+        )
 
-    def encode(self, text_list):
-        return self.model.encode(text_list)
+        # `False` for first call but might not for the subsequent calls
+        _use_sbert_model = getattr(self, "_use_sbert_model", False)
+
+        if not _use_sbert_model:
+            try:
+                # this snippet is to reduce dependency on sentence-transformers library
+                #   note that currently, the dependency is not fully eliminated due to backwards
+                #   compatability issues in huggingface-transformers between older (python 3.6)
+                #   and newer (python >=3.7) versions which needs more conditions to be implemented
+                #   in `_encode_local` and hence will be addressed in future work
+                # TODO: eliminate depedency on sentence-transformers library
+                results = self._encode_local(phrases,
+                                             batch_size=self._batch_size,
+                                             show_progress_bar=show_progress_bar,
+                                             output_value=self._output_value,
+                                             convert_to_numpy=self._convert_to_numpy,
+                                             convert_to_tensor=self._convert_to_tensor,
+                                             device=self.device,
+                                             concat_last_n_layers=self._concat_last_n_layers,
+                                             normalize_token_embs=self._normalize_token_embs)
+                setattr(self, "_use_sbert_model", False)
+            except TypeError as e:
+                logger.error(e)
+                if self._concat_last_n_layers != 1 or self._normalize_token_embs:
+                    msg = f"{'concat_last_n_layers,' if self._concat_last_n_layers != 1 else ''} " \
+                          f"{'normalize_token_embs' if self._normalize_token_embs else ''} " \
+                          f"ignored as resorting to using encode methods from sentence-transformers"
+                    logger.warning(msg)
+                setattr(self, "_use_sbert_model", True)
+
+        if getattr(self, "_use_sbert_model"):
+            results = self.model.sbert_model.encode(phrases,
+                                                    batch_size=self._batch_size,
+                                                    show_progress_bar=show_progress_bar,
+                                                    output_value=self._output_value,
+                                                    convert_to_numpy=self._convert_to_numpy,
+                                                    convert_to_tensor=self._convert_to_tensor,
+                                                    device=self.device)
+
+        return results
 
 
 class GloveEmbedder(Embedder):
@@ -145,7 +764,7 @@ def tokenize(self, text):
         token_list = [t["entity"] for t in tokens]
         return token_list
 
-    def load(self, **kwargs):
+    def _load(self, **kwargs):
         token_embedding_dimension = kwargs.get(
             "token_embedding_dimension", self.DEFAULT_EMBEDDING_DIM
         )
@@ -159,7 +778,7 @@ def load(self, **kwargs):
             use_padding=False,
         )
 
-    def encode(self, text_list):
+    def _encode(self, text_list):
         token_list = [self.tokenize(text) for text in text_list]
         vector_list = [self.model.encode_sequence_of_tokens(tl) for tl in token_list]
         encoded_vecs = []
@@ -176,7 +795,7 @@ def dump(self):
         self.model.save_embeddings()
 
 
-if register_bert:
+if _is_module_available("sentence_transformers"):
     register_embedder("bert", BertEmbedder)
 
 register_embedder("glove", GloveEmbedder)
diff --git a/mindmeld/models/helpers.py b/mindmeld/models/helpers.py
index f08c185c3..64e30f1c3 100644
--- a/mindmeld/models/helpers.py
+++ b/mindmeld/models/helpers.py
@@ -41,7 +41,6 @@
 CLASS_LABEL_TYPE = "class"
 ENTITIES_LABEL_TYPE = "entities"
 
-
 # resource/requirements names
 GAZETTEER_RSC = "gazetteers"
 QUERY_FREQ_RSC = "q_freq"
@@ -134,18 +133,31 @@ def get_label_encoder(config):
 
 
 def create_embedder_model(app_path, config):
-    """Creates and loads the embedder model."""
-    embedder_config = config.get("model_settings", {})
-    embedding_fields = embedder_config.get("embedding_fields", [])
-    if len(embedding_fields) == 0:
-        # No embedding fields specified in the app config, continuing without embedder model.
-        return None
-    try:
-        embedder_type = embedder_config["embedder_type"]
-    except KeyError as e:
-        raise ValueError(
-            "Invalid model configuration: No provided embedder type."
-        ) from e
+    """Creates and loads an embedder model
+
+    Args:
+        config (dict): Model settings passed in as a dictionary with
+            'embedder_type' being a required key
+
+    Returns:
+        Embedder: An instance of appropriate embedder class
+
+    Raises:
+        ValueError: When model configuration is invalid or required key is missing
+    """
+
+    if "model_settings" in config and config["model_settings"]:
+        # when config = {"model_settings": {"embedder_type": ..., "..": ...}}
+        embedder_config = config["model_settings"]
+    else:
+        # when config = {"embedder_type": ..., "..": ...}}
+        embedder_config = config
+
+    embedder_type = embedder_config.get("embedder_type")
+    if not embedder_type:
+        raise KeyError(
+            "Missing required argument in config supplied to create embedder model: 'embedder_type'"
+        )
 
     try:
         return EMBEDDER_MAP[embedder_type](app_path, **embedder_config)
diff --git a/mindmeld/path.py b/mindmeld/path.py
index becb5fd1f..0ade39405 100644
--- a/mindmeld/path.py
+++ b/mindmeld/path.py
@@ -54,11 +54,19 @@
 GEN_INDEXES_FOLDER = os.path.join(GEN_FOLDER, "indexes")
 GEN_INDEX_FOLDER = os.path.join(GEN_INDEXES_FOLDER, "{index}")
 RANKING_MODEL_PATH = os.path.join(GEN_INDEX_FOLDER, "ranking.pkl")
-GEN_EMBEDDER_MODEL_PATH = os.path.join(
+DEPRECATED_GEN_EMBEDDER_MODEL_PATH = os.path.join(
     GEN_INDEXES_FOLDER, "{embedder_type}_{model_name}_cache.pkl"
 )
+GEN_EMBEDDER_MODEL_PATH = os.path.join(
+    GEN_INDEXES_FOLDER, "{model_id}_cache.pkl"
+)
 GEN_ENTITY_RESOLVERS_FOLDER = os.path.join(GEN_FOLDER, "entity_resolvers")
 GEN_ENTITY_RESOLVER_CACHE = os.path.join(GEN_ENTITY_RESOLVERS_FOLDER, "{uid}.pkl")
+GEN_QUESTION_ANSWERERS_FOLDER = os.path.join(GEN_FOLDER, "question_answerers")
+GEN_QUESTION_ANSWERER_INDICES_CACHE = os.path.join(GEN_QUESTION_ANSWERERS_FOLDER, "{uid}.pkl")
+NATIVE_QUESTION_ANSWERER_INDICES_CACHE_PATH = os.path.join(
+    os.path.expanduser("~"), ".cache/mindmeld"
+)
 
 # Domains sub tree for labeled queries
 DOMAINS_FOLDER = os.path.join(APP_PATH, "domains")
@@ -528,22 +536,42 @@ def get_ranking_file_path(app_path, index):
 
 
 @safe_path
-def get_embedder_cache_file_path(app_path, embedder_type, model_name):
+def get_embedder_cache_file_path(app_path, embedder_type, model_name=None):
     """Gets the path to the model_cache.json file for a given embedder model.
 
     Args:
         app_path (str): The path to the app data.
         embedder_type (str): The name of the embedder type.
-        model_name (str): The name of the specific trained model.
+        model_name (str, optional): The name of the specific trained model.
 
     Returns:
         (str) The path for the json cached of the embedded values.
     """
-    return GEN_EMBEDDER_MODEL_PATH.format(
-        app_path=app_path,
-        embedder_type=embedder_type,
-        model_name=model_name,
-    )
+    if model_name:
+        return DEPRECATED_GEN_EMBEDDER_MODEL_PATH.format(
+            app_path=app_path,
+            embedder_type=embedder_type,
+            model_name=model_name,
+        )
+    else:
+        return GEN_EMBEDDER_MODEL_PATH.format(
+            app_path=app_path,
+            model_id=embedder_type
+        )
+
+
+@safe_path
+def get_question_answerer_index_cache_file_path(app_path, uid):
+    """Gets the path to the question answerer index cache file.
+
+    Args:
+        app_path (str): The path to the app data.
+        uid (str): A unique filename for the .pkl file
+
+    Returns:
+        (str) The path for the .pkl cache.
+    """
+    return GEN_QUESTION_ANSWERER_INDICES_CACHE.format(app_path=app_path, uid=uid)
 
 
 @safe_path
diff --git a/setup.py b/setup.py
index c90c45762..671bdc526 100644
--- a/setup.py
+++ b/setup.py
@@ -108,5 +108,8 @@
         "language_annotator": [
             "google-cloud-translate>=3.0.1",
         ],
+        "elasticsearch": [
+            "elasticsearch>=5.0",
+        ]
     },
 )
diff --git a/source/userguide/entity_resolver.rst b/source/userguide/entity_resolver.rst
index 952c080b4..8ccd8d694 100644
--- a/source/userguide/entity_resolver.rst
+++ b/source/userguide/entity_resolver.rst
@@ -371,11 +371,11 @@ Here's an example from a ``config.py`` file where custom settings optimized for
       }
   }
 
-Alternatively, you can also specify the type of resolver through the `er_config` argument while creating a resolver as below.
+Alternatively, you can also specify the type of resolver through the `config` argument while creating a resolver as below.
 
 .. code-block:: python
 
-  er = EntityResolverFactory.create_resolver(app_path='food_ordering', entity_type='dish', er_config=ENTITY_RESOLVER_CONFIG)
+  er = EntityResolverFactory.create_resolver(app_path='food_ordering', entity_type='dish', config=ENTITY_RESOLVER_CONFIG)
   er
 
 .. code-block:: console
diff --git a/source/userguide/kb.rst b/source/userguide/kb.rst
index 2b002a89e..98e8b75c5 100644
--- a/source/userguide/kb.rst
+++ b/source/userguide/kb.rst
@@ -20,7 +20,7 @@ Not every MindMeld application needs these two components. An app always has eit
 
 .. warning::
 
-   The QuestionAnswerer requires Elasticsearch. Make sure that Elasticsearch is running in a separate shell before invoking the QuestionAnswerer.
+   The QuestionAnswerer by default uses `Elasticsearch <https://www.elastic.co/products/elasticsearch>`_ full-text search and analytics engine for information retrieval, unless explicitly specified in configs to not to use it. In which case, make sure that Elasticsearch is running in a separate shell before invoking the QuestionAnswerer. If you do not wish to use Elasticsearch, you can disable it following the :ref:`QuestionAnswerer without Elasticsearch backend <non_elasticsearch_question_answerer>` section below.
 
 Decide Whether your App Needs a Knowledge Base
 ----------------------------------------------
@@ -629,7 +629,7 @@ The ``field`` parameter specifies the knowledge base field for sort, the ``sort_
 
 The custom sort can be applied to any number or date fields desirable and the score for ranking will be a optimized blend of sort score with other scoring factors including text relevance scores when available.
 
-In the example below, we search for ``menu_item`` objects that best match the text query ``fish and chips``, priced from lowest to highest. TO do this, we combining the text relevance and sort scores on the ``price`` field:
+In the example below, we search for ``menu_item`` objects that best match the text query ``fish and chips``, priced from lowest to highest. To do this, we combine the text relevance and sort scores on the ``price`` field:
 
 .. code:: python
 
@@ -716,6 +716,45 @@ In the example below, we search for restaurants whose names best match ``firetra
    We can set the ``size`` parameter of the :meth:`execute()` method to specify the maximum number of records.
 
 
+.. _question_answerer_config:
+
+QuestionAnswerer Configurations
+-------------------------------
+
+To override MindMeld's default QuestionAnswerer configuration with custom settings, you can edit the app configuration file by adding a dictionary of custom setting named :data:`QUESTION_ANSWERER_CONFIG`. If no such dictionary is present in the ``config.py`` file, MindMeld loads a QuestionAnswerer with default settings. The following are the default settings:
+
+.. code-block:: python
+
+  QUESTION_ANSWERER_CONFIG = {
+      'model_type': 'elasticsearch',
+      'model_settings': {
+          'query_type': 'keyword'
+      }
+  }
+
+The ``query_type`` can be one of ``keyword`` or ``text``. While specifying the former optimizes your QuestionAnswerer search for keywords or short spans of text, the latter optimizes for searching on larger paragraphs or passages of unstructured text. In order to leverage embeddings-based semantic matching along with surface-level text features matching, you can specify one of the three embedder query types- ``embedder``, ``embedder_text``, or ``embedder_keyword``. For using embeddings of your choice, you can specify embedder configurations in the :data:`QUESTION_ANSWERER_CONFIG` within the key 'model_settings'. For full details about using embedders in QuestionAnswerer, check the :ref:`Leveraging semantic embeddings <semantic_embeddings>` section below. Note that the specified ``query_type`` is also the default ``query_type`` for all QuestionAnswerer calls in your application, but you can always pass in a different query type to your ``qa.get(query_type=...)`` command as desired.
+
+.. _non_elasticsearch_question_answerer:
+
+QuestionAnswerer without Elasticsearch
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Search operations of QuestionAnswerer are by default backed by Elasticsearch. While it is recommended to use the Elasticsearch based QuestionAnswerer, it might not always be feasible to use it due to various constraints. In such cases, one can easily disable Elasticsearch and perform the search natively by setting `"model_type": "native"` in the ``QUESTION_ANSWERER_CONFIG`` as follows.
+
+.. code-block:: python
+
+  QUESTION_ANSWERER_CONFIG = {
+      'model_type': 'native',
+      'model_settings': {
+          'query_type': 'keyword'
+      }
+  }
+
+You can set this key irrespective of leveraging embedders or not. If unspecified, this key will be set to ``elasticsearch``. It is noteworthy that the search results obtained with and without Elasticsearch might have minor differences between them.
+
+The native QuestionAnswerer, similar to the Elasticsearch backed QuestionAnswerer, is capable of doing both surface-level text features matching as well as semantic matching based on embeddings. Upon loading a Knowledge Base, the native QuestionAnswerer stores the KB data at the location '/Users/<username>/.cache/mindmeld/.generated/question_answerer/<indexname>.pkl'. If using an embedder, the embeddings of the required data fields will also be saved under the same '.generated' folder. Note that the matching scores of the Elasticsearch backed QuestionAnswerer are larger in magnitude whereas the scores from the native backend are generally fractions less than 1.
+
+
 .. _unstructured_data:
 
 Dealing with unstructured text
@@ -820,7 +859,7 @@ Leveraging semantic embeddings
 
 .. note::
 
-    In order to use the embedding features, you must be on ElasticSearch 7 or above. If you are upgrading to ES7 from an older version, we recommend you delete and recreate all of your indexes.
+    If you are working with QuestionAnswerer's default Elasticsearch backend, in order to use the embedding features, you must be on ElasticSearch 7 or above. If you are upgrading to ES7 from an older version, we recommend you delete and recreate all of your indexes.
 
 .. note::
 
@@ -830,15 +869,16 @@ The question answerer capabilities described so far rely purely on text-based re
 
 To leverage semantic embeddings in search, the first step is to generate the embeddings for your desired knowledge base fields. You can use one of the provided embedders or use your own. If your app mainly consists of standard English vocabulary, one of the provided embedders may work well enough, but if the text you are searching against has quite a bit of domain-specific vocabulary, you may benefit from training or fine tuning your own embedder on your data.
 
-The settings for semantic embeddings are part of the ``QUESTION_ANSWERER_CONFIG`` in the app configuration file, ``config.py``. To use semantic embeddings, you need to specify a supported ``model_type``,  the ``model_settings``, and the fields you would like to generate embeddings for in ``embedding_fields``. The ``embedding_fields`` parameter takes a dictionary where the key is the name of your index, and the value is a list of field names or regexes to match against the field names for that index.
+The settings for semantic embeddings are part of the ``QUESTION_ANSWERER_CONFIG`` in the app configuration file, ``config.py``. To use semantic embeddings, you need to specify a supported ``query_type``,  the ``model_settings``, and the fields you would like to generate embeddings for in ``embedding_fields``. The ``embedding_fields`` parameter takes a dictionary where the key is the name of your index, and the value is a list of field names or regexes to match against the field names for that index.
 
 Using the HR Assistant blueprint as an example, here is what the question answerer config could look like.
 
 .. code:: python
 
   QUESTION_ANSWERER_CONFIG = {
-      "model_type": "embedder",
+      "model_type": "elasticsearch",
       "model_settings": {
+          "query_type": "embedder",
           "embedder_type": "bert",
           "embedding_fields": {
               "faq_data": ["question", "answer"]
@@ -846,10 +886,10 @@ Using the HR Assistant blueprint as an example, here is what the question answer
       }
   }
 
-There are three available model types which leverage semantic embedders. The specified ``model_type`` is also the default ``query_type`` for all question answering calls for your application, but you can always pass in a different query type to your ``qa.get()`` command as desired.
+There are three available query types which leverage semantic embedders. The specified ``query_type`` is also the default ``query_type`` for all question answering calls for your application, but you can always pass in a different query type to your ``qa.get()`` command as desired.
 
   +-----------------------+------------------------------------------------------------------------------------------------------------+
-  | Model type            | Description                                                                                                |
+  | Query type            | Description                                                                                                |
   +=======================+============================================================================================================+
   | embedder              | Only leverage deep semantic embedders. This option allows for using deep semantic embedders like           |
   |                       | Sentence-BERT or GloVe for doing vector-based retrieval.                                                   |
@@ -862,7 +902,7 @@ There are three available model types which leverage semantic embedders. The spe
   +-----------------------+------------------------------------------------------------------------------------------------------------+
 
 
-The two included embedder types are `Sentence-BERT <https://github.com/UKPLab/sentence-transformers>`_ and `GloVe <https://nlp.stanford.edu/projects/glove/>`_. 
+The two included embedder types are `Sentence-BERT <https://github.com/UKPLab/sentence-transformers>`_ and `GloVe <https://nlp.stanford.edu/projects/glove/>`_.
 
 Sentence-BERT is a modification of the pretrained BERT network that uses siamese and triplet network structures to derive semantically meaningful sentence embeddings that can be compared using cosine-similarity. To use this embedder type, specify ``bert`` as the ``embedder_type`` in the ``model_settings``.
 
@@ -870,7 +910,7 @@ Multiple models fine-tuned on BERT / RoBERTa / DistilBERT / ALBERT / XLNet are p
 
 The provided GloVe embeddings are word vectors trained on the `Wikipedia <https://dumps.wikimedia.org/>`_ and `Gigaword 5 <https://catalog.ldc.upenn.edu/LDC2011T07>`_ datasets. To use this embedder type, specify ``glove`` as the ``embedder_type`` in the ``model_settings``.
 
-The 50, 100, 200, and 300 dimension word embeddings are supported. The desired dimension can be specified via the ``token_embedding_dimension`` in the ``model_settings`` section of the config (defaults to 300). For fields with more than one token, word vector averaging is used. 
+The 50, 100, 200, and 300 dimension word embeddings are supported. The desired dimension can be specified via the ``token_embedding_dimension`` in the ``model_settings`` section of the config (defaults to 300). For fields with more than one token, word vector averaging is used.
 
 Once your config file is specified, you can load the Knowledge Base and the embeddings will automatically be generated as specified in the config and stored in your index as dense vectors. Note that you must pass the application path in order for the config to be processed. This can be done via the CLI command with the ``--app-path`` option or via the ``app_path`` parameter for the ``load_kb`` method.
 
@@ -879,7 +919,7 @@ Once your config file is specified, you can load the Knowledge Base and the embe
 
   mindmeld load-kb hr_assistant faq_data data/hr_faq_data.json --app-path .
 
-All of the vectors generated at load time will be cached for faster retrieval at inference time and for future loads. It is stored with other generated data in the generated folder under the provided model name. It’s important to update the mode name when updating the model settings to maintain consistency with the cache. 
+All of the vectors generated at load time will be cached for faster retrieval at inference time and for future loads. It is stored with other generated data in the generated folder under the provided model name. It’s important to update the mode name when updating the model settings to maintain consistency with the cache.
 
 .. code-block:: console
 
@@ -889,15 +929,15 @@ If our built-in embedders don't fit your use case and you would like to use your
 
 .. code-block:: python
 
-  from mindmeld.models import Embedder, register_embedder 
-  
-  class MyEmbedder(Embedder): 
-      def load(self, **kwargs): 
-          # return the loaded model 
-      def encode(self, text_list): 
-          # encodes each query in the list  
+  from mindmeld.models import Embedder, register_embedder
+
+  class MyEmbedder(Embedder):
+      def load(self, **kwargs):
+          # return the loaded model
+      def encode(self, text_list):
+          # encodes each query in the list
 
-  register_embedder('my_embedder_name', MyEmbedder) 
+  register_embedder('my_embedder_name', MyEmbedder)
 
 
 .. code-block:: python
@@ -910,14 +950,15 @@ In many cases, you may like to provide some parameters to your ``load`` method t
 
 .. code-block:: python
 
-  QUESTION_ANSWERER_CONFIG = { 
-      "model_type": "embedder", 
-      "model_settings": { 
-          "embedder_type": "my_embedder_name", 
-          "some_model_param": "my_model_param", 
-          "embedding_fields": {"faq": ['question', 'answer']} 
-      } 
-  } 
+  QUESTION_ANSWERER_CONFIG = {
+      "model_type": "elasticsearch",
+      "model_settings": {
+          "query_type": "embedder",
+          "embedder_type": "my_embedder_name",
+          "some_model_param": "my_model_param",
+          "embedding_fields": {"faq": ['question', 'answer']}
+      }
+  }
 
 
 Once your knowledge base has been created, to search it while leveraging vector similarity, we will use the :meth:`get()` API as before with one small modification. We set the ``query_type`` parameter to ``embedder``, ``embedder_keyword``, or ``embedder_text``. This will automatically find results for which the embedded search fields are close in cosine similarity to the embedded query.


Test patch:
diff --git a/tests/components/test_question_answerer.py b/tests/components/test_question_answerer.py
index 8e8353d8f..e4422fdce 100644
--- a/tests/components/test_question_answerer.py
+++ b/tests/components/test_question_answerer.py
@@ -11,7 +11,6 @@
 
 # pylint: disable=locally-disabled,redefined-outer-name
 import pytest
-
 from mindmeld.components._elasticsearch_helpers import create_es_client
 from mindmeld.components.question_answerer import QuestionAnswerer
 
@@ -21,6 +20,8 @@
     os.path.dirname(__file__) + "/../food_ordering/data/menu_items.json"
 )
 
+""" fixtures and tests when using elastic search """
+
 
 @pytest.fixture
 def es_client():
@@ -232,9 +233,8 @@ def test_unstructured_search(food_ordering_answerer):
     assert len(res) > 0
 
     res = (
-        s.query(query_type="text", description="maybe a spicy roll with some salmon")
-        .filter(query_type="text", name="spicy roll")
-        .execute()
+        s.query(query_type="text", description="maybe a spicy roll with some salmon").filter(
+            query_type="text", name="spicy roll").execute()
     )
     assert len(res) > 0
 
@@ -319,3 +319,327 @@ def test_embedder_search_glove(food_ordering_with_glove):
         name="pasta with tomato sauce",
     )
     assert len(res) > 0
+
+
+""" fixtures and tests without elastic search """
+
+
+@pytest.fixture
+def answerer_without_elasticsearch(kwik_e_mart_app_path):
+    QA_CONFIG = {
+        "model_type": "native",
+        "model_settings": {
+            "query_type": "keyword"
+        }
+    }
+    QuestionAnswerer.load_kb(
+        app_namespace="kwik_e_mart",
+        index_name="store_name",
+        data_file=STORE_DATA_FILE_PATH,
+        config=QA_CONFIG
+    )
+
+    qa = QuestionAnswerer(kwik_e_mart_app_path)
+    return qa
+
+
+@pytest.fixture
+def relative_answerer_without_elasticsearch(kwik_e_mart_app_path):
+    QA_CONFIG = {
+        "model_type": "native",
+        "model_settings": {
+            "query_type": "keyword"
+        }
+    }
+    QuestionAnswerer.load_kb(
+        app_namespace="kwik_e_mart",
+        index_name="store_name",
+        data_file=STORE_DATA_FILE_PATH,
+        config=QA_CONFIG
+    )
+    old_cwd = os.getcwd()
+    os.chdir(kwik_e_mart_app_path)
+    qa = QuestionAnswerer(".")
+    os.chdir(old_cwd)
+    return qa
+
+
+@pytest.fixture
+def food_ordering_answerer_without_elasticsearch(food_ordering_app_path):
+    QA_CONFIG = {
+        "model_type": "native",
+        "model_settings": {
+            "query_type": "keyword"
+        }
+    }
+    QuestionAnswerer.load_kb(
+        app_namespace="food_ordering",
+        index_name="menu_items",
+        data_file=DISH_DATA_FILE_PATH,
+        config=QA_CONFIG
+    )
+
+    qa = QuestionAnswerer(food_ordering_app_path)
+    return qa
+
+
+@pytest.mark.extras
+@pytest.fixture
+def food_ordering_with_bert_without_elasticsearch(food_ordering_app_path):
+    bert_qa_config = {
+        "model_type": "native",
+        "model_settings": {
+            "query_type": "embedder",
+            "embedder_type": "bert",
+            "embedding_fields": {"menu_items_bert": ["name"]},
+        }
+    }
+    QuestionAnswerer.load_kb(
+        app_namespace="food_ordering",
+        index_name="menu_items_bert",
+        data_file=DISH_DATA_FILE_PATH,
+        config=bert_qa_config,
+    )
+
+    qa = QuestionAnswerer(food_ordering_app_path, config=bert_qa_config)
+    return qa
+
+
+@pytest.mark.extras
+@pytest.fixture
+def food_ordering_with_glove_without_elasticsearch(food_ordering_app_path):
+    glove_qa_config = {
+        "model_type": "native",
+        "model_settings": {
+            "query_type": "embedder",
+            "embedder_type": "glove",
+            "embedding_fields": {"menu_items_bert": ["name"]},
+        }
+    }
+    QuestionAnswerer.load_kb(
+        app_namespace="food_ordering",
+        index_name="menu_items_glove",
+        data_file=DISH_DATA_FILE_PATH,
+        config=glove_qa_config,
+    )
+
+    qa = QuestionAnswerer(food_ordering_app_path, config=glove_qa_config)
+    return qa
+
+
+def test_basic_relative_search_without_elasticsearch(relative_answerer_without_elasticsearch):
+    """Test basic search."""
+
+    # retrieve object using ID
+    res = relative_answerer_without_elasticsearch.get(index="store_name", id="20")
+    assert len(res) > 0
+
+    # simple text query
+    res = relative_answerer_without_elasticsearch.get(index="store_name", store_name="peanut")
+    assert len(res) > 0
+
+    # simple text query
+    res = relative_answerer_without_elasticsearch.get(index="store_name",
+                                                      store_name="Springfield Heights")
+    assert len(res) > 0
+
+    # multiple text queries
+    res = relative_answerer_without_elasticsearch.get(
+        index="store_name", store_name="peanut", address="peanut st"
+    )
+    assert len(res) > 0
+
+
+def test_advanced_search_without_elasticsearch(answerer_without_elasticsearch):
+    """Test advanced search."""
+
+    s = answerer_without_elasticsearch.build_search(index="store_name")
+    res = s.query(store_name="peanut").execute()
+    assert len(res) > 0
+
+
+def test_partial_match_without_elasticsearch(answerer_without_elasticsearch):
+    """Test partial match."""
+
+    # test partial match
+    res = answerer_without_elasticsearch.get(index="store_name", store_name="Garden")
+    assert len(res) > 0
+
+
+def test_sort_by_distance_without_elasticsearch(answerer_without_elasticsearch):
+    """Test sort by distance."""
+
+    # retrieve object using ID
+    res = answerer_without_elasticsearch.get(
+        index="store_name",
+        _sort="location",
+        _sort_type="distance",
+        _sort_location="44.24,-123.12",
+    )
+    assert len(res) > 0
+    assert res[0].get("id") == "19"
+
+
+def test_basic_search_validation_without_elasticsearch(
+    food_ordering_answerer_without_elasticsearch
+):
+    """Test validation."""
+
+    # index not exist
+    with pytest.raises(ValueError):
+        food_ordering_answerer_without_elasticsearch.get(index="nosuchindex", nosuchfield="novalue")
+
+    # field not exist
+    with pytest.raises(ValueError):
+        food_ordering_answerer_without_elasticsearch.get(index="menu_items", nosuchfield="novalue")
+
+    # invalid field type
+    with pytest.raises(ValueError):
+        food_ordering_answerer_without_elasticsearch.get(index="menu_items", price="novalue")
+
+    # invalid sort type
+    with pytest.raises(ValueError):
+        food_ordering_answerer_without_elasticsearch.get(
+            index="menu_items", _sort="price", _sort_type="distance"
+        )
+
+    # invalid sort type
+    with pytest.raises(ValueError):
+        food_ordering_answerer_without_elasticsearch.get(
+            index="menu_items", _sort="location", _sort_type="asc"
+        )
+
+    # missing origin
+    with pytest.raises(ValueError):
+        food_ordering_answerer_without_elasticsearch.get(
+            index="menu_items", _sort="location", _sort_type="distance"
+        )
+
+
+def test_unstructured_search_without_elasticsearch(food_ordering_answerer_without_elasticsearch):
+    res = food_ordering_answerer_without_elasticsearch.get(
+        index="menu_items",
+        query_type="text",
+        description="something with crab meat and scallops",
+    )
+    assert len(res) > 0
+
+    s = food_ordering_answerer_without_elasticsearch.build_search(index="menu_items")
+    res = s.query(
+        query_type="text", description="maybe a roll with some salmon"
+    ).execute()
+    assert len(res) > 0
+
+    res = (
+        s.query(query_type="text", description="maybe a spicy roll with some salmon").filter(
+            query_type="text", name="spicy roll").execute()
+    )
+    assert len(res) > 0
+
+
+def test_advanced_search_validation_without_elasticsearch(answerer_without_elasticsearch):
+    """Tests validation in advanced search."""
+
+    # index not exist
+    with pytest.raises(ValueError):
+        s = answerer_without_elasticsearch.build_search(index="nosuchindex")
+        s.query(fieldnotexist="test")
+
+    # field not exist
+    with pytest.raises(ValueError):
+        s = answerer_without_elasticsearch.build_search(index="store_name")
+        s.query(fieldnotexist="test")
+
+    # invalid field type
+    with pytest.raises(ValueError):
+        s = answerer_without_elasticsearch.build_search(index="store_name")
+        s.query(location="testlocation")
+
+    # range filter can only be specified with number or date fields.
+    with pytest.raises(ValueError):
+        s = answerer_without_elasticsearch.build_search(index="store_name")
+        s.filter(field="phone_number", gt=10)
+
+    # sort field to be number or date type.
+    with pytest.raises(ValueError):
+        s = answerer_without_elasticsearch.build_search(index="store_name")
+        s.sort(field="store_name", sort_type="asc")
+
+    # missing origin
+    with pytest.raises(ValueError):
+        s = answerer_without_elasticsearch.build_search(index="store_name")
+        s.sort(field="location", sort_type="distance")
+
+
+@pytest.mark.extras
+@pytest.mark.bert
+@pytest.mark.es7
+def test_embedder_search_bert_without_elasticsearch(food_ordering_with_bert_without_elasticsearch):
+    res = food_ordering_with_bert_without_elasticsearch.get(
+        index="menu_items_bert", query_type="embedder", name="pasta with tomato sauce"
+    )
+    assert len(res) > 0
+
+    res = food_ordering_with_bert_without_elasticsearch.get(
+        index="menu_items_bert",
+        query_type="embedder_keyword",
+        name="pasta with tomato sauce",
+    )
+    assert len(res) > 0
+
+    res = food_ordering_with_bert_without_elasticsearch.get(
+        index="menu_items_bert",
+        query_type="embedder_text",
+        name="pasta with tomato sauce",
+    )
+    assert len(res) > 0
+
+
+@pytest.mark.extras
+@pytest.mark.es7
+@pytest.mark.xfail(strict=False)
+def test_embedder_search_glove_without_elasticsearch(
+    food_ordering_with_glove_without_elasticsearch
+):
+    res = food_ordering_with_glove_without_elasticsearch.get(
+        index="menu_items_glove", query_type="embedder", name="pasta with tomato sauce"
+    )
+    assert len(res) > 0
+
+    res = food_ordering_with_glove_without_elasticsearch.get(
+        index="menu_items_glove",
+        query_type="embedder_keyword",
+        name="pasta with tomato sauce",
+    )
+    assert len(res) > 0
+
+    res = food_ordering_with_glove_without_elasticsearch.get(
+        index="menu_items_glove",
+        query_type="embedder_text",
+        name="pasta with tomato sauce",
+    )
+    assert len(res) > 0
+
+
+def test_basic_search_without_elasticsearch(answerer_without_elasticsearch):
+    """Test basic search."""
+
+    # retrieve object using ID
+    res = answerer_without_elasticsearch.get(index="store_name", id="20")
+    assert len(res) > 0
+
+    # simple text query
+    res = answerer_without_elasticsearch.get(index="store_name", store_name="peanut")
+    assert len(res) > 0
+
+    # simple text query
+    res = answerer_without_elasticsearch.get(index="store_name", store_name="Springfield Heights")
+    assert len(res) > 0
+
+    # multiple text queries
+    res = answerer_without_elasticsearch.get(index="store_name", store_name="peanut",
+                                             address="peanut st")
+    assert len(res) > 0
+
+    # check that score is included in response
+    assert res[0].get('_score') is not None