langchain-cairn 0.3.2__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.
@@ -0,0 +1,4 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .pytest_cache/
4
+ dist/
@@ -0,0 +1,54 @@
1
+ Metadata-Version: 2.5
2
+ Name: langchain-cairn
3
+ Version: 0.3.2
4
+ Summary: LangChain vector store for Cairn, the hybrid search database where a deletion is final
5
+ Project-URL: Homepage, https://github.com/Cairn-DB/cairn
6
+ Project-URL: Repository, https://github.com/Cairn-DB/cairn
7
+ Project-URL: Issues, https://github.com/Cairn-DB/cairn/issues
8
+ Project-URL: Changelog, https://github.com/Cairn-DB/cairn/blob/main/CHANGELOG.md
9
+ License-Expression: Apache-2.0
10
+ Keywords: cairn,database,hybrid-search,langchain,rag,vector-search
11
+ Requires-Python: >=3.10
12
+ Requires-Dist: cairn-db-client>=0.3.2
13
+ Requires-Dist: langchain-core>=0.3
14
+ Provides-Extra: test
15
+ Requires-Dist: pytest>=7; extra == 'test'
16
+ Description-Content-Type: text/markdown
17
+
18
+ # langchain-cairn
19
+
20
+ LangChain vector store for [Cairn](https://github.com/Cairn-DB/cairn), the hybrid search
21
+ database where a deletion is final.
22
+
23
+ ```bash
24
+ pip install langchain-cairn
25
+ ```
26
+
27
+ ```python
28
+ from cairn_db import Client
29
+ from langchain_cairn import CairnVectorStore, collection_schema
30
+
31
+ admin = Client("http://localhost:7200", api_key=ADMIN_KEY)
32
+ admin.create_collection("docs", collection_schema(1536, filterable={"source": "Enum"}))
33
+
34
+ store = CairnVectorStore(Client("http://localhost:7200", api_key=KEY).collection("docs"), embeddings)
35
+ store.add_documents(chunks) # chunk.metadata["parent"] = the source document
36
+ store.similarity_search("what changed in 2024?", k=4, filter={"source": "handbook"})
37
+ store.similarity_search("...", k=4, group_by="parent") # each document once
38
+ store.hybrid_search("error E1234", k=4) # vector + BM25, fused
39
+ store.as_retriever()
40
+ store.delete(parent="handbook.pdf") # the document and all its chunks, everywhere
41
+ ```
42
+
43
+ - **Schema.** `collection_schema` creates the fields the store needs:
44
+ - the text and the embedding;
45
+ - `parent`;
46
+ - `metadata`, which holds each document's whole metadata as JSON, so any metadata
47
+ round-trips;
48
+ - the `filterable` fields, which store the same metadata keys so they can be filtered.
49
+ - **Filters.** Either a plain dict (`{"source": "a"}`: every key equals its value), or a
50
+ Cairn filter (`{"field": "page", "gte": 2}`, `and`/`or`/`not`).
51
+ - **Scores.** `similarity_search_with_score` returns the similarity for Cosine and Dot, and
52
+ the squared distance for L2. `similarity_search_with_relevance_scores` maps them to [0, 1].
53
+ - **Deletions.** A deletion by ids, by parent or by filter is final. The client carries its
54
+ consistency token, so later searches never return the deleted documents, on any node.
@@ -0,0 +1,37 @@
1
+ # langchain-cairn
2
+
3
+ LangChain vector store for [Cairn](https://github.com/Cairn-DB/cairn), the hybrid search
4
+ database where a deletion is final.
5
+
6
+ ```bash
7
+ pip install langchain-cairn
8
+ ```
9
+
10
+ ```python
11
+ from cairn_db import Client
12
+ from langchain_cairn import CairnVectorStore, collection_schema
13
+
14
+ admin = Client("http://localhost:7200", api_key=ADMIN_KEY)
15
+ admin.create_collection("docs", collection_schema(1536, filterable={"source": "Enum"}))
16
+
17
+ store = CairnVectorStore(Client("http://localhost:7200", api_key=KEY).collection("docs"), embeddings)
18
+ store.add_documents(chunks) # chunk.metadata["parent"] = the source document
19
+ store.similarity_search("what changed in 2024?", k=4, filter={"source": "handbook"})
20
+ store.similarity_search("...", k=4, group_by="parent") # each document once
21
+ store.hybrid_search("error E1234", k=4) # vector + BM25, fused
22
+ store.as_retriever()
23
+ store.delete(parent="handbook.pdf") # the document and all its chunks, everywhere
24
+ ```
25
+
26
+ - **Schema.** `collection_schema` creates the fields the store needs:
27
+ - the text and the embedding;
28
+ - `parent`;
29
+ - `metadata`, which holds each document's whole metadata as JSON, so any metadata
30
+ round-trips;
31
+ - the `filterable` fields, which store the same metadata keys so they can be filtered.
32
+ - **Filters.** Either a plain dict (`{"source": "a"}`: every key equals its value), or a
33
+ Cairn filter (`{"field": "page", "gte": 2}`, `and`/`or`/`not`).
34
+ - **Scores.** `similarity_search_with_score` returns the similarity for Cosine and Dot, and
35
+ the squared distance for L2. `similarity_search_with_relevance_scores` maps them to [0, 1].
36
+ - **Deletions.** A deletion by ids, by parent or by filter is final. The client carries its
37
+ consistency token, so later searches never return the deleted documents, on any node.
@@ -0,0 +1,26 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.24"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "langchain-cairn"
7
+ version = "0.3.2"
8
+ description = "LangChain vector store for Cairn, the hybrid search database where a deletion is final"
9
+ readme = "README.md"
10
+ license = "Apache-2.0"
11
+ requires-python = ">=3.10"
12
+ dependencies = ["cairn-db-client>=0.3.2", "langchain-core>=0.3"]
13
+
14
+ keywords = ["cairn", "vector-search", "hybrid-search", "rag", "database", "langchain"]
15
+
16
+ [project.urls]
17
+ Homepage = "https://github.com/Cairn-DB/cairn"
18
+ Repository = "https://github.com/Cairn-DB/cairn"
19
+ Issues = "https://github.com/Cairn-DB/cairn/issues"
20
+ Changelog = "https://github.com/Cairn-DB/cairn/blob/main/CHANGELOG.md"
21
+
22
+ [project.optional-dependencies]
23
+ test = ["pytest>=7"]
24
+
25
+ [tool.hatch.build.targets.wheel]
26
+ packages = ["src/langchain_cairn"]
@@ -0,0 +1,5 @@
1
+ """LangChain vector store for Cairn (ADR 0031)."""
2
+
3
+ from .vectorstores import CairnVectorStore, collection_schema
4
+
5
+ __all__ = ["CairnVectorStore", "collection_schema"]
@@ -0,0 +1,242 @@
1
+ """``CairnVectorStore``: LangChain's vector store interface over a Cairn collection.
2
+
3
+ Documents are stored with the text in ``text_field``, the embedding in ``vector_field``, the
4
+ whole metadata as JSON in the ``metadata`` field (so any metadata round-trips), and the
5
+ metadata keys that are also fields of the schema in those fields (so they can be filtered).
6
+ A document's ``parent`` metadata, if the schema has a ``parent`` field, lets
7
+ ``delete(parent=...)`` remove a document and all its chunks.
8
+
9
+ Deletions are final: once ``delete`` returns, no search or read through this store returns
10
+ the documents again, on any node (the client passes its consistency token).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import base64
16
+ import json
17
+ import math
18
+ import uuid
19
+ from typing import Any, Callable, Iterable, Optional, Sequence
20
+
21
+ from cairn_db import Client, and_, eq
22
+ from langchain_core.documents import Document
23
+ from langchain_core.embeddings import Embeddings
24
+ from langchain_core.vectorstores import VectorStore
25
+
26
+ METADATA_FIELD = "metadata"
27
+
28
+
29
+ def collection_schema(
30
+ dims: int,
31
+ *,
32
+ metric: str = "Cosine",
33
+ text_field: str = "text",
34
+ vector_field: str = "embedding",
35
+ filterable: Optional[dict[str, str]] = None,
36
+ ) -> dict[str, Any]:
37
+ """A schema for a store: text, embedding, ``parent``, the full metadata as JSON, and
38
+ ``filterable`` metadata fields (name -> kind: ``Enum``, ``I64``, ``F64``, ``Date``,
39
+ ``Bool``, ``Set`` or ``Text``)."""
40
+ fields = [
41
+ {"name": vector_field, "kind": {"Vector": {"dims": dims, "metric": metric}}},
42
+ {"name": text_field, "kind": "Text"},
43
+ {"name": "parent", "kind": "Enum"},
44
+ {"name": METADATA_FIELD, "kind": "Blob"},
45
+ ]
46
+ for name, kind in (filterable or {}).items():
47
+ fields.append({"name": name, "kind": kind})
48
+ return {"fields": fields}
49
+
50
+
51
+ def _filter(f: Optional[dict[str, Any]]) -> Optional[dict[str, Any]]:
52
+ """A Cairn filter, or ``{"field": value, ...}`` meaning every field equals its value."""
53
+ if not f:
54
+ return None
55
+ if any(k in f for k in ("and", "or", "not", "field")):
56
+ return f
57
+ parts = [eq(k, v) for k, v in f.items()]
58
+ return parts[0] if len(parts) == 1 else and_(*parts)
59
+
60
+
61
+ class CairnVectorStore(VectorStore):
62
+ """A Cairn collection as a LangChain vector store.
63
+
64
+ >>> from cairn_db import Client
65
+ >>> client = Client("http://localhost:7200", api_key=KEY)
66
+ >>> client.create_collection("docs", collection_schema(1536, filterable={"source": "Enum"}))
67
+ >>> store = CairnVectorStore(client.collection("docs"), embeddings)
68
+ >>> store.add_documents(chunks)
69
+ >>> store.similarity_search("what changed in 2024?", k=4, filter={"source": "handbook"})
70
+ >>> store.delete(parent="handbook.pdf") # the document and all its chunks
71
+ """
72
+
73
+ def __init__(
74
+ self,
75
+ client: Client,
76
+ embedding: Embeddings,
77
+ *,
78
+ text_field: str = "text",
79
+ vector_field: str = "embedding",
80
+ ) -> None:
81
+ self.client = client
82
+ self._embedding = embedding
83
+ self.text_field = text_field
84
+ self.vector_field = vector_field
85
+ fields = {f["name"]: f["kind"] for f in client.schema()["fields"]}
86
+ if vector_field not in fields or text_field not in fields:
87
+ raise ValueError(f"the collection needs a {vector_field!r} and a {text_field!r} field")
88
+ self.metric = fields[vector_field]["Vector"]["metric"]
89
+ self._fields = fields
90
+
91
+ @property
92
+ def embeddings(self) -> Embeddings:
93
+ return self._embedding
94
+
95
+ # ------------------------------------------------------------------ writes
96
+
97
+ def _doc(self, id: str, text: str, vector: list[float], metadata: dict[str, Any]) -> dict[str, Any]:
98
+ d: dict[str, Any] = {"id": id, self.text_field: text, self.vector_field: vector}
99
+ if METADATA_FIELD in self._fields:
100
+ d[METADATA_FIELD] = base64.b64encode(json.dumps(metadata, default=str).encode()).decode()
101
+ for k, v in metadata.items():
102
+ if k in self._fields and k not in (self.text_field, self.vector_field, METADATA_FIELD):
103
+ d[k] = v
104
+ return d
105
+
106
+ def add_texts(
107
+ self,
108
+ texts: Iterable[str],
109
+ metadatas: Optional[list[dict[str, Any]]] = None,
110
+ *,
111
+ ids: Optional[list[str]] = None,
112
+ batch_size: int = 256,
113
+ **kwargs: Any,
114
+ ) -> list[str]:
115
+ texts = list(texts)
116
+ metadatas = metadatas or [{} for _ in texts]
117
+ ids = ids or [str(uuid.uuid4()) for _ in texts]
118
+ vectors = self._embedding.embed_documents(texts)
119
+ for i in range(0, len(texts), batch_size):
120
+ self.client.upsert(
121
+ [self._doc(ids[j], texts[j], vectors[j], metadatas[j]) for j in range(i, min(i + batch_size, len(texts)))]
122
+ )
123
+ return ids
124
+
125
+ def add_documents(self, documents: list[Document], **kwargs: Any) -> list[str]:
126
+ ids = kwargs.pop("ids", None) or [d.id or str(uuid.uuid4()) for d in documents]
127
+ return self.add_texts([d.page_content for d in documents], [d.metadata for d in documents], ids=ids, **kwargs)
128
+
129
+ def delete(self, ids: Optional[list[str]] = None, **kwargs: Any) -> Optional[bool]:
130
+ """Deletes ``ids``, or with ``parent=`` a document and all its chunks, or with
131
+ ``filter=`` every document that matches."""
132
+ if ids:
133
+ self.client.delete(ids=list(ids))
134
+ elif "parent" in kwargs:
135
+ self.client.delete(parent=kwargs["parent"])
136
+ elif kwargs.get("filter"):
137
+ self.client.delete(filter=_filter(kwargs["filter"]))
138
+ else:
139
+ raise ValueError("give ids, parent= or filter=")
140
+ return True
141
+
142
+ # ------------------------------------------------------------------ reads
143
+
144
+ def _to_document(self, id: Any, doc: dict[str, Any]) -> Document:
145
+ metadata: dict[str, Any] = {}
146
+ raw = doc.get(METADATA_FIELD)
147
+ if raw:
148
+ try:
149
+ metadata = json.loads(base64.b64decode(raw))
150
+ except ValueError:
151
+ metadata = {}
152
+ else:
153
+ metadata = {k: v for k, v in doc.items() if k not in ("id", self.text_field, self.vector_field)}
154
+ return Document(id=str(id), page_content=doc.get(self.text_field, ""), metadata=metadata)
155
+
156
+ def get_by_ids(self, ids: Sequence[str], /) -> list[Document]:
157
+ out = []
158
+ for i in ids:
159
+ d = self.client.get(i)
160
+ if d is not None:
161
+ out.append(self._to_document(i, d))
162
+ return out
163
+
164
+ def _score(self, hit: Any) -> float:
165
+ """Similarity for Cosine and Dot (higher is better), squared distance for L2."""
166
+ leg = hit.legs[0] if hit.legs else None
167
+ if leg is None:
168
+ return float("nan")
169
+ return leg.score if self.metric == "L2" else -leg.score
170
+
171
+ def similarity_search_by_vector_with_score(
172
+ self,
173
+ embedding: list[float],
174
+ k: int = 4,
175
+ filter: Optional[dict[str, Any]] = None,
176
+ **kwargs: Any,
177
+ ) -> list[tuple[Document, float]]:
178
+ hits = self.client.search(
179
+ k=k,
180
+ vector={"field": self.vector_field, "values": list(embedding)},
181
+ filter=_filter(filter),
182
+ group_by=kwargs.get("group_by"),
183
+ )
184
+ return [(self._to_document(h.id, h.document or {}), self._score(h)) for h in hits]
185
+
186
+ def similarity_search_with_score(
187
+ self, query: str, k: int = 4, filter: Optional[dict[str, Any]] = None, **kwargs: Any
188
+ ) -> list[tuple[Document, float]]:
189
+ """Nearest documents to ``query``. ``group_by="parent"`` returns each document once."""
190
+ return self.similarity_search_by_vector_with_score(
191
+ self._embedding.embed_query(query), k, filter, **kwargs
192
+ )
193
+
194
+ def similarity_search(
195
+ self, query: str, k: int = 4, filter: Optional[dict[str, Any]] = None, **kwargs: Any
196
+ ) -> list[Document]:
197
+ return [d for d, _ in self.similarity_search_with_score(query, k, filter, **kwargs)]
198
+
199
+ def similarity_search_by_vector(
200
+ self, embedding: list[float], k: int = 4, filter: Optional[dict[str, Any]] = None, **kwargs: Any
201
+ ) -> list[Document]:
202
+ return [d for d, _ in self.similarity_search_by_vector_with_score(embedding, k, filter, **kwargs)]
203
+
204
+ def hybrid_search(
205
+ self, query: str, k: int = 4, filter: Optional[dict[str, Any]] = None, **kwargs: Any
206
+ ) -> list[Document]:
207
+ """Vector and BM25 legs fused (RRF): exact words and meaning together."""
208
+ hits = self.client.search(
209
+ k=k,
210
+ vector={"field": self.vector_field, "values": self._embedding.embed_query(query)},
211
+ text=query,
212
+ text_field=self.text_field,
213
+ filter=_filter(filter),
214
+ group_by=kwargs.get("group_by"),
215
+ )
216
+ return [self._to_document(h.id, h.document or {}) for h in hits]
217
+
218
+ def _select_relevance_score_fn(self) -> Callable[[float], float]:
219
+ if self.metric == "Cosine":
220
+ return lambda s: (1.0 + s) / 2.0
221
+ if self.metric == "L2":
222
+ return lambda d: 1.0 - math.sqrt(max(d, 0.0)) / math.sqrt(2)
223
+ return self._max_inner_product_relevance_score_fn
224
+
225
+ @classmethod
226
+ def from_texts(
227
+ cls,
228
+ texts: list[str],
229
+ embedding: Embeddings,
230
+ metadatas: Optional[list[dict[str, Any]]] = None,
231
+ *,
232
+ ids: Optional[list[str]] = None,
233
+ client: Optional[Client] = None,
234
+ **kwargs: Any,
235
+ ) -> "CairnVectorStore":
236
+ """A store over ``client`` (a :class:`cairn_db.Client`, or a collection view of one)
237
+ holding ``texts``."""
238
+ if client is None:
239
+ raise ValueError("from_texts needs client=cairn_db.Client(...)")
240
+ store = cls(client, embedding, **kwargs)
241
+ store.add_texts(texts, metadatas, ids=ids)
242
+ return store
@@ -0,0 +1,83 @@
1
+ """Against a running node (clients/test-live.sh): CAIRN_URL, CAIRN_ADMIN_KEY, CAIRN_KEY."""
2
+
3
+ import hashlib
4
+ import math
5
+ import os
6
+
7
+ import pytest
8
+ from cairn_db import Client, NotFoundError
9
+ from langchain_core.documents import Document
10
+ from langchain_core.embeddings import Embeddings
11
+
12
+ from langchain_cairn import CairnVectorStore, collection_schema
13
+
14
+ URL = os.environ.get("CAIRN_URL")
15
+ pytestmark = pytest.mark.skipif(not URL, reason="CAIRN_URL not set")
16
+ DIMS = 64
17
+
18
+
19
+ class WordHash(Embeddings):
20
+ """Words hashed into 64 dimensions: texts sharing words are close."""
21
+
22
+ def _one(self, text: str) -> list[float]:
23
+ v = [0.0] * DIMS
24
+ for w in text.lower().split():
25
+ h = hashlib.sha1(w.encode()).digest()
26
+ v[h[0] % DIMS] += 1.0 if h[1] & 1 else -1.0
27
+ n = math.sqrt(sum(x * x for x in v)) or 1.0
28
+ return [x / n for x in v]
29
+
30
+ def embed_documents(self, texts):
31
+ return [self._one(t) for t in texts]
32
+
33
+ def embed_query(self, text):
34
+ return self._one(text)
35
+
36
+
37
+ def test_langchain_store_against_a_live_node():
38
+ admin = Client(URL, os.environ["CAIRN_ADMIN_KEY"])
39
+ name = f"lc-{os.getpid()}"
40
+ admin.create_collection(name, collection_schema(DIMS, filterable={"source": "Enum", "page": "I64"}), shards=2)
41
+ try:
42
+ store = CairnVectorStore(Client(URL, os.environ["CAIRN_KEY"]).collection(name), WordHash())
43
+ chunks = [
44
+ Document(page_content="the otter swims in the river", metadata={"source": "a", "parent": "doc-a", "page": 1, "extra": {"k": [1, 2]}}),
45
+ Document(page_content="an otter eats fish near the river bank", metadata={"source": "a", "parent": "doc-a", "page": 2}),
46
+ Document(page_content="the stock market fell sharply today", metadata={"source": "b", "parent": "doc-b", "page": 1}),
47
+ Document(page_content="markets and stocks after the election", metadata={"source": "b", "parent": "doc-b", "page": 2}),
48
+ ]
49
+ ids = store.add_documents(chunks, ids=["a1", "a2", "b1", "b2"])
50
+ assert ids == ["a1", "a2", "b1", "b2"]
51
+
52
+ top = store.similarity_search("otter river", k=2)
53
+ assert {d.id for d in top} == {"a1", "a2"}
54
+ assert top[0].metadata["parent"] == "doc-a"
55
+ # Metadata round-trips whole, including keys that are not fields.
56
+ got = store.get_by_ids(["a1"])[0]
57
+ assert got.metadata == chunks[0].metadata and got.page_content == chunks[0].page_content
58
+ # Filters on declared fields: a plain dict, or a Cairn filter.
59
+ only_b = store.similarity_search("otter river", k=4, filter={"source": "b"})
60
+ assert {d.id for d in only_b} == {"b1", "b2"}
61
+ pages = store.similarity_search("otter", k=4, filter={"field": "page", "gte": 2})
62
+ assert {d.id for d in pages} == {"a2", "b2"}
63
+ scored = store.similarity_search_with_relevance_scores("otter river", k=4)
64
+ assert all(0.0 <= s <= 1.0 for _, s in scored) and scored[0][1] >= scored[-1][1]
65
+ # One hit per parent, a retriever, and hybrid search.
66
+ grouped = store.similarity_search("otter river market", k=4, group_by="parent")
67
+ assert sorted(d.metadata["parent"] for d in grouped) == ["doc-a", "doc-b"]
68
+ assert store.as_retriever(search_kwargs={"k": 1}).invoke("stock market")[0].id == "b1"
69
+ assert store.hybrid_search("election", k=1)[0].id == "b2"
70
+
71
+ # Deletions are final, through any read.
72
+ store.delete(parent="doc-a")
73
+ assert {d.id for d in store.similarity_search("otter river", k=4)} == {"b1", "b2"}
74
+ assert store.get_by_ids(["a1", "a2"]) == []
75
+ store.delete(ids=["b1"])
76
+ assert [d.id for d in store.similarity_search("market", k=4)] == ["b2"]
77
+
78
+ again = CairnVectorStore.from_texts(["hello otter"], WordHash(), client=store.client, ids=["h1"])
79
+ assert again.similarity_search("otter", k=1)[0].id == "h1"
80
+ finally:
81
+ admin.drop_collection(name)
82
+ with pytest.raises(NotFoundError):
83
+ store.similarity_search("otter", k=1)