Repository: deepset-ai/haystack

Issue:
`NamedEntityExtractor`
Very similar to our Haystack 1.x `EntityExtractor`: given some text, extracts all named entities it can find. It will take Documents and attach those entities into the Document’s metadata (together with the entity’s position in the original text).

Draft I/O for `NamedEntityExtractor`:
```python
@component
class NamedEntityExtractor:
    
    def __init__(self, model_name_or_path: str, entities: List[str]):
        self.model_name_or_path = model_name_or_path

    def warm_up(self):
        # init the model

    @component.output_types(documents=List[Document])
    def run(self, documents: List[Document]):
        # extracts the entities and puts them in the docs metadata
        return {"documents": documents}
```


Discussion:
CLAassistant: [![CLA assistant check](https://cla-assistant.io/pull/badge/not_signed)](https://cla-assistant.io/deepset-ai/haystack?pullRequest=6689) <br/>Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our [Contributor License Agreement](https://cla-assistant.io/deepset-ai/haystack?pullRequest=6689) before we can accept your contribution.<br/><sub>You have signed the CLA already but the status is still pending? Let us [recheck](https://cla-assistant.io/check/deepset-ai/haystack?pullRequest=6689) it.</sub>
shadeMe: The integration test failures seem to be unrelated to this PR (missing OpenAI secrets, etc), so I'll mark this as ready for reviewing.
vblagoje: @masci as I'll be on PTO, can you please assign another reviewer to go over some minor points in the review? Or perhaps personally review it yourself. TIA 🙏 
coveralls: ## Pull Request Test Coverage Report for [Build 7459268439](https://coveralls.io/builds/64956292)


* **0** of **0**   changed or added relevant lines in **0** files are covered.
* No unchanged relevant lines lost coverage.
* Overall coverage decreased (**-0.6%**) to **86.767%**

---



|  Totals | [![Coverage Status](https://coveralls.io/builds/64956292/badge)](https://coveralls.io/builds/64956292) |
| :-- | --: |
| Change from base [Build 7458712685](https://coveralls.io/builds/64955309): |  -0.6% |
| Covered Lines: | 4170 |
| Relevant Lines: | 4806 |

---
##### 💛  - [Coveralls](https://coveralls.io)


Patch:
diff --git a/examples/pipelines/indexing_pipeline_with_meta.py b/examples/pipelines/indexing_pipeline_with_meta.py
new file mode 100644
index 00000000..01990564
--- /dev/null
+++ b/examples/pipelines/indexing_pipeline_with_meta.py
@@ -0,0 +1,43 @@
+from typing import Dict, Any
+from pathlib import Path
+from datetime import datetime
+
+from haystack import Pipeline
+from haystack.components.others import Multiplexer
+from haystack.components.converters import PyPDFToDocument, TextFileToDocument
+from haystack.components.preprocessors import DocumentCleaner, DocumentSplitter
+from haystack.components.routers import FileTypeRouter, DocumentJoiner
+from haystack.components.writers import DocumentWriter
+from haystack.document_stores import InMemoryDocumentStore
+
+
+document_store = InMemoryDocumentStore()
+
+p = Pipeline()
+p.add_component(instance=FileTypeRouter(mime_types=["text/plain", "application/pdf"]), name="file_type_router")
+p.add_component(instance=Multiplexer(Dict[str, Any]), name="metadata_multiplexer")
+p.add_component(instance=TextFileToDocument(), name="text_file_converter")
+p.add_component(instance=PyPDFToDocument(), name="pdf_file_converter")
+p.add_component(instance=DocumentJoiner(), name="joiner")
+p.add_component(instance=DocumentCleaner(), name="cleaner")
+p.add_component(instance=DocumentSplitter(split_by="sentence", split_length=250, split_overlap=30), name="splitter")
+p.add_component(instance=DocumentWriter(document_store=document_store), name="writer")
+
+p.connect("file_type_router.text/plain", "text_file_converter.sources")
+p.connect("file_type_router.application/pdf", "pdf_file_converter.sources")
+p.connect("metadata_multiplexer", "text_file_converter.meta")
+p.connect("metadata_multiplexer", "pdf_file_converter.meta")
+p.connect("text_file_converter.documents", "joiner.documents")
+p.connect("pdf_file_converter.documents", "joiner.documents")
+p.connect("joiner.documents", "cleaner.documents")
+p.connect("cleaner.documents", "splitter.documents")
+p.connect("splitter.documents", "writer.documents")
+
+result = p.run(
+    {
+        "file_type_router": {"sources": list(Path(".").iterdir())},
+        "metadata_multiplexer": {"value": {"date_added": datetime.now().isoformat()}},
+    }
+)
+
+assert all("date_added" in doc.meta for doc in document_store.filter_documents())
diff --git a/haystack/components/converters/azure.py b/haystack/components/converters/azure.py
index 8c03fb9e..5e1d20ab 100644
--- a/haystack/components/converters/azure.py
+++ b/haystack/components/converters/azure.py
@@ -6,7 +6,7 @@ import os
 from haystack.lazy_imports import LazyImport
 from haystack import component, Document, default_to_dict
 from haystack.dataclasses import ByteStream
-from haystack.components.converters.utils import get_bytestream_from_source
+from haystack.components.converters.utils import get_bytestream_from_source, normalize_metadata
 
 logger = logging.getLogger(__name__)
 
@@ -31,7 +31,7 @@ class AzureOCRDocumentConverter:
     from haystack.components.converters.azure import AzureOCRDocumentConverter
 
     converter = AzureOCRDocumentConverter()
-    results = converter.run(sources=["image-based-document.pdf"])
+    results = converter.run(sources=["image-based-document.pdf"], meta={"date_added": datetime.now().isoformat()})
     documents = results["documents"]
     print(documents[0].content)
     # 'This is a text from the PDF file.'
@@ -76,20 +76,19 @@ class AzureOCRDocumentConverter:
         the raw responses from Azure's Document Intelligence service.
 
         :param sources: List of file paths or ByteStream objects.
-        :param meta: Optional list of metadata to attach to the Documents.
-          The length of the list must match the number of sources. Defaults to `None`.
+        :param meta: Optional metadata to attach to the Documents.
+          This value can be either a list of dictionaries or a single dictionary.
+          If it's a single dictionary, its content is added to the metadata of all produced Documents.
+          If it's a list, the length of the list must match the number of sources, because the two lists will be zipped.
+          Defaults to `None`.
         :return: A dictionary containing a list of Document objects under the 'documents' key
           and the raw Azure response under the 'raw_azure_response' key.
         """
         documents = []
         azure_output = []
+        meta_list = normalize_metadata(meta=meta, sources_count=len(sources))
 
-        if meta is None:
-            meta = [{}] * len(sources)
-        elif len(sources) != len(meta):
-            raise ValueError("The length of the metadata list must match the number of sources.")
-
-        for source, metadata in zip(sources, meta):
+        for source, metadata in zip(sources, meta_list):
             try:
                 bytestream = get_bytestream_from_source(source=source)
             except Exception as e:
diff --git a/haystack/components/converters/html.py b/haystack/components/converters/html.py
index 88230ce6..0586065c 100644
--- a/haystack/components/converters/html.py
+++ b/haystack/components/converters/html.py
@@ -50,7 +50,11 @@ class HTMLToDocument:
         self.extractor_type = extractor_type
 
     @component.output_types(documents=List[Document])
-    def run(self, sources: List[Union[str, Path, ByteStream]], meta: Optional[List[Dict[str, Any]]] = None):
+    def run(
+        self,
+        sources: List[Union[str, Path, ByteStream]],
+        meta: Optional[Union[Dict[str, Any], List[Dict[str, Any]]]] = None,
+    ):
         """
         Converts a list of HTML files to Documents.
 
diff --git a/haystack/components/converters/markdown.py b/haystack/components/converters/markdown.py
index c5c5a83a..9ba9756c 100644
--- a/haystack/components/converters/markdown.py
+++ b/haystack/components/converters/markdown.py
@@ -7,7 +7,7 @@ from tqdm import tqdm
 from haystack import Document, component
 from haystack.dataclasses import ByteStream
 from haystack.lazy_imports import LazyImport
-from haystack.components.converters.utils import get_bytestream_from_source
+from haystack.components.converters.utils import get_bytestream_from_source, normalize_metadata
 
 with LazyImport("Run 'pip install markdown-it-py mdit_plain'") as markdown_conversion_imports:
     from markdown_it import MarkdownIt
@@ -27,7 +27,7 @@ class MarkdownToDocument:
     from haystack.components.converters.markdown import MarkdownToDocument
 
     converter = MarkdownToDocument()
-    results = converter.run(sources=["sample.md"])
+    results = converter.run(sources=["sample.md"], meta={"date_added": datetime.now().isoformat()})
     documents = results["documents"]
     print(documents[0].content)
     # 'This is a text from the markdown file.'
@@ -45,13 +45,20 @@ class MarkdownToDocument:
         self.progress_bar = progress_bar
 
     @component.output_types(documents=List[Document])
-    def run(self, sources: List[Union[str, Path, ByteStream]], meta: Optional[List[Dict[str, Any]]] = None):
+    def run(
+        self,
+        sources: List[Union[str, Path, ByteStream]],
+        meta: Optional[Union[Dict[str, Any], List[Dict[str, Any]]]] = None,
+    ):
         """
         Reads text from a markdown file and executes optional preprocessing steps.
 
         :param sources: A list of markdown data sources (file paths or binary objects)
-        :param meta: Optional list of metadata to attach to the Documents.
-          The length of the list must match the number of paths. Defaults to `None`.
+        :param meta: Optional metadata to attach to the Documents.
+          This value can be either a list of dictionaries or a single dictionary.
+          If it's a single dictionary, its content is added to the metadata of all produced Documents.
+          If it's a list, the length of the list must match the number of sources, because the two lists will be zipped.
+          Defaults to `None`.
         :return: A dictionary containing a list of Document objects under the 'documents' key.
         """
         parser = MarkdownIt(renderer_cls=RendererPlain)
@@ -59,14 +66,10 @@ class MarkdownToDocument:
             parser.enable("table")
 
         documents = []
-
-        if meta is None:
-            meta = [{}] * len(sources)
-        elif len(sources) != len(meta):
-            raise ValueError("The length of the metadata list must match the number of sources.")
+        meta_list = normalize_metadata(meta=meta, sources_count=len(sources))
 
         for source, metadata in tqdm(
-            zip(sources, meta),
+            zip(sources, meta_list),
             total=len(sources),
             desc="Converting markdown files to Documents",
             disable=not self.progress_bar,
diff --git a/haystack/components/converters/pypdf.py b/haystack/components/converters/pypdf.py
index b14d0b75..021029e0 100644
--- a/haystack/components/converters/pypdf.py
+++ b/haystack/components/converters/pypdf.py
@@ -82,7 +82,11 @@ class PyPDFToDocument:
         return default_to_dict(self, converter_name=self.converter_name)
 
     @component.output_types(documents=List[Document])
-    def run(self, sources: List[Union[str, Path, ByteStream]], meta: Optional[List[Dict[str, Any]]] = None):
+    def run(
+        self,
+        sources: List[Union[str, Path, ByteStream]],
+        meta: Optional[Union[Dict[str, Any], List[Dict[str, Any]]]] = None,
+    ):
         """
         Converts a list of PDF sources into Document objects using the configured converter.
 
diff --git a/haystack/components/extractors/__init__.py b/haystack/components/extractors/__init__.py
new file mode 100644
index 00000000..e5df0e69
--- /dev/null
+++ b/haystack/components/extractors/__init__.py
@@ -0,0 +1,7 @@
+from haystack.components.extractors.named_entity_extractor import (
+    NamedEntityAnnotation,
+    NamedEntityExtractor,
+    NamedEntityExtractorBackend,
+)
+
+__all__ = ["NamedEntityExtractor", "NamedEntityExtractorBackend", "NamedEntityAnnotation"]
diff --git a/haystack/components/extractors/named_entity_extractor.py b/haystack/components/extractors/named_entity_extractor.py
new file mode 100644
index 00000000..70b99ab9
--- /dev/null
+++ b/haystack/components/extractors/named_entity_extractor.py
@@ -0,0 +1,429 @@
+from abc import ABC, abstractmethod
+from contextlib import contextmanager
+from dataclasses import dataclass
+from enum import Enum, EnumMeta
+from typing import Any, Dict, List, Optional, Union
+
+from ... import ComponentError, DeserializationError, Document, component, default_from_dict, default_to_dict
+from ...lazy_imports import LazyImport
+
+with LazyImport(message="Run 'pip install transformers[torch]'") as transformers_import:
+    from transformers import AutoModelForTokenClassification, AutoTokenizer
+    from transformers import Pipeline as HfPipeline
+    from transformers import pipeline
+
+with LazyImport(message="Run 'pip install spacy'") as spacy_import:
+    import spacy
+    from spacy import Language as SpacyPipeline
+
+
+class _BackendEnumMeta(EnumMeta):
+    """
+    Metaclass for fine-grained error handling of backend enums.
+    """
+
+    def __call__(cls, value, names=None, *, module=None, qualname=None, type=None, start=1):
+        if names is None:
+            try:
+                return EnumMeta.__call__(cls, value, names, module=module, qualname=qualname, type=type, start=start)
+            except ValueError:
+                supported_backends = ", ".join(sorted(v.value for v in cls))  # pylint: disable=not-an-iterable
+                raise ComponentError(
+                    f"Invalid backend `{value}` for named entity extractor. "
+                    f"Supported backends: {supported_backends}"
+                )
+        else:
+            return EnumMeta.__call__(  # pylint: disable=too-many-function-args
+                cls, value, names, module, qualname, type, start
+            )
+
+
+class NamedEntityExtractorBackend(Enum, metaclass=_BackendEnumMeta):
+    """
+    NLP backend to use for Named Entity Recognition.
+    """
+
+    #: Hugging Face.
+    #:
+    #: Uses an Hugging Face model and pipeline.
+    HUGGING_FACE = "hugging_face"
+
+    #: spaCy.
+    #:
+    #: Uses a spaCy model and pipeline.
+    SPACY = "spacy"
+
+
+@dataclass
+class NamedEntityAnnotation:
+    """
+    Describes a single NER annotation.
+
+    :param entity:
+        Entity label.
+    :param start:
+        Start index of the entity in the document.
+    :param end:
+        End index of the entity in the document.
+    :param score:
+        Score calculated by the model.
+    """
+
+    entity: str
+    start: int
+    end: int
+    score: Optional[float] = None
+
+
+@component
+class NamedEntityExtractor:
+    _METADATA_KEY = "named_entities"
+
+    def __init__(
+        self,
+        *,
+        backend: Union[str, NamedEntityExtractorBackend],
+        model_name_or_path: str,
+        pipeline_kwargs: Optional[Dict[str, Any]] = None,
+        device_id: int = -1,
+    ) -> None:
+        """
+        Construct a Named Entity extractor component.
+
+        :param backend:
+            Backend to use for NER.
+        :param model_name_or_path:
+            Name of the model or a path to the model on
+            the local disk.
+
+            Dependent on the backend.
+        :param pipeline_kwargs:
+            Keyword arguments passed to the pipeline. The
+            pipeline can override these arguments.
+
+            Dependent on the backend.
+        :param device_id:
+            Identifier of the device on which the backend
+            is executed.
+
+            To execute on the CPU, pass a value of `-1`.
+            To execute on the GPU, pass the GPU identifier.
+        """
+
+        if isinstance(backend, str):
+            backend = NamedEntityExtractorBackend(backend)
+
+        self._backend: _NerBackend
+        if backend == NamedEntityExtractorBackend.HUGGING_FACE:
+            self._backend = _HfBackend(
+                model_name_or_path=model_name_or_path, device_id=device_id, pipeline_kwargs=pipeline_kwargs
+            )
+        elif backend == NamedEntityExtractorBackend.SPACY:
+            self._backend = _SpacyBackend(
+                model_name_or_path=model_name_or_path, device_id=device_id, pipeline_kwargs=pipeline_kwargs
+            )
+        else:
+            raise ComponentError(f"Unknown NER backend '{type(backend).__name__}' for extractor")
+
+    def warm_up(self):
+        try:
+            self._backend.initialize()
+        except Exception as e:
+            raise ComponentError(
+                f"Named entity extractor with backend '{self._backend.type} failed to initialize."
+            ) from e
+
+    @component.output_types(documents=List[Document])
+    def run(self, documents: List[Document], batch_size: int = 1) -> Dict[str, Any]:
+        texts = [doc.content if doc.content is not None else "" for doc in documents]
+        annotations = self._backend.annotate(texts, batch_size=batch_size)
+
+        if len(annotations) != len(documents):
+            raise ComponentError(
+                "NER backend did not return the correct number of annotations; "
+                f"got {len(annotations)} but expected {len(documents)}"
+            )
+
+        for doc, doc_annotations in zip(documents, annotations):
+            doc.meta[self._METADATA_KEY] = doc_annotations
+
+        return {"documents": documents}
+
+    def to_dict(self) -> Dict[str, Any]:
+        return default_to_dict(
+            self,
+            backend=self._backend.type,
+            model_name_or_path=self._backend.model_name,
+            device_id=self._backend.device_id,
+            pipeline_kwargs=self._backend._pipeline_kwargs,
+        )
+
+    @classmethod
+    def from_dict(cls, data: Dict[str, Any]) -> "NamedEntityExtractor":
+        try:
+            return default_from_dict(cls, data)
+        except Exception as e:
+            raise DeserializationError(f"Couldn't deserialize {cls.__name__} instance") from e
+
+    @property
+    def initialized(self) -> bool:
+        """
+        Returns if the extractor is ready to annotate text.
+        """
+        return self._backend.initialized
+
+    @classmethod
+    def get_stored_annotations(cls, document: Document) -> Optional[List[NamedEntityAnnotation]]:
+        """
+        Returns the document's named entity annotations stored
+        in its metadata, if any.
+
+        :param document:
+            Document whose annotations are to be fetched.
+        :returns:
+            The stored annotations.
+        """
+
+        return document.meta.get(cls._METADATA_KEY)
+
+
+class _NerBackend(ABC):
+    """
+    Base class for NER backends.
+    """
+
+    def __init__(self, type: NamedEntityExtractorBackend, pipeline_kwargs: Optional[Dict[str, Any]] = None) -> None:
+        super().__init__()
+
+        self._type = type
+        self._pipeline_kwargs = pipeline_kwargs if pipeline_kwargs is not None else {}
+
+    @abstractmethod
+    def initialize(self):
+        """
+        Initializes the backend. This would usually
+        entail loading models, pipelines, etc.
+        """
+
+    @property
+    @abstractmethod
+    def initialized(self) -> bool:
+        """
+        Returns if the backend has been initialized, i.e,
+        ready to annotate text.
+        """
+
+    @abstractmethod
+    def annotate(self, texts: List[str], *, batch_size: int = 1) -> List[List[NamedEntityAnnotation]]:
+        """
+        Predict annotations for a collection of documents.
+
+        :param texts:
+            Raw texts to be annotated.
+        :param batch_size:
+            Size of text batches that are
+            passed to the model.
+        :returns:
+            NER annotations.
+        """
+
+    @property
+    @abstractmethod
+    def model_name(self) -> str:
+        """
+        Returns the model name or path on the local disk.
+        """
+
+    @property
+    @abstractmethod
+    def device_id(self) -> int:
+        """
+        Returns the identifier of the device on which
+        the backend's model is loaded.
+        """
+
+    @property
+    def type(self) -> NamedEntityExtractorBackend:
+        """
+        Returns the type of the backend.
+        """
+        return self._type
+
+
+class _HfBackend(_NerBackend):
+    """
+    Hugging Face backend for NER.
+    """
+
+    def __init__(
+        self, *, model_name_or_path: str, device_id: int, pipeline_kwargs: Optional[Dict[str, Any]] = None
+    ) -> None:
+        """
+        Construct a Hugging Face NER backend.
+
+        :param model_name_or_path:
+            Name of the model or a path to the Hugging Face
+            model on the local disk.
+        :param device_id:
+            Identifier of the device on which the backend
+            is executed.
+
+            To execute on the CPU, pass a value of `-1`.
+            To execute on the GPU, pass the GPU identifier.
+        :param pipeline_kwargs:
+            Keyword arguments passed to the pipeline. The
+            pipeline can override these arguments.
+        """
+        super().__init__(NamedEntityExtractorBackend.HUGGING_FACE, pipeline_kwargs)
+
+        transformers_import.check()
+
+        self._model_name_or_path = model_name_or_path
+        self._device_id = device_id
+
+        self.tokenizer: Optional[AutoTokenizer] = None
+        self.model: Optional[AutoModelForTokenClassification] = None
+        self.pipeline: Optional[HfPipeline] = None
+
+    def initialize(self):
+        self.tokenizer = AutoTokenizer.from_pretrained(self._model_name_or_path)
+        self.model = AutoModelForTokenClassification.from_pretrained(self._model_name_or_path)
+
+        pipeline_params = {
+            "task": "ner",
+            "model": self.model,
+            "tokenizer": self.tokenizer,
+            "aggregation_strategy": "simple",
+            "device": self.device_id,
+        }
+        pipeline_params.update({k: v for k, v in self._pipeline_kwargs.items() if k not in pipeline_params})
+        self.pipeline = pipeline(**pipeline_params)
+
+    def annotate(self, texts: List[str], *, batch_size: int = 1) -> List[List[NamedEntityAnnotation]]:
+        if not self.initialized:
+            raise ComponentError("Hugging Face NER backend was not initialized - Did you call `warm_up()`?")
+
+        assert self.pipeline is not None
+        outputs = self.pipeline(texts, batch_size=batch_size)
+        return [
+            [
+                NamedEntityAnnotation(
+                    entity=annotation["entity"] if "entity" in annotation else annotation["entity_group"],
+                    start=annotation["start"],
+                    end=annotation["end"],
+                    score=annotation["score"],
+                )
+                for annotation in annotations
+            ]
+            for annotations in outputs
+        ]
+
+    @property
+    def initialized(self) -> bool:
+        return self.tokenizer is not None and self.model is not None or self.pipeline is not None
+
+    @property
+    def model_name(self) -> str:
+        return self._model_name_or_path
+
+    @property
+    def device_id(self) -> int:
+        return self._device_id
+
+
+class _SpacyBackend(_NerBackend):
+    """
+    spaCy backend for NER.
+    """
+
+    def __init__(
+        self, *, model_name_or_path: str, device_id: int, pipeline_kwargs: Optional[Dict[str, Any]] = None
+    ) -> None:
+        """
+        Construct a spaCy NER backend.
+
+        :param model_name_or_path:
+            Name of the model or a path to the spaCy
+            model on the local disk.
+        :param device_id:
+            Identifier of the device on which the backend
+            is executed.
+
+            To execute on the CPU, pass a value of `-1`.
+            To execute on the GPU, pass the GPU identifier.
+        :param pipeline_kwargs:
+            Keyword arguments passed to the pipeline. The
+            pipeline can override these arguments.
+        """
+        super().__init__(NamedEntityExtractorBackend.SPACY, pipeline_kwargs)
+
+        spacy_import.check()
+
+        self._model_name_or_path = model_name_or_path
+        self._device_id = device_id
+
+        self.pipeline: Optional[SpacyPipeline] = None
+
+    def initialize(self):
+        # We need to initialize the model on the GPU if needed.
+        with self._select_device():
+            self.pipeline = spacy.load(self._model_name_or_path)
+
+        if not self.pipeline.has_pipe("ner"):
+            raise ComponentError(f"spaCy pipeline '{self._model_name_or_path}' does not contain an NER component")
+
+        # Disable unnecessary pipes.
+        pipes_to_keep = ("ner", "tok2vec", "transformer", "curated_transformer")
+        for name in self.pipeline.pipe_names:
+            if name not in pipes_to_keep:
+                self.pipeline.disable_pipe(name)
+
+        self._pipeline_kwargs = {k: v for k, v in self._pipeline_kwargs.items() if k not in ("texts", "batch_size")}
+
+    def annotate(self, texts: List[str], *, batch_size: int = 1) -> List[List[NamedEntityAnnotation]]:
+        if not self.initialized:
+            raise ComponentError("spaCy NER backend was not initialized - Did you call `warm_up()`?")
+
+        assert self.pipeline is not None
+        with self._select_device():
+            outputs = list(self.pipeline.pipe(texts=texts, batch_size=batch_size, **self._pipeline_kwargs))
+
+        return [
+            [
+                NamedEntityAnnotation(entity=entity.label_, start=entity.start_char, end=entity.end_char)
+                for entity in doc.ents
+            ]
+            for doc in outputs
+        ]
+
+    @property
+    def initialized(self) -> bool:
+        return self.pipeline is not None
+
+    @property
+    def model_name(self) -> str:
+        return self._model_name_or_path
+
+    @property
+    def device_id(self) -> int:
+        return self._device_id
+
+    @contextmanager
+    def _select_device(self):
+        """
+        Context manager used to run spaCy models on a specific
+        GPU in a scoped manner.
+        """
+
+        # TODO: This won't restore the active device.
+        # Since there are no opaque API functions to determine
+        # the active device in spaCy/Thinc, we can't do much
+        # about it as a consumer unless we start poking into their
+        # internals.
+        try:
+            if self._device_id >= 0:
+                spacy.require_gpu(self._device_id)
+            yield
+        finally:
+            if self._device_id >= 0:
+                spacy.require_cpu()
diff --git a/haystack/core/component/component.py b/haystack/core/component/component.py
index d0ef595b..ed88c7fb 100644
--- a/haystack/core/component/component.py
+++ b/haystack/core/component/component.py
@@ -140,11 +140,10 @@ class ComponentMeta(type):
         run_signature = inspect.signature(getattr(cls, "run"))
         for param in list(run_signature.parameters)[1:]:  # First is 'self' and it doesn't matter.
             if run_signature.parameters[param].kind == inspect.Parameter.POSITIONAL_OR_KEYWORD:  # ignore `**kwargs`
-                instance.__canals_input__[param] = InputSocket(
-                    name=param,
-                    type=run_signature.parameters[param].annotation,
-                    is_mandatory=run_signature.parameters[param].default == inspect.Parameter.empty,
-                )
+                socket_kwargs = {"name": param, "type": run_signature.parameters[param].annotation}
+                if run_signature.parameters[param].default != inspect.Parameter.empty:
+                    socket_kwargs["default_value"] = run_signature.parameters[param].default
+                instance.__canals_input__[param] = InputSocket(**socket_kwargs)
         return instance
 
 
diff --git a/haystack/core/component/descriptions.py b/haystack/core/component/descriptions.py
deleted file mode 100644
index c7bda6e0..00000000
--- a/haystack/core/component/descriptions.py
+++ /dev/null
@@ -1,42 +0,0 @@
-from typing import Dict, Any
-
-
-def find_component_inputs(component: Any) -> Dict[str, Dict[str, Any]]:
-    """
-    Returns a mapping of input names to their expected types and optionality for a given component.
-
-    :param component: The target component to introspect.
-    :return: A dictionary where keys are input names, with each key's value being another dictionary
-    containing 'type' (the data type expected) and 'is_optional' (a boolean indicating if the input is optional).
-
-    :raise: Throws a ValueError if the class of component instance is not appropriately decorated with @component.
-    """
-    if not hasattr(component, "__canals_input__"):
-        raise ValueError(
-            f"Component {component} does not have defined inputs or is improperly decorated. "
-            "Ensure it is a valid @component with declared inputs."
-        )
-
-    return {
-        name: {"type": socket.type, "is_mandatory": socket.is_mandatory, "is_variadic": socket.is_variadic}
-        for name, socket in component.__canals_input__.items()
-    }
-
-
-def find_component_outputs(component: Any) -> Dict[str, Dict[str, Any]]:
-    """
-    Returns a mapping of component output names to their expected types.
-
-    :param component: The component being examined for its outputs.
-    :return: A dictionary where each key is an output name and the value is a dictionary with a 'type' key
-    indicating the data type of the output.
-
-    :raise: Throws a ValueError if the class of component instance is not appropriately decorated with @component.
-    """
-    if not hasattr(component, "__canals_output__"):
-        raise ValueError(
-            f"The specified component {component} does not have defined outputs or is not properly decorated. "
-            "Check that it is a valid @component with outputs specified."
-        )
-
-    return {name: {"type": socket.type} for name, socket in component.__canals_output__.items()}
diff --git a/haystack/core/component/sockets.py b/haystack/core/component/sockets.py
index a78479ad..f2461dd8 100644
--- a/haystack/core/component/sockets.py
+++ b/haystack/core/component/sockets.py
@@ -1,7 +1,7 @@
 # SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
 #
 # SPDX-License-Identifier: Apache-2.0
-from typing import get_args, List, Type
+from typing import get_args, List, Type, Any
 import logging
 from dataclasses import dataclass, field
 
@@ -11,14 +11,22 @@ from haystack.core.component.types import CANALS_VARIADIC_ANNOTATION
 logger = logging.getLogger(__name__)
 
 
+class _empty:
+    """Custom object for marking InputSocket.default_value as not set."""
+
+
 @dataclass
 class InputSocket:
     name: str
     type: Type
-    is_mandatory: bool = True
+    default_value: Any = _empty
     is_variadic: bool = field(init=False)
     senders: List[str] = field(default_factory=list)
 
+    @property
+    def is_mandatory(self):
+        return self.default_value == _empty
+
     def __post_init__(self):
         try:
             # __metadata__ is a tuple
diff --git a/haystack/core/pipeline/pipeline.py b/haystack/core/pipeline/pipeline.py
index 9addb91d..6dd74153 100644
--- a/haystack/core/pipeline/pipeline.py
+++ b/haystack/core/pipeline/pipeline.py
@@ -361,11 +361,16 @@ class Pipeline:
             A dictionary where each key is a pipeline component name and each value is a dictionary of
             inputs sockets of that component.
         """
-        inputs = {
-            comp: {socket.name: {"type": socket.type, "is_mandatory": socket.is_mandatory} for socket in data}
-            for comp, data in find_pipeline_inputs(self.graph).items()
-            if data
-        }
+        inputs: Dict[str, Dict[str, Any]] = {}
+        for component_name, data in find_pipeline_inputs(self.graph).items():
+            sockets_description = {}
+            for socket in data:
+                sockets_description[socket.name] = {"type": socket.type, "is_mandatory": socket.is_mandatory}
+                if not socket.is_mandatory:
+                    sockets_description[socket.name]["default_value"] = socket.default_value
+
+            if sockets_description:
+                inputs[component_name] = sockets_description
         return inputs
 
     def outputs(self) -> Dict[str, Dict[str, Any]]:
diff --git a/releasenotes/notes/add-default-value-to-input-socket-2e62116fc4be5214.yaml b/releasenotes/notes/add-default-value-to-input-socket-2e62116fc4be5214.yaml
new file mode 100644
index 00000000..c2cb22f9
--- /dev/null
+++ b/releasenotes/notes/add-default-value-to-input-socket-2e62116fc4be5214.yaml
@@ -0,0 +1,5 @@
+---
+enhancements:
+  - |
+    Add a field called `default_value` to the `InputSocket` dataclass.
+    Derive `is_mandatory` value from the presence of `default_value`.
diff --git a/releasenotes/notes/named-entity-extractor-component-8fd647ee748892ca.yaml b/releasenotes/notes/named-entity-extractor-component-8fd647ee748892ca.yaml
new file mode 100644
index 00000000..2d9adfea
--- /dev/null
+++ b/releasenotes/notes/named-entity-extractor-component-8fd647ee748892ca.yaml
@@ -0,0 +1,6 @@
+---
+features:
+  - |
+    Added a new extractor component, namely NamedEntityExtractor. This component accepts a list of Documents as its input - the raw text in the documents are annotated by the extractor and the annotations are stored in the document's meta dictionary (under the key named_entities).
+
+    The component is designed to support multiple NER backends, and the current implementations support two at the moment: Hugging Face and spaCy. These two backends implement support for any HF/spaCy model that supports token classification/NER respectively.
diff --git a/releasenotes/notes/route-meta-to-converters-ed85acc43b5aa96a.yaml b/releasenotes/notes/route-meta-to-converters-ed85acc43b5aa96a.yaml
new file mode 100644
index 00000000..73f67d99
--- /dev/null
+++ b/releasenotes/notes/route-meta-to-converters-ed85acc43b5aa96a.yaml
@@ -0,0 +1,3 @@
+---
+enhancements:
+  - Add example script about how to use Multiplexer to route meta to file converters.
diff --git a/releasenotes/notes/single-meta-in-azureconverter-ce1cc196a9b161f3.yaml b/releasenotes/notes/single-meta-in-azureconverter-ce1cc196a9b161f3.yaml
new file mode 100644
index 00000000..ccc96750
--- /dev/null
+++ b/releasenotes/notes/single-meta-in-azureconverter-ce1cc196a9b161f3.yaml
@@ -0,0 +1,5 @@
+---
+enhancements:
+  - |
+    Adds support for single metadata dictionary input in `AzureOCRDocumentConverter`. In this way, additional metadata can be added to all files processed by this component even when the length of the list of sources is unknown.
+
diff --git a/releasenotes/notes/single-meta-in-markdown2document-082bae7b20bd605d.yaml b/releasenotes/notes/single-meta-in-markdown2document-082bae7b20bd605d.yaml
new file mode 100644
index 00000000..e692cce4
--- /dev/null
+++ b/releasenotes/notes/single-meta-in-markdown2document-082bae7b20bd605d.yaml
@@ -0,0 +1,4 @@
+---
+features:
+  - |
+    Adds support for single metadata dictionary input in `MarkdownToDocument``.


Test patch:
diff --git a/e2e/pipelines/test_named_entity_extractor.py b/e2e/pipelines/test_named_entity_extractor.py
new file mode 100644
index 00000000..f64a921c
--- /dev/null
+++ b/e2e/pipelines/test_named_entity_extractor.py
@@ -0,0 +1,110 @@
+import pytest
+
+from haystack import Document, Pipeline, ComponentError
+from haystack.components.extractors import NamedEntityAnnotation, NamedEntityExtractor, NamedEntityExtractorBackend
+
+
+@pytest.fixture
+def raw_texts():
+    return [
+        "My name is Clara and I live in Berkeley, California.",
+        "I'm Merlin, the happy pig!",
+        "New York State declared a state of emergency after the announcement of the end of the world.",
+        "",  # Intentionally empty.
+    ]
+
+
+@pytest.fixture
+def hf_annotations():
+    return [
+        [
+            NamedEntityAnnotation(entity="PER", start=11, end=16),
+            NamedEntityAnnotation(entity="LOC", start=31, end=39),
+            NamedEntityAnnotation(entity="LOC", start=41, end=51),
+        ],
+        [NamedEntityAnnotation(entity="PER", start=4, end=10)],
+        [NamedEntityAnnotation(entity="LOC", start=0, end=14)],
+        [],
+    ]
+
+
+@pytest.fixture
+def spacy_annotations():
+    return [
+        [
+            NamedEntityAnnotation(entity="PERSON", start=11, end=16),
+            NamedEntityAnnotation(entity="GPE", start=31, end=39),
+            NamedEntityAnnotation(entity="GPE", start=41, end=51),
+        ],
+        [NamedEntityAnnotation(entity="PERSON", start=4, end=10)],
+        [NamedEntityAnnotation(entity="GPE", start=0, end=14)],
+        [],
+    ]
+
+
+def test_ner_extractor_init():
+    extractor = NamedEntityExtractor(
+        backend=NamedEntityExtractorBackend.HUGGING_FACE, model_name_or_path="dslim/bert-base-NER", device_id=-1
+    )
+
+    with pytest.raises(ComponentError, match=r"not initialized"):
+        extractor.run(documents=[])
+
+    assert not extractor.initialized
+    extractor.warm_up()
+    assert extractor.initialized
+
+
+@pytest.mark.parametrize("batch_size", [1, 3])
+def test_ner_extractor_hf_backend(raw_texts, hf_annotations, batch_size):
+    extractor = NamedEntityExtractor(
+        backend=NamedEntityExtractorBackend.HUGGING_FACE, model_name_or_path="dslim/bert-base-NER"
+    )
+    extractor.warm_up()
+
+    _extract_and_check_predictions(extractor, raw_texts, hf_annotations, batch_size)
+
+
+@pytest.mark.parametrize("batch_size", [1, 3])
+def test_ner_extractor_spacy_backend(raw_texts, spacy_annotations, batch_size):
+    extractor = NamedEntityExtractor(backend=NamedEntityExtractorBackend.SPACY, model_name_or_path="en_core_web_trf")
+    extractor.warm_up()
+
+    _extract_and_check_predictions(extractor, raw_texts, spacy_annotations, batch_size)
+
+
+@pytest.mark.parametrize("batch_size", [1, 3])
+def test_ner_extractor_in_pipeline(raw_texts, hf_annotations, batch_size):
+    pipeline = Pipeline()
+    pipeline.add_component(
+        name="ner_extractor",
+        instance=NamedEntityExtractor(
+            backend=NamedEntityExtractorBackend.HUGGING_FACE, model_name_or_path="dslim/bert-base-NER"
+        ),
+    )
+
+    outputs = pipeline.run(
+        {"ner_extractor": {"documents": [Document(content=text) for text in raw_texts], "batch_size": batch_size}}
+    )["ner_extractor"]["documents"]
+    predicted = [NamedEntityExtractor.get_stored_annotations(doc) for doc in outputs]
+    _check_predictions(predicted, hf_annotations)
+
+
+def _extract_and_check_predictions(extractor, texts, expected, batch_size):
+    docs = [Document(content=text) for text in texts]
+    outputs = extractor.run(documents=docs, batch_size=batch_size)["documents"]
+    assert all(id(a) == id(b) for a, b in zip(docs, outputs))
+    predicted = [NamedEntityExtractor.get_stored_annotations(doc) for doc in outputs]
+
+    _check_predictions(predicted, expected)
+
+
+def _check_predictions(predicted, expected):
+    assert len(predicted) == len(expected)
+    for pred, exp in zip(predicted, expected):
+        assert len(pred) == len(exp)
+
+        for a, b in zip(pred, exp):
+            assert a.entity == b.entity
+            assert a.start == b.start
+            assert a.end == b.end
diff --git a/test/components/converters/test_azure_ocr_doc_converter.py b/test/components/converters/test_azure_ocr_doc_converter.py
index df751ffa..be01b72b 100644
--- a/test/components/converters/test_azure_ocr_doc_converter.py
+++ b/test/components/converters/test_azure_ocr_doc_converter.py
@@ -44,17 +44,19 @@ class TestAzureOCRDocumentConverter:
                 "pages": [{"lines": [{"content": "mocked line 1"}, {"content": "mocked line 2"}]}],
             }
 
-    def test_run_with_meta(self):
+    def test_run_with_meta(self, test_files_path):
         bytestream = ByteStream(data=b"test", meta={"author": "test_author", "language": "en"})
-
         with patch("haystack.components.converters.azure.DocumentAnalysisClient"):
             component = AzureOCRDocumentConverter(endpoint="test_endpoint", api_key="test_credential_key")
 
-        output = component.run(sources=[bytestream], meta=[{"language": "it"}])
-        document = output["documents"][0]
+        output = component.run(
+            sources=[bytestream, test_files_path / "pdf" / "sample_pdf_1.pdf"], meta={"language": "it"}
+        )
 
         # check that the metadata from the bytestream is merged with that from the meta parameter
-        assert document.meta == {"author": "test_author", "language": "it"}
+        assert output["documents"][0].meta["author"] == "test_author"
+        assert output["documents"][0].meta["language"] == "it"
+        assert output["documents"][1].meta["language"] == "it"
 
     @pytest.mark.integration
     @pytest.mark.skipif(not os.environ.get("CORE_AZURE_CS_ENDPOINT", None), reason="Azure credentials not available")
diff --git a/test/components/converters/test_markdown_to_document.py b/test/components/converters/test_markdown_to_document.py
index 3764fd7a..0f7e1307 100644
--- a/test/components/converters/test_markdown_to_document.py
+++ b/test/components/converters/test_markdown_to_document.py
@@ -31,17 +31,33 @@ class TestMarkdownToDocument:
             assert "What to build with Haystack" in doc.content
             assert "# git clone https://github.com/deepset-ai/haystack.git" in doc.content
 
-    def test_run_with_meta(self):
+    def test_run_calls_normalize_metadata(self, test_files_path):
+        bytestream = ByteStream(data=b"test", meta={"author": "test_author", "language": "en"})
+
+        converter = MarkdownToDocument()
+
+        with patch("haystack.components.converters.markdown.normalize_metadata") as normalize_metadata, patch(
+            "haystack.components.converters.markdown.MarkdownIt"
+        ):
+            converter.run(sources=[bytestream, test_files_path / "markdown" / "sample.md"], meta={"language": "it"})
+
+        # check that the metadata normalizer is called properly
+        normalize_metadata.assert_called_with(meta={"language": "it"}, sources_count=2)
+
+    def test_run_with_meta(self, test_files_path):
         bytestream = ByteStream(data=b"test", meta={"author": "test_author", "language": "en"})
 
         converter = MarkdownToDocument()
 
         with patch("haystack.components.converters.markdown.MarkdownIt"):
-            output = converter.run(sources=[bytestream], meta=[{"language": "it"}])
-        document = output["documents"][0]
+            output = converter.run(
+                sources=[bytestream, test_files_path / "markdown" / "sample.md"], meta={"language": "it"}
+            )
 
         # check that the metadata from the bytestream is merged with that from the meta parameter
-        assert document.meta == {"author": "test_author", "language": "it"}
+        assert output["documents"][0].meta["author"] == "test_author"
+        assert output["documents"][0].meta["language"] == "it"
+        assert output["documents"][1].meta["language"] == "it"
 
     @pytest.mark.integration
     def test_run_wrong_file_type(self, test_files_path, caplog):
diff --git a/test/components/extractors/__init__.py b/test/components/extractors/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/test/components/extractors/test_named_entity_extractor.py b/test/components/extractors/test_named_entity_extractor.py
new file mode 100644
index 00000000..9ac2c3e4
--- /dev/null
+++ b/test/components/extractors/test_named_entity_extractor.py
@@ -0,0 +1,36 @@
+import pytest
+
+from haystack import ComponentError, DeserializationError
+from haystack.components.extractors import NamedEntityExtractor, NamedEntityExtractorBackend
+
+
+@pytest.mark.unit
+def test_named_entity_extractor_backend():
+    _ = NamedEntityExtractor(backend=NamedEntityExtractorBackend.HUGGING_FACE, model_name_or_path="dslim/bert-base-NER")
+
+    _ = NamedEntityExtractor(backend="hugging_face", model_name_or_path="dslim/bert-base-NER")
+
+    _ = NamedEntityExtractor(backend=NamedEntityExtractorBackend.SPACY, model_name_or_path="en_core_web_sm")
+
+    _ = NamedEntityExtractor(backend="spacy", model_name_or_path="en_core_web_sm")
+
+    with pytest.raises(ComponentError, match=r"Invalid backend"):
+        NamedEntityExtractor(backend="random_backend", model_name_or_path="dslim/bert-base-NER")
+
+
+@pytest.mark.unit
+def test_named_entity_extractor_serde():
+    extractor = NamedEntityExtractor(
+        backend=NamedEntityExtractorBackend.HUGGING_FACE, model_name_or_path="dslim/bert-base-NER", device_id=-1
+    )
+
+    serde_data = extractor.to_dict()
+    new_extractor = NamedEntityExtractor.from_dict(serde_data)
+
+    assert type(new_extractor._backend) == type(extractor._backend)
+    assert new_extractor._backend.model_name == extractor._backend.model_name
+    assert new_extractor._backend.device_id == extractor._backend.device_id
+
+    with pytest.raises(DeserializationError, match=r"Couldn't deserialize"):
+        serde_data["init_parameters"].pop("backend")
+        _ = NamedEntityExtractor.from_dict(serde_data)
diff --git a/test/core/component/test_component.py b/test/core/component/test_component.py
index 96d297e3..cba6fc42 100644
--- a/test/core/component/test_component.py
+++ b/test/core/component/test_component.py
@@ -4,7 +4,6 @@ from typing import Any, Optional
 import pytest
 
 from haystack.core.component import component
-from haystack.core.component.descriptions import find_component_inputs, find_component_outputs
 from haystack.core.errors import ComponentError
 from haystack.core.component import InputSocket, OutputSocket, Component
 
@@ -167,131 +166,13 @@ def test_component_decorator_set_it_as_component():
     assert isinstance(comp, Component)
 
 
-def test_inputs_method_no_inputs():
-    @component
-    class MockComponent:
-        def run(self):
-            return {"value": 1}
-
-    comp = MockComponent()
-    assert find_component_inputs(comp) == {}
-
-
-def test_inputs_method_one_input():
-    @component
-    class MockComponent:
-        def run(self, value: int):
-            return {"value": 1}
-
-    comp = MockComponent()
-    assert find_component_inputs(comp) == {"value": {"is_mandatory": True, "is_variadic": False, "type": int}}
-
-
-def test_inputs_method_multiple_inputs():
-    @component
-    class MockComponent:
-        def run(self, value1: int, value2: str):
-            return {"value": 1}
-
-    comp = MockComponent()
-    assert find_component_inputs(comp) == {
-        "value1": {"is_mandatory": True, "is_variadic": False, "type": int},
-        "value2": {"is_mandatory": True, "is_variadic": False, "type": str},
-    }
-
-
-def test_inputs_method_multiple_inputs_optional():
-    @component
-    class MockComponent:
-        def run(self, value1: int, value2: Optional[str]):
-            return {"value": 1}
-
-    comp = MockComponent()
-    assert find_component_inputs(comp) == {
-        "value1": {"is_mandatory": True, "is_variadic": False, "type": int},
-        "value2": {"is_mandatory": True, "is_variadic": False, "type": typing.Optional[str]},
-    }
-
-
-def test_inputs_method_variadic_positional_args():
-    @component
-    class MockComponent:
-        def __init__(self):
-            component.set_input_types(self, value=Any)
-
-        def run(self, *args):
-            return {"value": 1}
-
-    comp = MockComponent()
-    assert find_component_inputs(comp) == {"value": {"is_mandatory": True, "is_variadic": False, "type": typing.Any}}
-
-
-def test_inputs_method_variadic_keyword_positional_args():
-    @component
-    class MockComponent:
-        def __init__(self):
-            component.set_input_types(self, value=Any)
-
-        def run(self, **kwargs):
-            return {"value": 1}
-
-    comp = MockComponent()
-    assert find_component_inputs(comp) == {"value": {"is_mandatory": True, "is_variadic": False, "type": typing.Any}}
-
-
-def test_inputs_dynamic_from_init():
-    @component
-    class MockComponent:
-        def __init__(self):
-            component.set_input_types(self, value=int)
-
-        def run(self, value: int, **kwargs):
-            return {"value": 1}
-
-    comp = MockComponent()
-    assert find_component_inputs(comp) == {"value": {"is_mandatory": True, "is_variadic": False, "type": int}}
-
-
-def test_outputs_method_no_outputs():
-    @component
-    class MockComponent:
-        def run(self):
-            return {}
-
-    comp = MockComponent()
-    assert find_component_outputs(comp) == {}
-
-
-def test_outputs_method_one_output():
+def test_input_has_default_value():
     @component
     class MockComponent:
         @component.output_types(value=int)
-        def run(self):
-            return {"value": 1}
-
-    comp = MockComponent()
-    assert find_component_outputs(comp) == {"value": {"type": int}}
-
-
-def test_outputs_method_multiple_outputs():
-    @component
-    class MockComponent:
-        @component.output_types(value1=int, value2=str)
-        def run(self):
-            return {"value1": 1, "value2": "test"}
-
-    comp = MockComponent()
-    assert find_component_outputs(comp) == {"value1": {"type": int}, "value2": {"type": str}}
-
-
-def test_outputs_dynamic_from_init():
-    @component
-    class MockComponent:
-        def __init__(self):
-            component.set_output_types(self, value=int)
-
-        def run(self):
-            return {"value": 1}
+        def run(self, value: int = 42):
+            return {"value": value}
 
     comp = MockComponent()
-    assert find_component_outputs(comp) == {"value": {"type": int}}
+    assert comp.__canals_input__["value"].default_value == 42
+    assert not comp.__canals_input__["value"].is_mandatory
diff --git a/test/core/component/test_connection.py b/test/core/component/test_connection.py
index fa096abf..46194d52 100644
--- a/test/core/component/test_connection.py
+++ b/test/core/component/test_connection.py
@@ -28,7 +28,7 @@ def test_is_mandatory():
     c = Connection(None, None, "destination_component", InputSocket("in", int))
     assert c.is_mandatory
 
-    c = Connection(None, None, "destination_component", InputSocket("in", int, is_mandatory=False))
+    c = Connection(None, None, "destination_component", InputSocket("in", int, 42))
     assert not c.is_mandatory
 
     c = Connection("source_component", OutputSocket("out", int), None, None)
diff --git a/test/core/pipeline/test_pipeline.py b/test/core/pipeline/test_pipeline.py
index e067c172..8cac7d4c 100644
--- a/test/core/pipeline/test_pipeline.py
+++ b/test/core/pipeline/test_pipeline.py
@@ -106,7 +106,7 @@ def test_from_dict():
     assert add_two["instance"].add == 2
     assert add_two["input_sockets"] == {
         "value": InputSocket(name="value", type=int),
-        "add": InputSocket(name="add", type=Optional[int], is_mandatory=False),
+        "add": InputSocket(name="add", type=Optional[int], default_value=None),
     }
     assert add_two["output_sockets"] == {"result": OutputSocket(name="result", type=int, receivers=["double"])}
     assert add_two["visits"] == 0
@@ -116,7 +116,7 @@ def test_from_dict():
     assert add_default["instance"].add == 1
     assert add_default["input_sockets"] == {
         "value": InputSocket(name="value", type=int, senders=["double"]),
-        "add": InputSocket(name="add", type=Optional[int], is_mandatory=False),
+        "add": InputSocket(name="add", type=Optional[int], default_value=None),
     }
     assert add_default["output_sockets"] == {"result": OutputSocket(name="result", type=int)}
     assert add_default["visits"] == 0
@@ -184,7 +184,7 @@ def test_from_dict_with_components_instances():
     assert add_two_data["instance"].add == 2
     assert add_two_data["input_sockets"] == {
         "value": InputSocket(name="value", type=int),
-        "add": InputSocket(name="add", type=Optional[int], is_mandatory=False),
+        "add": InputSocket(name="add", type=Optional[int], default_value=None),
     }
     assert add_two_data["output_sockets"] == {"result": OutputSocket(name="result", type=int, receivers=["double"])}
     assert add_two_data["visits"] == 0
@@ -195,7 +195,7 @@ def test_from_dict_with_components_instances():
     assert add_default_data["instance"].add == 1
     assert add_default_data["input_sockets"] == {
         "value": InputSocket(name="value", type=int, senders=["double"]),
-        "add": InputSocket(name="add", type=Optional[int], is_mandatory=False),
+        "add": InputSocket(name="add", type=Optional[int], default_value=None),
     }
     assert add_default_data["output_sockets"] == {"result": OutputSocket(name="result", type=int, receivers=[])}
     assert add_default_data["visits"] == 0
diff --git a/test/core/pipeline/test_validation_pipeline_io.py b/test/core/pipeline/test_validation_pipeline_io.py
index 347149c1..ce71eeee 100644
--- a/test/core/pipeline/test_validation_pipeline_io.py
+++ b/test/core/pipeline/test_validation_pipeline_io.py
@@ -39,7 +39,7 @@ def test_find_pipeline_input_two_inputs_same_component():
     pipe.connect("comp1", "comp2")
 
     assert find_pipeline_inputs(pipe.graph) == {
-        "comp1": [InputSocket(name="value", type=int), InputSocket(name="add", type=Optional[int], is_mandatory=False)],
+        "comp1": [InputSocket(name="value", type=int), InputSocket(name="add", type=Optional[int], default_value=None)],
         "comp2": [],
     }
 
@@ -53,7 +53,7 @@ def test_find_pipeline_input_some_inputs_different_components():
     pipe.connect("comp2.value", "comp3.add")
 
     assert find_pipeline_inputs(pipe.graph) == {
-        "comp1": [InputSocket(name="value", type=int), InputSocket(name="add", type=Optional[int], is_mandatory=False)],
+        "comp1": [InputSocket(name="value", type=int), InputSocket(name="add", type=Optional[int], default_value=None)],
         "comp2": [InputSocket(name="value", type=int)],
         "comp3": [],
     }
@@ -66,7 +66,7 @@ def test_find_pipeline_variable_input_nodes_in_the_pipeline():
     pipe.add_component("comp3", Sum())
 
     assert find_pipeline_inputs(pipe.graph) == {
-        "comp1": [InputSocket(name="value", type=int), InputSocket(name="add", type=Optional[int], is_mandatory=False)],
+        "comp1": [InputSocket(name="value", type=int), InputSocket(name="add", type=Optional[int], default_value=None)],
         "comp2": [InputSocket(name="value", type=int)],
         "comp3": [InputSocket(name="values", type=Variadic[int])],
     }
diff --git a/test/test_requirements.txt b/test/test_requirements.txt
index 3d094068..5e9c19f7 100644
--- a/test/test_requirements.txt
+++ b/test/test_requirements.txt
@@ -3,6 +3,9 @@
 # Package                                        Components
 
 transformers[torch,sentencepiece]==4.36.2       # ExtractiveReader, TransformersSimilarityRanker, LocalWhisperTranscriber, HFGenerators...
+spacy>=3.7,<3.8                                 # NamedEntityExtractor
+spacy-curated-transformers>=0.2,<=0.3           # NamedEntityExtractor
+https://github.com/explosion/spacy-models/releases/download/en_core_web_trf-3.7.3/en_core_web_trf-3.7.3-py3-none-any.whl # NamedEntityExtractor
 
 # Converters
 pypdf                                           # PyPDFConverter