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.
- langchain_cairn-0.3.2/.gitignore +4 -0
- langchain_cairn-0.3.2/PKG-INFO +54 -0
- langchain_cairn-0.3.2/README.md +37 -0
- langchain_cairn-0.3.2/pyproject.toml +26 -0
- langchain_cairn-0.3.2/src/langchain_cairn/__init__.py +5 -0
- langchain_cairn-0.3.2/src/langchain_cairn/vectorstores.py +242 -0
- langchain_cairn-0.3.2/tests/test_langchain_live.py +83 -0
|
@@ -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,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)
|