Repository: deepset-ai/haystack

Issue:
Document Embedders modify Documents in place
### Current behavior of Document Embedders
```python
from haystack.components.embedders import SentenceTransformersDocumentEmbedder
from haystack import Document

docs = [Document(content="Hello world")]

embedder = SentenceTransformersDocumentEmbedder(model="sentence-transformers/all-MiniLM-L6-v2")
embedder.warm_up()

docs_w_embeddings = embedder.run(docs)["documents"]

print("original docs: ", docs)
# original docs:  [Document(id=dd4..., content: 'Hello world', embedding: vector of size 384)]
print("docs with embeddings: ", docs_w_embeddings)
# docs with embeddings:  [Document(id=dd43..., content: 'Hello world', embedding: vector of size 384)]
```


### Problem and potential solutions
I find it surprising.
In short, when Document Embedders are run not in a Pipeline, they modify the existing Documents in place.

> If you run the embedder in `Pipeline` then the documents are not modified in place because we always deepcopy inputs and outputs between components.

I would either:
- modify the Document Embedders to use a copy of the Documents
- explain clearly that Documents are modified in place.

To be discussed.

@sjrl 


Discussion:
Some potential solutions to this:
- Use deepcopy like we do in Pipeline
- Could also do a shallow copy and utilize the `from dataclasses import replace` function to create a new dataclass object (so `Document` in this case) and only update the fields that are being changed. For example,
```python
                new_meta = {
                    **document.meta,
                    "new_key": new_value,
                }
                # We use replace to ensure we don't modify the original document
                new_documents.append(replace(document, meta=new_meta))
```
The same attitude happen in `SentenceTransformersDocumentEmbedder`, `OpenAIDocumentEmbedder`, `AzureOpenAIDocumentEmbedder`, and `HuggingFaceAPIDocumentEmbedder`. 
However in the `SentenceTransformersDocumentImageEmbedder` the embedding is not saved into the documents `embedding_source` attribute is added to the meta.
 ```python
from haystack import Document
from haystack.components.embedders.image import SentenceTransformersDocumentImageEmbedder

embedder = SentenceTransformersDocumentImageEmbedder(model="sentence-transformers/clip-ViT-B-32")
embedder.warm_up()

documents = [
    Document(content="A photo of a cat", meta={"file_path": "1920px-CatVibrissaeFullFace.JPG"}),
]

result = embedder.run(documents=documents)
documents_with_embeddings = result["documents"]
print(documents)
# [Document(id=87de6e126e30d3f6d4e7e85396f86058fdbff00edcf34ee55ae85fc3c8cee081, content: 'A photo of a cat', meta: {'file_path': '1920px-CatVibrissaeFullFace.JPG', 'embedding_source': {'type': 'image', 'file_path_meta_field': 'file_path'}})]
```

I can use deepcopy for all of them to make it consistent with what is expected just like pipline. If you have no objection I'll start PR to solve this




Patch:
diff --git a/haystack/components/embedders/hugging_face_api_document_embedder.py b/haystack/components/embedders/hugging_face_api_document_embedder.py
index a0a95abdfa..45d04af638 100644
--- a/haystack/components/embedders/hugging_face_api_document_embedder.py
+++ b/haystack/components/embedders/hugging_face_api_document_embedder.py
@@ -2,6 +2,7 @@
 #
 # SPDX-License-Identifier: Apache-2.0
 
+from dataclasses import replace
 from typing import Any, Optional, Union
 
 from tqdm import tqdm
@@ -328,10 +329,11 @@ def run(self, documents: list[Document]):
 
         embeddings = self._embed_batch(texts_to_embed=texts_to_embed, batch_size=self.batch_size)
 
+        new_documents = []
         for doc, emb in zip(documents, embeddings):
-            doc.embedding = emb
+            new_documents.append(replace(doc, embedding=emb))
 
-        return {"documents": documents}
+        return {"documents": new_documents}
 
     @component.output_types(documents=list[Document])
     async def run_async(self, documents: list[Document]):
@@ -355,7 +357,8 @@ async def run_async(self, documents: list[Document]):
 
         embeddings = await self._embed_batch_async(texts_to_embed=texts_to_embed, batch_size=self.batch_size)
 
+        new_documents = []
         for doc, emb in zip(documents, embeddings):
-            doc.embedding = emb
+            new_documents.append(replace(doc, embedding=emb))
 
