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.
- falkordb_haystack-1.0.0.dist-info/METADATA +51 -0
- falkordb_haystack-1.0.0.dist-info/RECORD +11 -0
- falkordb_haystack-1.0.0.dist-info/WHEEL +4 -0
- falkordb_haystack-1.0.0.dist-info/licenses/LICENSE.txt +201 -0
- haystack_integrations/components/retrievers/falkordb/__init__.py +8 -0
- haystack_integrations/components/retrievers/falkordb/cypher_retriever.py +113 -0
- haystack_integrations/components/retrievers/falkordb/embedding_retriever.py +120 -0
- haystack_integrations/components/retrievers/py.typed +1 -0
- haystack_integrations/document_stores/falkordb/__init__.py +7 -0
- haystack_integrations/document_stores/falkordb/document_store.py +728 -0
- haystack_integrations/document_stores/py.typed +0 -0
|
@@ -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
|
+
[](https://pypi.org/project/falkordb-haystack)
|
|
32
|
+
[](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,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 @@
|
|
|
1
|
+
|
|
@@ -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
|