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,,
|