-        return {"documents": documents}
+        return {"documents": new_documents}
diff --git a/haystack/components/embedders/image/sentence_transformers_doc_image_embedder.py b/haystack/components/embedders/image/sentence_transformers_doc_image_embedder.py
index 1d7d01e8df..9cea6713c0 100644
--- a/haystack/components/embedders/image/sentence_transformers_doc_image_embedder.py
+++ b/haystack/components/embedders/image/sentence_transformers_doc_image_embedder.py
@@ -2,7 +2,7 @@
 #
 # SPDX-License-Identifier: Apache-2.0
 
-from copy import copy
+from dataclasses import replace
 from typing import Any, Literal, Optional
 
 from haystack import Document, component, default_from_dict, default_to_dict
@@ -281,10 +281,12 @@ def run(self, documents: list[Document]) -> dict[str, list[Document]]:
 
         docs_with_embeddings = []
         for doc, emb in zip(documents, embeddings):
-            copied_doc = copy(doc)
-            copied_doc.embedding = emb
             # we store this information for later inspection
-            copied_doc.meta["embedding_source"] = {"type": "image", "file_path_meta_field": self.file_path_meta_field}
-            docs_with_embeddings.append(copied_doc)
+            new_meta = {
+                **doc.meta,
+                "embedding_source": {"type": "image", "file_path_meta_field": self.file_path_meta_field},
+            }
+            new_doc = replace(doc, meta=new_meta, embedding=emb)
+            docs_with_embeddings.append(new_doc)
 
         return {"documents": docs_with_embeddings}
diff --git a/haystack/components/embedders/openai_document_embedder.py b/haystack/components/embedders/openai_document_embedder.py
index 4c3b8682bd..dde8df063c 100644
--- a/haystack/components/embedders/openai_document_embedder.py
+++ b/haystack/components/embedders/openai_document_embedder.py
@@ -3,6 +3,7 @@
 # SPDX-License-Identifier: Apache-2.0
 
 import os
+from dataclasses import replace
 from typing import Any, Optional
 
 from more_itertools import batched
@@ -307,11 +308,14 @@ def run(self, documents: list[Document]):
 
         doc_ids_to_embeddings, meta = self._embed_batch(texts_to_embed=texts_to_embed, batch_size=self.batch_size)
 
-        doc_id_to_document = {doc.id: doc for doc in documents}
-        for doc_id, emb in doc_ids_to_embeddings.items():
-            doc_id_to_document[doc_id].embedding = emb
+        new_documents = []
+        for doc in documents:
+            if doc.id in doc_ids_to_embeddings:
+                new_documents.append(replace(doc, embedding=doc_ids_to_embeddings[doc.id]))
+            else:
+                new_documents.append(replace(doc))
 
-        return {"documents": list(doc_id_to_document.values()), "meta": meta}
+        return {"documents": new_documents, "meta": meta}
 
     @component.output_types(documents=list[Document], meta=dict[str, Any])
     async def run_async(self, documents: list[Document]):
@@ -338,8 +342,11 @@ async def run_async(self, documents: list[Document]):
             texts_to_embed=texts_to_embed, batch_size=self.batch_size
         )
 
-        doc_id_to_document = {doc.id: doc for doc in documents}
-        for doc_id, emb in doc_ids_to_embeddings.items():
-            doc_id_to_document[doc_id].embedding = emb
+        new_documents = []
+        for doc in documents:
+            if doc.id in doc_ids_to_embeddings:
+                new_documents.append(replace(doc, embedding=doc_ids_to_embeddings[doc.id]))
+            else:
+                new_documents.append(replace(doc))
 
-        return {"documents": list(doc_id_to_document.values()), "meta": meta}
+        return {"documents": new_documents, "meta": meta}
diff --git a/haystack/components/embedders/sentence_transformers_document_embedder.py b/haystack/components/embedders/sentence_transformers_document_embedder.py
index 798c9dfb24..8b0a9c2cf6 100644
--- a/haystack/components/embedders/sentence_transformers_document_embedder.py
+++ b/haystack/components/embedders/sentence_transformers_document_embedder.py
@@ -2,6 +2,7 @@
 #
 # SPDX-License-Identifier: Apache-2.0
 
+from dataclasses import replace
 from typing import Any, Literal, Optional
 
 from haystack import Document, component, default_from_dict, default_to_dict
@@ -257,7 +258,8 @@ def run(self, documents: list[Document]):
             **(self.encode_kwargs if self.encode_kwargs else {}),
         )
 
+        new_documents = []
         for doc, emb in zip(documents, embeddings):
-            doc.embedding = emb
+            new_documents.append(replace(doc, embedding=emb))
 
