langgraph-store-postgres 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,164 @@
1
+ """PostgreSQL implementation of the LangGraph long-term-memory ``BaseStore``.
2
+
3
+ ``PostgresStore`` persists namespaced key/value memory in a single table and
4
+ supports get / put / delete / search (namespace-prefix + filter) and
5
+ ``list_namespaces``. With an ``IndexConfig`` it also does **semantic search**
6
+ natively via `pgvector <https://github.com/pgvector/pgvector>`_ (cosine
7
+ distance, HNSW index). Built on ``langgraph-store-core``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import List, Optional, Tuple
13
+
14
+ from sqlalchemy import (
15
+ Column,
16
+ Index,
17
+ MetaData,
18
+ String,
19
+ Table,
20
+ create_engine,
21
+ delete as sa_delete,
22
+ select,
23
+ text,
24
+ )
25
+ from sqlalchemy.dialects.postgresql import JSONB, insert as pg_insert
26
+ from sqlalchemy.engine import Engine
27
+
28
+ from langgraph_store_core import IndexConfig, KVStore
29
+
30
+ __all__ = ["PostgresStore"]
31
+
32
+
33
+ def _escape_like(value: str) -> str:
34
+ return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
35
+
36
+
37
+ class PostgresStore(KVStore):
38
+ """LangGraph ``BaseStore`` backed by PostgreSQL.
39
+
40
+ Example:
41
+ ```python
42
+ from langgraph_store_postgres import PostgresStore
43
+
44
+ store = PostgresStore("postgresql://user:pass@localhost:5432/db")
45
+ store.put(("users", "1"), "profile", {"name": "Kamal"})
46
+ item = store.get(("users", "1"), "profile")
47
+
48
+ # semantic search (needs the pgvector extension on the server)
49
+ store = PostgresStore(url, index={"dims": 1024, "embed": my_embedder, "fields": ["text"]})
50
+ hits = store.search(("users", "1"), query="what does the user like?")
51
+ ```
52
+
53
+ When ``index`` is given the table gains an ``embedding vector(dims)`` column
54
+ and an HNSW cosine index; ``CREATE EXTENSION IF NOT EXISTS vector`` is run
55
+ (requires the extension to be installed on the server).
56
+ """
57
+
58
+ supports_native_vector_search = True
59
+
60
+ def __init__(
61
+ self,
62
+ url: Optional[str] = None,
63
+ *,
64
+ table_name: str = "langgraph_store",
65
+ engine: Optional[Engine] = None,
66
+ index: Optional[IndexConfig] = None,
67
+ ) -> None:
68
+ super().__init__(index=index)
69
+ if engine is None and url is None:
70
+ raise ValueError("Provide either 'url' or 'engine'")
71
+ self._engine = engine or create_engine(url) # type: ignore[arg-type]
72
+ self._metadata = MetaData()
73
+ columns = [
74
+ Column("prefix", String, primary_key=True),
75
+ Column("key", String, primary_key=True),
76
+ Column("value", JSONB, nullable=False),
77
+ Column("created_at", String, nullable=False),
78
+ Column("updated_at", String, nullable=False),
79
+ ]
80
+ if self.dims:
81
+ from pgvector.sqlalchemy import Vector # optional dependency
82
+
83
+ columns.append(Column("embedding", Vector(self.dims), nullable=True))
84
+ with self._engine.begin() as conn:
85
+ conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector"))
86
+ self._table = Table(table_name, self._metadata, *columns)
87
+ if self.dims:
88
+ Index(
89
+ f"{table_name}_embedding_hnsw",
90
+ self._table.c.embedding,
91
+ postgresql_using="hnsw",
92
+ postgresql_with={"m": 16, "ef_construction": 64},
93
+ postgresql_ops={"embedding": "vector_cosine_ops"},
94
+ )
95
+ self._metadata.create_all(self._engine)
96
+
97
+ def _row(self, r) -> dict:
98
+ emb = getattr(r, "embedding", None) if self.dims else None
99
+ return {
100
+ "prefix": r.prefix, "key": r.key, "value": r.value,
101
+ "created_at": r.created_at, "updated_at": r.updated_at,
102
+ "embedding": list(emb) if emb is not None else None,
103
+ }
104
+
105
+ def _read(self, prefix: str, key: str) -> Optional[dict]:
106
+ t = self._table
107
+ with self._engine.connect() as conn:
108
+ r = conn.execute(
109
+ select(t).where(t.c.prefix == prefix, t.c.key == key)
110
+ ).first()
111
+ return self._row(r) if r else None
112
+
113
+ def _write(self, row: dict) -> None:
114
+ t = self._table
115
+ values = {k: row[k] for k in ("prefix", "key", "value", "created_at", "updated_at")}
116
+ if self.dims:
117
+ values["embedding"] = row.get("embedding")
118
+ stmt = pg_insert(t).values(**values).on_conflict_do_update(
119
+ index_elements=[t.c.prefix, t.c.key],
120
+ set_={k: v for k, v in values.items() if k not in ("prefix", "key")},
121
+ )
122
+ with self._engine.begin() as conn:
123
+ conn.execute(stmt)
124
+
125
+ def _remove(self, prefix: str, key: str) -> None:
126
+ t = self._table
127
+ with self._engine.begin() as conn:
128
+ conn.execute(sa_delete(t).where(t.c.prefix == prefix, t.c.key == key))
129
+
130
+ def _prefix_clause(self, prefix: str):
131
+ t = self._table
132
+ if prefix == "":
133
+ return None
134
+ return t.c.prefix.like(f"{_escape_like(prefix)}%", escape="\\")
135
+
136
+ def _scan(self, prefix: str) -> List[dict]:
137
+ t = self._table
138
+ stmt = select(t)
139
+ clause = self._prefix_clause(prefix)
140
+ if clause is not None:
141
+ stmt = stmt.where(clause)
142
+ with self._engine.connect() as conn:
143
+ rows = conn.execute(stmt).all()
144
+ return [self._row(r) for r in rows]
145
+
146
+ def _vector_search(
147
+ self, prefix: str, vector: List[float], filter: Optional[dict], limit: int
148
+ ) -> List[Tuple[dict, float]]:
149
+ """Native pgvector ANN: cosine distance ordered, prefix + equality filters in SQL."""
150
+ t = self._table
151
+ distance = t.c.embedding.cosine_distance(vector).label("distance")
152
+ stmt = select(t, distance).where(t.c.embedding.isnot(None))
153
+ clause = self._prefix_clause(prefix)
154
+ if clause is not None:
155
+ stmt = stmt.where(clause)
156
+ # Push plain-equality filters into SQL as JSONB containment (type-safe);
157
+ # operator filters ($gt, $in, ...) are applied by core on the returned rows.
158
+ equality = {f: v for f, v in (filter or {}).items() if not isinstance(v, dict)}
159
+ if equality:
160
+ stmt = stmt.where(t.c.value.contains(equality))
161
+ stmt = stmt.order_by(distance).limit(limit)
162
+ with self._engine.connect() as conn:
163
+ rows = conn.execute(stmt).all()
164
+ return [(self._row(r), 1.0 - float(r.distance)) for r in rows]
@@ -0,0 +1,68 @@
1
+ Metadata-Version: 2.5
2
+ Name: langgraph-store-postgres
3
+ Version: 0.1.0
4
+ Summary: PostgreSQL long-term-memory store (BaseStore) for LangGraph — namespaced key/value memory with filters, list_namespaces and native semantic (pgvector) search
5
+ Project-URL: Homepage, https://github.com/skamalj/langgraph-store
6
+ Project-URL: Repository, https://github.com/skamalj/langgraph-store.git
7
+ Author-email: Kamal <skamalj@gmail.com>
8
+ Keywords: agent-memory,basestore,langgraph,long-term-memory,postgres,postgresql,store
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
13
+ Requires-Python: >=3.10
14
+ Requires-Dist: langgraph-store-core>=0.1.0
15
+ Requires-Dist: pgvector>=0.2
16
+ Requires-Dist: psycopg2-binary
17
+ Requires-Dist: sqlalchemy>=2.0
18
+ Description-Content-Type: text/markdown
19
+
20
+ # langgraph-store-postgres
21
+
22
+ A **PostgreSQL** long-term-memory store (`BaseStore`) for [LangGraph](https://langchain-ai.github.io/langgraph/) — namespaced key/value memory with prefix search, filters, `list_namespaces`, and **native semantic search via pgvector**.
23
+
24
+ ```bash
25
+ pip install langgraph-store-postgres
26
+ ```
27
+
28
+ ```python
29
+ from langgraph_store_postgres import PostgresStore
30
+
31
+ store = PostgresStore("postgresql://user:pass@localhost:5432/db") # or engine=<Engine>
32
+
33
+ store.put(("users", "1", "memories"), "food", {"text": "loves sushi", "kind": "pref"})
34
+ item = store.get(("users", "1", "memories"), "food")
35
+ hits = store.search(("users", "1"), filter={"kind": "pref"}, limit=10)
36
+ spaces = store.list_namespaces(prefix=("users",))
37
+ ```
38
+
39
+ Use it as a LangGraph store: `graph.compile(store=PostgresStore(...))`. Async methods (`aget`/`aput`/`asearch`/…) work too — the sync calls run in a thread.
40
+
41
+ ## Semantic search (pgvector)
42
+
43
+ Pass a LangGraph `IndexConfig` and the store embeds the configured fields on `put` and ranks `search(query=...)` by cosine similarity using pgvector (`<=>`, HNSW index):
44
+
45
+ ```python
46
+ from langgraph_store_core import bedrock_titan_embeddings
47
+ from langgraph_store_postgres import PostgresStore
48
+
49
+ store = PostgresStore(url, table_name="memory",
50
+ index={"dims": 1024, "embed": bedrock_titan_embeddings(dimensions=1024), "fields": ["text"]})
51
+ store.put(("memories", "kamal"), "k1", {"text": "the user loves sushi", "kind": "pref"})
52
+ hits = store.search(("memories", "kamal"), query="what food does the user like?", filter={"kind": "pref"})
53
+ print(hits[0].score, hits[0].value)
54
+ ```
55
+
56
+ `embed` may be any LangChain `Embeddings`, a `list[str] -> list[list[float]]` callable, or a provider string. `fields` defaults to `["$"]` (whole value as JSON). `put(..., index=False)` skips embedding for one item.
57
+
58
+ Requires the [pgvector](https://github.com/pgvector/pgvector) extension on the server (`apt install postgresql-16-pgvector`, or built in on RDS / Cloud SQL / Azure Database). The store runs `CREATE EXTENSION IF NOT EXISTS vector`, adds an `embedding vector(dims)` column and an HNSW cosine index. Namespace prefix and plain-equality filters run inside the SQL query (JSONB containment); operator filters (`$gt`, `$in`, …) are applied on the returned candidates.
59
+
60
+ ## Data model
61
+
62
+ A single table `(prefix, key, value JSONB, created_at, updated_at[, embedding vector])` with primary key `(prefix, key)`, created automatically. Namespace tuples are joined with a unit separator into `prefix`; search is a prefix scan, filters and namespace matching are evaluated in the [core](https://pypi.org/project/langgraph-store-core/).
63
+
64
+ Docs: <https://skamalj.github.io/agentstate-reducer/>
65
+
66
+ ## License
67
+
68
+ MIT
@@ -0,0 +1,4 @@
1
+ langgraph_store_postgres/__init__.py,sha256=rVY1t69g2-4YzjlqSY6He0beKwx3T7PAjVV7faF3Hr4,6511
2
+ langgraph_store_postgres-0.1.0.dist-info/METADATA,sha256=B0k8P8K32eTXOJu_H0pQlo-kcrlHJY24r3h7yDcKtP4,3649
3
+ langgraph_store_postgres-0.1.0.dist-info/WHEEL,sha256=THafob7ofN-NsuMN7Mg4qZyHaQI7KkD-QlcQatYhXPo,87
4
+ langgraph_store_postgres-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.3
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any