pgvector-haystack 8.1.1__tar.gz → 8.2.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.
Files changed (28) hide show
  1. {pgvector_haystack-8.1.1 → pgvector_haystack-8.2.0}/CHANGELOG.md +14 -0
  2. {pgvector_haystack-8.1.1 → pgvector_haystack-8.2.0}/PKG-INFO +1 -1
  3. {pgvector_haystack-8.1.1 → pgvector_haystack-8.2.0}/src/haystack_integrations/document_stores/pgvector/document_store.py +190 -12
  4. pgvector_haystack-8.2.0/src/haystack_integrations/document_stores/pgvector/table_replacement.py +440 -0
  5. {pgvector_haystack-8.1.1 → pgvector_haystack-8.2.0}/tests/conftest.py +39 -0
  6. {pgvector_haystack-8.1.1 → pgvector_haystack-8.2.0}/tests/test_document_store.py +552 -25
  7. {pgvector_haystack-8.1.1 → pgvector_haystack-8.2.0}/tests/test_document_store_async.py +225 -14
  8. {pgvector_haystack-8.1.1 → pgvector_haystack-8.2.0}/.gitignore +0 -0
  9. {pgvector_haystack-8.1.1 → pgvector_haystack-8.2.0}/LICENSE.txt +0 -0
  10. {pgvector_haystack-8.1.1 → pgvector_haystack-8.2.0}/README.md +0 -0
  11. {pgvector_haystack-8.1.1 → pgvector_haystack-8.2.0}/examples/embedding_retrieval.py +0 -0
  12. {pgvector_haystack-8.1.1 → pgvector_haystack-8.2.0}/examples/hybrid_retrieval.py +0 -0
  13. {pgvector_haystack-8.1.1 → pgvector_haystack-8.2.0}/pydoc/config_docusaurus.yml +0 -0
  14. {pgvector_haystack-8.1.1 → pgvector_haystack-8.2.0}/pyproject.toml +0 -0
  15. {pgvector_haystack-8.1.1 → pgvector_haystack-8.2.0}/src/haystack_integrations/components/retrievers/pgvector/__init__.py +0 -0
  16. {pgvector_haystack-8.1.1 → pgvector_haystack-8.2.0}/src/haystack_integrations/components/retrievers/pgvector/embedding_retriever.py +0 -0
  17. {pgvector_haystack-8.1.1 → pgvector_haystack-8.2.0}/src/haystack_integrations/components/retrievers/pgvector/keyword_retriever.py +0 -0
  18. {pgvector_haystack-8.1.1 → pgvector_haystack-8.2.0}/src/haystack_integrations/components/retrievers/py.typed +0 -0
  19. {pgvector_haystack-8.1.1 → pgvector_haystack-8.2.0}/src/haystack_integrations/document_stores/pgvector/__init__.py +0 -0
  20. {pgvector_haystack-8.1.1 → pgvector_haystack-8.2.0}/src/haystack_integrations/document_stores/pgvector/converters.py +0 -0
  21. {pgvector_haystack-8.1.1 → pgvector_haystack-8.2.0}/src/haystack_integrations/document_stores/pgvector/filters.py +0 -0
  22. {pgvector_haystack-8.1.1 → pgvector_haystack-8.2.0}/src/haystack_integrations/document_stores/py.typed +0 -0
  23. {pgvector_haystack-8.1.1 → pgvector_haystack-8.2.0}/tests/__init__.py +0 -0
  24. {pgvector_haystack-8.1.1 → pgvector_haystack-8.2.0}/tests/test_converters.py +0 -0
  25. {pgvector_haystack-8.1.1 → pgvector_haystack-8.2.0}/tests/test_filters.py +0 -0
  26. {pgvector_haystack-8.1.1 → pgvector_haystack-8.2.0}/tests/test_retrieval.py +0 -0
  27. {pgvector_haystack-8.1.1 → pgvector_haystack-8.2.0}/tests/test_retrieval_async.py +0 -0
  28. {pgvector_haystack-8.1.1 → pgvector_haystack-8.2.0}/tests/test_retrievers.py +0 -0
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [integrations/pgvector-v8.1.2] - 2026-10-21
4
+
5
+ ### 🚀 Features
6
+
7
+ - *(pgvector)* Ensure safe SQL handling for HNSW index creation and embedding queries (#4139)
8
+
9
+
10
+ ## [integrations/pgvector-v8.1.1] - 2026-10-21
11
+
12
+ ### 🐛 Bug Fixes
13
+
14
+ - *(pgvector)* Pass document ids as a bound parameter in delete_docum… (#4135)
15
+
16
+
3
17
  ## [integrations/pgvector-v8.1.0] - 2026-10-20
4
18
 
5
19
  ### 🚀 Features
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pgvector-haystack
3
- Version: 8.1.1
3
+ Version: 8.2.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
@@ -22,6 +22,7 @@ from pgvector.psycopg import register_vector, register_vector_async
22
22
 
23
23
  from .converters import _from_haystack_to_pg_documents, _from_pg_to_haystack_documents
24
24
  from .filters import _convert_filters_to_where_clause_and_params, _validate_filters
25
+ from .table_replacement import replace_table, replace_table_async
25
26
 
26
27
  logger = logging.getLogger(__name__)
27
28
 
@@ -635,6 +636,176 @@ class PgvectorDocumentStore:
635
636
  error_msg=f"Could not delete table {self.schema_name}.{self.table_name} in PgvectorDocumentStore",
636
637
  )
637
638
 
639
+ def replace_table(
640
+ self,
641
+ staging_store: "PgvectorDocumentStore",
642
+ *,
643
+ backup_table_name: str | None = None,
644
+ lock_timeout_ms: int = 5000,
645
+ ) -> str:
646
+ """
647
+ Atomically replace this store's table with the table of a populated staging store.
648
+
649
+ Use this to rebuild a document store without downtime: write the new documents into a staging store, then
650
+ swap it in. Queries see either the old or the new documents, never a partially filled table. The former
651
+ table is kept as a backup until you drop it.
652
+
653
+ Requirements:
654
+ - `staging_store` must connect to the same PostgreSQL database and use the same `schema_name`,
655
+ `embedding_dimension`, `vector_type`, `vector_function`, `language`, and `search_strategy`, plus the same
656
+ `hnsw_index_creation_kwargs` with the `hnsw` search strategy.
657
+ - Index names are unique per schema, so `staging_store` needs its own `keyword_index_name` and, with the
658
+ `hnsw` search strategy, its own `hnsw_index_name`. A staging store with the default index names fails
659
+ when it creates its table.
660
+ - The staging table must already exist. This method never creates it.
661
+ - Pause writes to this store during the swap. A write that is in flight when the swap starts lands in the
662
+ backup table, not in the promoted one.
663
+
664
+ Locking: the swap takes an `ACCESS EXCLUSIVE` lock on both tables, so it first waits for running queries on
665
+ them to finish. While it waits, new reads and writes on both tables wait too. If the locks are not acquired
666
+ within `lock_timeout_ms`, nothing is changed. The renames themselves are fast.
667
+
668
+ After a successful swap, this store serves the promoted documents under its own table and index names, and
669
+ `staging_store` is repointed to the backup: its `table_name`, `keyword_index_name`, and, with the `hnsw`
670
+ search strategy, `hnsw_index_name` are set to the backup names. Call `staging_store.delete_table()` to
671
+ drop the former documents once you no longer need them. Because these attributes change,
672
+ `staging_store.to_dict()` also returns the backup names after the swap.
673
+
674
+ Don't create stores that serve queries with `recreate_table=True`. Any new instance with that setting, for
675
+ example in another process or a pipeline loaded from YAML, drops the live table when it first connects.
676
+
677
+ ### Usage example
678
+
679
+ ```python
680
+ from haystack import Document
681
+ from haystack_integrations.document_stores.pgvector import PgvectorDocumentStore
682
+
683
+ live_store = PgvectorDocumentStore(table_name="docs", embedding_dimension=768)
684
+ live_store.write_documents([Document(content="The current corpus")]) # already serving queries
685
+
686
+ # The staging store can reuse the same names on every rebuild: after a swap they are free again.
687
+ staging_store = PgvectorDocumentStore(
688
+ table_name="docs_staging",
689
+ embedding_dimension=768,
690
+ keyword_index_name="docs_staging_keyword_index",
691
+ )
692
+ staging_store.write_documents([Document(content="The rebuilt corpus")])
693
+
694
+ # Pause writes to live_store, then swap.
695
+ backup_table_name = live_store.replace_table(staging_store)
696
+
697
+ # live_store now serves the rebuilt corpus. staging_store points at the backup table.
698
+ staging_store.delete_table()
699
+ ```
700
+
701
+ :param staging_store: A populated store whose table replaces this store's table. Must meet the
702
+ requirements above.
703
+ :param backup_table_name: Name to keep the former table under. Must not exist yet. If not set, a unique
704
+ name of the form `<table_name>_backup_<suffix>` is generated.
705
+ :param lock_timeout_ms: How long to wait for the table locks, in milliseconds, before giving up. Reads and
706
+ writes on both tables also wait during this time. Must be a positive integer.
707
+ :returns: The name of the backup table that now holds the former documents.
708
+ :raises TypeError: If `lock_timeout_ms` is not an `int`.
709
+ :raises ValueError: If `staging_store` is configured incompatibly with this store as described in the
710
+ requirements above, connects to a different database, or if `backup_table_name` is invalid or
711
+ `lock_timeout_ms` is not positive.
712
+ :raises DocumentStoreError: If either table doesn't exist, the tables have different columns or
713
+ constraints, an expected index is missing, `backup_table_name` or a generated backup index name is
714
+ already taken, the locks are not acquired within `lock_timeout_ms`, or the database reports another
715
+ error. In every case the swap is rolled back and nothing is changed.
716
+ """
717
+ return replace_table(
718
+ self,
719
+ staging_store,
720
+ backup_table_name=backup_table_name,
721
+ lock_timeout_ms=lock_timeout_ms,
722
+ )
723
+
724
+ async def replace_table_async(
725
+ self,
726
+ staging_store: "PgvectorDocumentStore",
727
+ *,
728
+ backup_table_name: str | None = None,
729
+ lock_timeout_ms: int = 5000,
730
+ ) -> str:
731
+ """
732
+ Asynchronously and atomically replace this store's table with the table of a populated staging store.
733
+
734
+ Use this to rebuild a document store without downtime: write the new documents into a staging store, then
735
+ swap it in. Queries see either the old or the new documents, never a partially filled table. The former
736
+ table is kept as a backup until you drop it.
737
+
738
+ Requirements:
739
+ - `staging_store` must connect to the same PostgreSQL database and use the same `schema_name`,
740
+ `embedding_dimension`, `vector_type`, `vector_function`, `language`, and `search_strategy`, plus the same
741
+ `hnsw_index_creation_kwargs` with the `hnsw` search strategy.
742
+ - Index names are unique per schema, so `staging_store` needs its own `keyword_index_name` and, with the
743
+ `hnsw` search strategy, its own `hnsw_index_name`. A staging store with the default index names fails
744
+ when it creates its table.
745
+ - The staging table must already exist. This method never creates it.
746
+ - Pause writes to this store during the swap. A write that is in flight when the swap starts lands in the
747
+ backup table, not in the promoted one.
748
+
749
+ Locking: the swap takes an `ACCESS EXCLUSIVE` lock on both tables, so it first waits for running queries on
750
+ them to finish. While it waits, new reads and writes on both tables wait too. If the locks are not acquired
751
+ within `lock_timeout_ms`, nothing is changed. The renames themselves are fast.
752
+
753
+ After a successful swap, this store serves the promoted documents under its own table and index names, and
754
+ `staging_store` is repointed to the backup: its `table_name`, `keyword_index_name`, and, with the `hnsw`
755
+ search strategy, `hnsw_index_name` are set to the backup names. Call `staging_store.delete_table_async()` to
756
+ drop the former documents once you no longer need them. Because these attributes change,
757
+ `staging_store.to_dict()` also returns the backup names after the swap.
758
+
759
+ Don't create stores that serve queries with `recreate_table=True`. Any new instance with that setting, for
760
+ example in another process or a pipeline loaded from YAML, drops the live table when it first connects.
761
+
762
+ ### Usage example
763
+
764
+ ```python
765
+ from haystack import Document
766
+ from haystack_integrations.document_stores.pgvector import PgvectorDocumentStore
767
+
768
+ live_store = PgvectorDocumentStore(table_name="docs", embedding_dimension=768)
769
+ await live_store.write_documents_async([Document(content="The current corpus")]) # already serving queries
770
+
771
+ # The staging store can reuse the same names on every rebuild: after a swap they are free again.
772
+ staging_store = PgvectorDocumentStore(
773
+ table_name="docs_staging",
774
+ embedding_dimension=768,
775
+ keyword_index_name="docs_staging_keyword_index",
776
+ )
777
+ await staging_store.write_documents_async([Document(content="The rebuilt corpus")])
778
+
779
+ # Pause writes to live_store, then swap.
780
+ backup_table_name = await live_store.replace_table_async(staging_store)
781
+
782
+ # live_store now serves the rebuilt corpus. staging_store points at the backup table.
783
+ await staging_store.delete_table_async()
784
+ ```
785
+
786
+ :param staging_store: A populated store whose table replaces this store's table. Must meet the
787
+ requirements above.
788
+ :param backup_table_name: Name to keep the former table under. Must not exist yet. If not set, a unique
789
+ name of the form `<table_name>_backup_<suffix>` is generated.
790
+ :param lock_timeout_ms: How long to wait for the table locks, in milliseconds, before giving up. Reads and
791
+ writes on both tables also wait during this time. Must be a positive integer.
792
+ :returns: The name of the backup table that now holds the former documents.
793
+ :raises TypeError: If `lock_timeout_ms` is not an `int`.
794
+ :raises ValueError: If `staging_store` is configured incompatibly with this store as described in the
795
+ requirements above, connects to a different database, or if `backup_table_name` is invalid or
796
+ `lock_timeout_ms` is not positive.
797
+ :raises DocumentStoreError: If either table doesn't exist, the tables have different columns or
798
+ constraints, an expected index is missing, `backup_table_name` or a generated backup index name is
799
+ already taken, the locks are not acquired within `lock_timeout_ms`, or the database reports another
800
+ error. In every case the swap is rolled back and nothing is changed.
801
+ """
802
+ return await replace_table_async(
803
+ self,
804
+ staging_store,
805
+ backup_table_name=backup_table_name,
806
+ lock_timeout_ms=lock_timeout_ms,
807
+ )
808
+
638
809
  def _build_hnsw_queries(self) -> tuple[Composed | None, SQL, Composed, Composed]:
639
810
  """Common method to build all HNSW-related SQL queries"""
640
811
 
@@ -678,8 +849,10 @@ class PgvectorDocumentStore:
678
849
  k: v for k, v in self.hnsw_index_creation_kwargs.items() if k in HNSW_INDEX_CREATION_VALID_KWARGS
679
850
  }
