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.
Files changed (27) hide show
  1. {pgvector_haystack-8.1.0 → pgvector_haystack-8.1.2}/CHANGELOG.md +14 -0
  2. {pgvector_haystack-8.1.0 → pgvector_haystack-8.1.2}/PKG-INFO +1 -1
  3. {pgvector_haystack-8.1.0 → pgvector_haystack-8.1.2}/src/haystack_integrations/document_stores/pgvector/document_store.py +28 -21
  4. {pgvector_haystack-8.1.0 → pgvector_haystack-8.1.2}/tests/test_document_store.py +102 -24
  5. {pgvector_haystack-8.1.0 → pgvector_haystack-8.1.2}/tests/test_document_store_async.py +70 -13
  6. {pgvector_haystack-8.1.0 → pgvector_haystack-8.1.2}/.gitignore +0 -0
  7. {pgvector_haystack-8.1.0 → pgvector_haystack-8.1.2}/LICENSE.txt +0 -0
  8. {pgvector_haystack-8.1.0 → pgvector_haystack-8.1.2}/README.md +0 -0
  9. {pgvector_haystack-8.1.0 → pgvector_haystack-8.1.2}/examples/embedding_retrieval.py +0 -0
  10. {pgvector_haystack-8.1.0 → pgvector_haystack-8.1.2}/examples/hybrid_retrieval.py +0 -0
  11. {pgvector_haystack-8.1.0 → pgvector_haystack-8.1.2}/pydoc/config_docusaurus.yml +0 -0
  12. {pgvector_haystack-8.1.0 → pgvector_haystack-8.1.2}/pyproject.toml +0 -0
  13. {pgvector_haystack-8.1.0 → pgvector_haystack-8.1.2}/src/haystack_integrations/components/retrievers/pgvector/__init__.py +0 -0
  14. {pgvector_haystack-8.1.0 → pgvector_haystack-8.1.2}/src/haystack_integrations/components/retrievers/pgvector/embedding_retriever.py +0 -0
  15. {pgvector_haystack-8.1.0 → pgvector_haystack-8.1.2}/src/haystack_integrations/components/retrievers/pgvector/keyword_retriever.py +0 -0
  16. {pgvector_haystack-8.1.0 → pgvector_haystack-8.1.2}/src/haystack_integrations/components/retrievers/py.typed +0 -0
  17. {pgvector_haystack-8.1.0 → pgvector_haystack-8.1.2}/src/haystack_integrations/document_stores/pgvector/__init__.py +0 -0
  18. {pgvector_haystack-8.1.0 → pgvector_haystack-8.1.2}/src/haystack_integrations/document_stores/pgvector/converters.py +0 -0
  19. {pgvector_haystack-8.1.0 → pgvector_haystack-8.1.2}/src/haystack_integrations/document_stores/pgvector/filters.py +0 -0
  20. {pgvector_haystack-8.1.0 → pgvector_haystack-8.1.2}/src/haystack_integrations/document_stores/py.typed +0 -0
  21. {pgvector_haystack-8.1.0 → pgvector_haystack-8.1.2}/tests/__init__.py +0 -0
  22. {pgvector_haystack-8.1.0 → pgvector_haystack-8.1.2}/tests/conftest.py +0 -0
  23. {pgvector_haystack-8.1.0 → pgvector_haystack-8.1.2}/tests/test_converters.py +0 -0
  24. {pgvector_haystack-8.1.0 → pgvector_haystack-8.1.2}/tests/test_filters.py +0 -0
  25. {pgvector_haystack-8.1.0 → pgvector_haystack-8.1.2}/tests/test_retrieval.py +0 -0
  26. {pgvector_haystack-8.1.0 → pgvector_haystack-8.1.2}/tests/test_retrieval_async.py +0 -0
  27. {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.0
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
- kwargs_str = ", ".join(f"{k} = {v}" for k, v in valid_kwargs.items())
682
- sql_create_hnsw_index += SQL(" WITH ({})").format(SQL(kwargs_str))
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
- document_ids_str = ", ".join(f"'{document_id}'" for document_id in document_ids)
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, sql_query=delete_sql, error_msg="Could not delete documents from PgvectorDocumentStore"
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
- document_ids_str = ", ".join(f"'{document_id}'" for document_id in document_ids)
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
- query_embedding_for_postgres = f"'[{','.join(str(el) for el in query_embedding)}]'"
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 = f"1 - (embedding <=> {query_embedding_for_postgres}) AS score"
1414
- order_by_definition = f"embedding <=> {query_embedding_for_postgres}"
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 = f"(embedding <#> {query_embedding_for_postgres}) * -1 AS score"
1417
- order_by_definition = f"embedding <#> {query_embedding_for_postgres}"
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 = f"embedding <-> {query_embedding_for_postgres} AS score"
1420
- order_by_definition = f"embedding <-> {query_embedding_for_postgres}"
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=SQL(score_definition),
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=SQL(order_by_definition),
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.2]') AS score",
327
- "embedding <=> '[0.1,0.2]'",
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.2]') * -1 AS score",
332
- "embedding <#> '[0.1,0.2]'",
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.2]' AS score",
337
- "embedding <-> '[0.1,0.2]'",
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, 0.2],
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 get_index_oid(document_store, schema_name, index_name):
495
- sql_get_index_oid = """
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(sql_get_index_oid, (schema_name, index_name)).fetchone()[0]
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 = get_index_oid(ds1, ds1.schema_name, hnws_index_name)
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(**params, hnsw_recreate_index_if_exists=True)
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 = get_index_oid(ds2, ds2.schema_name, hnws_index_name)
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 get_index_oid(cursor, document_store, schema_name, index_name):
209
- sql_get_index_oid = SQL("""
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=sql_get_index_oid, params=(schema_name, index_name)
239
+ cursor=cursor, sql_query=sql_get_index_oid_and_reloptions, params=(schema_name, index_name)
219
240
  )
220
- return (await result.fetchone())[0]
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"CREATE SCHEMA IF NOT EXISTS {schema_name}")
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 get_index_oid(
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(**params, hnsw_recreate_index_if_exists=True)
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 get_index_oid(
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: