Repository: deepset-ai/haystack

Issue:
Error with classification with PineconeDocumentStore
**Describe the bug**
When running [this notebook](https://gist.github.com/jamescalam/3d5a219226b46539afbda8982b2bb2dd) which uses classification + PineconeDocumentStore an error is thrown during `write_documents`.

The error is caused because the classification pipeline produces a multilevel dictionary that is then indexed in Pinecone, however, Pinecone does not support multilevel metadata dictionaries.

**Error message**
```
ApiException: (400)
Reason: Bad Request
HTTP response headers: HTTPHeaderDict({'content-type': 'application/json', 'date': 'Thu, 01 Dec 2022 19:33:51 GMT', 'x-envoy-upstream-service-time': '1', 'content-length': '301', 'server': 'envoy'})
HTTP response body: {"code":3,"message":"value must be a string, number, boolean or list of strings, got {\"details\":{\"history\":0.1159370169043541,\"music\":0.8191020488739014,\"natural language processing\":0.06496085971593857},\"label\":\"music\",\"score\":0.8191020488739014} for field classification","details":[]}
```

**Expected behavior**
No error should be raised. Multilevel metadata dictionaries should be indexed.

**Additional context**
Add any other context about the problem here, like document types / preprocessing steps / settings of reader etc.

**To Reproduce**
Follow [this notebook](https://gist.github.com/jamescalam/3d5a219226b46539afbda8982b2bb2dd)

**FAQ Check**
- [x] Have you had a look at [our new FAQ page](https://docs.haystack.deepset.ai/docs/faq)?

**System:**
 - OS: Colab
 - GPU/CPU: GPU
 - Haystack version (commit or version number): 1.12
 - DocumentStore: PineconeDocumentStore
 - Reader: N/A
 - Retriever: N/A



Discussion:

Resolving patch:
diff --git a/haystack/document_stores/pinecone.py b/haystack/document_stores/pinecone.py
--- a/haystack/document_stores/pinecone.py
+++ b/haystack/document_stores/pinecone.py
@@ -2,6 +2,8 @@
 
 import logging
 from itertools import islice
+from functools import reduce
+import operator
 
 import pinecone
 import numpy as np
@@ -24,6 +26,16 @@ def _sanitize_index_name(index: Optional[str]) -> Optional[str]:
     return None
 
 
+def _get_by_path(root, items):
+    """Access a nested object in root by item sequence."""
+    return reduce(operator.getitem, items, root)
+
+
+def _set_by_path(root, items, value):
+    """Set a value in a nested object in root by item sequence."""
+    _get_by_path(root, items[:-1])[items[-1]] = value
+
+
 class PineconeDocumentStore(BaseDocumentStore):
     """
     Document store for very large scale embedding based dense retrievers like the DPR. This is a hosted document store,
@@ -394,7 +406,7 @@ def write_documents(
                                 # Otherwise, we raise an error
                                 raise DuplicateDocumentError(f"Duplicate document IDs found in batch: {ids}")
                     metadata = [
-                        {"content": doc.content, "content_type": doc.content_type, **doc.meta}
+                        self._meta_for_pinecone({"content": doc.content, "content_type": doc.content_type, **doc.meta})
                         for doc in document_objects[i : i + batch_size]
                     ]
                     if add_vectors:
@@ -503,7 +515,9 @@ def update_embeddings(
                 metadata = []
                 ids = []
                 for doc in document_batch:
-                    metadata.append({"content": doc.content, "content_type": doc.content_type, **doc.meta})
+                    metadata.append(
+                        self._meta_for_pinecone({"content": doc.content, "content_type": doc.content_type, **doc.meta})
+                    )
                     ids.append(doc.id)
                 # Update existing vectors in pinecone index
                 self.pinecone_indexes[index].upsert(
@@ -810,7 +824,7 @@ def get_documents_by_id(
             embedding_matrix = []
             for _id in result["vectors"].keys():
                 vector_id_matrix.append(_id)
-                meta_matrix.append(result["vectors"][_id]["metadata"])
+                meta_matrix.append(self._pinecone_meta_format(result["vectors"][_id]["metadata"]))
                 if return_embedding:
                     embedding_matrix.append(result["vectors"][_id]["values"])
             if return_embedding:
@@ -1317,25 +1331,79 @@ def load(cls):
         """
         raise NotImplementedError("load method not supported for PineconeDocumentStore")
 
-    def _meta_for_pinecone(self, meta: Dict[str, Any]) -> Dict[str, Any]:
+    def _meta_for_pinecone(self, meta: Dict[str, Any], parent_key: str = "", labels: bool = False) -> Dict[str, Any]:
         """
         Converts the meta dictionary to a format that can be stored in Pinecone.
+        :param meta: Metadata dictionary to be converted.
+        :param parent_key: Optional, used for recursive calls to keep track of parent keys, for example:
+            ```
+            {"parent1": {"parent2": {"child": "value"}}}
+            ```
+            On the second recursive call, parent_key would be "parent1", and the final key would be "parent1.parent2.child".
+        :param labels: Optional, used to indicate whether the metadata is being stored as a label or not. If True the
+            the flattening of dictionaries is not required.
         """
-        # Replace any None values with empty strings
-        for key, value in meta.items():
-            if value is None:
-                meta[key] = ""
+        items: list = []
+        if labels:
+            # Replace any None values with empty strings
+            for key, value in meta.items():
+                if value is None:
+                    meta[key] = ""
+        else:
+            # Explode dict of dicts into single flattened dict
+            for key, value in meta.items():
+                # Replace any None values with empty strings
+                if value == None:
+                    value = ""
+                # format key
+                new_key = f"{parent_key}.{key}" if parent_key else key
+                # if value is dict, expand
+                if isinstance(value, dict):
+                    items.extend(self._meta_for_pinecone(value, parent_key=new_key).items())
+                else:
+                    items.append((new_key, value))
+            # Create new flattened dictionary
+            meta = dict(items)
         return meta
 
-    def _pinecone_meta_format(self, meta: Dict[str, Any]) -> Dict[str, Any]:
+    def _pinecone_meta_format(self, meta: Dict[str, Any], labels: bool = False) -> Dict[str, Any]:
         """
         Converts the meta extracted from Pinecone into a better format for Python.
+        :param meta: Metadata dictionary to be converted.
+        :param labels: Optional, used to indicate whether the metadata is being stored as a label or not. If True the
+            the flattening of dictionaries is not required.
         """
-        # Replace any empty strings with None values
-        for key, value in meta.items():
-            if value == "":
-                meta[key] = None
-        return meta
+        new_meta: Dict[str, Any] = {}
+
+        if labels:
+            # Replace any empty strings with None values
+            for key, value in meta.items():
+                if value == "":
+                    meta[key] = None
+            return meta
+        else:
+            for key, value in meta.items():
+                # Replace any empty strings with None values
+                if value == "":
+                    value = None
+                if "." in key:
+                    # We must split into nested dictionary
+                    keys = key.split(".")
+                    # Iterate through each dictionary level
+                    for i in range(len(keys)):
+                        path = keys[: i + 1]
+                        # Check if path exists
+                        try:
+                            _get_by_path(new_meta, path)
+                        except KeyError:
+                            # Create path
+                            if i == len(keys) - 1:
+                                _set_by_path(new_meta, path, value)
+                            else:
+                                _set_by_path(new_meta, path, {})
+                else:
+                    new_meta[key] = value
+            return new_meta
 
     def _label_to_meta(self, labels: list) -> dict:
         """
@@ -1390,7 +1458,7 @@ def _label_to_meta(self, labels: list) -> dict:
                     meta["label-answer-offsets-in-context-start"] = None
                     meta["label-answer-offsets-in-context-end"] = None
             metadata[label.id] = meta
-        metadata = self._meta_for_pinecone(metadata)
+        metadata = self._meta_for_pinecone(metadata, labels=True)
         return metadata
 
     def _meta_to_labels(self, documents: List[Document]) -> List[Label]:
@@ -1530,7 +1598,7 @@ def get_all_labels(self, index=None, filters: Optional[dict] = None, headers: Op
 
         documents = self.get_all_documents(index=index, filters=filters, headers=headers, namespace="labels")
         for doc in documents:
-            doc.meta = self._pinecone_meta_format(doc.meta)
+            doc.meta = self._pinecone_meta_format(doc.meta, labels=True)
         labels = self._meta_to_labels(documents)
         return labels
 


Test patch:
diff --git a/test/document_stores/test_pinecone.py b/test/document_stores/test_pinecone.py
--- a/test/document_stores/test_pinecone.py
+++ b/test/document_stores/test_pinecone.py
@@ -402,3 +402,18 @@ def test_get_all_documents_extended_filter_compound_same_level_not(
 
         with pytest.raises(FilterError, match="Comparison value for '\$[l|g]te' operation must be a float or int."):
             doc_store_with_docs.get_all_documents(filters=filters)
+
+    @pytest.mark.integration
+    def test_multilayer_dict(self, doc_store_with_docs: PineconeDocumentStore):
+        # Test that multilayer dict can be upserted
+        multilayer_meta = {
+            "parent1": {"parent2": {"parent3": {"child1": 1, "child2": 2}}},
+            "meta_field": "multilayer-test",
+        }
+        doc = Document(content=f"Multilayered dict", meta=multilayer_meta, embedding=[0.0] * 768)
+
+        doc_store_with_docs.write_documents([doc])
+        retrieved_docs = doc_store_with_docs.get_all_documents(filters={"meta_field": {"$eq": "multilayer-test"}})
+
+        assert len(retrieved_docs) == 1
+        assert retrieved_docs[0].meta == multilayer_meta