pgvector-haystack 8.1.0__tar.gz → 8.1.2__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-8.1.0 → pgvector_haystack-8.1.2}/CHANGELOG.md +14 -0
- {pgvector_haystack-8.1.0 → pgvector_haystack-8.1.2}/PKG-INFO +1 -1
- {pgvector_haystack-8.1.0 → pgvector_haystack-8.1.2}/src/haystack_integrations/document_stores/pgvector/document_store.py +28 -21
- {pgvector_haystack-8.1.0 → pgvector_haystack-8.1.2}/tests/test_document_store.py +102 -24
- {pgvector_haystack-8.1.0 → pgvector_haystack-8.1.2}/tests/test_document_store_async.py +70 -13
- {pgvector_haystack-8.1.0 → pgvector_haystack-8.1.2}/.gitignore +0 -0
- {pgvector_haystack-8.1.0 → pgvector_haystack-8.1.2}/LICENSE.txt +0 -0
- {pgvector_haystack-8.1.0 → pgvector_haystack-8.1.2}/README.md +0 -0
- {pgvector_haystack-8.1.0 → pgvector_haystack-8.1.2}/examples/embedding_retrieval.py +0 -0
- {pgvector_haystack-8.1.0 → pgvector_haystack-8.1.2}/examples/hybrid_retrieval.py +0 -0
- {pgvector_haystack-8.1.0 → pgvector_haystack-8.1.2}/pydoc/config_docusaurus.yml +0 -0
- {pgvector_haystack-8.1.0 → pgvector_haystack-8.1.2}/pyproject.toml +0 -0
- {pgvector_haystack-8.1.0 → pgvector_haystack-8.1.2}/src/haystack_integrations/components/retrievers/pgvector/__init__.py +0 -0
- {pgvector_haystack-8.1.0 → pgvector_haystack-8.1.2}/src/haystack_integrations/components/retrievers/pgvector/embedding_retriever.py +0 -0
- {pgvector_haystack-8.1.0 → pgvector_haystack-8.1.2}/src/haystack_integrations/components/retrievers/pgvector/keyword_retriever.py +0 -0
- {pgvector_haystack-8.1.0 → pgvector_haystack-8.1.2}/src/haystack_integrations/components/retrievers/py.typed +0 -0
- {pgvector_haystack-8.1.0 → pgvector_haystack-8.1.2}/src/haystack_integrations/document_stores/pgvector/__init__.py +0 -0
- {pgvector_haystack-8.1.0 → pgvector_haystack-8.1.2}/src/haystack_integrations/document_stores/pgvector/converters.py +0 -0
- {pgvector_haystack-8.1.0 → pgvector_haystack-8.1.2}/src/haystack_integrations/document_stores/pgvector/filters.py +0 -0
- {pgvector_haystack-8.1.0 → pgvector_haystack-8.1.2}/src/haystack_integrations/document_stores/py.typed +0 -0
- {pgvector_haystack-8.1.0 → pgvector_haystack-8.1.2}/tests/__init__.py +0 -0
- {pgvector_haystack-8.1.0 → pgvector_haystack-8.1.2}/tests/conftest.py +0 -0
- {pgvector_haystack-8.1.0 → pgvector_haystack-8.1.2}/tests/test_converters.py +0 -0
- {pgvector_haystack-8.1.0 → pgvector_haystack-8.1.2}/tests/test_filters.py +0 -0
- {pgvector_haystack-8.1.0 → pgvector_haystack-8.1.2}/tests/test_retrieval.py +0 -0
- {pgvector_haystack-8.1.0 → pgvector_haystack-8.1.2}/tests/test_retrieval_async.py +0 -0
- {pgvector_haystack-8.1.0 → pgvector_haystack-8.1.2}/tests/test_retrievers.py +0 -0
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [integrations/pgvector-v8.1.1] - 2026-10-21
|
|
4
|
+
|
|
5
|
+
### 🐛 Bug Fixes
|
|
6
|
+
|
|
7
|
+
- *(pgvector)* Pass document ids as a bound parameter in delete_docum… (#4135)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
## [integrations/pgvector-v8.1.0] - 2026-10-20
|
|
11
|
+
|
|
12
|
+
### 🚀 Features
|
|
13
|
+
|
|
14
|
+
- *(pgvector)* Make PgvectorDocumentStore connection overridable for PostgreSQL-backed stores (#4129)
|
|
15
|
+
|
|
16
|
+
|
|
3
17
|
## [integrations/pgvector-v8.0.0] - 2026-10-16
|
|
4
18
|
|
|
5
19
|
### 🧪 Testing
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: pgvector-haystack
|
|
3
|
-
Version: 8.1.
|
|
3
|
+
Version: 8.1.2
|
|
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
|
|
@@ -678,8 +678,10 @@ class PgvectorDocumentStore:
|
|
|
678
678
|
k: v for k, v in self.hnsw_index_creation_kwargs.items() if k in HNSW_INDEX_CREATION_VALID_KWARGS
|
|
679
679
|
}
|
|
680
680
|
if valid_kwargs:
|
|
681
|
-
|
|
682
|
-
sql_create_hnsw_index += SQL(" WITH ({})").format(
|
|
681
|
+
# the names are allowlisted; pass the values as literals, so they are never interpreted as SQL
|
|
682
|
+
sql_create_hnsw_index += SQL(" WITH ({})").format(
|
|
683
|
+
SQL(", ").join(SQL("{} = {}").format(SQL(k), SQLLiteral(v)) for k, v in valid_kwargs.items())
|
|
684
|
+
)
|
|
683
685
|
|
|
684
686
|
return sql_set_hnsw_ef_search, sql_hnsw_index_exists, sql_drop_hnsw_index, sql_create_hnsw_index
|
|
685
687
|
|
|
@@ -1023,18 +1025,19 @@ class PgvectorDocumentStore:
|
|
|
1023
1025
|
if not document_ids:
|
|
1024
1026
|
return
|
|
1025
1027
|
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
delete_sql = SQL("DELETE FROM {schema_name}.{table_name} WHERE id IN ({document_ids_str})").format(
|
|
1028
|
+
# pass the ids as a bound parameter, so they are never interpreted as SQL
|
|
1029
|
+
delete_sql = SQL("DELETE FROM {schema_name}.{table_name} WHERE id = ANY(%s)").format(
|
|
1029
1030
|
schema_name=Identifier(self.schema_name),
|
|
1030
1031
|
table_name=Identifier(self.table_name),
|
|
1031
|
-
document_ids_str=SQL(document_ids_str),
|
|
1032
1032
|
)
|
|
1033
1033
|
|
|
1034
1034
|
self._ensure_db_setup()
|
|
1035
1035
|
assert self._cursor is not None
|
|
1036
1036
|
self._execute_sql(
|
|
1037
|
-
cursor=self._cursor,
|
|
1037
|
+
cursor=self._cursor,
|
|
1038
|
+
sql_query=delete_sql,
|
|
1039
|
+
params=(document_ids,),
|
|
1040
|
+
error_msg="Could not delete documents from PgvectorDocumentStore",
|
|
1038
1041
|
)
|
|
1039
1042
|
|
|
1040
1043
|
async def delete_documents_async(self, document_ids: list[str]) -> None:
|
|
@@ -1046,12 +1049,10 @@ class PgvectorDocumentStore:
|
|
|
1046
1049
|
if not document_ids:
|
|
1047
1050
|
return
|
|
1048
1051
|
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
delete_sql = SQL("DELETE FROM {schema_name}.{table_name} WHERE id IN ({document_ids_str})").format(
|
|
1052
|
+
# pass the ids as a bound parameter, so they are never interpreted as SQL
|
|
1053
|
+
delete_sql = SQL("DELETE FROM {schema_name}.{table_name} WHERE id = ANY(%s)").format(
|
|
1052
1054
|
schema_name=Identifier(self.schema_name),
|
|
1053
1055
|
table_name=Identifier(self.table_name),
|
|
1054
|
-
document_ids_str=SQL(document_ids_str),
|
|
1055
1056
|
)
|
|
1056
1057
|
|
|
1057
1058
|
await self._ensure_db_setup_async()
|
|
@@ -1059,6 +1060,7 @@ class PgvectorDocumentStore:
|
|
|
1059
1060
|
await self._execute_sql_async(
|
|
1060
1061
|
cursor=self._async_cursor,
|
|
1061
1062
|
sql_query=delete_sql,
|
|
1063
|
+
params=(document_ids,),
|
|
1062
1064
|
error_msg="Could not delete documents from PgvectorDocumentStore",
|
|
1063
1065
|
)
|
|
1064
1066
|
|
|
@@ -1403,26 +1405,31 @@ class PgvectorDocumentStore:
|
|
|
1403
1405
|
msg = f"vector_function must be one of {VALID_VECTOR_FUNCTIONS}, but got {vector_function}"
|
|
1404
1406
|
raise ValueError(msg)
|
|
1405
1407
|
|
|
1406
|
-
# the vector must be a string with this format: "'[3,1,2]'"
|
|
1407
|
-
|
|
1408
|
+
# the vector must be a string with this format: "'[3.0,1.0,2.0]'"; the elements are converted to floats
|
|
1409
|
+
# and the string is passed as a literal, so it is never interpreted as SQL
|
|
1410
|
+
try:
|
|
1411
|
+
query_embedding_for_postgres = SQLLiteral(f"[{','.join(str(float(el)) for el in query_embedding)}]")
|
|
1412
|
+
except (TypeError, ValueError) as e:
|
|
1413
|
+
msg = "query_embedding must be a list of floats"
|
|
1414
|
+
raise TypeError(msg) from e
|
|
1408
1415
|
|
|
1409
1416
|
# to compute the scores, we use the approach described in pgvector README:
|
|
1410
1417
|
# https://github.com/pgvector/pgvector?tab=readme-ov-file#distances
|
|
1411
1418
|
# cosine_similarity and inner_product are modified from the result of the operator
|
|
1412
1419
|
if vector_function == "cosine_similarity":
|
|
1413
|
-
score_definition =
|
|
1414
|
-
order_by_definition =
|
|
1420
|
+
score_definition = SQL("1 - (embedding <=> {}) AS score").format(query_embedding_for_postgres)
|
|
1421
|
+
order_by_definition = SQL("embedding <=> {}").format(query_embedding_for_postgres)
|
|
1415
1422
|
elif vector_function == "inner_product":
|
|
1416
|
-
score_definition =
|
|
1417
|
-
order_by_definition =
|
|
1423
|
+
score_definition = SQL("(embedding <#> {}) * -1 AS score").format(query_embedding_for_postgres)
|
|
1424
|
+
order_by_definition = SQL("embedding <#> {}").format(query_embedding_for_postgres)
|
|
1418
1425
|
elif vector_function == "l2_distance":
|
|
1419
|
-
score_definition =
|
|
1420
|
-
order_by_definition =
|
|
1426
|
+
score_definition = SQL("embedding <-> {} AS score").format(query_embedding_for_postgres)
|
|
1427
|
+
order_by_definition = SQL("embedding <-> {}").format(query_embedding_for_postgres)
|
|
1421
1428
|
|
|
1422
1429
|
sql_select = SQL("SELECT *, {score} FROM {schema_name}.{table_name}").format(
|
|
1423
1430
|
schema_name=Identifier(self.schema_name),
|
|
1424
1431
|
table_name=Identifier(self.table_name),
|
|
1425
|
-
score=
|
|
1432
|
+
score=score_definition,
|
|
1426
1433
|
)
|
|
1427
1434
|
|
|
1428
1435
|
sql_where_clause: Composed | SQL = SQL("")
|
|
@@ -1432,7 +1439,7 @@ class PgvectorDocumentStore:
|
|
|
1432
1439
|
|
|
1433
1440
|
sql_sort = SQL(" ORDER BY {order_by} ASC LIMIT {top_k}").format(
|
|
1434
1441
|
top_k=SQLLiteral(top_k),
|
|
1435
|
-
order_by=
|
|
1442
|
+
order_by=order_by_definition,
|
|
1436
1443
|
)
|
|
1437
1444
|
|
|
1438
1445
|
sql_query = sql_select + sql_where_clause + sql_sort
|
|
@@ -205,6 +205,21 @@ def test_init(monkeypatch):
|
|
|
205
205
|
assert document_store.keyword_index_name == "my_keyword_index"
|
|
206
206
|
|
|
207
207
|
|
|
208
|
+
@pytest.mark.usefixtures("patches_for_unit_tests")
|
|
209
|
+
def test_build_hnsw_queries_passes_creation_kwargs_as_literals():
|
|
210
|
+
document_store = PgvectorDocumentStore(
|
|
211
|
+
search_strategy="hnsw",
|
|
212
|
+
hnsw_index_creation_kwargs={"m": 32, "ef_construction": "64); DROP TABLE haystack; --", "unknown": 1},
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
*_, sql_create_hnsw_index = document_store._build_hnsw_queries()
|
|
216
|
+
|
|
217
|
+
assert (
|
|
218
|
+
"SQL(' WITH ('), Composed([Composed([SQL('m'), SQL(' = '), Literal(32)]), SQL(', '), "
|
|
219
|
+
"Composed([SQL('ef_construction'), SQL(' = '), Literal('64); DROP TABLE haystack; --')])]), SQL(')')"
|
|
220
|
+
) in repr(sql_create_hnsw_index)
|
|
221
|
+
|
|
222
|
+
|
|
208
223
|
@pytest.mark.usefixtures("patches_for_unit_tests")
|
|
209
224
|
def test_init_invalid_vector_type():
|
|
210
225
|
with pytest.raises(ValueError, match=r"vector_type must be one of.*"):
|
|
@@ -323,18 +338,18 @@ def test_check_and_build_embedding_retrieval_query_rejects_invalid_vector_functi
|
|
|
323
338
|
[
|
|
324
339
|
(
|
|
325
340
|
"cosine_similarity",
|
|
326
|
-
"1 - (embedding <=> '[0.1,0
|
|
327
|
-
"embedding <=> '[0.1,0
|
|
341
|
+
"Composed([SQL('1 - (embedding <=> '), Literal('[0.1,2.0]'), SQL(') AS score')])",
|
|
342
|
+
"Composed([SQL('embedding <=> '), Literal('[0.1,2.0]')])",
|
|
328
343
|
),
|
|
329
344
|
(
|
|
330
345
|
"inner_product",
|
|
331
|
-
"(embedding <#> '[0.1,0
|
|
332
|
-
"embedding <#> '[0.1,0
|
|
346
|
+
"Composed([SQL('(embedding <#> '), Literal('[0.1,2.0]'), SQL(') * -1 AS score')])",
|
|
347
|
+
"Composed([SQL('embedding <#> '), Literal('[0.1,2.0]')])",
|
|
333
348
|
),
|
|
334
349
|
(
|
|
335
350
|
"l2_distance",
|
|
336
|
-
"embedding <-> '[0.1,0
|
|
337
|
-
"embedding <-> '[0.1,0
|
|
351
|
+
"Composed([SQL('embedding <-> '), Literal('[0.1,2.0]'), SQL(' AS score')])",
|
|
352
|
+
"Composed([SQL('embedding <-> '), Literal('[0.1,2.0]')])",
|
|
338
353
|
),
|
|
339
354
|
],
|
|
340
355
|
)
|
|
@@ -344,21 +359,29 @@ def test_check_and_build_embedding_retrieval_query_orders_by_distance_operator(
|
|
|
344
359
|
mock_store.embedding_dimension = 2
|
|
345
360
|
|
|
346
361
|
sql_query, params = mock_store._check_and_build_embedding_retrieval_query(
|
|
347
|
-
query_embedding=[0.1,
|
|
362
|
+
query_embedding=[0.1, 2],
|
|
348
363
|
vector_function=vector_function,
|
|
349
364
|
top_k=5,
|
|
350
365
|
)
|
|
351
366
|
|
|
352
367
|
rendered = repr(sql_query)
|
|
353
|
-
assert score_sql in rendered
|
|
354
|
-
assert "SQL(' ORDER BY ')" in rendered
|
|
355
|
-
assert f'SQL("{order_by_sql}")' in rendered
|
|
356
|
-
assert "SQL(' ASC LIMIT ')" in rendered
|
|
357
|
-
assert "Literal(5)" in rendered
|
|
358
|
-
assert "ORDER BY score" not in rendered
|
|
368
|
+
assert f"SQL('SELECT *, '), {score_sql}" in rendered
|
|
369
|
+
assert f"SQL(' ORDER BY '), {order_by_sql}, SQL(' ASC LIMIT '), Literal(5)" in rendered
|
|
359
370
|
assert params == ()
|
|
360
371
|
|
|
361
372
|
|
|
373
|
+
@pytest.mark.parametrize("bad_element", ["0.1]'); DROP TABLE haystack; --", None])
|
|
374
|
+
def test_check_and_build_embedding_retrieval_query_rejects_non_numeric_elements(mock_store, bad_element):
|
|
375
|
+
mock_store.embedding_dimension = 2
|
|
376
|
+
|
|
377
|
+
with pytest.raises(TypeError, match="query_embedding must be a list of floats"):
|
|
378
|
+
mock_store._check_and_build_embedding_retrieval_query(
|
|
379
|
+
query_embedding=[bad_element, 0.2],
|
|
380
|
+
vector_function="cosine_similarity",
|
|
381
|
+
top_k=5,
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
|
|
362
385
|
@pytest.mark.parametrize(
|
|
363
386
|
"bad_field",
|
|
364
387
|
[
|
|
@@ -491,16 +514,16 @@ def test_halfvec_hnsw_write_documents(document_store_w_halfvec_hnsw_index: Pgvec
|
|
|
491
514
|
|
|
492
515
|
@pytest.mark.integration
|
|
493
516
|
def test_hnsw_index_recreation():
|
|
494
|
-
def
|
|
495
|
-
|
|
496
|
-
SELECT c.oid
|
|
517
|
+
def get_index_oid_and_reloptions(document_store, schema_name, index_name):
|
|
518
|
+
sql_get_index_oid_and_reloptions = """
|
|
519
|
+
SELECT c.oid, c.reloptions
|
|
497
520
|
FROM pg_class c
|
|
498
521
|
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
499
522
|
WHERE c.relkind = 'i'
|
|
500
523
|
AND n.nspname = %s
|
|
501
524
|
AND c.relname = %s;
|
|
502
525
|
"""
|
|
503
|
-
return document_store._cursor.execute(
|
|
526
|
+
return document_store._cursor.execute(sql_get_index_oid_and_reloptions, (schema_name, index_name)).fetchone()
|
|
504
527
|
|
|
505
528
|
# create a new schema
|
|
506
529
|
connection_string = "postgresql://postgres:postgres@localhost:5432/postgres"
|
|
@@ -519,19 +542,53 @@ def test_hnsw_index_recreation():
|
|
|
519
542
|
ds1 = PgvectorDocumentStore(**params)
|
|
520
543
|
ds1._ensure_db_setup()
|
|
521
544
|
|
|
522
|
-
# get the hnsw index oid
|
|
545
|
+
# get the hnsw index oid and options
|
|
523
546
|
hnws_index_name = "haystack_hnsw_index"
|
|
524
|
-
first_oid =
|
|
547
|
+
first_oid, first_reloptions = get_index_oid_and_reloptions(ds1, ds1.schema_name, hnws_index_name)
|
|
548
|
+
assert first_reloptions is None
|
|
525
549
|
|
|
526
|
-
# create second document store with recreation enabled
|
|
527
|
-
ds2 = PgvectorDocumentStore(
|
|
550
|
+
# create second document store with recreation enabled and custom index creation kwargs
|
|
551
|
+
ds2 = PgvectorDocumentStore(
|
|
552
|
+
**params, hnsw_recreate_index_if_exists=True, hnsw_index_creation_kwargs={"m": 32, "ef_construction": 128}
|
|
553
|
+
)
|
|
528
554
|
ds2._ensure_db_setup()
|
|
529
555
|
|
|
530
|
-
# get the index oid
|
|
531
|
-
second_oid =
|
|
556
|
+
# get the index oid and options
|
|
557
|
+
second_oid, second_reloptions = get_index_oid_and_reloptions(ds2, ds2.schema_name, hnws_index_name)
|
|
532
558
|
|
|
533
|
-
# verify that oids differ
|
|
559
|
+
# verify that oids differ and the recreated index uses the new kwargs
|
|
534
560
|
assert second_oid != first_oid, "Index was not recreated (OID remained the same)"
|
|
561
|
+
assert sorted(second_reloptions) == ["ef_construction=128", "m=32"]
|
|
562
|
+
|
|
563
|
+
# Clean up: drop the schema after the test
|
|
564
|
+
with psycopg.connect(connection_string, autocommit=True) as conn:
|
|
565
|
+
conn.execute(f"DROP SCHEMA IF EXISTS {schema_name} CASCADE")
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
@pytest.mark.integration
|
|
569
|
+
def test_hnsw_index_creation_kwargs_values_are_not_interpreted_as_sql():
|
|
570
|
+
connection_string = "postgresql://postgres:postgres@localhost:5432/postgres"
|
|
571
|
+
schema_name = "test_schema_hnsw_kwargs_injection"
|
|
572
|
+
with psycopg.connect(connection_string, autocommit=True) as conn:
|
|
573
|
+
conn.execute(f"DROP SCHEMA IF EXISTS {schema_name} CASCADE")
|
|
574
|
+
conn.execute(f"CREATE SCHEMA {schema_name}")
|
|
575
|
+
conn.execute(f"CREATE TABLE {schema_name}.canary (id int)")
|
|
576
|
+
|
|
577
|
+
document_store = PgvectorDocumentStore(
|
|
578
|
+
connection_string=Secret.from_token(connection_string),
|
|
579
|
+
schema_name=schema_name,
|
|
580
|
+
table_name="haystack_test_hnsw_kwargs_injection",
|
|
581
|
+
search_strategy="hnsw",
|
|
582
|
+
hnsw_index_creation_kwargs={"m": f"16); DROP TABLE {schema_name}.canary; --"},
|
|
583
|
+
)
|
|
584
|
+
|
|
585
|
+
# the value is passed as a literal, so PostgreSQL rejects it as an invalid value for "m"
|
|
586
|
+
with pytest.raises(DocumentStoreError, match="Could not create HNSW index"):
|
|
587
|
+
document_store._ensure_db_setup()
|
|
588
|
+
|
|
589
|
+
with psycopg.connect(connection_string) as conn:
|
|
590
|
+
canary = conn.execute("SELECT to_regclass(%s)", (f"{schema_name}.canary",)).fetchone()[0]
|
|
591
|
+
assert canary is not None
|
|
535
592
|
|
|
536
593
|
# Clean up: drop the schema after the test
|
|
537
594
|
with psycopg.connect(connection_string, autocommit=True) as conn:
|
|
@@ -605,6 +662,27 @@ def test_delete_documents_empty_list_is_noop(document_store: PgvectorDocumentSto
|
|
|
605
662
|
assert after == before
|
|
606
663
|
|
|
607
664
|
|
|
665
|
+
@pytest.mark.integration
|
|
666
|
+
def test_delete_documents_with_quotes_in_ids(document_store: PgvectorDocumentStore):
|
|
667
|
+
"""
|
|
668
|
+
The ids are passed as a bound parameter, so quotes in them are not interpreted as SQL.
|
|
669
|
+
"""
|
|
670
|
+
docs = [
|
|
671
|
+
Document(id="O'Brien", content="quote in id"),
|
|
672
|
+
Document(id="x') OR ('1'='1", content="injection-shaped id"),
|
|
673
|
+
Document(id="1", content="hello"),
|
|
674
|
+
Document(id="2", content="world"),
|
|
675
|
+
]
|
|
676
|
+
document_store.write_documents(docs)
|
|
677
|
+
|
|
678
|
+
document_store.delete_documents(["O'Brien"])
|
|
679
|
+
assert sorted(doc.id for doc in document_store.filter_documents()) == ["1", "2", "x') OR ('1'='1"]
|
|
680
|
+
|
|
681
|
+
# only matches the document with this exact id, instead of every document
|
|
682
|
+
document_store.delete_documents(["x') OR ('1'='1"])
|
|
683
|
+
assert sorted(doc.id for doc in document_store.filter_documents()) == ["1", "2"]
|
|
684
|
+
|
|
685
|
+
|
|
608
686
|
@pytest.mark.integration
|
|
609
687
|
def test_update_by_filter_empty_meta_raises_error(document_store: PgvectorDocumentStore):
|
|
610
688
|
docs = [Document(content="Doc 1", meta={"category": "A"})]
|
|
@@ -189,6 +189,27 @@ class TestDocumentStoreAsync(
|
|
|
189
189
|
"""
|
|
190
190
|
await document_store.delete_table_async() # if throw error, test fails
|
|
191
191
|
|
|
192
|
+
async def test_delete_documents_async_with_quotes_in_ids(self, document_store: PgvectorDocumentStore):
|
|
193
|
+
"""
|
|
194
|
+
The ids are passed as a bound parameter, so quotes in them are not interpreted as SQL.
|
|
195
|
+
"""
|
|
196
|
+
docs = [
|
|
197
|
+
Document(id="O'Brien", content="quote in id"),
|
|
198
|
+
Document(id="x') OR ('1'='1", content="injection-shaped id"),
|
|
199
|
+
Document(id="1", content="hello"),
|
|
200
|
+
Document(id="2", content="world"),
|
|
201
|
+
]
|
|
202
|
+
await document_store.write_documents_async(docs)
|
|
203
|
+
|
|
204
|
+
await document_store.delete_documents_async(["O'Brien"])
|
|
205
|
+
remaining = await document_store.filter_documents_async()
|
|
206
|
+
assert sorted(doc.id for doc in remaining) == ["1", "2", "x') OR ('1'='1"]
|
|
207
|
+
|
|
208
|
+
# only matches the document with this exact id, instead of every document
|
|
209
|
+
await document_store.delete_documents_async(["x') OR ('1'='1"])
|
|
210
|
+
remaining = await document_store.filter_documents_async()
|
|
211
|
+
assert sorted(doc.id for doc in remaining) == ["1", "2"]
|
|
212
|
+
|
|
192
213
|
async def test_close_async_and_reopen(self, document_store: PgvectorDocumentStore):
|
|
193
214
|
assert await document_store.count_documents_async() == 0
|
|
194
215
|
assert document_store._async_connection is not None
|
|
@@ -205,9 +226,9 @@ class TestDocumentStoreAsync(
|
|
|
205
226
|
@pytest.mark.integration
|
|
206
227
|
@pytest.mark.asyncio
|
|
207
228
|
async def test_hnsw_index_recreation():
|
|
208
|
-
async def
|
|
209
|
-
|
|
210
|
-
SELECT c.oid
|
|
229
|
+
async def get_index_oid_and_reloptions(cursor, document_store, schema_name, index_name):
|
|
230
|
+
sql_get_index_oid_and_reloptions = SQL("""
|
|
231
|
+
SELECT c.oid, c.reloptions
|
|
211
232
|
FROM pg_class c
|
|
212
233
|
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
213
234
|
WHERE c.relkind = 'i'
|
|
@@ -215,15 +236,16 @@ async def test_hnsw_index_recreation():
|
|
|
215
236
|
AND c.relname = %s;
|
|
216
237
|
""")
|
|
217
238
|
result = await document_store._execute_sql_async(
|
|
218
|
-
cursor=cursor, sql_query=
|
|
239
|
+
cursor=cursor, sql_query=sql_get_index_oid_and_reloptions, params=(schema_name, index_name)
|
|
219
240
|
)
|
|
220
|
-
return
|
|
241
|
+
return await result.fetchone()
|
|
221
242
|
|
|
222
243
|
# create a new schema
|
|
223
244
|
connection_string = "postgresql://postgres:postgres@localhost:5432/postgres"
|
|
224
245
|
schema_name = "test_schema"
|
|
225
246
|
async with await psycopg.AsyncConnection.connect(connection_string, autocommit=True) as conn:
|
|
226
|
-
await conn.execute(f"
|
|
247
|
+
await conn.execute(f"DROP SCHEMA IF EXISTS {schema_name} CASCADE")
|
|
248
|
+
await conn.execute(f"CREATE SCHEMA {schema_name}")
|
|
227
249
|
|
|
228
250
|
# create a first document store and trigger the creation of the hnsw index
|
|
229
251
|
params = {
|
|
@@ -235,23 +257,58 @@ async def test_hnsw_index_recreation():
|
|
|
235
257
|
ds1 = PgvectorDocumentStore(**params)
|
|
236
258
|
await ds1._ensure_db_setup_async()
|
|
237
259
|
|
|
238
|
-
# get the hnsw index oid
|
|
260
|
+
# get the hnsw index oid and options
|
|
239
261
|
hnws_index_name = "haystack_hnsw_index"
|
|
240
|
-
first_oid = await
|
|
262
|
+
first_oid, first_reloptions = await get_index_oid_and_reloptions(
|
|
241
263
|
cursor=ds1._async_cursor, document_store=ds1, schema_name=ds1.schema_name, index_name=hnws_index_name
|
|
242
264
|
)
|
|
265
|
+
assert first_reloptions is None
|
|
243
266
|
|
|
244
|
-
# create second document store with recreation enabled
|
|
245
|
-
ds2 = PgvectorDocumentStore(
|
|
267
|
+
# create second document store with recreation enabled and custom index creation kwargs
|
|
268
|
+
ds2 = PgvectorDocumentStore(
|
|
269
|
+
**params, hnsw_recreate_index_if_exists=True, hnsw_index_creation_kwargs={"m": 32, "ef_construction": 128}
|
|
270
|
+
)
|
|
246
271
|
await ds2._ensure_db_setup_async()
|
|
247
272
|
|
|
248
|
-
# get the index oid
|
|
249
|
-
second_oid = await
|
|
273
|
+
# get the index oid and options
|
|
274
|
+
second_oid, second_reloptions = await get_index_oid_and_reloptions(
|
|
250
275
|
cursor=ds2._async_cursor, document_store=ds2, schema_name=ds2.schema_name, index_name=hnws_index_name
|
|
251
276
|
)
|
|
252
277
|
|
|
253
|
-
# verify that oids differ
|
|
278
|
+
# verify that oids differ and the recreated index uses the new kwargs
|
|
254
279
|
assert second_oid != first_oid, "Index was not recreated (OID remained the same)"
|
|
280
|
+
assert sorted(second_reloptions) == ["ef_construction=128", "m=32"]
|
|
281
|
+
|
|
282
|
+
# Clean up: drop the schema after the test
|
|
283
|
+
async with await psycopg.AsyncConnection.connect(connection_string, autocommit=True) as conn:
|
|
284
|
+
await conn.execute(f"DROP SCHEMA IF EXISTS {schema_name} CASCADE")
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
@pytest.mark.integration
|
|
288
|
+
@pytest.mark.asyncio
|
|
289
|
+
async def test_hnsw_index_creation_kwargs_values_are_not_interpreted_as_sql():
|
|
290
|
+
connection_string = "postgresql://postgres:postgres@localhost:5432/postgres"
|
|
291
|
+
schema_name = "test_schema_hnsw_kwargs_injection_async"
|
|
292
|
+
async with await psycopg.AsyncConnection.connect(connection_string, autocommit=True) as conn:
|
|
293
|
+
await conn.execute(f"DROP SCHEMA IF EXISTS {schema_name} CASCADE")
|
|
294
|
+
await conn.execute(f"CREATE SCHEMA {schema_name}")
|
|
295
|
+
await conn.execute(f"CREATE TABLE {schema_name}.canary (id int)")
|
|
296
|
+
|
|
297
|
+
document_store = PgvectorDocumentStore(
|
|
298
|
+
connection_string=Secret.from_token(connection_string),
|
|
299
|
+
schema_name=schema_name,
|
|
300
|
+
table_name="haystack_test_hnsw_kwargs_injection",
|
|
301
|
+
search_strategy="hnsw",
|
|
302
|
+
hnsw_index_creation_kwargs={"m": f"16); DROP TABLE {schema_name}.canary; --"},
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
# the value is passed as a literal, so PostgreSQL rejects it as an invalid value for "m"
|
|
306
|
+
with pytest.raises(DocumentStoreError, match="Could not create HNSW index"):
|
|
307
|
+
await document_store._ensure_db_setup_async()
|
|
308
|
+
|
|
309
|
+
async with await psycopg.AsyncConnection.connect(connection_string) as conn:
|
|
310
|
+
canary = await (await conn.execute("SELECT to_regclass(%s)", (f"{schema_name}.canary",))).fetchone()
|
|
311
|
+
assert canary[0] is not None
|
|
255
312
|
|
|
256
313
|
# Clean up: drop the schema after the test
|
|
257
314
|
async with await psycopg.AsyncConnection.connect(connection_string, autocommit=True) as conn:
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|