pgvector-haystack 6.1.0__tar.gz → 6.3.0__tar.gz
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.
- {pgvector_haystack-6.1.0 → pgvector_haystack-6.3.0}/CHANGELOG.md +23 -0
- {pgvector_haystack-6.1.0 → pgvector_haystack-6.3.0}/PKG-INFO +2 -2
- pgvector_haystack-6.3.0/pydoc/config_docusaurus.yml +15 -0
- {pgvector_haystack-6.1.0 → pgvector_haystack-6.3.0}/pyproject.toml +6 -2
- {pgvector_haystack-6.1.0 → pgvector_haystack-6.3.0}/src/haystack_integrations/document_stores/pgvector/converters.py +5 -1
- {pgvector_haystack-6.1.0 → pgvector_haystack-6.3.0}/src/haystack_integrations/document_stores/pgvector/filters.py +52 -50
- {pgvector_haystack-6.1.0 → pgvector_haystack-6.3.0}/tests/test_converters.py +17 -0
- {pgvector_haystack-6.1.0 → pgvector_haystack-6.3.0}/tests/test_document_store.py +18 -218
- {pgvector_haystack-6.1.0 → pgvector_haystack-6.3.0}/tests/test_filters.py +46 -18
- pgvector_haystack-6.1.0/pydoc/config_docusaurus.yml +0 -30
- {pgvector_haystack-6.1.0 → pgvector_haystack-6.3.0}/.gitignore +0 -0
- {pgvector_haystack-6.1.0 → pgvector_haystack-6.3.0}/LICENSE.txt +0 -0
- {pgvector_haystack-6.1.0 → pgvector_haystack-6.3.0}/README.md +0 -0
- {pgvector_haystack-6.1.0 → pgvector_haystack-6.3.0}/examples/embedding_retrieval.py +0 -0
- {pgvector_haystack-6.1.0 → pgvector_haystack-6.3.0}/examples/hybrid_retrieval.py +0 -0
- {pgvector_haystack-6.1.0 → pgvector_haystack-6.3.0}/src/haystack_integrations/components/retrievers/pgvector/__init__.py +0 -0
- {pgvector_haystack-6.1.0 → pgvector_haystack-6.3.0}/src/haystack_integrations/components/retrievers/pgvector/embedding_retriever.py +0 -0
- {pgvector_haystack-6.1.0 → pgvector_haystack-6.3.0}/src/haystack_integrations/components/retrievers/pgvector/keyword_retriever.py +0 -0
- {pgvector_haystack-6.1.0 → pgvector_haystack-6.3.0}/src/haystack_integrations/components/retrievers/py.typed +0 -0
- {pgvector_haystack-6.1.0 → pgvector_haystack-6.3.0}/src/haystack_integrations/document_stores/pgvector/__init__.py +0 -0
- {pgvector_haystack-6.1.0 → pgvector_haystack-6.3.0}/src/haystack_integrations/document_stores/pgvector/document_store.py +0 -0
- {pgvector_haystack-6.1.0 → pgvector_haystack-6.3.0}/src/haystack_integrations/document_stores/py.typed +0 -0
- {pgvector_haystack-6.1.0 → pgvector_haystack-6.3.0}/tests/__init__.py +0 -0
- {pgvector_haystack-6.1.0 → pgvector_haystack-6.3.0}/tests/conftest.py +0 -0
- {pgvector_haystack-6.1.0 → pgvector_haystack-6.3.0}/tests/test_document_store_async.py +0 -0
- {pgvector_haystack-6.1.0 → pgvector_haystack-6.3.0}/tests/test_retrieval.py +0 -0
- {pgvector_haystack-6.1.0 → pgvector_haystack-6.3.0}/tests/test_retrieval_async.py +0 -0
- {pgvector_haystack-6.1.0 → pgvector_haystack-6.3.0}/tests/test_retrievers.py +0 -0
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [integrations/pgvector-v6.2.0] - 2026-03-02
|
|
4
|
+
|
|
5
|
+
### 🐛 Bug Fixes
|
|
6
|
+
|
|
7
|
+
- Add metadata field-name validation using regex in `PGVectorDocumentStore` filters to prevent SQL injection vectors. (#2881)
|
|
8
|
+
- Remove NUL bytes when converting from haystack to pg documents (#2892)
|
|
9
|
+
|
|
10
|
+
### 📚 Documentation
|
|
11
|
+
|
|
12
|
+
- Simplify pydoc configs (#2855)
|
|
13
|
+
|
|
14
|
+
### 🧪 Testing
|
|
15
|
+
|
|
16
|
+
- Replacing each `DocumentStore` specific tests and used the generalised ones from `haystack.testing.document_store` (#2812)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
## [integrations/pgvector-v6.1.0] - 2026-01-28
|
|
20
|
+
|
|
21
|
+
### 🚀 Features
|
|
22
|
+
|
|
23
|
+
- Adding count, filtering and metadata related operations to `PGVectorDocumentStore` (#2768)
|
|
24
|
+
|
|
25
|
+
|
|
3
26
|
## [integrations/pgvector-v6.0.0] - 2026-01-12
|
|
4
27
|
|
|
5
28
|
### 🧹 Chores
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pgvector-haystack
|
|
3
|
-
Version: 6.
|
|
3
|
+
Version: 6.3.0
|
|
4
4
|
Summary: An integration of pgvector (vector search extension for Postgres) with Haystack
|
|
5
5
|
Project-URL: Source, https://github.com/deepset-ai/haystack-core-integrations
|
|
6
6
|
Project-URL: Documentation, https://github.com/deepset-ai/haystack-core-integrations/blob/main/integrations/pgvector/README.md
|
|
@@ -18,7 +18,7 @@ Classifier: Programming Language :: Python :: 3.13
|
|
|
18
18
|
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
19
19
|
Classifier: Programming Language :: Python :: Implementation :: PyPy
|
|
20
20
|
Requires-Python: >=3.10
|
|
21
|
-
Requires-Dist: haystack-ai>=2.
|
|
21
|
+
Requires-Dist: haystack-ai>=2.24.0
|
|
22
22
|
Requires-Dist: pgvector>=0.3.0
|
|
23
23
|
Requires-Dist: psycopg[binary]
|
|
24
24
|
Description-Content-Type: text/markdown
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
loaders:
|
|
2
|
+
- modules:
|
|
3
|
+
- haystack_integrations.components.retrievers.pgvector.embedding_retriever
|
|
4
|
+
- haystack_integrations.components.retrievers.pgvector.keyword_retriever
|
|
5
|
+
- haystack_integrations.document_stores.pgvector.document_store
|
|
6
|
+
search_path: [../src]
|
|
7
|
+
processors:
|
|
8
|
+
- type: filter
|
|
9
|
+
documented_only: true
|
|
10
|
+
skip_empty_modules: true
|
|
11
|
+
renderer:
|
|
12
|
+
description: Pgvector integration for Haystack
|
|
13
|
+
id: integrations-pgvector
|
|
14
|
+
filename: pgvector.md
|
|
15
|
+
title: Pgvector
|
|
@@ -22,7 +22,11 @@ classifiers = [
|
|
|
22
22
|
"Programming Language :: Python :: Implementation :: CPython",
|
|
23
23
|
"Programming Language :: Python :: Implementation :: PyPy",
|
|
24
24
|
]
|
|
25
|
-
dependencies = [
|
|
25
|
+
dependencies = [
|
|
26
|
+
"haystack-ai>=2.24.0",
|
|
27
|
+
"pgvector>=0.3.0",
|
|
28
|
+
"psycopg[binary]"
|
|
29
|
+
]
|
|
26
30
|
|
|
27
31
|
[project.urls]
|
|
28
32
|
Source = "https://github.com/deepset-ai/haystack-core-integrations"
|
|
@@ -45,7 +49,7 @@ installer = "uv"
|
|
|
45
49
|
dependencies = ["haystack-pydoc-tools", "ruff"]
|
|
46
50
|
|
|
47
51
|
[tool.hatch.envs.default.scripts]
|
|
48
|
-
docs = ["pydoc
|
|
52
|
+
docs = ["haystack-pydoc pydoc/config_docusaurus.yml"]
|
|
49
53
|
fmt = "ruff check --fix {args}; ruff format {args}"
|
|
50
54
|
fmt-check = "ruff check {args} && ruff format --check {args}"
|
|
51
55
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
from dataclasses import replace
|
|
1
2
|
from typing import Any
|
|
2
3
|
|
|
3
4
|
from haystack import logging
|
|
@@ -22,6 +23,9 @@ def _from_haystack_to_pg_documents(documents: list[Document]) -> list[dict[str,
|
|
|
22
23
|
db_document["blob_meta"] = Jsonb(blob.meta) if blob and blob.meta else None
|
|
23
24
|
db_document["blob_mime_type"] = blob.mime_type if blob and blob.mime_type else None
|
|
24
25
|
db_document["meta"] = Jsonb(db_document["meta"])
|
|
26
|
+
# PostgreSQL text fields cannot contain NUL (0x00) bytes, removing NUL bytes
|
|
27
|
+
if content := db_document["content"]:
|
|
28
|
+
db_document["content"] = content.replace("\x00", "")
|
|
25
29
|
|
|
26
30
|
if "sparse_embedding" in db_document:
|
|
27
31
|
sparse_embedding = db_document.pop("sparse_embedding", None)
|
|
@@ -65,7 +69,7 @@ def _from_pg_to_haystack_documents(documents: list[dict[str, Any]]) -> list[Docu
|
|
|
65
69
|
|
|
66
70
|
if blob_data:
|
|
67
71
|
blob = ByteStream(data=blob_data, meta=blob_meta, mime_type=blob_mime_type)
|
|
68
|
-
haystack_document
|
|
72
|
+
haystack_document = replace(haystack_document, blob=blob)
|
|
69
73
|
|
|
70
74
|
haystack_documents.append(haystack_document)
|
|
71
75
|
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
# SPDX-FileCopyrightText: 2023-present deepset GmbH <info@deepset.ai>
|
|
2
2
|
#
|
|
3
3
|
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
4
5
|
from datetime import datetime
|
|
5
6
|
from itertools import chain
|
|
6
7
|
from typing import Any, Literal
|
|
7
8
|
|
|
8
9
|
from haystack.errors import FilterError
|
|
9
|
-
from psycopg.sql import SQL, Composed
|
|
10
|
+
from psycopg.sql import SQL, Composable, Composed, Identifier
|
|
11
|
+
from psycopg.sql import Literal as SQLLiteral
|
|
10
12
|
from psycopg.types.json import Jsonb
|
|
11
13
|
|
|
12
14
|
# we need this mapping to cast meta values to the correct type,
|
|
@@ -45,13 +47,13 @@ def _convert_filters_to_where_clause_and_params(
|
|
|
45
47
|
else:
|
|
46
48
|
query, values = _parse_logical_condition(filters)
|
|
47
49
|
|
|
48
|
-
where_clause = SQL(f" {operator} ") +
|
|
50
|
+
where_clause = SQL(f" {operator} ") + query
|
|
49
51
|
params = tuple(value for value in values if value != NO_VALUE)
|
|
50
52
|
|
|
51
53
|
return where_clause, params
|
|
52
54
|
|
|
53
55
|
|
|
54
|
-
def _parse_logical_condition(condition: dict[str, Any]) -> tuple[
|
|
56
|
+
def _parse_logical_condition(condition: dict[str, Any]) -> tuple[Composed, list[Any]]:
|
|
55
57
|
if "operator" not in condition:
|
|
56
58
|
msg = f"'operator' key missing in {condition}"
|
|
57
59
|
raise FilterError(msg)
|
|
@@ -81,17 +83,14 @@ def _parse_logical_condition(condition: dict[str, Any]) -> tuple[str, list[Any]]
|
|
|
81
83
|
values = list(chain.from_iterable(values))
|
|
82
84
|
|
|
83
85
|
if operator == "AND":
|
|
84
|
-
sql_query =
|
|
85
|
-
|
|
86
|
-
sql_query =
|
|
87
|
-
else:
|
|
88
|
-
msg = f"Unknown logical operator '{operator}'"
|
|
89
|
-
raise FilterError(msg)
|
|
86
|
+
sql_query = SQL("(") + SQL(" AND ").join(query_parts) + SQL(")")
|
|
87
|
+
else: # operator == "OR"
|
|
88
|
+
sql_query = SQL("(") + SQL(" OR ").join(query_parts) + SQL(")")
|
|
90
89
|
|
|
91
90
|
return sql_query, values
|
|
92
91
|
|
|
93
92
|
|
|
94
|
-
def _parse_comparison_condition(condition: dict[str, Any]) -> tuple[
|
|
93
|
+
def _parse_comparison_condition(condition: dict[str, Any]) -> tuple[Composed, list[Any]]:
|
|
95
94
|
field: str = condition["field"]
|
|
96
95
|
if "operator" not in condition:
|
|
97
96
|
msg = f"'operator' key missing in {condition}"
|
|
@@ -107,28 +106,37 @@ def _parse_comparison_condition(condition: dict[str, Any]) -> tuple[str, list[An
|
|
|
107
106
|
value: Any = condition["value"]
|
|
108
107
|
|
|
109
108
|
if field.startswith("meta."):
|
|
110
|
-
|
|
109
|
+
sql_field: Composable = _treat_meta_field(field, value)
|
|
110
|
+
else:
|
|
111
|
+
sql_field = Identifier(field)
|
|
111
112
|
|
|
112
|
-
|
|
113
|
-
return
|
|
113
|
+
sql_expr, value = COMPARISON_OPERATORS[operator](sql_field, value)
|
|
114
|
+
return sql_expr, [value]
|
|
114
115
|
|
|
115
116
|
|
|
116
|
-
def _treat_meta_field(field: str, value: Any) ->
|
|
117
|
+
def _treat_meta_field(field: str, value: Any) -> Composed:
|
|
117
118
|
"""
|
|
118
|
-
Internal method that
|
|
119
|
-
to make the meta JSONB field queryable.
|
|
119
|
+
Internal method that returns a psycopg Composed object
|
|
120
|
+
to make the meta JSONB field queryable safely.
|
|
121
|
+
|
|
122
|
+
Uses psycopg.sql.Literal to embed the field name, preventing SQL injection
|
|
123
|
+
via metadata field names without requiring regex validation.
|
|
124
|
+
|
|
125
|
+
Use the ->> operator to access keys in the meta JSONB field.
|
|
120
126
|
|
|
121
127
|
Examples:
|
|
122
128
|
>>> _treat_meta_field(field="meta.number", value=9)
|
|
123
|
-
|
|
129
|
+
Composed([SQL('(meta->>'), Literal('number'), SQL(')::integer')])
|
|
124
130
|
|
|
125
131
|
>>> _treat_meta_field(field="meta.name", value="my_name")
|
|
126
|
-
|
|
127
|
-
"""
|
|
132
|
+
Composed([SQL('meta->>'), Literal('name')])
|
|
128
133
|
|
|
129
|
-
|
|
134
|
+
"""
|
|
130
135
|
field_name = field.split(".", 1)[-1]
|
|
131
|
-
|
|
136
|
+
|
|
137
|
+
# Use SQLLiteral to safely embed the field name as a SQL string literal,
|
|
138
|
+
# preventing SQL injection via metadata field names.
|
|
139
|
+
composed: Composed = SQL("meta->>") + SQLLiteral(field_name)
|
|
132
140
|
|
|
133
141
|
# meta fields are stored as strings in the JSONB field,
|
|
134
142
|
# so we need to cast them to the correct type
|
|
@@ -137,25 +145,24 @@ def _treat_meta_field(field: str, value: Any) -> str:
|
|
|
137
145
|
type_value = PYTHON_TYPES_TO_PG_TYPES.get(type(value[0]))
|
|
138
146
|
|
|
139
147
|
if type_value:
|
|
140
|
-
|
|
148
|
+
composed = SQL("(") + composed + SQL(f")::{type_value}")
|
|
141
149
|
|
|
142
|
-
return
|
|
150
|
+
return composed
|
|
143
151
|
|
|
144
152
|
|
|
145
|
-
def _equal(field:
|
|
153
|
+
def _equal(field: Composable, value: Any) -> tuple[Composed, Any]:
|
|
146
154
|
if value is None:
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
return f"{field} = %s", value
|
|
155
|
+
return SQL("{} IS NULL").format(field), NO_VALUE
|
|
156
|
+
return SQL("{} = %s").format(field), value
|
|
150
157
|
|
|
151
158
|
|
|
152
|
-
def _not_equal(field:
|
|
159
|
+
def _not_equal(field: Composable, value: Any) -> tuple[Composed, Any]:
|
|
153
160
|
# we use IS DISTINCT FROM to correctly handle NULL values
|
|
154
161
|
# (not handled by !=)
|
|
155
|
-
return
|
|
162
|
+
return SQL("{} IS DISTINCT FROM %s").format(field), value
|
|
156
163
|
|
|
157
164
|
|
|
158
|
-
def _greater_than(field:
|
|
165
|
+
def _greater_than(field: Composable, value: Any) -> tuple[Composed, Any]:
|
|
159
166
|
if isinstance(value, str):
|
|
160
167
|
try:
|
|
161
168
|
datetime.fromisoformat(value)
|
|
@@ -168,11 +175,10 @@ def _greater_than(field: str, value: Any) -> tuple[str, Any]:
|
|
|
168
175
|
if type(value) in [list, Jsonb]:
|
|
169
176
|
msg = f"Filter value can't be of type {type(value)} using operators '>', '>=', '<', '<='"
|
|
170
177
|
raise FilterError(msg)
|
|
178
|
+
return SQL("{} > %s").format(field), value
|
|
171
179
|
|
|
172
|
-
return f"{field} > %s", value
|
|
173
180
|
|
|
174
|
-
|
|
175
|
-
def _greater_than_equal(field: str, value: Any) -> tuple[str, Any]:
|
|
181
|
+
def _greater_than_equal(field: Composable, value: Any) -> tuple[Composed, Any]:
|
|
176
182
|
if isinstance(value, str):
|
|
177
183
|
try:
|
|
178
184
|
datetime.fromisoformat(value)
|
|
@@ -185,11 +191,10 @@ def _greater_than_equal(field: str, value: Any) -> tuple[str, Any]:
|
|
|
185
191
|
if type(value) in [list, Jsonb]:
|
|
186
192
|
msg = f"Filter value can't be of type {type(value)} using operators '>', '>=', '<', '<='"
|
|
187
193
|
raise FilterError(msg)
|
|
188
|
-
|
|
189
|
-
return f"{field} >= %s", value
|
|
194
|
+
return SQL("{} >= %s").format(field), value
|
|
190
195
|
|
|
191
196
|
|
|
192
|
-
def _less_than(field:
|
|
197
|
+
def _less_than(field: Composable, value: Any) -> tuple[Composed, Any]:
|
|
193
198
|
if isinstance(value, str):
|
|
194
199
|
try:
|
|
195
200
|
datetime.fromisoformat(value)
|
|
@@ -202,11 +207,10 @@ def _less_than(field: str, value: Any) -> tuple[str, Any]:
|
|
|
202
207
|
if type(value) in [list, Jsonb]:
|
|
203
208
|
msg = f"Filter value can't be of type {type(value)} using operators '>', '>=', '<', '<='"
|
|
204
209
|
raise FilterError(msg)
|
|
210
|
+
return SQL("{} < %s").format(field), value
|
|
205
211
|
|
|
206
|
-
return f"{field} < %s", value
|
|
207
212
|
|
|
208
|
-
|
|
209
|
-
def _less_than_equal(field: str, value: Any) -> tuple[str, Any]:
|
|
213
|
+
def _less_than_equal(field: Composable, value: Any) -> tuple[Composed, Any]:
|
|
210
214
|
if isinstance(value, str):
|
|
211
215
|
try:
|
|
212
216
|
datetime.fromisoformat(value)
|
|
@@ -219,39 +223,37 @@ def _less_than_equal(field: str, value: Any) -> tuple[str, Any]:
|
|
|
219
223
|
if type(value) in [list, Jsonb]:
|
|
220
224
|
msg = f"Filter value can't be of type {type(value)} using operators '>', '>=', '<', '<='"
|
|
221
225
|
raise FilterError(msg)
|
|
222
|
-
|
|
223
|
-
return f"{field} <= %s", value
|
|
226
|
+
return SQL("{} <= %s").format(field), value
|
|
224
227
|
|
|
225
228
|
|
|
226
|
-
def _not_in(field:
|
|
229
|
+
def _not_in(field: Composable, value: Any) -> tuple[Composed, list]:
|
|
227
230
|
if not isinstance(value, list):
|
|
228
231
|
msg = f"{field}'s value must be a list when using 'not in' comparator in Pinecone"
|
|
229
232
|
raise FilterError(msg)
|
|
230
|
-
|
|
231
|
-
return f"{field} IS NULL OR {field} != ALL(%s)", [value]
|
|
233
|
+
return SQL("{} IS NULL OR {} != ALL(%s)").format(field, field), [value]
|
|
232
234
|
|
|
233
235
|
|
|
234
|
-
def _in(field:
|
|
236
|
+
def _in(field: Composable, value: Any) -> tuple[Composed, list]:
|
|
235
237
|
if not isinstance(value, list):
|
|
236
238
|
msg = f"{field}'s value must be a list when using 'in' comparator in Pinecone"
|
|
237
239
|
raise FilterError(msg)
|
|
238
240
|
|
|
239
241
|
# see https://www.psycopg.org/psycopg3/docs/basic/adapt.html#lists-adaptation
|
|
240
|
-
return
|
|
242
|
+
return SQL("{} = ANY(%s)").format(field), [value]
|
|
241
243
|
|
|
242
244
|
|
|
243
|
-
def _like(field:
|
|
245
|
+
def _like(field: Composable, value: Any) -> tuple[Composed, Any]:
|
|
244
246
|
if not isinstance(value, str):
|
|
245
247
|
msg = f"{field}'s value must be a str when using 'LIKE' "
|
|
246
248
|
raise FilterError(msg)
|
|
247
|
-
return
|
|
249
|
+
return SQL("{} LIKE %s").format(field), value
|
|
248
250
|
|
|
249
251
|
|
|
250
|
-
def _not_like(field:
|
|
252
|
+
def _not_like(field: Composable, value: Any) -> tuple[Composed, Any]:
|
|
251
253
|
if not isinstance(value, str):
|
|
252
254
|
msg = f"{field}'s value must be a str when using 'LIKE' "
|
|
253
255
|
raise FilterError(msg)
|
|
254
|
-
return
|
|
256
|
+
return SQL("{} NOT LIKE %s").format(field), value
|
|
255
257
|
|
|
256
258
|
|
|
257
259
|
COMPARISON_OPERATORS = {
|
|
@@ -30,6 +30,13 @@ def test_from_haystack_to_pg_documents():
|
|
|
30
30
|
embedding=[0.7, 0.8, 0.9],
|
|
31
31
|
score=0.7,
|
|
32
32
|
),
|
|
33
|
+
Document(
|
|
34
|
+
id="4",
|
|
35
|
+
content="This is another text\x00",
|
|
36
|
+
meta={"meta_key": "meta_value"},
|
|
37
|
+
embedding=[0.7, 0.8, 0.9],
|
|
38
|
+
score=0.8,
|
|
39
|
+
),
|
|
33
40
|
]
|
|
34
41
|
|
|
35
42
|
pg_docs = _from_haystack_to_pg_documents(haystack_docs)
|
|
@@ -64,6 +71,16 @@ def test_from_haystack_to_pg_documents():
|
|
|
64
71
|
assert pg_docs[2]["embedding"] == [0.7, 0.8, 0.9]
|
|
65
72
|
assert "score" not in pg_docs[2]
|
|
66
73
|
|
|
74
|
+
assert pg_docs[3]["id"] == "4"
|
|
75
|
+
assert pg_docs[3]["content"] == "This is another text"
|
|
76
|
+
assert pg_docs[3]["blob_data"] is None
|
|
77
|
+
assert pg_docs[3]["blob_meta"] is None
|
|
78
|
+
assert pg_docs[3]["blob_mime_type"] is None
|
|
79
|
+
assert "dataframe" not in pg_docs[3]
|
|
80
|
+
assert pg_docs[3]["meta"].obj == {"meta_key": "meta_value"}
|
|
81
|
+
assert pg_docs[3]["embedding"] == [0.7, 0.8, 0.9]
|
|
82
|
+
assert "score" not in pg_docs[3]
|
|
83
|
+
|
|
67
84
|
|
|
68
85
|
def test_from_pg_to_haystack_documents():
|
|
69
86
|
pg_docs = [
|
|
@@ -9,14 +9,30 @@ import pytest
|
|
|
9
9
|
from haystack.dataclasses.document import ByteStream, Document
|
|
10
10
|
from haystack.document_stores.errors import DocumentStoreError, DuplicateDocumentError
|
|
11
11
|
from haystack.document_stores.types import DuplicatePolicy
|
|
12
|
-
from haystack.testing.document_store import
|
|
12
|
+
from haystack.testing.document_store import (
|
|
13
|
+
CountDocumentsTest,
|
|
14
|
+
DeleteAllTest,
|
|
15
|
+
DeleteByFilterTest,
|
|
16
|
+
DeleteDocumentsTest,
|
|
17
|
+
FilterableDocsFixtureMixin,
|
|
18
|
+
UpdateByFilterTest,
|
|
19
|
+
WriteDocumentsTest,
|
|
20
|
+
)
|
|
13
21
|
from haystack.utils import Secret
|
|
14
22
|
|
|
15
23
|
from haystack_integrations.document_stores.pgvector import PgvectorDocumentStore
|
|
16
24
|
|
|
17
25
|
|
|
18
26
|
@pytest.mark.integration
|
|
19
|
-
class TestDocumentStore(
|
|
27
|
+
class TestDocumentStore(
|
|
28
|
+
CountDocumentsTest,
|
|
29
|
+
DeleteAllTest,
|
|
30
|
+
DeleteByFilterTest,
|
|
31
|
+
DeleteDocumentsTest,
|
|
32
|
+
FilterableDocsFixtureMixin,
|
|
33
|
+
UpdateByFilterTest,
|
|
34
|
+
WriteDocumentsTest,
|
|
35
|
+
):
|
|
20
36
|
def test_write_documents(self, document_store: PgvectorDocumentStore):
|
|
21
37
|
docs = [Document(id="1")]
|
|
22
38
|
assert document_store.write_documents(docs) == 1
|
|
@@ -53,13 +69,6 @@ class TestDocumentStore(CountDocumentsTest, WriteDocumentsTest, DeleteDocumentsT
|
|
|
53
69
|
same_connection = document_store._connection
|
|
54
70
|
assert same_connection is document_store._connection
|
|
55
71
|
|
|
56
|
-
def test_delete_all_documents(self, document_store: PgvectorDocumentStore) -> None:
|
|
57
|
-
document_store.write_documents([Document(id=str(i)) for i in range(10)])
|
|
58
|
-
document_store.delete_all_documents()
|
|
59
|
-
assert document_store.count_documents() == 0
|
|
60
|
-
document_store.write_documents([Document(id="1")])
|
|
61
|
-
assert document_store.count_documents() == 1
|
|
62
|
-
|
|
63
72
|
def test_invalid_connection_string(self, monkeypatch):
|
|
64
73
|
monkeypatch.setenv("PG_CONN_STR", "invalid_connection_string")
|
|
65
74
|
document_store = PgvectorDocumentStore()
|
|
@@ -262,215 +271,6 @@ def test_delete_table_first_call(document_store):
|
|
|
262
271
|
document_store.delete_table() # if throw error, test fails
|
|
263
272
|
|
|
264
273
|
|
|
265
|
-
@pytest.mark.integration
|
|
266
|
-
def test_delete_by_filter(document_store: PgvectorDocumentStore):
|
|
267
|
-
docs = [
|
|
268
|
-
Document(content="Doc 1", meta={"category": "A", "year": 2023}),
|
|
269
|
-
Document(content="Doc 2", meta={"category": "B", "year": 2023}),
|
|
270
|
-
Document(content="Doc 3", meta={"category": "A", "year": 2024}),
|
|
271
|
-
]
|
|
272
|
-
document_store.write_documents(docs)
|
|
273
|
-
assert document_store.count_documents() == 3
|
|
274
|
-
|
|
275
|
-
deleted_count = document_store.delete_by_filter(filters={"field": "meta.category", "operator": "==", "value": "A"})
|
|
276
|
-
assert deleted_count == 2
|
|
277
|
-
assert document_store.count_documents() == 1
|
|
278
|
-
|
|
279
|
-
remaining_docs = document_store.filter_documents()
|
|
280
|
-
assert len(remaining_docs) == 1
|
|
281
|
-
assert remaining_docs[0].meta["category"] == "B"
|
|
282
|
-
|
|
283
|
-
deleted_count = document_store.delete_by_filter(filters={"field": "meta.year", "operator": "==", "value": 2023})
|
|
284
|
-
assert deleted_count == 1
|
|
285
|
-
assert document_store.count_documents() == 0
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
@pytest.mark.integration
|
|
289
|
-
def test_delete_by_filter_no_matches(document_store: PgvectorDocumentStore):
|
|
290
|
-
docs = [
|
|
291
|
-
Document(content="Doc 1", meta={"category": "A"}),
|
|
292
|
-
Document(content="Doc 2", meta={"category": "B"}),
|
|
293
|
-
]
|
|
294
|
-
document_store.write_documents(docs)
|
|
295
|
-
assert document_store.count_documents() == 2
|
|
296
|
-
|
|
297
|
-
deleted_count = document_store.delete_by_filter(filters={"field": "meta.category", "operator": "==", "value": "C"})
|
|
298
|
-
assert deleted_count == 0
|
|
299
|
-
assert document_store.count_documents() == 2
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
@pytest.mark.integration
|
|
303
|
-
def test_delete_by_filter_advanced_filters(document_store: PgvectorDocumentStore):
|
|
304
|
-
docs = [
|
|
305
|
-
Document(content="Doc 1", meta={"category": "A", "year": 2023, "status": "draft"}),
|
|
306
|
-
Document(content="Doc 2", meta={"category": "A", "year": 2024, "status": "published"}),
|
|
307
|
-
Document(content="Doc 3", meta={"category": "B", "year": 2023, "status": "draft"}),
|
|
308
|
-
]
|
|
309
|
-
document_store.write_documents(docs)
|
|
310
|
-
assert document_store.count_documents() == 3
|
|
311
|
-
|
|
312
|
-
# AND condition
|
|
313
|
-
deleted_count = document_store.delete_by_filter(
|
|
314
|
-
filters={
|
|
315
|
-
"operator": "AND",
|
|
316
|
-
"conditions": [
|
|
317
|
-
{"field": "meta.category", "operator": "==", "value": "A"},
|
|
318
|
-
{"field": "meta.year", "operator": "==", "value": 2023},
|
|
319
|
-
],
|
|
320
|
-
}
|
|
321
|
-
)
|
|
322
|
-
assert deleted_count == 1
|
|
323
|
-
assert document_store.count_documents() == 2
|
|
324
|
-
|
|
325
|
-
# OR condition
|
|
326
|
-
deleted_count = document_store.delete_by_filter(
|
|
327
|
-
filters={
|
|
328
|
-
"operator": "OR",
|
|
329
|
-
"conditions": [
|
|
330
|
-
{"field": "meta.category", "operator": "==", "value": "B"},
|
|
331
|
-
{"field": "meta.status", "operator": "==", "value": "published"},
|
|
332
|
-
],
|
|
333
|
-
}
|
|
334
|
-
)
|
|
335
|
-
assert deleted_count == 2
|
|
336
|
-
assert document_store.count_documents() == 0
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
@pytest.mark.integration
|
|
340
|
-
def test_update_by_filter(document_store: PgvectorDocumentStore):
|
|
341
|
-
docs = [
|
|
342
|
-
Document(content="Doc 1", meta={"category": "A", "status": "draft"}),
|
|
343
|
-
Document(content="Doc 2", meta={"category": "B", "status": "draft"}),
|
|
344
|
-
Document(content="Doc 3", meta={"category": "A", "status": "draft"}),
|
|
345
|
-
]
|
|
346
|
-
document_store.write_documents(docs)
|
|
347
|
-
assert document_store.count_documents() == 3
|
|
348
|
-
|
|
349
|
-
# update status for category="A" documents
|
|
350
|
-
updated_count = document_store.update_by_filter(
|
|
351
|
-
filters={"field": "meta.category", "operator": "==", "value": "A"}, meta={"status": "published"}
|
|
352
|
-
)
|
|
353
|
-
assert updated_count == 2
|
|
354
|
-
|
|
355
|
-
# verify
|
|
356
|
-
published_docs = document_store.filter_documents(
|
|
357
|
-
filters={"field": "meta.status", "operator": "==", "value": "published"}
|
|
358
|
-
)
|
|
359
|
-
assert len(published_docs) == 2
|
|
360
|
-
for doc in published_docs:
|
|
361
|
-
assert doc.meta["category"] == "A"
|
|
362
|
-
assert doc.meta["status"] == "published"
|
|
363
|
-
|
|
364
|
-
# Verify category B still has draft status
|
|
365
|
-
draft_docs = document_store.filter_documents(filters={"field": "meta.status", "operator": "==", "value": "draft"})
|
|
366
|
-
assert len(draft_docs) == 1
|
|
367
|
-
assert draft_docs[0].meta["category"] == "B"
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
@pytest.mark.integration
|
|
371
|
-
def test_update_by_filter_multiple_fields(document_store: PgvectorDocumentStore):
|
|
372
|
-
docs = [
|
|
373
|
-
Document(content="Doc 1", meta={"category": "A", "year": 2023}),
|
|
374
|
-
Document(content="Doc 2", meta={"category": "A", "year": 2023}),
|
|
375
|
-
Document(content="Doc 3", meta={"category": "B", "year": 2024}),
|
|
376
|
-
]
|
|
377
|
-
document_store.write_documents(docs)
|
|
378
|
-
assert document_store.count_documents() == 3
|
|
379
|
-
|
|
380
|
-
# update multiple fields for category="A" documents
|
|
381
|
-
updated_count = document_store.update_by_filter(
|
|
382
|
-
filters={"field": "meta.category", "operator": "==", "value": "A"},
|
|
383
|
-
meta={"status": "published", "priority": "high", "reviewed": True},
|
|
384
|
-
)
|
|
385
|
-
assert updated_count == 2
|
|
386
|
-
|
|
387
|
-
# verify
|
|
388
|
-
published_docs = document_store.filter_documents(filters={"field": "meta.category", "operator": "==", "value": "A"})
|
|
389
|
-
assert len(published_docs) == 2
|
|
390
|
-
for doc in published_docs:
|
|
391
|
-
assert doc.meta["status"] == "published"
|
|
392
|
-
assert doc.meta["priority"] == "high"
|
|
393
|
-
assert doc.meta["reviewed"] is True
|
|
394
|
-
assert doc.meta["year"] == 2023 # Original field should still be present
|
|
395
|
-
|
|
396
|
-
# verify category B was not updated
|
|
397
|
-
b_docs = document_store.filter_documents(filters={"field": "meta.category", "operator": "==", "value": "B"})
|
|
398
|
-
assert len(b_docs) == 1
|
|
399
|
-
assert "status" not in b_docs[0].meta
|
|
400
|
-
assert "priority" not in b_docs[0].meta
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
@pytest.mark.integration
|
|
404
|
-
def test_update_by_filter_no_matches(document_store: PgvectorDocumentStore):
|
|
405
|
-
docs = [
|
|
406
|
-
Document(content="Doc 1", meta={"category": "A"}),
|
|
407
|
-
Document(content="Doc 2", meta={"category": "B"}),
|
|
408
|
-
]
|
|
409
|
-
document_store.write_documents(docs)
|
|
410
|
-
assert document_store.count_documents() == 2
|
|
411
|
-
|
|
412
|
-
# update documents with category="C" (no matches)
|
|
413
|
-
updated_count = document_store.update_by_filter(
|
|
414
|
-
filters={"field": "meta.category", "operator": "==", "value": "C"}, meta={"status": "published"}
|
|
415
|
-
)
|
|
416
|
-
assert updated_count == 0
|
|
417
|
-
assert document_store.count_documents() == 2
|
|
418
|
-
|
|
419
|
-
# verify no documents were updated
|
|
420
|
-
published_docs = document_store.filter_documents(
|
|
421
|
-
filters={"field": "meta.status", "operator": "==", "value": "published"}
|
|
422
|
-
)
|
|
423
|
-
assert len(published_docs) == 0
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
@pytest.mark.integration
|
|
427
|
-
def test_update_by_filter_advanced_filters(document_store: PgvectorDocumentStore):
|
|
428
|
-
docs = [
|
|
429
|
-
Document(content="Doc 1", meta={"category": "A", "year": 2023, "status": "draft"}),
|
|
430
|
-
Document(content="Doc 2", meta={"category": "A", "year": 2024, "status": "draft"}),
|
|
431
|
-
Document(content="Doc 3", meta={"category": "B", "year": 2023, "status": "draft"}),
|
|
432
|
-
]
|
|
433
|
-
document_store.write_documents(docs)
|
|
434
|
-
assert document_store.count_documents() == 3
|
|
435
|
-
|
|
436
|
-
# AND condition
|
|
437
|
-
updated_count = document_store.update_by_filter(
|
|
438
|
-
filters={
|
|
439
|
-
"operator": "AND",
|
|
440
|
-
"conditions": [
|
|
441
|
-
{"field": "meta.category", "operator": "==", "value": "A"},
|
|
442
|
-
{"field": "meta.year", "operator": "==", "value": 2023},
|
|
443
|
-
],
|
|
444
|
-
},
|
|
445
|
-
meta={"status": "published"},
|
|
446
|
-
)
|
|
447
|
-
assert updated_count == 1
|
|
448
|
-
|
|
449
|
-
# verify only one document was updated
|
|
450
|
-
published_docs = document_store.filter_documents(
|
|
451
|
-
filters={"field": "meta.status", "operator": "==", "value": "published"}
|
|
452
|
-
)
|
|
453
|
-
assert len(published_docs) == 1
|
|
454
|
-
assert published_docs[0].meta["category"] == "A"
|
|
455
|
-
assert published_docs[0].meta["year"] == 2023
|
|
456
|
-
|
|
457
|
-
# OR condition
|
|
458
|
-
updated_count = document_store.update_by_filter(
|
|
459
|
-
filters={
|
|
460
|
-
"operator": "OR",
|
|
461
|
-
"conditions": [
|
|
462
|
-
{"field": "meta.category", "operator": "==", "value": "B"},
|
|
463
|
-
{"field": "meta.year", "operator": "==", "value": 2024},
|
|
464
|
-
],
|
|
465
|
-
},
|
|
466
|
-
meta={"featured": True},
|
|
467
|
-
)
|
|
468
|
-
assert updated_count == 2
|
|
469
|
-
|
|
470
|
-
featured_docs = document_store.filter_documents(filters={"field": "meta.featured", "operator": "==", "value": True})
|
|
471
|
-
assert len(featured_docs) == 2
|
|
472
|
-
|
|
473
|
-
|
|
474
274
|
@pytest.mark.integration
|
|
475
275
|
def test_update_by_filter_empty_meta_raises_error(document_store: PgvectorDocumentStore):
|
|
476
276
|
docs = [Document(content="Doc 1", meta={"category": "A"})]
|
|
@@ -1,7 +1,13 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2023-present deepset GmbH <info@deepset.ai>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
1
5
|
import pytest
|
|
2
6
|
from haystack.dataclasses.document import Document
|
|
3
7
|
from haystack.testing.document_store import FilterDocumentsTest
|
|
4
|
-
from psycopg.
|
|
8
|
+
from psycopg.adapt import Transformer
|
|
9
|
+
from psycopg.sql import SQL, Composed
|
|
10
|
+
from psycopg.sql import Literal as SQLLiteral
|
|
5
11
|
|
|
6
12
|
from haystack_integrations.document_stores.pgvector.filters import (
|
|
7
13
|
FilterError,
|
|
@@ -13,6 +19,11 @@ from haystack_integrations.document_stores.pgvector.filters import (
|
|
|
13
19
|
)
|
|
14
20
|
|
|
15
21
|
|
|
22
|
+
def _render(composed: Composed) -> str:
|
|
23
|
+
"""Render a psycopg Composed object to a plain SQL string for assertions."""
|
|
24
|
+
return composed.as_string(Transformer())
|
|
25
|
+
|
|
26
|
+
|
|
16
27
|
@pytest.mark.integration
|
|
17
28
|
class TestFilters(FilterDocumentsTest):
|
|
18
29
|
def assert_documents_are_equal(self, received: list[Document], expected: list[Document]):
|
|
@@ -129,19 +140,26 @@ class TestFilters(FilterDocumentsTest):
|
|
|
129
140
|
|
|
130
141
|
|
|
131
142
|
def test_treat_meta_field():
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
assert _treat_meta_field(field="meta.number", value=
|
|
138
|
-
assert _treat_meta_field(field="meta.
|
|
139
|
-
assert _treat_meta_field(field="meta.
|
|
143
|
+
cast_integer = SQL("(") + SQL("meta->>") + SQLLiteral("number") + SQL(")::integer")
|
|
144
|
+
no_cast_name = SQL("meta->>") + SQLLiteral("name")
|
|
145
|
+
cast_real = SQL("(") + SQL("meta->>") + SQLLiteral("number") + SQL(")::real")
|
|
146
|
+
cast_boolean = SQL("(") + SQL("meta->>") + SQLLiteral("bool") + SQL(")::boolean")
|
|
147
|
+
|
|
148
|
+
assert _treat_meta_field(field="meta.number", value=9) == cast_integer
|
|
149
|
+
assert _treat_meta_field(field="meta.number", value=[1, 2, 3]) == cast_integer
|
|
150
|
+
assert _treat_meta_field(field="meta.name", value="my_name") == no_cast_name
|
|
151
|
+
assert _treat_meta_field(field="meta.name", value=["my_name"]) == no_cast_name
|
|
152
|
+
assert _treat_meta_field(field="meta.number", value=1.1) == cast_real
|
|
153
|
+
assert _treat_meta_field(field="meta.number", value=[1.1, 2.2]) == cast_real
|
|
154
|
+
assert _treat_meta_field(field="meta.bool", value=True) == cast_boolean
|
|
155
|
+
assert _treat_meta_field(field="meta.bool", value=[True, False]) == cast_boolean
|
|
140
156
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
157
|
+
|
|
158
|
+
def test_treat_meta_field_sql_injection_is_safely_escaped():
|
|
159
|
+
# SQL injection attempts are safely escaped by SQLLiteral rather than rejected
|
|
160
|
+
result = _treat_meta_field(field="meta.name' OR 1=1 --", value="x")
|
|
161
|
+
assert isinstance(result, Composed)
|
|
162
|
+
assert result == SQL("meta->>") + SQLLiteral("name' OR 1=1 --")
|
|
145
163
|
|
|
146
164
|
|
|
147
165
|
def test_comparison_condition_missing_operator():
|
|
@@ -201,10 +219,16 @@ def test_logical_condition_nested():
|
|
|
201
219
|
],
|
|
202
220
|
}
|
|
203
221
|
query, values = _parse_logical_condition(condition)
|
|
204
|
-
assert query
|
|
205
|
-
|
|
206
|
-
|
|
222
|
+
assert isinstance(query, Composed)
|
|
223
|
+
|
|
224
|
+
expected_sql = (
|
|
225
|
+
"("
|
|
226
|
+
"(meta->>'domain' IS DISTINCT FROM %s OR meta->>'chapter' = ANY(%s))"
|
|
227
|
+
" AND "
|
|
228
|
+
"((meta->>'number')::integer >= %s OR meta->>'author' IS NULL OR meta->>'author' != ALL(%s))"
|
|
229
|
+
")"
|
|
207
230
|
)
|
|
231
|
+
assert _render(query) == expected_sql
|
|
208
232
|
assert values == ["science", [["intro", "conclusion"]], 90, [["John", "Jane"]]]
|
|
209
233
|
|
|
210
234
|
|
|
@@ -217,7 +241,9 @@ def test_convert_filters_to_where_clause_and_params():
|
|
|
217
241
|
],
|
|
218
242
|
}
|
|
219
243
|
where_clause, params = _convert_filters_to_where_clause_and_params(filters)
|
|
220
|
-
|
|
244
|
+
|
|
245
|
+
expected_sql = " WHERE ((meta->>'number')::integer = %s AND meta->>'chapter' = %s)"
|
|
246
|
+
assert _render(where_clause) == expected_sql
|
|
221
247
|
assert params == (100, "intro")
|
|
222
248
|
|
|
223
249
|
|
|
@@ -230,7 +256,9 @@ def test_convert_filters_to_where_clause_and_params_handle_null():
|
|
|
230
256
|
],
|
|
231
257
|
}
|
|
232
258
|
where_clause, params = _convert_filters_to_where_clause_and_params(filters)
|
|
233
|
-
|
|
259
|
+
|
|
260
|
+
expected_sql = " WHERE (meta->>'number' IS NULL AND meta->>'chapter' = %s)"
|
|
261
|
+
assert _render(where_clause) == expected_sql
|
|
234
262
|
assert params == ("intro",)
|
|
235
263
|
|
|
236
264
|
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
loaders:
|
|
2
|
-
- ignore_when_discovered:
|
|
3
|
-
- __init__
|
|
4
|
-
modules:
|
|
5
|
-
- haystack_integrations.components.retrievers.pgvector.embedding_retriever
|
|
6
|
-
- haystack_integrations.components.retrievers.pgvector.keyword_retriever
|
|
7
|
-
- haystack_integrations.document_stores.pgvector.document_store
|
|
8
|
-
search_path:
|
|
9
|
-
- ../src
|
|
10
|
-
type: haystack_pydoc_tools.loaders.CustomPythonLoader
|
|
11
|
-
processors:
|
|
12
|
-
- do_not_filter_modules: false
|
|
13
|
-
documented_only: true
|
|
14
|
-
expression: null
|
|
15
|
-
skip_empty_modules: true
|
|
16
|
-
type: filter
|
|
17
|
-
- type: smart
|
|
18
|
-
- type: crossref
|
|
19
|
-
renderer:
|
|
20
|
-
description: Pgvector integration for Haystack
|
|
21
|
-
id: integrations-pgvector
|
|
22
|
-
markdown:
|
|
23
|
-
add_member_class_prefix: false
|
|
24
|
-
add_method_class_prefix: true
|
|
25
|
-
classdef_code_block: false
|
|
26
|
-
descriptive_class_title: false
|
|
27
|
-
descriptive_module_title: true
|
|
28
|
-
filename: pgvector.md
|
|
29
|
-
title: Pgvector
|
|
30
|
-
type: haystack_pydoc_tools.renderers.DocusaurusRenderer
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|