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.
- {pgvector_haystack-8.1.1 → pgvector_haystack-8.2.0}/CHANGELOG.md +14 -0
- {pgvector_haystack-8.1.1 → pgvector_haystack-8.2.0}/PKG-INFO +1 -1
- {pgvector_haystack-8.1.1 → pgvector_haystack-8.2.0}/src/haystack_integrations/document_stores/pgvector/document_store.py +190 -12
- pgvector_haystack-8.2.0/src/haystack_integrations/document_stores/pgvector/table_replacement.py +440 -0
- {pgvector_haystack-8.1.1 → pgvector_haystack-8.2.0}/tests/conftest.py +39 -0
- {pgvector_haystack-8.1.1 → pgvector_haystack-8.2.0}/tests/test_document_store.py +552 -25
- {pgvector_haystack-8.1.1 → pgvector_haystack-8.2.0}/tests/test_document_store_async.py +225 -14
- {pgvector_haystack-8.1.1 → pgvector_haystack-8.2.0}/.gitignore +0 -0
- {pgvector_haystack-8.1.1 → pgvector_haystack-8.2.0}/LICENSE.txt +0 -0
- {pgvector_haystack-8.1.1 → pgvector_haystack-8.2.0}/README.md +0 -0
- {pgvector_haystack-8.1.1 → pgvector_haystack-8.2.0}/examples/embedding_retrieval.py +0 -0
- {pgvector_haystack-8.1.1 → pgvector_haystack-8.2.0}/examples/hybrid_retrieval.py +0 -0
- {pgvector_haystack-8.1.1 → pgvector_haystack-8.2.0}/pydoc/config_docusaurus.yml +0 -0
- {pgvector_haystack-8.1.1 → pgvector_haystack-8.2.0}/pyproject.toml +0 -0
- {pgvector_haystack-8.1.1 → pgvector_haystack-8.2.0}/src/haystack_integrations/components/retrievers/pgvector/__init__.py +0 -0
- {pgvector_haystack-8.1.1 → pgvector_haystack-8.2.0}/src/haystack_integrations/components/retrievers/pgvector/embedding_retriever.py +0 -0
- {pgvector_haystack-8.1.1 → pgvector_haystack-8.2.0}/src/haystack_integrations/components/retrievers/pgvector/keyword_retriever.py +0 -0
- {pgvector_haystack-8.1.1 → pgvector_haystack-8.2.0}/src/haystack_integrations/components/retrievers/py.typed +0 -0
- {pgvector_haystack-8.1.1 → pgvector_haystack-8.2.0}/src/haystack_integrations/document_stores/pgvector/__init__.py +0 -0
- {pgvector_haystack-8.1.1 → pgvector_haystack-8.2.0}/src/haystack_integrations/document_stores/pgvector/converters.py +0 -0
- {pgvector_haystack-8.1.1 → pgvector_haystack-8.2.0}/src/haystack_integrations/document_stores/pgvector/filters.py +0 -0
- {pgvector_haystack-8.1.1 → pgvector_haystack-8.2.0}/src/haystack_integrations/document_stores/py.typed +0 -0
- {pgvector_haystack-8.1.1 → pgvector_haystack-8.2.0}/tests/__init__.py +0 -0
- {pgvector_haystack-8.1.1 → pgvector_haystack-8.2.0}/tests/test_converters.py +0 -0
- {pgvector_haystack-8.1.1 → pgvector_haystack-8.2.0}/tests/test_filters.py +0 -0
- {pgvector_haystack-8.1.1 → pgvector_haystack-8.2.0}/tests/test_retrieval.py +0 -0
- {pgvector_haystack-8.1.1 → pgvector_haystack-8.2.0}/tests/test_retrieval_async.py +0 -0
- {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.
|
|
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
|
-
|
|
682
|
-
sql_create_hnsw_index += SQL(" WITH ({})").format(
|
|
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
|
-
|
|
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 =
|
|
1414
|
-
order_by_definition =
|
|
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 =
|
|
1417
|
-
order_by_definition =
|
|
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 =
|
|
1420
|
-
order_by_definition =
|
|
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=
|
|
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=
|
|
1613
|
+
order_by=order_by_definition,
|
|
1436
1614
|
)
|
|
1437
1615
|
|
|
1438
1616
|
sql_query = sql_select + sql_where_clause + sql_sort
|