680
851
  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))
852
+ # the names are allowlisted; pass the values as literals, so they are never interpreted as SQL
853
+ sql_create_hnsw_index += SQL(" WITH ({})").format(
854
+ SQL(", ").join(SQL("{} = {}").format(SQL(k), SQLLiteral(v)) for k, v in valid_kwargs.items())
855
+ )
683
856
 
684
857
  return sql_set_hnsw_ef_search, sql_hnsw_index_exists, sql_drop_hnsw_index, sql_create_hnsw_index
685
858
 
@@ -1403,26 +1576,31 @@ class PgvectorDocumentStore:
1403
1576
  msg = f"vector_function must be one of {VALID_VECTOR_FUNCTIONS}, but got {vector_function}"
1404
1577
  raise ValueError(msg)
1405
1578
 
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)}]'"
1579
+ # the vector must be a string with this format: "'[3.0,1.0,2.0]'"; the elements are converted to floats
1580
+ # and the string is passed as a literal, so it is never interpreted as SQL
1581
+ try:
1582
+ query_embedding_for_postgres = SQLLiteral(f"[{','.join(str(float(el)) for el in query_embedding)}]")
1583
+ except (TypeError, ValueError) as e:
1584
+ msg = "query_embedding must be a list of floats"
1585
+ raise TypeError(msg) from e
1408
1586
 
