llama-index-vector-stores-couchbase 0.1.0__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,19 @@
1
+ Metadata-Version: 2.1
2
+ Name: llama-index-vector-stores-couchbase
3
+ Version: 0.1.0
4
+ Summary: llama-index vector_stores couchbase integration
5
+ License: MIT
6
+ Author: Couchbase
7
+ Author-email: devadvocates@couchbase.com
8
+ Requires-Python: >=3.8.1,<4.0
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.9
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Requires-Dist: couchbase (>=4.2.0,<5.0.0)
15
+ Requires-Dist: llama-index-core (>=0.10.1)
16
+ Description-Content-Type: text/markdown
17
+
18
+ # LlamaIndex Vector_Stores Integration: Couchbase
19
+
@@ -0,0 +1 @@
1
+ # LlamaIndex Vector_Stores Integration: Couchbase
@@ -0,0 +1,4 @@
1
+ from llama_index.vector_stores.couchbase.base import CouchbaseVectorStore
2
+
3
+
4
+ __all__ = ["CouchbaseVectorStore"]
@@ -0,0 +1,529 @@
1
+ """
2
+ Couchbase Vector store interface.
3
+ """
4
+
5
+ from typing import Any, Dict, List, Optional
6
+
7
+ import logging
8
+
9
+ from llama_index.core.schema import BaseNode, MetadataMode, TextNode
10
+ from llama_index.core.vector_stores.types import (
11
+ BasePydanticVectorStore,
12
+ VectorStoreQuery,
13
+ VectorStoreQueryResult,
14
+ MetadataFilters,
15
+ )
16
+ from llama_index.core.vector_stores.utils import (
17
+ node_to_metadata_dict,
18
+ metadata_dict_to_node,
19
+ )
20
+ from llama_index.core.bridge.pydantic import PrivateAttr
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ def _transform_couchbase_filter_condition(condition: str) -> str:
26
+ """
27
+ Convert standard metadata filter condition to Couchbase specific condition.
28
+
29
+ Args:
30
+ condition: standard metadata filter condition
31
+
32
+ Returns:
33
+ Couchbase specific condition
34
+ """
35
+ if condition == "and":
36
+ return "conjuncts"
37
+ elif condition == "or":
38
+ return "disjuncts"
39
+ else:
40
+ raise ValueError(f"Filter condition {condition} not supported")
41
+
42
+
43
+ def _transform_couchbase_filter_operator(
44
+ operator: str, field: str, value: Any
45
+ ) -> Dict[str, Any]:
46
+ """
47
+ Convert standard metadata filter operator to Couchbase specific filter operation.
48
+
49
+ Args:
50
+ operator: standard metadata filter operator
51
+ field: metadata field
52
+ value: value to apply for the filter
53
+
54
+ Returns:
55
+ Dictionary with Couchbase specific search operation.
56
+ """
57
+ if operator == "!=":
58
+ return {"must_not": {"disjuncts": [{"field": field, "match": value}]}}
59
+ elif operator == "==":
60
+ return {"field": field, "match": value}
61
+ elif operator == ">":
62
+ return {"min": value, "inclusive_min": False, "field": field}
63
+ elif operator == "<":
64
+ return {"max": value, "inclusive_max": False, "field": field}
65
+ elif operator == ">=":
66
+ return {"min": value, "inclusive_min": True, "field": field}
67
+ elif operator == "<=":
68
+ return {"max": value, "inclusive_max": True, "field": field}
69
+ elif operator == "text_match":
70
+ return {"match_phrase": value, "field": field}
71
+ else:
72
+ raise ValueError(f"Filter operator {operator} not supported")
73
+
74
+
75
+ def _to_couchbase_filter(standard_filters: MetadataFilters) -> Dict[str, Any]:
76
+ """
77
+ Convert standard filters to Couchbase filter.
78
+
79
+ Args:
80
+ standard_filters (str): Standard Llama-index filters.
81
+
82
+ Returns:
83
+ Dictionary with Couchbase search query.
84
+ """
85
+ filters = {}
86
+ filters_list = []
87
+ condition = standard_filters.condition
88
+ condition = _transform_couchbase_filter_condition(condition)
89
+
90
+ if standard_filters.filters:
91
+ for filter in standard_filters.filters:
92
+ if filter.operator:
93
+ transformed_filter = _transform_couchbase_filter_operator(
94
+ filter.operator, f"metadata.{filter.key}", filter.value
95
+ )
96
+
97
+ filters_list.append(transformed_filter)
98
+ else:
99
+ filters_list.append(
100
+ {
101
+ "match": {
102
+ "field": f"metadata.{filter.key}",
103
+ "value": filter.value,
104
+ }
105
+ }
106
+ )
107
+ if len(filters_list) == 1:
108
+ # If there is only one filter, return it directly
109
+ return filters_list[0]
110
+ elif len(filters_list) > 1:
111
+ filters[condition] = filters_list
112
+ return {"query": filters}
113
+
114
+
115
+ class CouchbaseVectorStore(BasePydanticVectorStore):
116
+ """
117
+ Couchbase Vector Store.
118
+
119
+ To use, you should have the ``couchbase`` python package installed.
120
+
121
+ """
122
+
123
+ stores_text: bool = True
124
+ flat_metadata: bool = True
125
+ # Default batch size
126
+ DEFAULT_BATCH_SIZE: int = 100
127
+
128
+ _cluster: Any = PrivateAttr()
129
+ _bucket: Any = PrivateAttr()
130
+ _scope: Any = PrivateAttr()
131
+ _collection: Any = PrivateAttr()
132
+ _bucket_name: str = PrivateAttr()
133
+ _scope_name: str = PrivateAttr()
134
+ _collection_name: str = PrivateAttr()
135
+ _index_name: str = PrivateAttr()
136
+ _id_key: str = PrivateAttr()
137
+ _text_key: str = PrivateAttr()
138
+ _embedding_key: str = PrivateAttr()
139
+ _metadata_key: str = PrivateAttr()
140
+ _scoped_index: bool = PrivateAttr()
141
+
142
+ def __init__(
143
+ self,
144
+ cluster: Any,
145
+ bucket_name: str,
146
+ scope_name: str,
147
+ collection_name: str,
148
+ index_name: str,
149
+ text_key: Optional[str] = "text",
150
+ embedding_key: Optional[str] = "embedding",
151
+ metadata_key: Optional[str] = "metadata",
152
+ scoped_index: bool = True,
153
+ ) -> None:
154
+ """
155
+ Initializes a connection to a Couchbase Vector Store.
156
+
157
+ Args:
158
+ cluster (Cluster): Couchbase cluster object with active connection.
159
+ bucket_name (str): Name of bucket to store documents in.
160
+ scope_name (str): Name of scope in the bucket to store documents in.
161
+ collection_name (str): Name of collection in the scope to store documents in.
162
+ index_name (str): Name of the Search index.
163
+ text_key (Optional[str], optional): The field for the document text.
164
+ Defaults to "text".
165
+ embedding_key (Optional[str], optional): The field for the document embedding.
166
+ Defaults to "embedding".
167
+ metadata_key (Optional[str], optional): The field for the document metadata.
168
+ Defaults to "metadata".
169
+ scoped_index (Optional[bool]): specify whether the index is a scoped index.
170
+ Set to True by default.
171
+
172
+ Returns:
173
+ None
174
+ """
175
+ try:
176
+ from couchbase.cluster import Cluster
177
+ except ImportError as e:
178
+ raise ImportError(
179
+ "Could not import couchbase python package. "
180
+ "Please install couchbase SDK with `pip install couchbase`."
181
+ )
182
+
183
+ if not isinstance(cluster, Cluster):
184
+ raise ValueError(
185
+ f"cluster should be an instance of couchbase.Cluster, "
186
+ f"got {type(cluster)}"
187
+ )
188
+
189
+ self._cluster = cluster
190
+
191
+ if not bucket_name:
192
+ raise ValueError("bucket_name must be provided.")
193
+
194
+ if not scope_name:
195
+ raise ValueError("scope_name must be provided.")
196
+
197
+ if not collection_name:
198
+ raise ValueError("collection_name must be provided.")
199
+
200
+ if not index_name:
201
+ raise ValueError("index_name must be provided.")
202
+
203
+ self._bucket_name = bucket_name
204
+ self._scope_name = scope_name
205
+ self._collection_name = collection_name
206
+ self._text_key = text_key
207
+ self._embedding_key = embedding_key
208
+ self._index_name = index_name
209
+ self._metadata_key = metadata_key
210
+ self._scoped_index = scoped_index
211
+
212
+ # Check if the bucket exists
213
+ if not self._check_bucket_exists():
214
+ raise ValueError(
215
+ f"Bucket {self._bucket_name} does not exist. "
216
+ " Please create the bucket before searching."
217
+ )
218
+
219
+ try:
220
+ self._bucket = self._cluster.bucket(self._bucket_name)
221
+ self._scope = self._bucket.scope(self._scope_name)
222
+ self._collection = self._scope.collection(self._collection_name)
223
+ except Exception as e:
224
+ raise ValueError(
225
+ "Error connecting to couchbase. "
226
+ "Please check the connection and credentials."
227
+ ) from e
228
+
229
+ # Check if the scope and collection exists. Throws ValueError if they don't
230
+ try:
231
+ self._check_scope_and_collection_exists()
232
+ except Exception as e:
233
+ raise
234
+
235
+ # Check if the index exists. Throws ValueError if it doesn't
236
+ try:
237
+ self._check_index_exists()
238
+ except Exception as e:
239
+ raise
240
+
241
+ self._bucket = self._cluster.bucket(self._bucket_name)
242
+ self._scope = self._bucket.scope(self._scope_name)
243
+ self._collection = self._scope.collection(self._collection_name)
244
+
245
+ super().__init__()
246
+
247
+ def add(self, nodes: List[BaseNode], **kwargs: Any) -> List[str]:
248
+ """
249
+ Add nodes to the collection and return their document IDs.
250
+
251
+ Args:
252
+ nodes (List[BaseNode]): List of nodes to add.
253
+ **kwargs (Any): Additional keyword arguments.
254
+ batch_size (int): Size of the batch for batch insert.
255
+
256
+ Returns:
257
+ List[str]: List of document IDs for the added nodes.
258
+ """
259
+ from couchbase.exceptions import DocumentExistsException
260
+
261
+ batch_size = kwargs.get("batch_size", self.DEFAULT_BATCH_SIZE)
262
+ documents_to_insert = []
263
+ doc_ids = []
264
+
265
+ for node in nodes:
266
+ metadata = node_to_metadata_dict(
267
+ node,
268
+ remove_text=True,
269
+ text_field=self._text_key,
270
+ flat_metadata=self.flat_metadata,
271
+ )
272
+ doc_id: str = node.node_id
273
+
274
+ doc = {
275
+ self._text_key: node.get_content(metadata_mode=MetadataMode.NONE),
276
+ self._embedding_key: node.embedding,
277
+ self._metadata_key: metadata,
278
+ }
279
+
280
+ documents_to_insert.append({doc_id: doc})
281
+
282
+ for i in range(0, len(documents_to_insert), batch_size):
283
+ batch = documents_to_insert[i : i + batch_size]
284
+ try:
285
+ # convert the list of dicts to a single dict for batch insert
286
+ insert_batch = {}
287
+ for doc in batch:
288
+ insert_batch.update(doc)
289
+
290
+ logger.debug("Inserting batch of documents to Couchbase", insert_batch)
291
+
292
+ # upsert the batch of documents into the collection
293
+ result = self._collection.upsert_multi(insert_batch)
294
+
295
+ logger.debug(f"Insert result: {result.all_ok}")
296
+ if result.all_ok:
297
+ doc_ids.extend(insert_batch.keys())
298
+
299
+ except DocumentExistsException as e:
300
+ logger.debug(f"Document already exists: {e}")
301
+
302
+ logger.debug("Inserted batch of documents to Couchbase")
303
+ return doc_ids
304
+
305
+ def delete(self, ref_doc_id: str, **kwargs: Any) -> None:
306
+ """
307
+ Delete a document by its reference document ID.
308
+
309
+ Args:
310
+ ref_doc_id: The reference document ID to be deleted.
311
+
312
+ Returns:
313
+ None
314
+ """
315
+ try:
316
+ document_field = self._metadata_key + ".ref_doc_id"
317
+ self._scope.query(
318
+ f"DELETE FROM `{self._collection_name}` WHERE {document_field} = '{ref_doc_id}'"
319
+ ).execute()
320
+ logger.debug(f"Deleted document {ref_doc_id}")
321
+ except Exception:
322
+ logger.error(f"Error deleting document {ref_doc_id}")
323
+ raise
324
+
325
+ def query(self, query: VectorStoreQuery, **kwargs: Any) -> VectorStoreQueryResult:
326
+ """
327
+ Executes a query in the vector store and returns the result.
328
+
329
+ Args:
330
+ query (VectorStoreQuery): The query object containing the search parameters.
331
+ **kwargs (Any): Additional keyword arguments.
332
+ cb_search_options (Dict): Search options to pass to Couchbase Search
333
+
334
+ Returns:
335
+ VectorStoreQueryResult: The result of the query containing the top-k nodes, similarities, and ids.
336
+ """
337
+ import couchbase.search as search
338
+ from couchbase.options import SearchOptions
339
+ from couchbase.vector_search import VectorQuery, VectorSearch
340
+
341
+ fields = query.output_fields
342
+
343
+ if not fields:
344
+ fields = ["*"]
345
+
346
+ # Document text field needs to be returned from the search
347
+ if self._text_key not in fields and fields != ["*"]:
348
+ fields.append(self._text_key)
349
+
350
+ logger.debug("Output Fields: ", fields)
351
+
352
+ k = query.similarity_top_k
353
+
354
+ # Get the search options
355
+ search_options = kwargs.get("cb_search_options", {})
356
+
357
+ if search_options and query.filters:
358
+ raise ValueError("Cannot use both filters and cb_search_options")
359
+ elif query.filters:
360
+ couchbase_options = _to_couchbase_filter(query.filters)
361
+ logger.debug(f"Filters transformed to Couchbase: {couchbase_options}")
362
+ search_options = couchbase_options
363
+
364
+ logger.debug(f"Filters: {search_options}")
365
+
366
+ # Create Search Request
367
+ search_req = search.SearchRequest.create(
368
+ VectorSearch.from_vector_query(
369
+ VectorQuery(
370
+ self._embedding_key,
371
+ query.query_embedding,
372
+ k,
373
+ )
374
+ )
375
+ )
376
+
377
+ try:
378
+ logger.debug("Querying Couchbase")
379
+ if self._scoped_index:
380
+ search_iter = self._scope.search(
381
+ self._index_name,
382
+ search_req,
383
+ SearchOptions(limit=k, fields=fields, raw=search_options),
384
+ )
385
+
386
+ else:
387
+ search_iter = self._cluster.search(
388
+ self._index_name,
389
+ search_req,
390
+ SearchOptions(limit=k, fields=fields, raw=search_options),
391
+ )
392
+ except Exception as e:
393
+ logger.debug(f"Search failed with error {e}")
394
+ raise ValueError(f"Search failed with error: {e}")
395
+
396
+ top_k_nodes = []
397
+ top_k_scores = []
398
+ top_k_ids = []
399
+
400
+ # Parse the results
401
+ for result in search_iter.rows():
402
+ text = result.fields.pop(self._text_key, "")
403
+
404
+ score = result.score
405
+
406
+ # Format the metadata into a dictionary
407
+ metadata_dict = self._format_metadata(result.fields)
408
+
409
+ id = result.id
410
+
411
+ try:
412
+ node = metadata_dict_to_node(metadata_dict, text)
413
+ except Exception:
414
+ # Deprecated legacy logic for backwards compatibility
415
+ node = TextNode(
416
+ text=text,
417
+ id_=id,
418
+ score=score,
419
+ metadata=metadata_dict,
420
+ )
421
+
422
+ top_k_nodes.append(node)
423
+ top_k_scores.append(score)
424
+ top_k_ids.append(id)
425
+
426
+ return VectorStoreQueryResult(
427
+ nodes=top_k_nodes, similarities=top_k_scores, ids=top_k_ids
428
+ )
429
+
430
+ @property
431
+ def client(self) -> Any:
432
+ """
433
+ Property function to access the client attribute.
434
+ """
435
+ return self._cluster
436
+
437
+ def _check_bucket_exists(self) -> bool:
438
+ """Check if the bucket exists in the linked Couchbase cluster.
439
+
440
+ Returns:
441
+ True if the bucket exists
442
+ """
443
+ bucket_manager = self._cluster.buckets()
444
+ try:
445
+ bucket_manager.get_bucket(self._bucket_name)
446
+ return True
447
+ except Exception as e:
448
+ logger.debug("Error checking if bucket exists:", e)
449
+ return False
450
+
451
+ def _check_scope_and_collection_exists(self) -> bool:
452
+ """Check if the scope and collection exists in the linked Couchbase bucket
453
+ Returns:
454
+ True if the scope and collection exist in the bucket
455
+ Raises a ValueError if either is not found.
456
+ """
457
+ scope_collection_map: Dict[str, Any] = {}
458
+
459
+ # Get a list of all scopes in the bucket
460
+ for scope in self._bucket.collections().get_all_scopes():
461
+ scope_collection_map[scope.name] = []
462
+
463
+ # Get a list of all the collections in the scope
464
+ for collection in scope.collections:
465
+ scope_collection_map[scope.name].append(collection.name)
466
+
467
+ # Check if the scope exists
468
+ if self._scope_name not in scope_collection_map:
469
+ raise ValueError(
470
+ f"Scope {self._scope_name} not found in Couchbase "
471
+ f"bucket {self._bucket_name}"
472
+ )
473
+
474
+ # Check if the collection exists in the scope
475
+ if self._collection_name not in scope_collection_map[self._scope_name]:
476
+ raise ValueError(
477
+ f"Collection {self._collection_name} not found in scope "
478
+ f"{self._scope_name} in Couchbase bucket {self._bucket_name}"
479
+ )
480
+
481
+ return True
482
+
483
+ def _check_index_exists(self) -> bool:
484
+ """Check if the Search index exists in the linked Couchbase cluster
485
+ Returns:
486
+ bool: True if the index exists, False otherwise.
487
+ Raises a ValueError if the index does not exist.
488
+ """
489
+ if self._scoped_index:
490
+ all_indexes = [
491
+ index.name for index in self._scope.search_indexes().get_all_indexes()
492
+ ]
493
+ if self._index_name not in all_indexes:
494
+ raise ValueError(
495
+ f"Index {self._index_name} does not exist. "
496
+ " Please create the index before searching."
497
+ )
498
+ else:
499
+ all_indexes = [
500
+ index.name for index in self._cluster.search_indexes().get_all_indexes()
501
+ ]
502
+ if self._index_name not in all_indexes:
503
+ raise ValueError(
504
+ f"Index {self._index_name} does not exist. "
505
+ " Please create the index before searching."
506
+ )
507
+
508
+ return True
509
+
510
+ def _format_metadata(self, row_fields: Dict[str, Any]) -> Dict[str, Any]:
511
+ """Helper method to format the metadata from the Couchbase Search API.
512
+
513
+ Args:
514
+ row_fields (Dict[str, Any]): The fields to format.
515
+
516
+ Returns:
517
+ Dict[str, Any]: The formatted metadata.
518
+ """
519
+ metadata = {}
520
+ for key, value in row_fields.items():
521
+ # Couchbase Search returns the metadata key with a prefix
522
+ # `metadata.` We remove it to get the original metadata key
523
+ if key.startswith(self._metadata_key):
524
+ new_key = key.split(self._metadata_key + ".")[-1]
525
+ metadata[new_key] = value
526
+ else:
527
+ metadata[key] = value
528
+
529
+ return metadata
@@ -0,0 +1,57 @@
1
+ [build-system]
2
+ build-backend = "poetry.core.masonry.api"
3
+ requires = ["poetry-core"]
4
+
5
+ [tool.codespell]
6
+ check-filenames = true
7
+ check-hidden = true
8
+ # Feel free to un-skip examples, and experimental, you will just need to
9
+ # work through many typos (--write-changes and --interactive will help)
10
+ skip = "*.csv,*.html,*.json,*.jsonl,*.pdf,*.txt,*.ipynb"
11
+
12
+ [tool.llamahub]
13
+ contains_example = false
14
+ import_path = "llama_index.vector_stores.couchbase"
15
+
16
+ [tool.llamahub.class_authors]
17
+ CouchbaseVectorStore = "nithishr"
18
+
19
+ [tool.mypy]
20
+ disallow_untyped_defs = true
21
+ # Remove venv skip when integrated with pre-commit
22
+ exclude = ["_static", "build", "examples", "notebooks", "venv"]
23
+ ignore_missing_imports = true
24
+ python_version = "3.8"
25
+
26
+ [tool.poetry]
27
+ authors = ["Couchbase <devadvocates@couchbase.com>"]
28
+ description = "llama-index vector_stores couchbase integration"
29
+ license = "MIT"
30
+ name = "llama-index-vector-stores-couchbase"
31
+ packages = [{include = "llama_index/"}]
32
+ readme = "README.md"
33
+ version = "0.1.0"
34
+
35
+ [tool.poetry.dependencies]
36
+ python = ">=3.8.1,<4.0"
37
+ llama-index-core = ">=0.10.1"
38
+ couchbase = "^4.2.0"
39
+
40
+ [tool.poetry.group.dev.dependencies]
41
+ black = {extras = ["jupyter"], version = "<=23.9.1,>=23.7.0"}
42
+ codespell = {extras = ["toml"], version = ">=v2.2.6"}
43
+ ipython = "8.10.0"
44
+ jupyter = "^1.0.0"
45
+ mypy = "0.991"
46
+ pre-commit = "3.2.0"
47
+ pylint = "2.15.10"
48
+ pytest = "7.2.1"
49
+ pytest-mock = "3.11.1"
50
+ ruff = "0.0.292"
51
+ tree-sitter-languages = "^1.8.0"
52
+ types-Deprecated = ">=0.1.0"
53
+ types-PyYAML = "^6.0.12.12"
54
+ types-protobuf = "^4.24.0.4"
55
+ types-redis = "4.5.5.0"
56
+ types-requests = "2.28.11.8" # TODO: unpin when mypy>0.991
57
+ types-setuptools = "67.1.0.0"