Skip to content

Commit bfb7a28

Browse files
authored
fix: improve Document compatibility/validation layer (#12016)
1 parent 7626194 commit bfb7a28

5 files changed

Lines changed: 76 additions & 67 deletions

File tree

haystack/components/rankers/lost_in_the_middle.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ def run(
100100
return {"documents": documents_to_reorder}
101101

102102
# Raise an error if any document is not textual
103-
if any(not doc.content_type == "text" for doc in documents_to_reorder):
103+
if any(doc.content is None for doc in documents_to_reorder):
104104
raise ValueError("Some provided documents are not textual; LostInTheMiddleRanker can process only text.")
105105

106106
# Initialize word count and indices for the "lost in the middle" order

haystack/dataclasses/document.py

Lines changed: 22 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -13,40 +13,23 @@
1313
from haystack.dataclasses.sparse_embedding import SparseEmbedding
1414
from haystack.utils.dataclasses import _warn_on_inplace_mutation
1515

16-
LEGACY_FIELDS = ["content_type", "id_hash_keys", "dataframe"]
16+
_LEGACY_FIELDS = ["content_type", "id_hash_keys", "dataframe"]
1717

1818

19-
class _BackwardCompatible(type):
20-
"""
21-
Metaclass that handles Document backward compatibility.
22-
"""
23-
19+
class _RemoveLegacyFields(type):
2420
def __call__(cls, *args: Any, **kwargs: Any) -> Any:
2521
"""
26-
Called before Document.__init__, handles legacy fields.
27-
28-
Embedding was stored as NumPy arrays in 1.x, so we convert it to a list of floats.
29-
Other legacy fields are removed.
22+
Called before Document.__init__, removes the legacy fields.
3023
"""
31-
### Conversion from 1.x Document ###
32-
content = kwargs.get("content")
33-
if content and not isinstance(content, str):
34-
raise ValueError("The `content` field must be a string or None.")
35-
36-
# Embedding were stored as NumPy arrays in 1.x, so we convert it to the new type
37-
if isinstance(embedding := kwargs.get("embedding"), ndarray):
38-
kwargs["embedding"] = embedding.tolist()
39-
40-
# Remove legacy fields
41-
for field_name in LEGACY_FIELDS:
24+
for field_name in _LEGACY_FIELDS:
4225
kwargs.pop(field_name, None)
4326

4427
return super().__call__(*args, **kwargs)
4528

4629

4730
@_warn_on_inplace_mutation
4831
@dataclass
49-
class Document(metaclass=_BackwardCompatible): # noqa: PLW1641
32+
class Document(metaclass=_RemoveLegacyFields): # noqa: PLW1641
5033
"""
5134
Base data class containing some data to be queried.
5235
@@ -70,6 +53,20 @@ class Document(metaclass=_BackwardCompatible): # noqa: PLW1641
7053
embedding: list[float] | None = field(default=None)
7154
sparse_embedding: SparseEmbedding | None = field(default=None)
7255

56+
def __post_init__(self) -> None:
57+
"""
58+
Checks content type, converts embedding from 1.x type and generates the ID based on the init parameters.
59+
"""
60+
if self.content is not None and not isinstance(self.content, str):
61+
raise ValueError("The `content` field must be a string or None.")
62+
63+
# Embeddings were stored as NumPy arrays in 1.x, so we convert them to the new type
64+
if isinstance(self.embedding, ndarray):
65+
self.embedding = self.embedding.tolist()
66+
67+
# Generate an id only if not explicitly set
68+
self.id = self.id or self._create_id()
69+
7370
def __repr__(self) -> str:
7471
fields = []
7572
if self.content is not None:
@@ -99,13 +96,6 @@ def __eq__(self, other: object) -> bool:
9996
return False
10097
return self.to_dict() == other.to_dict()
10198

102-
def __post_init__(self) -> None:
103-
"""
104-
Generate the ID based on the init parameters.
105-
"""
106-
# Generate an id only if not explicitly set
107-
self.id = self.id or self._create_id()
108-
10999
def _create_id(self) -> str:
110100
"""
111101
Creates a hash of the given content that acts as the document's ID.
@@ -162,10 +152,10 @@ def from_dict(cls, data: dict[str, Any]) -> "Document":
162152
# ValueError later if this is the case.
163153
meta = data.pop("meta", {})
164154
# Unflatten metadata if it was flattened. We assume any keyword argument that's not
165-
# a document field is a metadata key. We treat legacy fields as document fields
166-
# for backward compatibility.
155+
# a document field is a metadata key. We treat legacy fields as document fields, so that
156+
# `_RemoveLegacyFields` drops them instead of them ending up in `meta`.
167157
flatten_meta = {}
168-
document_fields = LEGACY_FIELDS + [f.name for f in fields(cls)]
158+
document_fields = _LEGACY_FIELDS + [f.name for f in fields(cls)]
169159
for key in list(data.keys()):
170160
if key not in document_fields:
171161
flatten_meta[key] = data.pop(key)
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
fixes:
3+
- |
4+
Improved the validation and compatibility layer of ``Document``:
5+
- since Haystack 2.x, ``embedding`` is a list of floats, while it was a NumPy array in 1.x, so a NumPy
6+
``embedding`` is converted to a list for backward compatibility. The conversion only happened when ``embedding``
7+
was passed as a keyword argument: passed positionally, the array was silently left as is, potentially causing
8+
downstream issues.
9+
- ``content`` type was validated only when passed as a keyword argument.
10+
- ``content`` type validation was also skipped when the value was falsy, such as ``0`` or ``[]``, and failed later
11+
with an unrelated error.

test/components/rankers/test_lost_in_the_middle.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from haystack import Document
88
from haystack.components.rankers.lost_in_the_middle import LostInTheMiddleRanker
9+
from haystack.dataclasses.byte_stream import ByteStream
910

1011

1112
class TestLostInTheMiddleRanker:
@@ -35,6 +36,12 @@ def test_lost_in_the_middle_order_two_docs(self):
3536
assert result["documents"][0].content == "1"
3637
assert result["documents"][1].content == "2"
3738

39+
def test_lost_in_the_middle_with_non_textual_documents(self):
40+
ranker = LostInTheMiddleRanker()
41+
docs = [Document(content="1"), Document(blob=ByteStream(b"some bytes"))]
42+
with pytest.raises(ValueError, match="Some provided documents are not textual"):
43+
ranker.run(documents=docs)
44+
3845
def test_lost_in_the_middle_init(self):
3946
# tests that LostInTheMiddleRanker initializes with default values
4047
ranker = LostInTheMiddleRanker()

test/dataclasses/test_document.py

Lines changed: 35 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from copy import deepcopy
77
from dataclasses import replace
88

9+
import numpy
910
import pytest
1011

1112
from haystack import Document
@@ -63,47 +64,47 @@ def test_init_with_parameters():
6364
assert doc.sparse_embedding == sparse_embedding
6465

6566

66-
def test_init_with_legacy_fields():
67-
doc = Document(
68-
content="test text",
69-
content_type="text",
70-
id_hash_keys=["content"],
71-
dataframe="placeholder",
72-
score=0.812,
73-
embedding=[0.1, 0.2, 0.3], # type: ignore
74-
)
75-
assert doc.id == "18fc2c114825872321cf5009827ca162f54d3be50ab9e9ffa027824b6ec223af"
76-
assert doc.content == "test text"
77-
assert doc.blob is None
78-
assert doc.meta == {}
79-
assert doc.score == 0.812
80-
assert doc.embedding == [0.1, 0.2, 0.3]
81-
assert doc.sparse_embedding is None
67+
@pytest.mark.parametrize(
68+
"legacy_fields,meta,expected_id",
69+
[
70+
(
71+
{"content_type": "text", "id_hash_keys": ["content"], "dataframe": "placeholder"},
72+
{},
73+
"18fc2c114825872321cf5009827ca162f54d3be50ab9e9ffa027824b6ec223af",
74+
),
75+
(
76+
{"content_type": "text", "id_hash_keys": ["content"]},
77+
{"date": "10-10-2023", "type": "article"},
78+
"dcd4914f727544e89ce8082f6f2e298d244dd0803a4dc167f19d24e7d43b28ac",
79+
),
80+
],
81+
)
82+
def test_init_with_legacy_fields(legacy_fields, meta, expected_id):
83+
doc = Document(content="test text", score=0.812, embedding=[0.1, 0.2, 0.3], meta=meta, **legacy_fields)
8284

85+
# legacy fields are dropped, so the Document is the same as if they were not passed at all
86+
assert doc == Document(content="test text", score=0.812, embedding=[0.1, 0.2, 0.3], meta=meta)
87+
assert doc.id == expected_id
8388
assert doc.content_type == "text" # this is a property now
84-
8589
assert not hasattr(doc, "id_hash_keys")
8690
assert not hasattr(doc, "dataframe")
8791

8892

89-
def test_init_with_legacy_field():
90-
doc = Document(
91-
content="test text",
92-
content_type="text", # type: ignore
93-
id_hash_keys=["content"],
94-
score=0.812,
95-
embedding=[0.1, 0.2, 0.3],
96-
meta={"date": "10-10-2023", "type": "article"},
97-
)
98-
assert doc.id == "dcd4914f727544e89ce8082f6f2e298d244dd0803a4dc167f19d24e7d43b28ac"
99-
assert doc.content == "test text"
100-
assert doc.meta == {"date": "10-10-2023", "type": "article"}
101-
assert doc.score == 0.812
102-
assert doc.embedding == [0.1, 0.2, 0.3]
103-
assert doc.sparse_embedding is None
93+
@pytest.mark.parametrize("content", [123, 0, []])
94+
def test_init_with_non_string_content(content):
95+
with pytest.raises(ValueError, match="must be a string or None"):
96+
Document(content=content)
10497

105-
assert doc.content_type == "text" # this is a property now
106-
assert not hasattr(doc, "id_hash_keys")
98+
with pytest.raises(ValueError, match="must be a string or None"):
99+
Document("", content)
100+
101+
102+
def test_init_with_numpy_embedding():
103+
expected = Document(content="test text", embedding=[0.1, 0.2, 0.3])
104+
105+
assert Document(content="test text", embedding=numpy.array([0.1, 0.2, 0.3])) == expected # type: ignore[arg-type]
106+
107+
assert Document("", "test text", None, {}, None, numpy.array([0.1, 0.2, 0.3])) == expected # type: ignore[arg-type]
107108

108109

109110
def test_basic_equality_type_mismatch():

0 commit comments

Comments
 (0)