1409
1587
  # to compute the scores, we use the approach described in pgvector README:
1410
1588
  # https://github.com/pgvector/pgvector?tab=readme-ov-file#distances
1411
1589
  # cosine_similarity and inner_product are modified from the result of the operator
1412
1590
  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}"
1591
+ score_definition = SQL("1 - (embedding <=> {}) AS score").format(query_embedding_for_postgres)
1592
+ order_by_definition = SQL("embedding <=> {}").format(query_embedding_for_postgres)
1415
1593
  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}"
1594
+ score_definition = SQL("(embedding <#> {}) * -1 AS score").format(query_embedding_for_postgres)
1595
+ order_by_definition = SQL("embedding <#> {}").format(query_embedding_for_postgres)
1418
1596
  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}"
1597
+ score_definition = SQL("embedding <-> {} AS score").format(query_embedding_for_postgres)
1598
+ order_by_definition = SQL("embedding <-> {}").format(query_embedding_for_postgres)
1421
1599
 
1422
1600
  sql_select = SQL("SELECT *, {score} FROM {schema_name}.{table_name}").format(
1423
1601
  schema_name=Identifier(self.schema_name),
1424
1602
  table_name=Identifier(self.table_name),
1425
- score=SQL(score_definition),
1603
+ score=score_definition,
1426
1604
  )
1427
1605
 
1428
1606
  sql_where_clause: Composed | SQL = SQL("")
@@ -1432,7 +1610,7 @@ class PgvectorDocumentStore:
1432
1610
 
1433
1611
  sql_sort = SQL(" ORDER BY {order_by} ASC LIMIT {top_k}").format(
1434
1612
  top_k=SQLLiteral(top_k),
1435
- order_by=SQL(order_by_definition),
1613
+ order_by=order_by_definition,
1436
1614
  )
1437
1615
 
1438
1616
  sql_query = sql_select + sql_where_clause + sql_sort