pgvector-haystack 8.1.1__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.1 → pgvector_haystack-8.1.2}/CHANGELOG.md +7 -0
  2. {pgvector_haystack-8.1.1 → pgvector_haystack-8.1.2}/PKG-INFO +1 -1
  3. {pgvector_haystack-8.1.1 → pgvector_haystack-8.1.2}/src/haystack_integrations/document_stores/pgvector/document_store.py +19 -12
  4. {pgvector_haystack-8.1.1 → pgvector_haystack-8.1.2}/tests/test_document_store.py +81 -24
  5. {pgvector_haystack-8.1.1 → pgvector_haystack-8.1.2}/tests/test_document_store_async.py +49 -13
  6. {pgvector_haystack-8.1.1 → pgvector_haystack-8.1.2}/.gitignore +0 -0
  7. {pgvector_haystack-8.1.1 → pgvector_haystack-8.1.2}/LICENSE.txt +0 -0
  8. {pgvector_haystack-8.1.1 → pgvector_haystack-8.1.2}/README.md +0 -0
  9. {pgvector_haystack-8.1.1 → pgvector_haystack-8.1.2}/examples/embedding_retrieval.py +0 -0
  10. {pgvector_haystack-8.1.1 → pgvector_haystack-8.1.2}/examples/hybrid_retrieval.py +0 -0
  11. {pgvector_haystack-8.1.1 → pgvector_haystack-8.1.2}/pydoc/config_docusaurus.yml +0 -0
  12. {pgvector_haystack-8.1.1 → pgvector_haystack-8.1.2}/pyproject.toml +0 -0
  13. {pgvector_haystack-8.1.1 → pgvector_haystack-8.1.2}/src/haystack_integrations/components/retrievers/pgvector/__init__.py +0 -0
  14. {pgvector_haystack-8.1.1 → pgvector_haystack-8.1.2}/src/haystack_integrations/components/retrievers/pgvector/embedding_retriever.py +0 -0
  15. {pgvector_haystack-8.1.1 → pgvector_haystack-8.1.2}/src/haystack_integrations/components/retrievers/pgvector/keyword_retriever.py +0 -0
  16. {pgvector_haystack-8.1.1 → pgvector_haystack-8.1.2}/src/haystack_integrations/components/retrievers/py.typed +0 -0
  17. {pgvector_haystack-8.1.1 → pgvector_haystack-8.1.2}/src/haystack_integrations/document_stores/pgvector/__init__.py +0 -0
  18. {pgvector_haystack-8.1.1 → pgvector_haystack-8.1.2}/src/haystack_integrations/document_stores/pgvector/converters.py +0 -0
  19. {pgvector_haystack-8.1.1 → pgvector_haystack-8.1.2}/src/haystack_integrations/document_stores/pgvector/filters.py +0 -0
  20. {pgvector_haystack-8.1.1 → pgvector_haystack-8.1.2}/src/haystack_integrations/document_stores/py.typed +0 -0
  21. {pgvector_haystack-8.1.1 → pgvector_haystack-8.1.2}/tests/__init__.py +0 -0
  22. {pgvector_haystack-8.1.1 → pgvector_haystack-8.1.2}/tests/conftest.py +0 -0
  23. {pgvector_haystack-8.1.1 → pgvector_haystack-8.1.2}/tests/test_converters.py +0 -0
  24. {pgvector_haystack-8.1.1 → pgvector_haystack-8.1.2}/tests/test_filters.py +0 -0
  25. {pgvector_haystack-8.1.1 → pgvector_haystack-8.1.2}/tests/test_retrieval.py +0 -0
  26. {pgvector_haystack-8.1.1 → pgvector_haystack-8.1.2}/tests/test_retrieval_async.py +0 -0
  27. {pgvector_haystack-8.1.1 → pgvector_haystack-8.1.2}/tests/test_retrievers.py +0 -0
@@ -1,5 +1,12 @@
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
+
3
10
  ## [integrations/pgvector-v8.1.0] - 2026-10-20
4
11
 
5
12
  ### 🚀 Features
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pgvector-haystack
3
- Version: 8.1.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
- 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
 
@@ -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:
@@ -226,9 +226,9 @@ class TestDocumentStoreAsync(
226
226
  @pytest.mark.integration
227
227
  @pytest.mark.asyncio
228
228
  async def test_hnsw_index_recreation():
229
- async def get_index_oid(cursor, document_store, schema_name, index_name):
230
- sql_get_index_oid = SQL("""
231
- 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
232
232
  FROM pg_class c
233
233
  JOIN pg_namespace n ON n.oid = c.relnamespace
234
234
  WHERE c.relkind = 'i'
@@ -236,15 +236,16 @@ async def test_hnsw_index_recreation():
236
236
  AND c.relname = %s;
237
237
  """)
238
238
  result = await document_store._execute_sql_async(
239
- 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)
240
240
  )
241
- return (await result.fetchone())[0]
241
+ return await result.fetchone()
242
242
 
243
243
  # create a new schema
244
244
  connection_string = "postgresql://postgres:postgres@localhost:5432/postgres"
245
245
  schema_name = "test_schema"
246
246
  async with await psycopg.AsyncConnection.connect(connection_string, autocommit=True) as conn:
247
- 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}")
248
249
 
249
250
  # create a first document store and trigger the creation of the hnsw index
250
251
  params = {
@@ -256,23 +257,58 @@ async def test_hnsw_index_recreation():
256
257
  ds1 = PgvectorDocumentStore(**params)
257
258
  await ds1._ensure_db_setup_async()
258
259
 
259
- # get the hnsw index oid
260
+ # get the hnsw index oid and options
260
261
  hnws_index_name = "haystack_hnsw_index"
261
- first_oid = await get_index_oid(
262
+ first_oid, first_reloptions = await get_index_oid_and_reloptions(
262
263
  cursor=ds1._async_cursor, document_store=ds1, schema_name=ds1.schema_name, index_name=hnws_index_name
263
264
  )
265
+ assert first_reloptions is None
264
266
 
265
- # create second document store with recreation enabled
266
- 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
+ )
267
271
  await ds2._ensure_db_setup_async()
268
272
 
269
- # get the index oid
270
- second_oid = await get_index_oid(
273
+ # get the index oid and options
274
+ second_oid, second_reloptions = await get_index_oid_and_reloptions(
271
275
  cursor=ds2._async_cursor, document_store=ds2, schema_name=ds2.schema_name, index_name=hnws_index_name
272
276
  )
273
277
 
274
- # verify that oids differ
278
+ # verify that oids differ and the recreated index uses the new kwargs
275
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
276
312
 
277
313
  # Clean up: drop the schema after the test
278
314
  async with await psycopg.AsyncConnection.connect(connection_string, autocommit=True) as conn: