Repository: deepset-ai/haystack

Issue:
`JoinDocuments` node fails if some of the documents has `score=None`
**Describe the bug**
Some of the retrievers - like the `TfidfRetriever `on Elasticsearch or `BM25Retriever` on Weaviate - will return documents with `score=None`.

If you try to use `JoinDocuments` on such documents, then you will receive the below error:
```
File "/home/.../haystack/haystack/nodes/other/join_docs.py", line 63, in run_accumulated
        sorted_docs = sorted(scores_map.items(), key=lambda d: d[1], reverse=True)
        TypeError: '<' not supported between instances of 'NoneType' and 'NoneType'
```

This error occurs because the `JoinDocuments` always tries to sort the documents by their `score` and when the `score=None` for any of the documents received, then it will error out.

**Error message**
```
File "/home/.../haystack/haystack/nodes/other/join_docs.py", line 63, in run_accumulated
        sorted_docs = sorted(scores_map.items(), key=lambda d: d[1], reverse=True)
        TypeError: '<' not supported between instances of 'NoneType' and 'NoneType'
```

**Expected behavior**
We should have the option to join documents from retrievers where the retriever can't calculate a score.

Basically we would need an argument for the `JoinDocuments` node to set whether or not we want sorting by score, just like the same thing was done for a similar issue for `JoinAnswers` in this PR: https://github.com/deepset-ai/haystack/pull/2436

**Additional context**
Obviously you are only effected if some of your documents have `score=None`

I will provide a PR for this momentarily.

**To Reproduce**
Try to join documents from a `TfidfRetriever` running on top of Elasticsearch and you will hit this issue.

