Repository: WordPress/openverse-api

Issue:
Exact match does not function as described in the documentation
## Description
<!-- Concisely describe the bug. Compare your experience with what you expected to happen. -->
<!-- For example: "I clicked the 'submit' button and instead of seeing a thank you message, I saw a blank page." -->
It would appear that using a quoted search (e.g. `"this is my search"` vs `this is my search`) does not behave as described in the documentation:

> ```
> # Example 2: Search for audio that is an exact match of Giacomo Puccini
> curl \
>   -H "Authorization: Bearer DLBYIcfnKfolaXKcmMC8RIDCavc2hW" \
>   "https://api.openverse.engineering/v1/audio/?q="Giacomo Puccini""
> ```

Firstly, this documentation doesn't escape the quotes correctly, so `Puccini` is read in as a bash command.

Additionally, the quoted search appears to do nothing to the results. From the search controller code, it looks like we're boosting exact matches in all cases, but ignoring quotes otherwise: 

https://href.li/?https://github.com/WordPress/openverse-api/blob/a44f8d19cc3b2612469bbf20a3aac54c44086712/api/catalog/api/controllers/search_controller.py#L340-L356

It seems that we will need to change this logic so that if a term is quoted, _only_ an exact match search is made. Exact matches could still be boosted in regular searches, but non-exact matches should not be returned in quoted searches.

## Reproduction
<!-- Provide detailed steps to reproduce the bug. -->
1. `just recreate`
2. Visit http://localhost:50280/v1/images/?q=food
3. Visit http://localhost:50280/v1/images/?q=%22food%22 and observe that the list remains unchanged, even though some posts (those on page 6) are matched on the tag food

## Resolution
<!-- Replace the [ ] with [x] to check the box. -->
- [ ] 🙋 I would be interested in resolving this bug.



Discussion:
I've labeled this as "high" because we've received direct user feedback about it and it is behavior that contradicts our documentation.
Update from @obulat:

> I looked through the code, and It appears that we first remove invalid quotes (if there are 3 quotes, for example) and then use `simple_query_string` [[Elastic docs](https://href.li/?https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-simple-query-string-query.html)] which actually should only return the results that have the exact matches. The `simple_query_string`  uses the search syntax (so, the quotes that mean exact matches, too) to query for results. And it uses an AND operator, so it should only return when all of the parts of the query match.

I was actually able to find a query string which produced different results when quoted too!

- https://api.openverse.engineering/v1/audio/?q=down%20by%20the%20river - produces 94 audio results
- https://api.openverse.engineering/v1/audio/?q=%22down%20by%20the%20river%22 - produces 17 audio results
@sarayourfriend suggested this may also be the result of [stemming](https://www.elastic.co/guide/en/elasticsearch/reference/current/stemming.html) (and potentially [stop token filtering](https://www.elastic.co/guide/en/elasticsearch/reference/current/analysis-stop-tokenfilter.html)). E.g. even quoted searches for "skiing" would return results on "ski" and "skis". Elasticsearch has a guide on how we could mix both stemming and exact searches: https://www.elastic.co/guide/en/elasticsearch/reference/current/mixing-exact-search-with-stemming.html. Perhaps that would be something we could add!

Resolving patch:
diff --git a/api/catalog/api/controllers/search_controller.py b/api/catalog/api/controllers/search_controller.py
--- a/api/catalog/api/controllers/search_controller.py
+++ b/api/catalog/api/controllers/search_controller.py
@@ -340,18 +340,25 @@ def search(
     search_fields = ["tags.name", "title", "description"]
     if "q" in search_params.data:
         query = _quote_escape(search_params.data["q"])
+        base_query_kwargs = {
+            "query": query,
+            "fields": search_fields,
+            "default_operator": "AND",
+        }
+
+        if '"' in query:
+            base_query_kwargs["quote_field_suffix"] = ".exact"
+
         s = s.query(
             "simple_query_string",
-            query=query,
-            fields=search_fields,
-            default_operator="AND",
+            **base_query_kwargs,
         )
-        # Boost exact matches
+        # Boost exact matches on the title
         quotes_stripped = query.replace('"', "")
         exact_match_boost = Q(
             "simple_query_string",
             fields=["title"],
-            query=f'"{quotes_stripped}"',
+            query=f"{quotes_stripped}",
             boost=10000,
         )
         s = search_client.query(Q("bool", must=s.query, should=exact_match_boost))
diff --git a/api/catalog/api/examples/audio_requests.py b/api/catalog/api/examples/audio_requests.py
--- a/api/catalog/api/examples/audio_requests.py
+++ b/api/catalog/api/examples/audio_requests.py
@@ -10,7 +10,7 @@
 syntax_examples = {
     "using single query parameter": "test",
     "using multiple query parameters": "test&license=pdm,by&categories=illustration&page_size=1&page=1",  # noqa: E501
-    "that is an exact match of Giacomo Puccini": '"Giacomo Puccini"',
+    "that is an exact match of Giacomo Puccini": r"%22Giacomo%20Puccini%22",
     "related to both dog and cat": "dog+cat",
     "related to dog or cat, but not necessarily both": "dog|cat",
     "related to dog but won't include results related to 'pug'": "dog -pug",
diff --git a/api/catalog/api/examples/image_requests.py b/api/catalog/api/examples/image_requests.py
--- a/api/catalog/api/examples/image_requests.py
+++ b/api/catalog/api/examples/image_requests.py
@@ -10,7 +10,7 @@
 syntax_examples = {
     "using single query parameter": "test",
     "using multiple query parameters": "test&license=pdm,by&categories=illustration&page_size=1&page=1",  # noqa: E501
-    "that are an exact match of Claude Monet": '"Claude Monet"',
+    "that are an exact match of Claude Monet": "%22Claude%20Monet%22",
     "related to both dog and cat": "dog+cat",
     "related to dog or cat, but not necessarily both": "dog|cat",
     "related to dog but won't include results related to 'pug'": "dog -pug",


Test patch:
diff --git a/api/test/audio_integration_test.py b/api/test/audio_integration_test.py
--- a/api/test/audio_integration_test.py
+++ b/api/test/audio_integration_test.py
@@ -15,6 +15,7 @@
     search_by_category,
     search_consistency,
     search_quotes,
+    search_quotes_exact,
     search_source_and_excluded,
     search_special_chars,
     stats,
@@ -101,6 +102,11 @@ def test_search_quotes():
     search_quotes("audio", "love")
 
 
+def test_search_quotes_exact():
+    # ``water running`` returns different results when quoted vs unquoted
+    search_quotes_exact("audio", "water running")
+
+
 def test_search_with_special_characters():
     search_special_chars("audio", "love")
 
diff --git a/api/test/image_integration_test.py b/api/test/image_integration_test.py
--- a/api/test/image_integration_test.py
+++ b/api/test/image_integration_test.py
@@ -15,6 +15,7 @@
     search_all_excluded,
     search_consistency,
     search_quotes,
+    search_quotes_exact,
     search_source_and_excluded,
     search_special_chars,
     stats,
@@ -53,6 +54,11 @@ def test_search_quotes():
     search_quotes("images", "dog")
 
 
+def test_search_quotes_exact():
+    # ``bird perched`` returns different results when quoted vs unquoted
+    search_quotes_exact("images", "bird perched")
+
+
 def test_search_with_special_characters():
     search_special_chars("images", "dog")
 
diff --git a/api/test/media_integration.py b/api/test/media_integration.py
--- a/api/test/media_integration.py
+++ b/api/test/media_integration.py
@@ -45,6 +45,25 @@ def search_quotes(media_path, q="test"):
     assert response.status_code == 200
 
 
+def search_quotes_exact(media_path, q):
+    """Only returns exact matches for the given query"""
+    unquoted_response = requests.get(f"{API_URL}/v1/{media_path}?q={q}", verify=False)
+    assert unquoted_response.status_code == 200
+    unquoted_result_count = unquoted_response.json()["result_count"]
+    assert unquoted_result_count > 0
+
+    quoted_response = requests.get(f'{API_URL}/v1/{media_path}?q="{q}"', verify=False)
+    assert quoted_response.status_code == 200
+    quoted_result_count = quoted_response.json()["result_count"]
+    assert quoted_result_count > 0
+
+    # The rationale here is that the unquoted results will match more records due
+    # to the query being overall less strict. Quoting the query will make it more
+    # strict causing it to return fewer results.
+    # Above we check that the results are not 0 to confirm that we do still get results back.
+    assert quoted_result_count < unquoted_result_count
+
+
 def search_special_chars(media_path, q="test"):
     """Returns a response when query includes special characters."""
     response = requests.get(f"{API_URL}/v1/{media_path}?q={q}!", verify=False)