-        return {"documents": documents}
+        return {"documents": new_documents}
diff --git a/releasenotes/notes/fix-embedder-deepcopy-c21216149f700993.yaml b/releasenotes/notes/fix-embedder-deepcopy-c21216149f700993.yaml
new file mode 100644
index 0000000000..81d0bad3a8
--- /dev/null
+++ b/releasenotes/notes/fix-embedder-deepcopy-c21216149f700993.yaml
@@ -0,0 +1,5 @@
+---
+fixes:
+  - |
+    Prevented in-place mutation of input `Document` objects in all `DocumentEmbedder` components
+    by creating copies with `dataclasses.replace` before processing.


Test patch:
diff --git a/test/components/embedders/image/test_sentence_transformers_doc_image_embedder.py b/test/components/embedders/image/test_sentence_transformers_doc_image_embedder.py
index 561af498b2..2e6b1525b3 100644
--- a/test/components/embedders/image/test_sentence_transformers_doc_image_embedder.py
+++ b/test/components/embedders/image/test_sentence_transformers_doc_image_embedder.py
@@ -209,13 +209,16 @@ def test_run(self, test_files_path):
 
         assert isinstance(result["documents"], list)
         assert len(result["documents"]) == len(documents)
-        for doc in result["documents"]:
-            assert isinstance(doc, Document)
-            assert isinstance(doc.embedding, list)
-            assert isinstance(doc.embedding[0], float)
-            assert "embedding_source" in doc.meta
-            assert doc.meta["embedding_source"]["type"] == "image"
-            assert "file_path_meta_field" in doc.meta["embedding_source"]
+        for doc, new_doc in zip(documents, result["documents"]):
+            assert doc.embedding is None
+            assert new_doc is not doc
+            assert isinstance(new_doc, Document)
+            assert isinstance(new_doc.embedding, list)
+            assert isinstance(new_doc.embedding[0], float)
+            assert "embedding_source" not in doc.meta
+            assert "embedding_source" in new_doc.meta
+            assert new_doc.meta["embedding_source"]["type"] == "image"
+            assert "file_path_meta_field" in new_doc.meta["embedding_source"]
 
     def test_run_no_warmup(self):
         embedder = SentenceTransformersDocumentImageEmbedder(model="model")
@@ -338,11 +341,14 @@ def test_live_run(self, test_files_path, monkeypatch):
 
         result = embedder.run(documents=documents)
         assert len(result["documents"]) == len(documents)
-        for doc in result["documents"]:
-            assert isinstance(doc, Document)
-            assert isinstance(doc.embedding, list)
-            assert len(doc.embedding) == 512
-            assert all(isinstance(x, float) for x in doc.embedding)
-            assert "embedding_source" in doc.meta
-            assert doc.meta["embedding_source"]["type"] == "image"
-            assert "file_path_meta_field" in doc.meta["embedding_source"]
+        for doc, new_doc in zip(documents, result["documents"]):
+            assert doc.embedding is None
+            assert new_doc is not doc
+            assert isinstance(new_doc, Document)
+            assert isinstance(new_doc.embedding, list)
+            assert len(new_doc.embedding) == 512
+            assert all(isinstance(x, float) for x in new_doc.embedding)
+            assert "embedding_source" not in doc.meta
+            assert "embedding_source" in new_doc.meta
+            assert new_doc.meta["embedding_source"]["type"] == "image"
+            assert "file_path_meta_field" in new_doc.meta["embedding_source"]
diff --git a/test/components/embedders/test_azure_document_embedder.py b/test/components/embedders/test_azure_document_embedder.py
index d320ad3446..a6cc113f38 100644
--- a/test/components/embedders/test_azure_document_embedder.py
+++ b/test/components/embedders/test_azure_document_embedder.py
@@ -265,11 +265,13 @@ def test_run(self):
 
         assert isinstance(documents_with_embeddings, list)
         assert len(documents_with_embeddings) == len(docs)
-        for doc in documents_with_embeddings:
-            assert isinstance(doc, Document)
-            assert isinstance(doc.embedding, list)
-            assert len(doc.embedding) == 1536
-            assert all(isinstance(x, float) for x in doc.embedding)
+        for doc, new_doc in zip(docs, documents_with_embeddings):
+            assert doc.embedding is None
+            assert new_doc is not doc
+            assert isinstance(new_doc, Document)
+            assert isinstance(new_doc.embedding, list)
+            assert len(new_doc.embedding) == 1536
+            assert all(isinstance(x, float) for x in new_doc.embedding)
 
         assert metadata["usage"]["prompt_tokens"] == 15
         assert metadata["usage"]["total_tokens"] == 15
