falkordb-haystack 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,51 @@
1
+ Metadata-Version: 2.4
2
+ Name: falkordb-haystack
3
+ Version: 1.0.0
4
+ Summary: FalkorDB graph database integration for Haystack — enables GraphRAG with OpenCypher
5
+ Project-URL: Documentation, https://github.com/deepset-ai/haystack-core-integrations/tree/main/integrations/falkordb#readme
6
+ Project-URL: Issues, https://github.com/deepset-ai/haystack-core-integrations/issues
7
+ Project-URL: Source, https://github.com/deepset-ai/haystack-core-integrations/tree/main/integrations/falkordb
8
+ Author-email: deepset GmbH <info@deepset.ai>
9
+ License-Expression: Apache-2.0
10
+ License-File: LICENSE.txt
11
+ Keywords: document-store,falkordb,graph,graph-database,graphrag,haystack,openCypher,rag,vector-search
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: License :: OSI Approved :: Apache Software License
14
+ Classifier: Programming Language :: Python
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Programming Language :: Python :: 3.14
20
+ Classifier: Programming Language :: Python :: Implementation :: CPython
21
+ Classifier: Programming Language :: Python :: Implementation :: PyPy
22
+ Classifier: Topic :: Database
23
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
24
+ Requires-Python: >=3.10
25
+ Requires-Dist: falkordb<2,>=1.1
26
+ Requires-Dist: haystack-ai>=2.26.1
27
+ Description-Content-Type: text/markdown
28
+
29
+ # falkordb-haystack
30
+
31
+ [![PyPI - Version](https://img.shields.io/pypi/v/falkordb-haystack.svg)](https://pypi.org/project/falkordb-haystack)
32
+ [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/falkordb-haystack.svg)](https://pypi.org/project/falkordb-haystack)
33
+
34
+ - [Changelog](https://github.com/deepset-ai/haystack-core-integrations/blob/main/integrations/falkordb/CHANGELOG.md)
35
+
36
+ ---
37
+
38
+
39
+ ## Contributing
40
+
41
+ Refer to the general [Contribution Guidelines](https://github.com/deepset-ai/haystack-core-integrations/blob/main/CONTRIBUTING.md).
42
+
43
+
44
+
45
+ ## Testing
46
+
47
+ To run the integration tests locally, you need a running FalkorDB instance. You can start one using the provided docker-compose file:
48
+
49
+ ```bash
50
+ docker-compose up -d
51
+ ```
@@ -0,0 +1,11 @@
1
+ haystack_integrations/components/retrievers/py.typed,sha256=4W8VliAYUP1KY2gLJ_YDy2TmcXYVm-PY7XikQD_bFwA,2
2
+ haystack_integrations/components/retrievers/falkordb/__init__.py,sha256=g681NER0VaB-xo14jFzOi66YQRh9oLxnIvmGI2ydJQs,294
3
+ haystack_integrations/components/retrievers/falkordb/cypher_retriever.py,sha256=yk5xghWjyHDVya57PD6Q8EADqh_UDoMB6MxTQgI8f_Q,4199
4
+ haystack_integrations/components/retrievers/falkordb/embedding_retriever.py,sha256=kV3Z5gdVjHT1Tfx0b2Lq9fAqiFaahkpW-bPJAE2r_78,4701
5
+ haystack_integrations/document_stores/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ haystack_integrations/document_stores/falkordb/__init__.py,sha256=v7rOS3LAZ6EYknSYtXmjkBD8O5eFIuLmV6TqFHb1arY,244
7
+ haystack_integrations/document_stores/falkordb/document_store.py,sha256=AvsWDtJeydLd0kGC-2HspaGu7ADDxUtUxK-gIInuV8g,28587
8
+ falkordb_haystack-1.0.0.dist-info/METADATA,sha256=oYpoJM43rIwrorG7PcTN0HjNjZSG7ku9SDdyF-erjvs,2184
9
+ falkordb_haystack-1.0.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
10
+ falkordb_haystack-1.0.0.dist-info/licenses/LICENSE.txt,sha256=OrAtTu4aPmrTjKDSTzkSPgo_ji6NOMWO2N9JOTFyAzM,11350
11
+ falkordb_haystack-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,201 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright 2023-present deepset GmbH
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
@@ -0,0 +1,8 @@
1
+ # SPDX-FileCopyrightText: 2024-present deepset GmbH <info@deepset.ai>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ from .cypher_retriever import FalkorDBCypherRetriever
6
+ from .embedding_retriever import FalkorDBEmbeddingRetriever
7
+
8
+ __all__ = ["FalkorDBCypherRetriever", "FalkorDBEmbeddingRetriever"]
@@ -0,0 +1,113 @@
1
+ # SPDX-FileCopyrightText: 2024-present deepset GmbH <info@deepset.ai>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ from typing import Any
6
+
7
+ from haystack import component, default_from_dict, default_to_dict
8
+ from haystack.dataclasses import Document
9
+
10
+ from haystack_integrations.document_stores.falkordb import FalkorDBDocumentStore
11
+
12
+
13
+ @component
14
+ class FalkorDBCypherRetriever:
15
+ """
16
+ A power-user retriever for executing arbitrary OpenCypher queries against FalkorDB.
17
+
18
+ This retriever allows you to leverage graph traversal and multi-hop queries in
19
+ GraphRAG pipelines. The query must return nodes or dictionaries that can be
20
+ mapped exactly to a Haystack `Document`.
21
+
22
+ **Security Warning:** Raw Cypher queries must only come from trusted sources. Do
23
+ not use un-sanitised user input directly in query strings. Use `parameters` instead.
24
+
25
+ Usage example:
26
+ ```python
27
+ from haystack_integrations.document_stores.falkordb import FalkorDBDocumentStore
28
+ from haystack_integrations.components.retrievers.falkordb import FalkorDBCypherRetriever
29
+
30
+ store = FalkorDBDocumentStore(host="localhost", port=6379)
31
+ retriever = FalkorDBCypherRetriever(
32
+ document_store=store,
33
+ custom_cypher_query="MATCH (d:Document)-[:RELATES_TO]->(:Concept {name: $concept}) RETURN d"
34
+ )
35
+
36
+ res = retriever.run(parameters={"concept": "GraphRAG"})
37
+ print(res["documents"])
38
+ ```
39
+ """
40
+
41
+ def __init__(
42
+ self,
43
+ document_store: FalkorDBDocumentStore,
44
+ custom_cypher_query: str | None = None,
45
+ ) -> None:
46
+ """
47
+ Create a new FalkorDBCypherRetriever.
48
+
49
+ :param document_store: The FalkorDBDocumentStore instance.
50
+ :param custom_cypher_query: A static OpenCypher query to execute. Can be
51
+ overridden at runtime by passing `query` to `run()`.
52
+ :raises ValueError: If the provided `document_store` is not a `FalkorDBDocumentStore`.
53
+ """
54
+ if not isinstance(document_store, FalkorDBDocumentStore):
55
+ msg = "document_store must be an instance of FalkorDBDocumentStore"
56
+ raise ValueError(msg)
57
+
58
+ self.document_store = document_store
59
+ self.custom_cypher_query = custom_cypher_query
60
+
61
+ def to_dict(self) -> dict[str, Any]:
62
+ """
63
+ Serialise the retriever to a dictionary.
64
+
65
+ :returns: Dictionary representation of the retriever.
66
+ """
67
+ return default_to_dict(
68
+ self,
69
+ document_store=self.document_store.to_dict(),
70
+ custom_cypher_query=self.custom_cypher_query,
71
+ )
72
+
73
+ @classmethod
74
+ def from_dict(cls, data: dict[str, Any]) -> "FalkorDBCypherRetriever":
75
+ """
76
+ Deserialise a `FalkorDBCypherRetriever` produced by `to_dict`.
77
+
78
+ :param data: Serialised retriever dictionary.
79
+ :returns: Reconstructed `FalkorDBCypherRetriever` instance.
80
+ """
81
+ init_params = data["init_parameters"]
82
+ init_params["document_store"] = FalkorDBDocumentStore.from_dict(init_params["document_store"])
83
+ return default_from_dict(cls, data)
84
+
85
+ @component.output_types(documents=list[Document])
86
+ def run(
87
+ self,
88
+ query: str | None = None,
89
+ parameters: dict[str, Any] | None = None,
90
+ ) -> dict[str, list[Document]]:
91
+ """
92
+ Retrieve documents by executing an OpenCypher query.
93
+
94
+ If a `query` is provided here, it overrides the `custom_cypher_query`
95
+ set during initialisation.
96
+
97
+ :param query: Optional OpenCypher query string.
98
+ :param parameters: Optional dictionary of query parameters (referenced as
99
+ `$param_name` in the Cypher string).
100
+ :raises ValueError: If no query string is provided (both here and at init).
101
+ :returns: Dictionary containing a `"documents"` key with the retrieved documents.
102
+ """
103
+ cypher = query or self.custom_cypher_query
104
+ if not cypher:
105
+ msg = "A Cypher query string must be provided either at init or at runtime."
106
+ raise ValueError(msg)
107
+
108
+ docs = self.document_store._cypher_retrieval(
109
+ cypher_query=cypher,
110
+ parameters=parameters,
111
+ )
112
+
113
+ return {"documents": docs}
@@ -0,0 +1,120 @@
1
+ # SPDX-FileCopyrightText: 2024-present deepset GmbH <info@deepset.ai>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ from typing import Any
6
+
7
+ from haystack import component, default_from_dict, default_to_dict
8
+ from haystack.dataclasses import Document
9
+ from haystack.document_stores.types import FilterPolicy, apply_filter_policy
10
+
11
+ from haystack_integrations.document_stores.falkordb import FalkorDBDocumentStore
12
+
13
+
14
+ @component
15
+ class FalkorDBEmbeddingRetriever:
16
+ """
17
+ A component for retrieving documents from a FalkorDBDocumentStore using vector similarity.
18
+
19
+ The retriever uses FalkorDB's native vector search index to find documents whose embeddings
20
+ are most similar to the provided query embedding.
21
+
22
+ Usage example:
23
+ ```python
24
+ from haystack.dataclasses import Document
25
+ from haystack_integrations.document_stores.falkordb import FalkorDBDocumentStore
26
+ from haystack_integrations.components.retrievers.falkordb import FalkorDBEmbeddingRetriever
27
+
28
+ store = FalkorDBDocumentStore(host="localhost", port=6379)
29
+ store.write_documents([
30
+ Document(content="GraphRAG is powerful.", embedding=[0.1, 0.2, 0.3]),
31
+ Document(content="FalkorDB is fast.", embedding=[0.8, 0.9, 0.1]),
32
+ ])
33
+
34
+ retriever = FalkorDBEmbeddingRetriever(document_store=store)
35
+ res = retriever.run(query_embedding=[0.1, 0.2, 0.3])
36
+ print(res["documents"][0].content) # "GraphRAG is powerful."
37
+ ```
38
+ """
39
+
40
+ def __init__(
41
+ self,
42
+ document_store: FalkorDBDocumentStore,
43
+ filters: dict[str, Any] | None = None,
44
+ top_k: int = 10,
45
+ filter_policy: FilterPolicy = FilterPolicy.REPLACE,
46
+ ) -> None:
47
+ """
48
+ Create a new FalkorDBEmbeddingRetriever.
49
+
50
+ :param document_store: The FalkorDBDocumentStore instance.
51
+ :param filters: Optional Haystack filters to narrow down the search space.
52
+ :param top_k: Maximum number of documents to retrieve.
53
+ :param filter_policy: Policy to determine how runtime filters are combined with
54
+ initialization filters.
55
+ :raises ValueError: If the provided `document_store` is not a `FalkorDBDocumentStore`.
56
+ """
57
+ if not isinstance(document_store, FalkorDBDocumentStore):
58
+ msg = "document_store must be an instance of FalkorDBDocumentStore"
59
+ raise ValueError(msg)
60
+
61
+ self.document_store = document_store
62
+ self.filters = filters
63
+ self.top_k = top_k
64
+ self.filter_policy = FilterPolicy(filter_policy) if isinstance(filter_policy, str) else filter_policy
65
+
66
+ def to_dict(self) -> dict[str, Any]:
67
+ """
68
+ Serialise the retriever to a dictionary.
69
+
70
+ :returns: Dictionary representation of the retriever.
71
+ """
72
+ return default_to_dict(
73
+ self,
74
+ document_store=self.document_store.to_dict(),
75
+ filters=self.filters,
76
+ top_k=self.top_k,
77
+ filter_policy=self.filter_policy.value,
78
+ )
79
+
80
+ @classmethod
81
+ def from_dict(cls, data: dict[str, Any]) -> "FalkorDBEmbeddingRetriever":
82
+ """
83
+ Deserialise a `FalkorDBEmbeddingRetriever` produced by `to_dict`.
84
+
85
+ :param data: Serialised retriever dictionary.
86
+ :returns: Reconstructed `FalkorDBEmbeddingRetriever` instance.
87
+ """
88
+ init_params = data["init_parameters"]
89
+ init_params["document_store"] = FalkorDBDocumentStore.from_dict(init_params["document_store"])
90
+ if "filter_policy" in init_params:
91
+ init_params["filter_policy"] = FilterPolicy(init_params["filter_policy"])
92
+ return default_from_dict(cls, data)
93
+
94
+ @component.output_types(documents=list[Document])
95
+ def run(
96
+ self,
97
+ query_embedding: list[float],
98
+ filters: dict[str, Any] | None = None,
99
+ top_k: int | None = None,
100
+ ) -> dict[str, list[Document]]:
101
+ """
102
+ Retrieve documents by vector similarity.
103
+
104
+ :param query_embedding: Query embedding vector.
105
+ :param filters: Optional Haystack filters to be combined with the init filters based
106
+ on the configured filter policy.
107
+ :param top_k: Maximum number of documents to return. If not provided, the default
108
+ top_k from initialization is used.
109
+ :returns: Dictionary containing a `"documents"` key with the retrieved documents.
110
+ """
111
+ final_filters = apply_filter_policy(self.filter_policy, self.filters, filters)
112
+ final_top_k = top_k if top_k is not None else self.top_k
113
+
114
+ docs = self.document_store._embedding_retrieval(
115
+ query_embedding=query_embedding,
116
+ top_k=final_top_k,
117
+ filters=final_filters,
118
+ )
119
+
120
+ return {"documents": docs}
@@ -0,0 +1,7 @@
1
+ # SPDX-FileCopyrightText: 2024-present deepset GmbH <info@deepset.ai>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ from haystack_integrations.document_stores.falkordb.document_store import FalkorDBDocumentStore
6
+
7
+ __all__ = ["FalkorDBDocumentStore"]
@@ -0,0 +1,728 @@
1
+ # SPDX-FileCopyrightText: 2024-present deepset GmbH <info@deepset.ai>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ from __future__ import annotations
6
+
7
+ import logging
8
+ from dataclasses import replace
9
+ from datetime import datetime
10
+ from typing import Any, Literal
11
+
12
+ from haystack import default_from_dict, default_to_dict
13
+ from haystack.dataclasses import Document
14
+ from haystack.document_stores.errors import DocumentStoreError, DuplicateDocumentError
15
+ from haystack.document_stores.types import DocumentStore, DuplicatePolicy
16
+ from haystack.errors import FilterError
17
+ from haystack.utils import Secret, deserialize_secrets_inplace
18
+ from redis.exceptions import ResponseError
19
+
20
+ import falkordb # type: ignore[import-untyped,import-not-found]
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ # Haystack filter operators → Cypher comparison operators.
25
+ _COMPARISON_OPS: dict[str, str] = {
26
+ "==": "=",
27
+ "!=": "<>",
28
+ ">": ">",
29
+ ">=": ">=",
30
+ "<": "<",
31
+ "<=": "<=",
32
+ }
33
+
34
+ SimilarityFunction = Literal["cosine", "euclidean"]
35
+
36
+
37
+ class FalkorDBDocumentStore(DocumentStore):
38
+ """
39
+ A Haystack DocumentStore backed by FalkorDB — a high-performance graph database.
40
+
41
+ Optimised for GraphRAG workloads.
42
+
43
+ Documents are stored as graph nodes (labelled `Document` by default) in a named
44
+ FalkorDB graph. Document properties, including `meta` fields, are stored
45
+ **flat** at the same level as `id` and `content` — exactly the same layout as
46
+ the `neo4j-haystack` reference integration.
47
+
48
+ Vector search is performed via FalkorDB's native vector index —
49
+ **no APOC is required**. All bulk writes use `UNWIND` + `MERGE` for safe,
50
+ idiomatic OpenCypher upserts.
51
+
52
+ Usage example:
53
+
54
+ ```python
55
+ from haystack_integrations.document_stores.falkordb import FalkorDBDocumentStore
56
+ from haystack.dataclasses import Document
57
+
58
+ store = FalkorDBDocumentStore(host="localhost", port=6379)
59
+ store.write_documents([
60
+ Document(content="Hello, GraphRAG!", meta={"year": 2024}),
61
+ ])
62
+ print(store.count_documents()) # 1
63
+ ```
64
+ """
65
+
66
+ def __init__(
67
+ self,
68
+ *,
69
+ host: str = "localhost",
70
+ port: int = 6379,
71
+ graph_name: str = "haystack",
72
+ username: str | None = None,
73
+ password: Secret | None = None,
74
+ node_label: str = "Document",
75
+ embedding_dim: int = 768,
76
+ embedding_field: str = "embedding",
77
+ similarity: SimilarityFunction = "cosine",
78
+ write_batch_size: int = 100,
79
+ recreate_graph: bool = False,
80
+ verify_connectivity: bool = False,
81
+ ) -> None:
82
+ """
83
+ Create a new FalkorDBDocumentStore.
84
+
85
+ :param host: Hostname of the FalkorDB server.
86
+ :param port: Port the FalkorDB server listens on.
87
+ :param graph_name: Name of the FalkorDB graph to use. Each graph is an isolated
88
+ namespace.
89
+ :param username: Optional username for FalkorDB authentication.
90
+ :param password: Optional :class:`haystack.utils.Secret` holding the FalkorDB
91
+ password. The secret value is resolved lazily on first connection.
92
+ :param node_label: Label used for document nodes in the graph.
93
+ :param embedding_dim: Dimensionality of the vector embeddings. Used when
94
+ creating the vector index.
95
+ :param embedding_field: Name of the node property that stores the embedding
96
+ vector.
97
+ :param similarity: Similarity function for the vector index. Accepted values
98
+ are `"cosine"` and `"euclidean"`.
99
+ :param write_batch_size: Number of documents written per `UNWIND` batch.
100
+ :param recreate_graph: When `True` the existing graph (and all its data) is
101
+ dropped and recreated on initialisation. Useful for tests.
102
+ :param verify_connectivity: When `True` a connectivity probe is run
103
+ immediately in `__init__` — raises if the server is unreachable.
104
+ :raises ValueError: If `similarity` is not `"cosine"` or `"euclidean"`.
105
+ """
106
+ if similarity not in ("cosine", "euclidean"):
107
+ msg = (
108
+ f"Provided similarity '{similarity}' is not supported by FalkorDBDocumentStore. "
109
+ "Please choose one of: 'cosine', 'euclidean'."
110
+ )
111
+ raise ValueError(msg)
112
+
113
+ self.host = host
114
+ self.port = port
115
+ self.graph_name = graph_name
116
+ self.username = username
117
+ self.password = password
118
+ self.node_label = node_label
119
+ self.embedding_dim = embedding_dim
120
+ self.embedding_field = embedding_field
121
+ self.similarity: SimilarityFunction = similarity
122
+ self.write_batch_size = write_batch_size
123
+ self.recreate_graph = recreate_graph
124
+ self.verify_connectivity = verify_connectivity
125
+
126
+ # Lazy — populated on first use via ensure_connected().
127
+ self.client: Any = None
128
+ self.graph: Any = None
129
+ self.initialized: bool = False
130
+
131
+ if verify_connectivity:
132
+ self._ensure_connected()
133
+
134
+ def to_dict(self) -> dict[str, Any]:
135
+ """
136
+ Serialise the store to a dictionary suitable for `from_dict`.
137
+
138
+ :returns: Dictionary representation of the store.
139
+ """
140
+ return default_to_dict(
141
+ self,
142
+ host=self.host,
143
+ port=self.port,
144
+ graph_name=self.graph_name,
145
+ username=self.username,
146
+ password=self.password.to_dict() if self.password is not None else None,
147
+ node_label=self.node_label,
148
+ embedding_dim=self.embedding_dim,
149
+ embedding_field=self.embedding_field,
150
+ similarity=self.similarity,
151
+ write_batch_size=self.write_batch_size,
152
+ recreate_graph=self.recreate_graph,
153
+ verify_connectivity=self.verify_connectivity,
154
+ )
155
+
156
+ @classmethod
157
+ def from_dict(cls, data: dict[str, Any]) -> FalkorDBDocumentStore:
158
+ """
159
+ Deserialise a `FalkorDBDocumentStore` produced by `to_dict`.
160
+
161
+ :param data: Serialised store dictionary.
162
+ :returns: Reconstructed `FalkorDBDocumentStore` instance.
163
+ """
164
+ deserialize_secrets_inplace(data["init_parameters"], keys=["password"])
165
+ return default_from_dict(cls, data)
166
+
167
+ # ------------------------------------------------------------------
168
+ # Internal connection helpers
169
+ # ------------------------------------------------------------------
170
+
171
+ def _ensure_connected(self) -> None:
172
+ """
173
+ Lazily open the FalkorDB connection and set up the graph schema.
174
+
175
+ Called at the start of every public method so the store remains
176
+ serialisable without an active database connection.
177
+ """
178
+ if self.initialized:
179
+ return
180
+
181
+ password_value = self.password.resolve_value() if self.password is not None else None
182
+
183
+ self.client = falkordb.FalkorDB(
184
+ host=self.host,
185
+ port=self.port,
186
+ username=self.username,
187
+ password=password_value,
188
+ )
189
+
190
+ if self.recreate_graph:
191
+ try:
192
+ # In falkordb-py, delete() is a method of the Graph object
193
+ self.client.select_graph(self.graph_name).delete()
194
+ except Exception:
195
+ logger.debug("Graph '%s' could not be deleted (may not exist yet).", self.graph_name)
196
+
197
+ self.graph = self.client.select_graph(self.graph_name)
198
+ self._ensure_schema()
199
+ self.initialized = True
200
+
201
+ def _ensure_schema(self) -> None:
202
+ """
203
+ Create the property index and vector index if they do not already exist.
204
+
205
+ Uses only standard OpenCypher / FalkorDB-native syntax — **no APOC**.
206
+ """
207
+ # Property index on (:node_label {id}) for fast MERGE lookups.
208
+ try:
209
+ self.graph.query(f"CREATE INDEX FOR (d:{self.node_label}) ON (d.id)")
210
+ except ResponseError as e:
211
+ if "already indexed" in str(e).lower() or "already exists" in str(e).lower():
212
+ logger.debug("Property index on %s(id) already exists — skipping creation.", self.node_label)
213
+ else:
214
+ raise e
215
+
216
+ # FalkorDB-native vector index syntax
217
+ try:
218
+ cypher = (
219
+ f"CREATE VECTOR INDEX FOR (d:{self.node_label}) "
220
+ f"ON (d.{self.embedding_field}) "
221
+ f"OPTIONS {{dimension: {self.embedding_dim}, similarityFunction: '{self.similarity}'}}"
222
+ )
223
+ self.graph.query(cypher)
224
+ except ResponseError as e:
225
+ if "already indexed" in str(e).lower() or "already exists" in str(e).lower():
226
+ logger.debug(
227
+ "Vector index on %s(%s) already exists — skipping creation.",
228
+ self.node_label,
229
+ self.embedding_field,
230
+ )
231
+ else:
232
+ raise e
233
+
234
+ # ------------------------------------------------------------------
235
+ # Haystack DocumentStore protocol
236
+ # ------------------------------------------------------------------
237
+
238
+ def count_documents(self) -> int:
239
+ """
240
+ Return the number of documents currently stored in the graph.
241
+
242
+ :returns: Integer count of document nodes.
243
+ """
244
+ self._ensure_connected()
245
+ result = self.graph.query(f"MATCH (d:{self.node_label}) RETURN count(d) AS n")
246
+ rows = result.result_set
247
+ return int(rows[0][0]) if rows else 0
248
+
249
+ def filter_documents(self, filters: dict[str, Any] | None = None) -> list[Document]:
250
+ """
251
+ Retrieve all documents that match the provided Haystack filters.
252
+
253
+ :param filters: Optional Haystack filter dict. When `None` all documents are
254
+ returned. For filter syntax see
255
+ [Metadata filtering](https://docs.haystack.deepset.ai/docs/metadata-filtering)
256
+ :returns: List of matching :class:`haystack.dataclasses.Document` objects.
257
+ :raises ValueError: If the filter dict is malformed.
258
+ """
259
+ self._ensure_connected()
260
+ if not filters:
261
+ result = self.graph.query(f"MATCH (d:{self.node_label}) RETURN d ORDER BY d.id")
262
+ return [_node_to_document(row[0]) for row in result.result_set]
263
+
264
+ if "operator" not in filters:
265
+ msg = "Invalid filter syntax. See https://docs.haystack.deepset.ai/docs/metadata-filtering"
266
+ raise FilterError(msg)
267
+
268
+ where_clause, params = _convert_filters(filters)
269
+ cypher = f"MATCH (d:{self.node_label}) WHERE {where_clause} RETURN d ORDER BY d.id"
270
+
271
+ result = self.graph.query(cypher, params)
272
+ return [_node_to_document(row[0]) for row in result.result_set]
273
+
274
+ def write_documents(
275
+ self,
276
+ documents: list[Document],
277
+ policy: DuplicatePolicy = DuplicatePolicy.NONE,
278
+ ) -> int:
279
+ """
280
+ Write documents to the FalkorDB graph using `UNWIND` + `MERGE` for batching.
281
+
282
+ Document `meta` fields are stored **flat** at the same level as `id` and
283
+ `content` — no prefix is added. This matches the layout used by the
284
+ `neo4j-haystack` reference integration.
285
+
286
+ :param documents: List of :class:`haystack.dataclasses.Document` objects.
287
+ :param policy: How to handle documents whose `id` already exists.
288
+ Defaults to :attr:`DuplicatePolicy.NONE` (treated as FAIL).
289
+ :raises ValueError: If `documents` contains non-Document elements.
290
+ :raises DuplicateDocumentError: If `policy` is FAIL / NONE and a duplicate
291
+ ID is encountered.
292
+ :raises DocumentStoreError: If any other DB error occurs.
293
+ :returns: Number of documents written or updated.
294
+ """
295
+ self._ensure_connected()
296
+
297
+ for doc in documents:
298
+ if not isinstance(doc, Document):
299
+ msg = f"write_documents() expects a list of Documents but got an element of type {type(doc)}."
300
+ raise ValueError(msg)
301
+
302
+ if not documents:
303
+ logger.warning("Calling FalkorDBDocumentStore.write_documents() with an empty list.")
304
+ return 0
305
+
306
+ if policy == DuplicatePolicy.NONE:
307
+ policy = DuplicatePolicy.FAIL
308
+
309
+ document_objects = self._handle_duplicate_documents(documents, policy)
310
+
311
+ written = 0
312
+ for batch_start in range(0, len(document_objects), self.write_batch_size):
313
+ batch = document_objects[batch_start : batch_start + self.write_batch_size]
314
+ written += self._write_batch(batch, policy)
315
+
316
+ return written
317
+
318
+ def _handle_duplicate_documents(
319
+ self,
320
+ documents: list[Document],
321
+ policy: DuplicatePolicy,
322
+ ) -> list[Document]:
323
+ """
324
+ Checks for IDs that already exist in the database.
325
+
326
+ :param documents: All documents to write.
327
+ :param policy: Duplicate handling policy.
328
+ :returns: Filtered list ready for batch writing.
329
+ :raises DuplicateDocumentError: When `policy` is FAIL and existing IDs found.
330
+ """
331
+ if policy in (DuplicatePolicy.SKIP, DuplicatePolicy.FAIL):
332
+ # Step 1: deduplicate within the incoming list itself.
333
+ documents = self._drop_duplicate_documents(documents)
334
+
335
+ # Step 2: find which IDs already exist in the DB.
336
+ ids = [doc.id for doc in documents]
337
+ existing = self.graph.query(
338
+ f"UNWIND $ids AS id MATCH (d:{self.node_label} {{id: id}}) RETURN d.id",
339
+ {"ids": ids},
340
+ )
341
+ ids_exist_in_db: list[str] = [row[0] for row in existing.result_set]
342
+
343
+ if ids_exist_in_db and policy == DuplicatePolicy.FAIL:
344
+ msg = f"Document with ids '{', '.join(ids_exist_in_db)}' already exists in graph '{self.graph_name}'."
345
+ raise DuplicateDocumentError(msg)
346
+
347
+ # For SKIP: remove those that already exist.
348
+ if ids_exist_in_db:
349
+ existing_set = set(ids_exist_in_db)
350
+ documents = [d for d in documents if d.id not in existing_set]
351
+
352
+ return documents
353
+
354
+ def _drop_duplicate_documents(self, documents: list[Document]) -> list[Document]:
355
+ """
356
+ Drop duplicate documents (by ID) within the provided list.
357
+
358
+ :param documents: Input list — may contain repeated IDs.
359
+ :returns: Deduplicated list preserving first-occurrence order.
360
+ """
361
+ seen_ids: set[str] = set()
362
+ unique: list[Document] = []
363
+ for doc in documents:
364
+ if doc.id in seen_ids:
365
+ logger.info(
366
+ "Duplicate Documents: Document with id '%s' already present in the batch — skipping.",
367
+ doc.id,
368
+ )
369
+ continue
370
+ unique.append(doc)
371
+ seen_ids.add(doc.id)
372
+ return unique
373
+
374
+ def _write_batch(self, documents: list[Document], policy: DuplicatePolicy) -> int:
375
+ """
376
+ Write a single batch of documents using a single UNWIND query.
377
+
378
+ By the time this is called, duplicate handling has already been performed by
379
+ :meth:`_handle_duplicate_documents`.
380
+
381
+ :param documents: Batch of Documents (≤ `write_batch_size`).
382
+ :param policy: Duplicate policy — only OVERWRITE needs a different Cypher template.
383
+ :returns: Number of nodes created or updated.
384
+ """
385
+ records = [_document_to_falkordb_record(doc) for doc in documents]
386
+
387
+ if policy == DuplicatePolicy.OVERWRITE:
388
+ # ON MATCH SET applies the full map (including updated fields).
389
+ cypher = f"""
390
+ UNWIND $docs AS doc
391
+ MERGE (d:{self.node_label} {{id: doc.id}})
392
+ ON CREATE SET d += doc
393
+ ON MATCH SET d = doc
394
+ RETURN count(d) AS n
395
+ """
396
+ else:
397
+ # FAIL already filtered duplicates above; SKIP excluded them.
398
+ # In both remaining cases we only write truly-new nodes.
399
+ cypher = f"""
400
+ UNWIND $docs AS doc
401
+ MERGE (d:{self.node_label} {{id: doc.id}})
402
+ ON CREATE SET d += doc
403
+ RETURN count(d) AS n
404
+ """
405
+
406
+ try:
407
+ result = self.graph.query(cypher, {"docs": records})
408
+ rows = result.result_set
409
+ written = int(rows[0][0]) if rows else 0
410
+ except Exception as exc:
411
+ msg = f"Failed to write documents to FalkorDB: {exc}"
412
+ raise DocumentStoreError(msg) from exc
413
+
414
+ # Second pass: store embeddings as vecf32 so FalkorDB's vector index
415
+ # recognises them as VALUE_VECTORF32 rather than a plain float array.
416
+ # vecf32() cannot be used inline in the MERGE query above because
417
+ # falkordb-py >=1.1 is required to parse VALUE_VECTORF32 results, and
418
+ # mixing it with RETURN count() triggers a client-side parsing error on
419
+ # older clients.
420
+ docs_with_emb = [doc for doc in documents if doc.embedding is not None]
421
+ if docs_with_emb:
422
+ emb_rows = [{"id": doc.id, "emb": doc.embedding} for doc in docs_with_emb]
423
+ try:
424
+ self.graph.query(
425
+ f"""
426
+ UNWIND $docs AS doc
427
+ MATCH (d:{self.node_label} {{id: doc.id}})
428
+ SET d.{self.embedding_field} = vecf32(doc.emb)
429
+ """,
430
+ {"docs": emb_rows},
431
+ )
432
+ except Exception as exc:
433
+ msg = f"Failed to set embeddings in FalkorDB: {exc}"
434
+ raise DocumentStoreError(msg) from exc
435
+
436
+ return written
437
+
438
+ def delete_documents(self, document_ids: list[str]) -> None:
439
+ """
440
+ Delete documents by their IDs using a single `UNWIND`-based query.
441
+
442
+ :param document_ids: List of document IDs to remove from the graph.
443
+ """
444
+ self._ensure_connected()
445
+ if not document_ids:
446
+ return
447
+ self.graph.query(
448
+ f"UNWIND $ids AS id MATCH (d:{self.node_label} {{id: id}}) DETACH DELETE d",
449
+ {"ids": document_ids},
450
+ )
451
+
452
+ # ------------------------------------------------------------------
453
+ # Internal retrieval helpers (called by retriever components)
454
+ # ------------------------------------------------------------------
455
+
456
+ def _embedding_retrieval(
457
+ self,
458
+ query_embedding: list[float],
459
+ top_k: int = 10,
460
+ filters: dict[str, Any] | None = None,
461
+ scale_score: bool = True,
462
+ ) -> list[Document]:
463
+ """
464
+ Retrieve documents by vector similarity using FalkorDB's native vector index.
465
+
466
+ Uses `CALL db.idx.vector.queryNodes` — FalkorDB's OpenCypher extension for
467
+ ANN search. **No APOC is required.**
468
+
469
+ Cosine scores are returned as distance in `[0, 2]`; when `scale_score=True` they are
470
+ scaled to `[0, 1]` using the formula:
471
+ `1 - (score / 2)`. Euclidean scores are transformed with `1 / (1 + score)`.
472
+
473
+ :param query_embedding: Query vector as a plain Python list of floats.
474
+ :param top_k: Maximum number of results to return.
475
+ :param filters: Optional Haystack filters applied as a `WHERE` predicate
476
+ on the vector search result set (post-filter).
477
+ :param scale_score: Whether to scale the raw similarity score to `[0, 1]`.
478
+ :returns: List of :class:`Document` objects ordered by similarity (best first).
479
+ """
480
+ self._ensure_connected()
481
+
482
+ if filters:
483
+ where_clause, filter_params = _convert_filters(filters)
484
+ cypher = f"""
485
+ CALL db.idx.vector.queryNodes('{self.node_label}', '{self.embedding_field}', $top_k, vecf32($query_embedding))
486
+ YIELD node AS d, score
487
+ WHERE {where_clause}
488
+ RETURN d, score
489
+ ORDER BY score ASC, d.id ASC
490
+ """
491
+ params: dict[str, Any] = {
492
+ "top_k": top_k,
493
+ "query_embedding": query_embedding,
494
+ **filter_params,
495
+ }
496
+ else:
497
+ cypher = f"""
498
+ CALL db.idx.vector.queryNodes('{self.node_label}', '{self.embedding_field}', $top_k, vecf32($query_embedding))
499
+ YIELD node AS d, score
500
+ RETURN d, score
501
+ ORDER BY score ASC, d.id ASC
502
+ """
503
+ params = {"top_k": top_k, "query_embedding": query_embedding}
504
+
505
+ result = self.graph.query(cypher, params)
506
+ documents = []
507
+ for row in result.result_set:
508
+ node, score = row[0], row[1]
509
+ doc = _node_to_document(node)
510
+ final_score = self._scale_to_unit_interval(float(score)) if scale_score else float(score)
511
+ doc = replace(doc, score=final_score)
512
+ documents.append(doc)
513
+ return documents
514
+
515
+ def _cypher_retrieval(
516
+ self,
517
+ cypher_query: str,
518
+ parameters: dict[str, Any] | None = None,
519
+ ) -> list[Document]:
520
+ """
521
+ Execute an arbitrary OpenCypher query and map the results to Documents.
522
+
523
+ The first element of each result row is converted to a
524
+ :class:`haystack.dataclasses.Document`.
525
+
526
+ :param cypher_query: A valid OpenCypher query string.
527
+ :param parameters: Optional query parameters (`$param` placeholders).
528
+ :returns: List of :class:`Document` objects built from the query results.
529
+ :raises DocumentStoreError: If the query fails.
530
+ """
531
+ self._ensure_connected()
532
+
533
+ try:
534
+ # We don't force ORDER BY here as the query is custom,
535
+ # but we ensured everything else is stable.
536
+ result = self.graph.query(cypher_query, parameters or {})
537
+ return [_node_to_document(row[0]) for row in result.result_set]
538
+ except Exception as exc:
539
+ msg = f"Cypher query failed: {exc}"
540
+ raise DocumentStoreError(msg) from exc
541
+
542
+ def _scale_to_unit_interval(self, score: float) -> float:
543
+ """
544
+ Scale a raw similarity score to the unit interval `[0, 1]`.
545
+
546
+ Uses the following formulas:
547
+ - Cosine: `1 - (score / 2)`
548
+ - Euclidean: `1 / (1 + score)`
549
+
550
+ :param score: Raw score returned by the vector index.
551
+ :returns: Scaled score in `[0, 1]`.
552
+ """
553
+ if self.similarity == "cosine":
554
+ return 1 - (score / 2)
555
+ return 1 / (1 + score)
556
+
557
+
558
+ # ---------------------------------------------------------------------------
559
+ # Module-level helpers
560
+ # ---------------------------------------------------------------------------
561
+
562
+
563
+ def _document_to_falkordb_record(doc: Document) -> dict[str, Any]:
564
+ """
565
+ Convert a Haystack Document to a flat dict of non-embedding node properties.
566
+
567
+ - `meta` fields are stored **at the same level** as `id` and `content`.
568
+ - `embedding` is excluded; `_write_batch` stores it separately via `vecf32()`
569
+ so FalkorDB's vector index recognises the property as VALUE_VECTORF32.
570
+
571
+ :param doc: The document to convert.
572
+ :returns: Flat dictionary of non-embedding node properties.
573
+ """
574
+ record: dict[str, Any] = {}
575
+ if doc.meta:
576
+ record.update(doc.meta)
577
+ record["id"] = doc.id
578
+ record["content"] = doc.content
579
+
580
+ # Filter out None values — FalkorDB nodes don't need null properties stored.
581
+ return {k: v for k, v in record.items() if v is not None}
582
+
583
+
584
+ def _node_to_document(node: Any) -> Document:
585
+ """
586
+ Convert a FalkorDB graph node back to a Haystack Document.
587
+
588
+ Properties that are not part of the standard Document schema are moved
589
+ into the `meta` dictionary.
590
+
591
+ :param node: A FalkorDB `Node` object or a plain `dict`.
592
+ :returns: Reconstructed :class:`haystack.dataclasses.Document`.
593
+ """
594
+ if hasattr(node, "properties"):
595
+ record: dict[str, Any] = dict(node.properties)
596
+ elif isinstance(node, dict):
597
+ record = node
598
+ else:
599
+ record = {}
600
+
601
+ # Standard Document fields
602
+ doc_id = record.pop("id", None)
603
+ content = record.pop("content", None)
604
+ embedding = record.pop("embedding", None)
605
+ score = record.pop("score", None)
606
+
607
+ # Everything else is metadata
608
+ # sparse_embedding is also popped if present (not supported by falkordb yet)
609
+ record.pop("sparse_embedding", None)
610
+
611
+ return Document(id=doc_id, content=content, embedding=embedding, meta=record, score=score)
612
+
613
+
614
+ def _convert_filters(filters: dict[str, Any]) -> tuple[str, dict[str, Any]]:
615
+ """
616
+ Translate a Haystack filter dict into an OpenCypher `WHERE` sub-expression.
617
+
618
+ Supports the full Haystack filter DSL:
619
+
620
+ - Logical: `AND`, `OR`, `NOT`
621
+ - Comparison: `==`, `!=`, `>`, `>=`, `<`, `<=`
622
+ - Membership: `in`, `not in`
623
+
624
+ All values are passed as named query parameters to prevent injection.
625
+
626
+ :param filters: A Haystack filter dictionary.
627
+ :returns: Tuple of `(where_clause_string, params_dict)`.
628
+ :raises ValueError: If an unsupported operator or malformed filter is provided.
629
+ """
630
+ params: dict[str, Any] = {}
631
+ clause = _build_clause(filters, params, counter=[0])
632
+ return clause, params
633
+
634
+
635
+ def _build_clause(node: dict[str, Any], params: dict[str, Any], counter: list[int]) -> str:
636
+ """
637
+ Recursively build a Cypher WHERE sub-expression from a Haystack filter node.
638
+
639
+ :param node: A filter node (logical group or comparison leaf).
640
+ :param params: Accumulating query parameter dict (mutated in place).
641
+ :param counter: Single-element list used as a mutable integer for unique param names.
642
+ :returns: Cypher expression string.
643
+ """
644
+ operator = node.get("operator", "")
645
+
646
+ # ------------------------------------------------------------------
647
+ # Logical / grouping operators
648
+ # ------------------------------------------------------------------
649
+ if operator.upper() in ("AND", "OR"):
650
+ if "conditions" not in node:
651
+ msg = f"Logical operator '{operator}' requires a 'conditions' key"
652
+ raise FilterError(msg)
653
+ sub_clauses = [_build_clause(c, params, counter) for c in node["conditions"]]
654
+ joiner = f" {operator.upper()} "
655
+ return f"({joiner.join(sub_clauses)})"
656
+
657
+ if operator.upper() == "NOT":
658
+ if "conditions" not in node:
659
+ msg = "Logical operator 'NOT' requires a 'conditions' key"
660
+ raise FilterError(msg)
661
+ sub_clauses = [_build_clause(c, params, counter) for c in node["conditions"]]
662
+ inner = " AND ".join(sub_clauses)
663
+ return f"NOT ({inner})"
664
+
665
+ # ------------------------------------------------------------------
666
+ # Leaf (comparison / membership) operators
667
+ # ------------------------------------------------------------------
668
+ if "field" not in node:
669
+ msg = f"Comparison operator '{operator}' requires a 'field' key"
670
+ raise FilterError(msg)
671
+ if "value" not in node:
672
+ msg = f"Comparison operator '{operator}' requires a 'value' key"
673
+ raise FilterError(msg)
674
+
675
+ field: str = node["field"]
676
+ value: Any = node["value"]
677
+
678
+ # Because meta fields are stored flat (no prefix), all fields map to d.<field>.
679
+ # We strip 'meta.' from the field name if Haystack adds it.
680
+ actual_field = field[5:] if field.startswith("meta.") else field
681
+ cypher_field = f"d.{actual_field}"
682
+
683
+ param_name = f"p{counter[0]}"
684
+ counter[0] += 1
685
+
686
+ if operator == "==":
687
+ if value is None:
688
+ return f"{cypher_field} IS NULL"
689
+ params[param_name] = value
690
+ return f"coalesce({cypher_field} = ${param_name}, false)"
691
+
692
+ if operator == "!=":
693
+ if value is None:
694
+ return f"{cypher_field} IS NOT NULL"
695
+ params[param_name] = value
696
+ return f"coalesce({cypher_field} <> ${param_name}, true)"
697
+
698
+ if operator in _COMPARISON_OPS:
699
+ if value is None:
700
+ return "false"
701
+ if isinstance(value, list):
702
+ msg = f"Operator '{operator}' does not support list values"
703
+ raise FilterError(msg)
704
+ if isinstance(value, str):
705
+ try:
706
+ datetime.fromisoformat(value)
707
+ except ValueError:
708
+ msg = f"Operator '{operator}' requires a numeric or ISO date value, got non-ISO string: '{value}'"
709
+ raise FilterError(msg) from None
710
+ params[param_name] = value
711
+ return f"coalesce({cypher_field} {_COMPARISON_OPS[operator]} ${param_name}, false)"
712
+
713
+ if operator == "in":
714
+ if not isinstance(value, list):
715
+ msg = f"Operator 'in' requires a list value, got {type(value).__name__}"
716
+ raise FilterError(msg)
717
+ params[param_name] = value
718
+ return f"coalesce({cypher_field} IN ${param_name}, false)"
719
+
720
+ if operator == "not in":
721
+ if not isinstance(value, list):
722
+ msg = f"Operator 'not in' requires a list value, got {type(value).__name__}"
723
+ raise FilterError(msg)
724
+ params[param_name] = value
725
+ return f"coalesce(NOT ({cypher_field} IN ${param_name}), true)"
726
+
727
+ msg = f"Unsupported filter operator: '{operator}'"
728
+ raise FilterError(msg)
File without changes