Repository: deepset-ai/haystack

Issue:
Add support for 'dot_product' and 'l2' similarity with the Weaviate document store
**Is your feature request related to a problem? Please describe.**
The current Weaviate integration only supports the `cosine` similarity, but since v1.14.x Weaviate also supports 'dot_product' and 'l2' distances, see https://weaviate.io/developers/weaviate/current/vector-index-plugins/distances.html

**Describe the solution you'd like**
Just like other document stores, Weaviate too should be made to work with the additional distance metrics.

**Describe alternatives you've considered**
Using only the current option (`cosine`) is quite limiting.

**Additional context**
I will provide a PR for this.

Add support for 'dot_product' and 'l2' similarity with the Weaviate document store
**Is your feature request related to a problem? Please describe.**
The current Weaviate integration only supports the `cosine` similarity, but since v1.14.x Weaviate also supports 'dot_product' and 'l2' distances, see https://weaviate.io/developers/weaviate/current/vector-index-plugins/distances.html

**Describe the solution you'd like**
Just like other document stores, Weaviate too should be made to work with the additional distance metrics.

**Describe alternatives you've considered**
Using only the current option (`cosine`) is quite limiting.

**Additional context**
I will provide a PR for this.



Discussion:

Resolving patch:
diff --git a/haystack/document_stores/weaviate.py b/haystack/document_stores/weaviate.py
--- a/haystack/document_stores/weaviate.py
+++ b/haystack/document_stores/weaviate.py
@@ -44,7 +44,6 @@ class WeaviateDocumentStore(BaseDocumentStore):
     2. Allows combination of vector search and scalar filtering, i.e. you can filter for a certain tag and do dense retrieval on that subset
     3. Has less variety of ANN algorithms, as of now only HNSW.
     4. Requires document ids to be in uuid-format. If wrongly formatted ids are provided at indexing time they will be replaced with uuids automatically.
-    5. Only support cosine similarity.
 
     Weaviate python client is used to connect to the server, more details are here
     https://weaviate-python-client.readthedocs.io/en/docs/weaviate.html
@@ -89,7 +88,7 @@ def __init__(
         :param content_field: Name of field that might contain the answer and will therefore be passed to the Reader Model (e.g. "full_text").
                            If no Reader is used (e.g. in FAQ-Style QA) the plain content of this field will just be returned.
         :param name_field: Name of field that contains the title of the the doc
-        :param similarity: The similarity function used to compare document vectors. 'cosine' is the only currently supported option and default.
+        :param similarity: The similarity function used to compare document vectors. Available options are 'cosine' (default), 'dot_product' and 'l2'.
                            'cosine' is recommended for Sentence Transformers.
         :param index_type: Index type of any vector object defined in weaviate schema. The vector index type is pluggable.
                            Currently, HSNW is only supported.
@@ -111,9 +110,6 @@ def __init__(
             created using the config you are using for initialization. Be aware that all data in the old index will be
             lost if you choose to recreate the index.
         """
-        if similarity != "cosine":
-            raise ValueError(f"Weaviate only supports cosine similarity, but you provided {similarity}")
-
         super().__init__()
 
         # Connect to Weaviate server using python binding
@@ -143,7 +139,16 @@ def __init__(
         self.embedding_dim = embedding_dim
         self.content_field = content_field
         self.name_field = name_field
-        self.similarity = similarity
+        if similarity == "cosine":
+            self.similarity = "cosine"
+        elif similarity == "dot_product":
+            self.similarity = "dot"
+        elif similarity == "l2":
+            self.similarity = "l2-squared"
+        else:
+            raise DocumentStoreError(
+                f"It looks like you provided value '{similarity}' for similarity in the WeaviateDocumentStore constructor. Choose one of these values: 'cosine', 'l2', and 'dot_product'"
+            )
         self.index_type = index_type
         self.custom_schema = custom_schema
         self.return_embedding = return_embedding
@@ -187,14 +192,28 @@ def _create_schema_and_index(self, index: Optional[str] = None, recreate_index:
                                 "name": self.content_field,
                             },
                         ],
+                        "vectorIndexConfig": {"distance": self.similarity},
                     }
                 ]
             }
+
         if not self.weaviate_client.schema.contains(schema):
             self.weaviate_client.schema.create(schema)
         elif recreate_index and index is not None:
             self._delete_index(index)
             self.weaviate_client.schema.create(schema)
+        else:
+            # The index already exists in Weaviate. We need to check if the index's similarity metrics matches
+            # the one this class is initialized with, as Weaviate doesn't allow switching similarity
+            # metrics once an index alreadty exists in Weaviate.
+            _db_similarity = self.weaviate_client.schema.get(class_name=index)["vectorIndexConfig"]["distance"]
+            if _db_similarity != self.similarity:
+                raise ValueError(
+                    f"This index already exists in Weaviate with similarity '{_db_similarity}'. "
+                    f"If there is a Weaviate index created with a certain similarity, you can't "
+                    f"query with a different similarity. If you need a different similarity, "
+                    f"recreate the index. To do this, set the `recreate_index=True` argument."
+                )
 
     def _convert_weaviate_result_to_document(
         self, result: dict, return_embedding: bool, scale_score: bool = True
@@ -243,6 +262,15 @@ def _convert_weaviate_result_to_document(
                 # weaviate returns already scaled values
                 if score and not scale_score:
                     score = score * 2 - 1
+            elif "distance" in props["_additional"]:
+                score = props["_additional"]["distance"]
+                if score:
+                    # Weaviate returns the negative dot product. To make score comparable
+                    # to other document stores, we take the negative.
+                    if self.similarity == "dot":
+                        score = -1 * score
+                    if scale_score:
+                        score = self.scale_to_unit_interval(score, self.similarity)
             if "id" in props["_additional"]:
                 id = props["_additional"]["id"]
             if "vector" in props["_additional"]:
@@ -679,7 +707,10 @@ def _get_all_documents_in_index(
 
         # Build the properties to retrieve from Weaviate
         properties = self._get_current_properties(index)
-        properties.append("_additional {id, certainty, vector}")
+        if self.similarity == "cosine":
+            properties.append("_additional {id, certainty, vector}")
+        else:
+            properties.append("_additional {id, distance, vector}")
 
         if filters:
             filter_dict = LogicalFilterClause.parse(filters).convert_to_weaviate()
@@ -908,7 +939,10 @@ def query(
 
         # Build the properties to retrieve from Weaviate
         properties = self._get_current_properties(index)
-        properties.append("_additional {id, certainty, vector}")
+        if self.similarity == "cosine":
+            properties.append("_additional {id, certainty, vector}")
+        else:
+            properties.append("_additional {id, distance, vector}")
 
         if query is None:
 
@@ -1084,7 +1118,10 @@ def query_by_embedding(
 
         # Build the properties to retrieve from Weaviate
         properties = self._get_current_properties(index)
-        properties.append("_additional {id, certainty, vector}")
+        if self.similarity == "cosine":
+            properties.append("_additional {id, certainty, vector}")
+        else:
+            properties.append("_additional {id, distance, vector}")
 
         if self.similarity == "cosine":
             self.normalize_embedding(query_emb)


Test patch:
diff --git a/test/conftest.py b/test/conftest.py
--- a/test/conftest.py
+++ b/test/conftest.py
@@ -808,7 +808,7 @@ def document_store_dot_product(request, tmp_path, monkeypatch):
     document_store.delete_index(document_store.index)
 
 
-@pytest.fixture(params=["memory", "faiss", "milvus1", "milvus", "elasticsearch", "pinecone"])
+@pytest.fixture(params=["memory", "faiss", "milvus1", "milvus", "elasticsearch", "pinecone", "weaviate"])
 def document_store_dot_product_with_docs(request, docs, tmp_path, monkeypatch):
     if request.param == "pinecone":
         mock_pinecone(monkeypatch)
@@ -907,6 +907,7 @@ def get_document_store(
     embedding_field="embedding",
     index="haystack_test",
     similarity: str = "cosine",
+    recreate_index: bool = True,
 ):  # cosine is default similarity as dot product is not supported by Weaviate
     if document_store_type == "sql":
         document_store = SQLDocumentStore(url=get_sql_url(tmp_path), index=index, isolation_level="AUTOCOMMIT")
@@ -928,7 +929,7 @@ def get_document_store(
             embedding_dim=embedding_dim,
             embedding_field=embedding_field,
             similarity=similarity,
-            recreate_index=True,
+            recreate_index=recreate_index,
         )
 
     elif document_store_type == "faiss":
@@ -962,12 +963,12 @@ def get_document_store(
             index=index,
             similarity=similarity,
             isolation_level="AUTOCOMMIT",
-            recreate_index=True,
+            recreate_index=recreate_index,
         )
 
     elif document_store_type == "weaviate":
         document_store = WeaviateDocumentStore(
-            index=index, similarity=similarity, embedding_dim=embedding_dim, recreate_index=True
+            index=index, similarity=similarity, embedding_dim=embedding_dim, recreate_index=recreate_index
         )
 
     elif document_store_type == "pinecone":
@@ -977,7 +978,7 @@ def get_document_store(
             embedding_field=embedding_field,
             index=index,
             similarity=similarity,
-            recreate_index=True,
+            recreate_index=recreate_index,
         )
 
     else:
diff --git a/test/document_stores/test_document_store.py b/test/document_stores/test_document_store.py
--- a/test/document_stores/test_document_store.py
+++ b/test/document_stores/test_document_store.py
@@ -1459,7 +1459,7 @@ def test_similarity_score_without_scaling(document_store_with_docs):
 
 
 @pytest.mark.parametrize(
-    "document_store_dot_product_with_docs", ["memory", "faiss", "milvus1", "elasticsearch"], indirect=True
+    "document_store_dot_product_with_docs", ["memory", "faiss", "milvus1", "elasticsearch", "weaviate"], indirect=True
 )
 @pytest.mark.embedding_dim(384)
 def test_similarity_score_dot_product(document_store_dot_product_with_docs):
@@ -1478,7 +1478,7 @@ def test_similarity_score_dot_product(document_store_dot_product_with_docs):
 
 
 @pytest.mark.parametrize(
-    "document_store_dot_product_with_docs", ["memory", "faiss", "milvus1", "elasticsearch"], indirect=True
+    "document_store_dot_product_with_docs", ["memory", "faiss", "milvus1", "elasticsearch", "weaviate"], indirect=True
 )
 @pytest.mark.embedding_dim(384)
 def test_similarity_score_dot_product_without_scaling(document_store_dot_product_with_docs):
diff --git a/test/document_stores/test_weaviate.py b/test/document_stores/test_weaviate.py
--- a/test/document_stores/test_weaviate.py
+++ b/test/document_stores/test_weaviate.py
@@ -144,3 +144,18 @@ def test_deleting_by_id_or_by_filters(document_store_with_docs):
     document_store_with_docs.delete_documents(filters={"name": ["filename2"]})
     document_store_with_docs.get_all_documents.assert_called()
     assert document_store_with_docs.get_document_count() == 1
+
+
+@pytest.mark.weaviate
+@pytest.mark.parametrize("similarity", ["cosine", "l2", "dot_product"])
+def test_similarity_existing_index(tmp_path, similarity):
+    """Testing non-matching similarity"""
+    # create the document_store
+    document_store = get_document_store("weaviate", tmp_path, similarity=similarity, recreate_index=True)
+
+    # try to connect to the same document store but using the wrong similarity
+    non_matching_similarity = "l2" if similarity == "cosine" else "cosine"
+    with pytest.raises(ValueError, match=r"This index already exists in Weaviate with similarity .*"):
+        document_store2 = get_document_store(
+            "weaviate", tmp_path, similarity=non_matching_similarity, recreate_index=False
+        )