diff --git a/test/components/embedders/test_hugging_face_api_document_embedder.py b/test/components/embedders/test_hugging_face_api_document_embedder.py
index 7311716e84..1fdde5c5c2 100644
--- a/test/components/embedders/test_hugging_face_api_document_embedder.py
+++ b/test/components/embedders/test_hugging_face_api_document_embedder.py
@@ -287,7 +287,6 @@ def test_run(self, mock_check_valid_model):
             Document(content="I love cheese", meta={"topic": "Cuisine"}),
             Document(content="A transformer is a deep learning architecture", meta={"topic": "ML"}),
         ]
-
         with patch("huggingface_hub.InferenceClient.feature_extraction") as mock_embedding_patch:
             mock_embedding_patch.side_effect = mock_embedding_generation
 
@@ -316,11 +315,13 @@ def test_run(self, mock_check_valid_model):
 
         assert isinstance(documents_with_embeddings, list)
         assert len(documents_with_embeddings) == len(docs)
-        for doc in documents_with_embeddings:
-            assert isinstance(doc, Document)
-            assert isinstance(doc.embedding, list)
-            assert len(doc.embedding) == 384
-            assert all(isinstance(x, float) for x in doc.embedding)
+        for doc, new_doc in zip(docs, documents_with_embeddings):
+            assert doc.embedding is None
+            assert new_doc is not doc
+            assert isinstance(new_doc, Document)
+            assert isinstance(new_doc.embedding, list)
+            assert len(new_doc.embedding) == 384
+            assert all(isinstance(x, float) for x in new_doc.embedding)
 
     def test_run_custom_batch_size(self, mock_check_valid_model):
         docs = [
diff --git a/test/components/embedders/test_openai_document_embedder.py b/test/components/embedders/test_openai_document_embedder.py
index b9934834f6..3f0e0375f9 100644
--- a/test/components/embedders/test_openai_document_embedder.py
+++ b/test/components/embedders/test_openai_document_embedder.py
@@ -281,11 +281,13 @@ def test_run(self):
 
         assert isinstance(documents_with_embeddings, list)
         assert len(documents_with_embeddings) == len(docs)
-        for doc in documents_with_embeddings:
-            assert isinstance(doc, Document)
-            assert isinstance(doc.embedding, list)
-            assert len(doc.embedding) == 1536
-            assert all(isinstance(x, float) for x in doc.embedding)
+        for doc, new_doc in zip(docs, documents_with_embeddings):
+            assert doc.embedding is None
+            assert new_doc is not doc
+            assert isinstance(new_doc, Document)
+            assert isinstance(new_doc.embedding, list)
+            assert len(new_doc.embedding) == 1536
+            assert all(isinstance(x, float) for x in new_doc.embedding)
 
         assert "text" in result["meta"]["model"] and "ada" in result["meta"]["model"], (
             "The model name does not contain 'text' and 'ada'"
@@ -311,11 +313,13 @@ async def test_run_async(self):
 
         assert isinstance(documents_with_embeddings, list)
         assert len(documents_with_embeddings) == len(docs)
-        for doc in documents_with_embeddings:
-            assert isinstance(doc, Document)
-            assert isinstance(doc.embedding, list)
-            assert len(doc.embedding) == 1536
-            assert all(isinstance(x, float) for x in doc.embedding)
+        for doc, new_doc in zip(docs, documents_with_embeddings):
+            assert doc.embedding is None
+            assert new_doc is not doc
+            assert isinstance(new_doc, Document)
+            assert isinstance(new_doc.embedding, list)
+            assert len(new_doc.embedding) == 1536
+            assert all(isinstance(x, float) for x in new_doc.embedding)
 
         assert "text" in result["meta"]["model"] and "ada" in result["meta"]["model"], (
             "The model name does not contain 'text' and 'ada'"
diff --git a/test/components/embedders/test_sentence_transformers_document_embedder.py b/test/components/embedders/test_sentence_transformers_document_embedder.py
index 8bf23e8a8a..61006d28f8 100644
--- a/test/components/embedders/test_sentence_transformers_document_embedder.py
+++ b/test/components/embedders/test_sentence_transformers_document_embedder.py
@@ -293,10 +293,12 @@ def test_run(self):
 
         assert isinstance(result["documents"], list)
         assert len(result["documents"]) == len(documents)
-        for doc in result["documents"]:
-            assert isinstance(doc, Document)
-            assert isinstance(doc.embedding, list)
-            assert isinstance(doc.embedding[0], float)
+        for doc, new_doc in zip(documents, result["documents"]):
+            assert new_doc is not doc
+            assert doc.embedding is None
+            assert isinstance(new_doc, Document)
+            assert isinstance(new_doc.embedding, list)
+            assert isinstance(new_doc.embedding[0], float)
 
     def test_run_wrong_input_format(self):
         embedder = SentenceTransformersDocumentEmbedder(model="model")