Repository: deepset-ai/haystack

Issue:
Implement `MetricsResult` class
As specified in proposal #5794 we need to implement a `MetricsResult` class.

This class will be really, it will be probably just inherit from `dict` and implement a simple method to save the results to a file or directory.

For more detailed information check out the original proposal.

Discussion:
silvanocerza: Hey @awinml can you maybe split this into multiple PRs? Ideally one for each issue should be enough, it will speed up review time quite a bit. :)
awinml: @silvanocerza Thanks! I have moved the Exact Match implementation to a separate PR (#6696).

I have decided to keep both `calculate_metrics` and `MetricsResult` in this PR, as compared to splitting them, since they are fairly straightforward classes and mostly have placeholders for the metrics at the moment.
coveralls: ## Pull Request Test Coverage Report for [Build 7450147221](https://coveralls.io/builds/64937438)


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

---



|  Totals | [![Coverage Status](https://coveralls.io/builds/64937438/badge)](https://coveralls.io/builds/64937438) |
| :-- | --: |
| Change from base [Build 7450144444](https://coveralls.io/builds/64937313): |  -0.8% |
| Covered Lines: | 4054 |
| Relevant Lines: | 4684 |

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

silvanocerza: Sorry, I merged base in again before noticing you already did. 🤦 

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/haystack/evaluation/__init__.py b/haystack/evaluation/__init__.py
index 090aadc2..7313d1ef 100644
--- a/haystack/evaluation/__init__.py
+++ b/haystack/evaluation/__init__.py
@@ -1,3 +1,4 @@
 from haystack.evaluation.eval import EvaluationResult, eval
+from haystack.evaluation.metrics import Metric, MetricsResult
 
-__all__ = ["eval", "EvaluationResult"]
+__all__ = ["eval", "EvaluationResult", "Metric", "MetricsResult"]
diff --git a/haystack/evaluation/eval.py b/haystack/evaluation/eval.py
index 0f9f1e87..28b4b76b 100644
--- a/haystack/evaluation/eval.py
+++ b/haystack/evaluation/eval.py
@@ -1,12 +1,14 @@
-from typing import Any, Dict, List, Union
+from typing import Any, Callable, Dict, List, Union
 
 from haystack import Pipeline
 from haystack.core.component import Component
+from haystack.evaluation.metrics import Metric, MetricsResult
 
 
 class EvaluationResult:
     """
-    EvaluationResult keeps track of all the information related to evaluation, namely the runnable (Pipeline or component), inputs, outputs, and expected outputs.
+    EvaluationResult keeps track of all the information related to evaluation, namely the runnable (Pipeline or
+    component), inputs, outputs, and expected outputs.
     The EvaluationResult keeps track of all the information stored by eval.
 
     :param runnable: The runnable (Pipeline or component) used for evaluation.
@@ -27,6 +29,48 @@ class EvaluationResult:
         self.outputs = outputs
         self.expected_outputs = expected_outputs
 
+        # Mapping of metrics to their corresponding functions.
+        # This should be kept in sync with the Metric enum
+        self._supported_metrics = {
+            Metric.RECALL: self._calculate_recall,
+            Metric.MRR: self._calculate_mrr,
+            Metric.MAP: self._calculate_map,
+            Metric.F1: self._calculate_f1,
+            Metric.EM: self._calculate_em,
+            Metric.SAS: self._calculate_sas,
+        }
+
+    def calculate_metrics(self, metric: Union[Metric, Callable[..., MetricsResult]], **kwargs) -> MetricsResult:
+        """
+        Calculate evaluation metrics based on the provided Metric or using the custom metric function.
+
+        :param metric: The Metric indicating the type of metric to calculate or custom function to compute.
+        :return: MetricsResult containing the calculated metric.
+        """
+
+        if isinstance(metric, Metric):
+            return self._supported_metrics[metric](**kwargs)
+
+        return metric(self, **kwargs)
+
+    def _calculate_recall(self):
+        return MetricsResult({"recall": None})
+
+    def _calculate_map(self):
+        return MetricsResult({"mean_average_precision": None})
+
+    def _calculate_mrr(self):
+        return MetricsResult({"mean_reciprocal_rank": None})
+
+    def _calculate_f1(self):
+        return MetricsResult({"f1": None})
+
+    def _calculate_em(self):
+        return MetricsResult({"exact_match": 1.0})
+
+    def _calculate_sas(self):
+        return MetricsResult({"exact_match": None})
+
 
 def eval(
     runnable: Union[Pipeline, Component], inputs: List[Dict[str, Any]], expected_outputs: List[Dict[str, Any]]
@@ -41,7 +85,8 @@ def eval(
     :param inputs: List of inputs used for evaluation.
     :param expected_outputs: List of expected outputs used for evaluation.
 
-    :return: An instance of EvaluationResult containing information about the evaluation, including the runnable, inputs, outputs, and expected outputs.
+    :return: An instance of EvaluationResult containing information about the evaluation, including the runnable,
+    inputs, outputs, and expected outputs.
     """
 
     outputs = []
@@ -49,8 +94,8 @@ def eval(
     # Check that expected outputs has the correct shape
     if len(inputs) != len(expected_outputs):
         raise ValueError(
-            f"The number of inputs ({len(inputs)}) does not match the number of expected outputs ({len(expected_outputs)}). "
-            " Please ensure that each input has a corresponding expected output."
+            f"The number of inputs ({len(inputs)}) does not match the number of expected outputs "
+            f"({len(expected_outputs)}). Please ensure that each input has a corresponding expected output."
         )
 
     for input_ in inputs:
diff --git a/haystack/evaluation/metrics.py b/haystack/evaluation/metrics.py
new file mode 100644
index 00000000..fbe2fec8
--- /dev/null
+++ b/haystack/evaluation/metrics.py
@@ -0,0 +1,32 @@
+import json
+from enum import Enum
+from pathlib import Path
+from typing import Union
+
+
+class Metric(Enum):
+    """
+    Contains a list of standard metrics available.
+    """
+
+    RECALL = "Recall"
+    MRR = "Mean Reciprocal Rank"
+    MAP = "Mean Average Precision"
+    F1 = "F1"
+    EM = "Exact Match"
+    SAS = "Semantic Answer Similarity"
+
+
+class MetricsResult(dict):
+    """
+    Stores the metric values computed during the evaluation.
+    """
+
+    def save(self, file: Union[str, Path]):
+        """
+        Save the metrics stored in the MetricsResult to a json file.
+
+        :param file: The file path or file name to save the data.
+        """
+        with open(file, "w") as outfile:
+            json.dump(self, outfile, indent=4)
diff --git a/releasenotes/notes/add-calculate-metrics-metricsresults-03bf27ce8b16cff5.yaml b/releasenotes/notes/add-calculate-metrics-metricsresults-03bf27ce8b16cff5.yaml
new file mode 100644
index 00000000..ab163705
--- /dev/null
+++ b/releasenotes/notes/add-calculate-metrics-metricsresults-03bf27ce8b16cff5.yaml
@@ -0,0 +1,6 @@
+---
+features:
+  - |
+    Adds `calculate_metrics()` function to EvaluationResult for computation of evaluation metrics.
+    Adds `Metric` class to store list of available metrics.
+    Adds `MetricsResult` class to store the metric values computed during the evaluation.
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_eval_extractive_qa_pipeline.py b/e2e/pipelines/test_eval_extractive_qa_pipeline.py
index 201a84e1..c0e171af 100644
--- a/e2e/pipelines/test_eval_extractive_qa_pipeline.py
+++ b/e2e/pipelines/test_eval_extractive_qa_pipeline.py
@@ -1,12 +1,15 @@
+import json
+
 from haystack import Pipeline
 from haystack.components.readers import ExtractiveReader
 from haystack.components.retrievers import InMemoryBM25Retriever
 from haystack.dataclasses import Document, ExtractedAnswer
 from haystack.document_stores import InMemoryDocumentStore
 from haystack.evaluation.eval import eval
+from haystack.evaluation.metrics import Metric
 
 
-def test_extractive_qa_pipeline():
+def test_extractive_qa_pipeline(tmp_path):
     # Create the pipeline
     qa_pipeline = Pipeline()
     qa_pipeline.add_component(instance=InMemoryBM25Retriever(document_store=InMemoryDocumentStore()), name="retriever")
@@ -123,3 +126,11 @@ def test_extractive_qa_pipeline():
     assert eval_result.expected_outputs == expected_outputs
     assert len(eval_result.outputs) == len(expected_outputs) == len(inputs)
     assert eval_result.runnable.to_dict() == qa_pipeline.to_dict()
+
+    metrics = eval_result.calculate_metrics(Metric.EM)
+    # Save metric results to json
+    metrics.save(tmp_path / "exact_match_score.json")
+
+    assert metrics["exact_match"] == 1.0
+    with open(tmp_path / "exact_match_score.json", "r") as f:
+        assert metrics == json.load(f)
diff --git a/e2e/pipelines/test_eval_rag_pipelines.py b/e2e/pipelines/test_eval_rag_pipelines.py
index 7ff365df..68251d32 100644
--- a/e2e/pipelines/test_eval_rag_pipelines.py
+++ b/e2e/pipelines/test_eval_rag_pipelines.py
@@ -1,3 +1,5 @@
+import json
+
 from haystack import Pipeline
 from haystack.components.builders.answer_builder import AnswerBuilder
 from haystack.components.builders.prompt_builder import PromptBuilder
@@ -8,9 +10,10 @@ from haystack.components.writers import DocumentWriter
 from haystack.dataclasses import Document
 from haystack.document_stores import InMemoryDocumentStore
 from haystack.evaluation.eval import eval
+from haystack.evaluation.metrics import Metric
 
 
-def test_bm25_rag_pipeline():
+def test_bm25_rag_pipeline(tmp_path):
     prompt_template = """
     Given these documents, answer the question.\nDocuments:
     {% for doc in documents %}
@@ -68,8 +71,16 @@ def test_bm25_rag_pipeline():
     assert len(eval_result.outputs) == len(expected_outputs) == len(inputs)
     assert eval_result.runnable.to_dict() == rag_pipeline.to_dict()
 
+    metrics = eval_result.calculate_metrics(Metric.EM)
+    # Save metric results to json
+    metrics.save(tmp_path / "exact_match_score.json")
+
+    assert metrics["exact_match"] == 1.0
+    with open(tmp_path / "exact_match_score.json", "r") as f:
+        assert metrics == json.load(f)
+
 
-def test_embedding_retrieval_rag_pipeline():
+def test_embedding_retrieval_rag_pipeline(tmp_path):
     # Create the RAG pipeline
     prompt_template = """
     Given these documents, answer the question.\nDocuments:
@@ -143,3 +154,11 @@ def test_embedding_retrieval_rag_pipeline():
     assert eval_result.expected_outputs == expected_outputs
     assert len(eval_result.outputs) == len(expected_outputs) == len(inputs)
     assert eval_result.runnable.to_dict() == rag_pipeline.to_dict()
+
+    metrics = eval_result.calculate_metrics(Metric.EM)
+    # Save metric results to json
+    metrics.save(tmp_path / "exact_match_score.json")
+
+    assert metrics["exact_match"] == 1.0
+    with open(tmp_path / "exact_match_score.json", "r") as f:
+        assert metrics == json.load(f)
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/evaluation/test_eval.py b/test/evaluation/test_eval.py
new file mode 100644
index 00000000..095c51d4
--- /dev/null
+++ b/test/evaluation/test_eval.py
@@ -0,0 +1,42 @@
+from unittest.mock import MagicMock
+
+from haystack.core.pipeline import Pipeline
+from haystack.evaluation.eval import EvaluationResult
+from haystack.evaluation.metrics import Metric
+
+
+class TestEvaluationResult:
+    def test_init(self):
+        runnable = Pipeline()
+        result = EvaluationResult(runnable=runnable, inputs=[], outputs=[], expected_outputs=[])
+
+        assert result.runnable == runnable
+        assert result.inputs == []
+        assert result.outputs == []
+        assert result.expected_outputs == []
+
+    def test_supported_metrics_contains_all_metrics(self):
+        runnable = Pipeline()
+        result = EvaluationResult(runnable=runnable, inputs=[], outputs=[], expected_outputs=[])
+
+        supported_metrics = [m.name for m in result._supported_metrics.keys()]
+        all_metric_names = [m.name for m in Metric]
+        assert supported_metrics == all_metric_names
+
+    def test_calculate_metrics_with_supported_metric(self):
+        runnable = Pipeline()
+        result = EvaluationResult(runnable=runnable, inputs=[], outputs=[], expected_outputs=[])
+        result._supported_metrics[Metric.RECALL] = MagicMock()
+        result.calculate_metrics(metric=Metric.RECALL)
+
+        assert result._supported_metrics[Metric.RECALL].called_once_with()
+
+    def test_calculate_metrics_with_non_supported_metric(self):
+        runnable = Pipeline()
+        result = EvaluationResult(runnable=runnable, inputs=[], outputs=[], expected_outputs=[])
+
+        unsupported_metric = MagicMock()
+
+        result.calculate_metrics(metric=unsupported_metric, some_argument="some_value")
+
+        assert unsupported_metric.called_once_with(some_argument="some_value")
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