langgraph-store-firestore 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,171 @@
|
|
|
1
|
+
"""Google Firestore implementation of the LangGraph long-term-memory ``BaseStore``.
|
|
2
|
+
|
|
3
|
+
``FirestoreStore`` persists namespaced key/value memory in a single collection 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 Firestore vector search (``find_nearest``, cosine). Built on
|
|
7
|
+
``langgraph-store-core``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
from typing import List, Optional, Tuple
|
|
14
|
+
from urllib.parse import quote
|
|
15
|
+
|
|
16
|
+
from google.cloud import firestore
|
|
17
|
+
from google.cloud.firestore_v1.base_query import FieldFilter
|
|
18
|
+
from google.cloud.firestore_v1.base_vector_query import DistanceMeasure
|
|
19
|
+
from google.cloud.firestore_v1.vector import Vector
|
|
20
|
+
|
|
21
|
+
from langgraph_store_core import IndexConfig, KVStore
|
|
22
|
+
|
|
23
|
+
__all__ = ["FirestoreStore"]
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
_FIELDS = ("prefix", "key", "value", "created_at", "updated_at")
|
|
28
|
+
_PREFIX_HIGH = chr(0xF8FF) # exclusive upper bound for a Firestore prefix scan
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class FirestoreStore(KVStore):
|
|
32
|
+
"""LangGraph ``BaseStore`` backed by Google Firestore.
|
|
33
|
+
|
|
34
|
+
Example:
|
|
35
|
+
```python
|
|
36
|
+
from langgraph_store_firestore import FirestoreStore
|
|
37
|
+
|
|
38
|
+
store = FirestoreStore(project_id="my-gcp-project")
|
|
39
|
+
store.put(("users", "1"), "profile", {"name": "Kamal"})
|
|
40
|
+
|
|
41
|
+
# semantic search
|
|
42
|
+
store = FirestoreStore(project_id=..., index={"dims": 1024, "embed": my_embedder, "fields": ["text"]})
|
|
43
|
+
hits = store.search(("users", "1"), query="what does the user like?")
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Authentication uses Application Default Credentials. With ``index`` the
|
|
47
|
+
embedding is stored as a Firestore ``Vector`` on ``embedding`` and queries
|
|
48
|
+
use ``find_nearest`` (cosine). Firestore requires a **vector index** on the
|
|
49
|
+
collection; when ``create_index=True`` (default) the store creates a
|
|
50
|
+
composite index ``prefix ASC + embedding (flat, dims)`` via the Admin API if
|
|
51
|
+
one is missing (index build is asynchronous; the first queries may fail
|
|
52
|
+
until it is READY).
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
supports_native_vector_search = True
|
|
56
|
+
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
*,
|
|
60
|
+
project_id: str,
|
|
61
|
+
collection: str = "langgraph_store",
|
|
62
|
+
client: Optional["firestore.Client"] = None,
|
|
63
|
+
index: Optional[IndexConfig] = None,
|
|
64
|
+
create_index: bool = True,
|
|
65
|
+
database: str = "(default)",
|
|
66
|
+
) -> None:
|
|
67
|
+
super().__init__(index=index)
|
|
68
|
+
self._project = project_id
|
|
69
|
+
self._database = database
|
|
70
|
+
self._client = client or firestore.Client(project=project_id, database=database)
|
|
71
|
+
self._col = self._client.collection(collection)
|
|
72
|
+
self._collection_name = collection
|
|
73
|
+
if self.dims and create_index:
|
|
74
|
+
self._ensure_vector_index()
|
|
75
|
+
|
|
76
|
+
# ------------------------------------------------------------------ index bootstrap
|
|
77
|
+
def _ensure_vector_index(self) -> None:
|
|
78
|
+
"""Create the composite vector index (prefix ASC, embedding flat) if absent."""
|
|
79
|
+
from google.cloud import firestore_admin_v1 as fa
|
|
80
|
+
|
|
81
|
+
admin = fa.FirestoreAdminClient()
|
|
82
|
+
parent = (
|
|
83
|
+
f"projects/{self._project}/databases/{self._database}"
|
|
84
|
+
f"/collectionGroups/{self._collection_name}"
|
|
85
|
+
)
|
|
86
|
+
for ix in admin.list_indexes(parent=parent):
|
|
87
|
+
paths = [f.field_path for f in ix.fields]
|
|
88
|
+
if "embedding" in paths and "prefix" in paths:
|
|
89
|
+
return
|
|
90
|
+
index = fa.Index(
|
|
91
|
+
query_scope=fa.Index.QueryScope.COLLECTION,
|
|
92
|
+
fields=[
|
|
93
|
+
fa.Index.IndexField(field_path="prefix", order=fa.Index.IndexField.Order.ASCENDING),
|
|
94
|
+
fa.Index.IndexField(
|
|
95
|
+
field_path="embedding",
|
|
96
|
+
vector_config=fa.Index.IndexField.VectorConfig(
|
|
97
|
+
dimension=self.dims, flat=fa.Index.IndexField.VectorConfig.FlatIndex()
|
|
98
|
+
),
|
|
99
|
+
),
|
|
100
|
+
],
|
|
101
|
+
)
|
|
102
|
+
try:
|
|
103
|
+
admin.create_index(parent=parent, index=index)
|
|
104
|
+
logger.info("FirestoreStore: creating vector index on %s (async)", self._collection_name)
|
|
105
|
+
except Exception as exc: # already exists / racing creation
|
|
106
|
+
logger.info("FirestoreStore: vector index create skipped: %s", exc)
|
|
107
|
+
|
|
108
|
+
# ------------------------------------------------------------------ primitives
|
|
109
|
+
@staticmethod
|
|
110
|
+
def _docid(prefix: str, key: str) -> str:
|
|
111
|
+
# Firestore doc ids can't contain '/'; url-encode both parts, join safely.
|
|
112
|
+
return f"{quote(prefix, safe='')}__{quote(key, safe='')}"
|
|
113
|
+
|
|
114
|
+
def _row(self, d: dict) -> dict:
|
|
115
|
+
row = {f: d[f] for f in _FIELDS}
|
|
116
|
+
emb = d.get("embedding")
|
|
117
|
+
row["embedding"] = list(emb.to_map_value()["value"]) if isinstance(emb, Vector) else (
|
|
118
|
+
list(emb) if emb is not None else None
|
|
119
|
+
)
|
|
120
|
+
return row
|
|
121
|
+
|
|
122
|
+
def _read(self, prefix: str, key: str) -> Optional[dict]:
|
|
123
|
+
doc = self._col.document(self._docid(prefix, key)).get()
|
|
124
|
+
if not doc.exists:
|
|
125
|
+
return None
|
|
126
|
+
return self._row(doc.to_dict())
|
|
127
|
+
|
|
128
|
+
def _write(self, row: dict) -> None:
|
|
129
|
+
doc = {f: row[f] for f in _FIELDS}
|
|
130
|
+
if self.dims and row.get("embedding") is not None:
|
|
131
|
+
doc["embedding"] = Vector(row["embedding"])
|
|
132
|
+
self._col.document(self._docid(row["prefix"], row["key"])).set(doc)
|
|
133
|
+
|
|
134
|
+
def _remove(self, prefix: str, key: str) -> None:
|
|
135
|
+
self._col.document(self._docid(prefix, key)).delete()
|
|
136
|
+
|
|
137
|
+
def _prefix_query(self, prefix: str):
|
|
138
|
+
if prefix == "":
|
|
139
|
+
return self._col
|
|
140
|
+
return (
|
|
141
|
+
self._col.where(filter=FieldFilter("prefix", ">=", prefix))
|
|
142
|
+
.where(filter=FieldFilter("prefix", "<", prefix + _PREFIX_HIGH))
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
def _scan(self, prefix: str) -> List[dict]:
|
|
146
|
+
return [self._row(d.to_dict()) for d in self._prefix_query(prefix).stream()]
|
|
147
|
+
|
|
148
|
+
def _vector_search(
|
|
149
|
+
self, prefix: str, vector: List[float], filter: Optional[dict], limit: int
|
|
150
|
+
) -> List[Tuple[dict, float]]:
|
|
151
|
+
"""Native Firestore ``find_nearest`` (cosine) after a prefix range pre-filter.
|
|
152
|
+
|
|
153
|
+
Firestore pre-filters are limited to indexed equality/range fields, so
|
|
154
|
+
value filters are applied by core on the returned candidates; we
|
|
155
|
+
oversample to leave room for that.
|
|
156
|
+
"""
|
|
157
|
+
oversample = limit * 4 if filter else limit
|
|
158
|
+
q = self._prefix_query(prefix).find_nearest(
|
|
159
|
+
vector_field="embedding",
|
|
160
|
+
query_vector=Vector(vector),
|
|
161
|
+
distance_measure=DistanceMeasure.COSINE,
|
|
162
|
+
limit=oversample,
|
|
163
|
+
distance_result_field="_distance",
|
|
164
|
+
)
|
|
165
|
+
out: List[Tuple[dict, float]] = []
|
|
166
|
+
for d in q.stream():
|
|
167
|
+
data = d.to_dict()
|
|
168
|
+
dist = data.pop("_distance", None)
|
|
169
|
+
row = self._row(data)
|
|
170
|
+
out.append((row, 1.0 - float(dist) if dist is not None else 0.0))
|
|
171
|
+
return out
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: langgraph-store-firestore
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Google Firestore long-term-memory store (BaseStore) for LangGraph — namespaced key/value memory with filters, list_namespaces and native semantic (Firestore vector) 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,firestore,gcp,langgraph,long-term-memory,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: google-cloud-firestore
|
|
15
|
+
Requires-Dist: langgraph-store-core>=0.1.0
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# langgraph-store-firestore
|
|
19
|
+
|
|
20
|
+
A **Google Firestore** 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 Firestore vector search**.
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install langgraph-store-firestore
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from langgraph_store_firestore import FirestoreStore
|
|
28
|
+
|
|
29
|
+
store = FirestoreStore(project_id="my-gcp-project")
|
|
30
|
+
store.put(("users", "1", "memories"), "food", {"text": "loves sushi", "kind": "pref"})
|
|
31
|
+
item = store.get(("users", "1", "memories"), "food")
|
|
32
|
+
hits = store.search(("users", "1"), filter={"kind": "pref"})
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Use it as a LangGraph store: `graph.compile(store=FirestoreStore(...))`. Async methods work too (sync calls run in a thread). Authentication uses Application Default Credentials (`gcloud auth application-default login`).
|
|
36
|
+
|
|
37
|
+
## Semantic search (Firestore vector search)
|
|
38
|
+
|
|
39
|
+
Pass a LangGraph `IndexConfig` and the store embeds the configured fields on `put` (stored as a Firestore `Vector`) and ranks `search(query=...)` by cosine similarity using `find_nearest`:
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from langgraph_store_core import bedrock_titan_embeddings
|
|
43
|
+
from langgraph_store_firestore import FirestoreStore
|
|
44
|
+
|
|
45
|
+
store = FirestoreStore(project_id="my-gcp-project", collection="memory",
|
|
46
|
+
index={"dims": 1024, "embed": bedrock_titan_embeddings(dimensions=1024), "fields": ["text"]})
|
|
47
|
+
store.put(("memories", "kamal"), "k1", {"text": "the user loves sushi", "kind": "pref"})
|
|
48
|
+
hits = store.search(("memories", "kamal"), query="what food does the user like?", filter={"kind": "pref"})
|
|
49
|
+
print(hits[0].score, hits[0].value)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`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.
|
|
53
|
+
|
|
54
|
+
Firestore requires a **vector index**. With `create_index=True` (default) the store creates a composite index `prefix ASC + embedding (flat, dims)` on the collection through the Firestore Admin API if one is missing; the build is asynchronous and `query` searches fail with `FAILED_PRECONDITION` until it is READY (usually a few minutes; check `gcloud firestore indexes composite list`). The caller needs `datastore.indexes.create` / `list` permissions, or create the index yourself and pass `create_index=False`:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
gcloud firestore indexes composite create --collection-group=memory --query-scope=COLLECTION \
|
|
58
|
+
--field-config=order=ASCENDING,field-path=prefix \
|
|
59
|
+
--field-config=vector-config='{"dimension":"1024","flat":"{}"}',field-path=embedding
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
The namespace prefix is a range pre-filter on the vector query. Firestore pre-filters are limited to indexed equality/range fields, so value `filter`s are applied on the returned candidates (the store oversamples when a filter is given).
|
|
63
|
+
|
|
64
|
+
## Data model
|
|
65
|
+
|
|
66
|
+
A single collection of documents `{prefix, key, value, created_at, updated_at[, embedding: Vector]}`, doc id = `enc(prefix)__enc(key)`. Search is a `prefix` range query; filters and namespace matching are evaluated in the [core](https://pypi.org/project/langgraph-store-core/).
|
|
67
|
+
|
|
68
|
+
Docs: <https://skamalj.github.io/agentstate-reducer/>
|
|
69
|
+
|
|
70
|
+
## License
|
|
71
|
+
|
|
72
|
+
MIT
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
langgraph_store_firestore/__init__.py,sha256=ShCoHiaY58TXzunNHZgwu-0ZjD-tJA9dB5H07ceRUME,7105
|
|
2
|
+
langgraph_store_firestore-0.1.0.dist-info/METADATA,sha256=7eWR6VqGOO0aDDt2ZwrQDxS8INnq8CkBeZhXzqmAXQg,4126
|
|
3
|
+
langgraph_store_firestore-0.1.0.dist-info/WHEEL,sha256=THafob7ofN-NsuMN7Mg4qZyHaQI7KkD-QlcQatYhXPo,87
|
|
4
|
+
langgraph_store_firestore-0.1.0.dist-info/RECORD,,
|