**FAQ Check**
- [ x ] Have you had a look at [our new FAQ page](https://haystack.deepset.ai/overview/faq)?

**System:**
 - OS: Linux Mint 20.2 
 - GPU/CPU: Nvidia
 - Haystack version (commit or version number): 1.6.0 
 - DocumentStore: Elasticsearch
 - Reader:  Any
 - Retriever: TfidfRetriever



Discussion:

Resolving patch:
diff --git a/haystack/nodes/other/join_docs.py b/haystack/nodes/other/join_docs.py
--- a/haystack/nodes/other/join_docs.py
+++ b/haystack/nodes/other/join_docs.py
@@ -1,10 +1,14 @@
 from collections import defaultdict
+import logging
+from math import inf
 
 from typing import Optional, List
 
 from haystack.schema import Document
 from haystack.nodes.other.join import JoinNode
 
+logger = logging.getLogger(__name__)
+
 
 class JoinDocuments(JoinNode):
     """
@@ -21,7 +25,11 @@ class JoinDocuments(JoinNode):
     outgoing_edges = 1
 
     def __init__(
-        self, join_mode: str = "concatenate", weights: Optional[List[float]] = None, top_k_join: Optional[int] = None
+        self,
+        join_mode: str = "concatenate",
+        weights: Optional[List[float]] = None,
+        top_k_join: Optional[int] = None,
+        sort_by_score: bool = True,
     ):
         """
         :param join_mode: `concatenate` to combine documents from multiple retrievers `merge` to aggregate scores of
@@ -30,6 +38,9 @@ def __init__(
                         adjusting document scores when using the `merge` join_mode. By default, equal weight is given
                         to each retriever score. This param is not compatible with the `concatenate` join_mode.
         :param top_k_join: Limit documents to top_k based on the resulting scores of the join.
+        :param sort_by_score: Whether to sort the incoming documents by their score. Set this to True if all your
+                              Documents are coming with `score` values. Set to False if any of the Documents come
+                              from sources where the `score` is set to `None`, like `TfidfRetriever` on Elasticsearch.
         """
         assert join_mode in [
             "concatenate",
@@ -46,6 +57,7 @@ def __init__(
         self.join_mode = join_mode
         self.weights = [float(i) / sum(weights) for i in weights] if weights else None
         self.top_k_join = top_k_join
+        self.sort_by_score = sort_by_score
 
     def run_accumulated(self, inputs: List[dict], top_k_join: Optional[int] = None):  # type: ignore
         results = [inp["documents"] for inp in inputs]
@@ -60,7 +72,17 @@ def run_accumulated(self, inputs: List[dict], top_k_join: Optional[int] = None):
         else:
             raise ValueError(f"Invalid join_mode: {self.join_mode}")
 
-        sorted_docs = sorted(scores_map.items(), key=lambda d: d[1], reverse=True)
+        # only sort the docs if that was requested
+        if self.sort_by_score:
+            sorted_docs = sorted(scores_map.items(), key=lambda d: d[1] if d[1] is not None else -inf, reverse=True)
+            if any(s is None for s in scores_map.values()):
+                logger.info(
+                    "The `JoinDocuments` node has received some documents with `score=None` - and was requested "
+                    "to sort the documents by score, so the `score=None` documents got sorted as if their "
+                    "score would be `-infinity`."
+                )
+        else:
+            sorted_docs = [(k, v) for k, v in scores_map.items()]
 
         if not top_k_join:
             top_k_join = self.top_k_join
@@ -111,7 +133,7 @@ def _calculate_comb_sum(self, results):
 
         for result, weight in zip(results, weights):
             for doc in result:
-                scores_map[doc.id] += doc.score * weight
+                scores_map[doc.id] += (doc.score if doc.score else 0) * weight
 
         return scores_map
 


Test patch:
diff --git a/test/nodes/test_other.py b/test/nodes/test_other.py
--- a/test/nodes/test_other.py
+++ b/test/nodes/test_other.py
@@ -4,6 +4,7 @@
 from haystack import Document, Answer
 from haystack.nodes.other.route_documents import RouteDocuments
 from haystack.nodes.other.join_answers import JoinAnswers
+from haystack.nodes.other.join_docs import JoinDocuments
 
 
 #
@@ -56,3 +57,45 @@ def test_joinanswers(join_mode):
     result, _ = join_answers.run(inputs, top_k_join=1)
     assert len(result["answers"]) == 1
     assert result["answers"][0].answer == "answer 2"
+
+
+#
+# JoinDocuments tests
+#
+
+
+@pytest.mark.parametrize("join_mode", ["concatenate", "merge", "reciprocal_rank_fusion"])
+def test_joindocuments(join_mode):
+    inputs = [
+        {"documents": [Document(content="text document 1", content_type="text", score=0.2)]},
+        {"documents": [Document(content="text document 2", content_type="text", score=0.7)]},
+    ]
+
+    join_docs = JoinDocuments(join_mode=join_mode)
+    result, _ = join_docs.run(inputs)
+    assert len(result["documents"]) == 2
+    assert result["documents"] == sorted(result["documents"], reverse=True)
+
+    result, _ = join_docs.run(inputs, top_k_join=1)
+    assert len(result["documents"]) == 1
+    if join_mode == "reciprocal_rank_fusion":
+        assert result["documents"][0].content == "text document 1"
+    else:
+        assert result["documents"][0].content == "text document 2"
+
+
+@pytest.mark.parametrize("join_mode", ["concatenate", "merge", "reciprocal_rank_fusion"])
+@pytest.mark.parametrize("sort_by_score", [True, False])
+def test_joindocuments_score_none(join_mode, sort_by_score):
+    """Testing JoinDocuments() node when some of the documents have `score=None`"""
+    inputs = [
+        {"documents": [Document(content="text document 1", content_type="text", score=0.2)]},
+        {"documents": [Document(content="text document 2", content_type="text", score=None)]},
+    ]
+
+    join_docs = JoinDocuments(join_mode=join_mode, sort_by_score=sort_by_score)
+    result, _ = join_docs.run(inputs)
+    assert len(result["documents"]) == 2
+
+    result, _ = join_docs.run(inputs, top_k_join=1)
+    assert len(result["documents"]) == 1