vectordb-bench 0.0.7__py3-none-any.whl → 0.0.9__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.
Files changed (25) hide show
  1. vectordb_bench/backend/clients/__init__.py +4 -4
  2. vectordb_bench/backend/clients/api.py +1 -0
  3. vectordb_bench/backend/clients/milvus/milvus.py +2 -3
  4. vectordb_bench/backend/clients/pgvecto_rs/config.py +44 -32
  5. vectordb_bench/backend/clients/pgvecto_rs/pgvecto_rs.py +16 -16
  6. vectordb_bench/backend/clients/pgvector/config.py +178 -24
  7. vectordb_bench/backend/clients/pgvector/pgvector.py +244 -70
  8. vectordb_bench/backend/clients/qdrant_cloud/config.py +19 -6
  9. vectordb_bench/backend/clients/qdrant_cloud/qdrant_cloud.py +11 -7
  10. vectordb_bench/backend/runner/serial_runner.py +0 -2
  11. vectordb_bench/backend/task_runner.py +1 -1
  12. vectordb_bench/frontend/components/run_test/caseSelector.py +6 -3
  13. vectordb_bench/frontend/const/dbCaseConfigs.py +128 -3
  14. vectordb_bench/models.py +6 -3
  15. vectordb_bench/results/PgVector/result_20230727_standard_pgvector.json +8 -0
  16. vectordb_bench/results/PgVector/result_20230808_standard_pgvector.json +9 -3
  17. vectordb_bench/results/ZillizCloud/{result_20240105_beta_202401_zillizcloud.json → result_20240105_standard_202401_zillizcloud.json} +365 -41
  18. vectordb_bench/results/getLeaderboardData.py +1 -1
  19. vectordb_bench/results/leaderboard.json +1 -1
  20. {vectordb_bench-0.0.7.dist-info → vectordb_bench-0.0.9.dist-info}/METADATA +4 -3
  21. {vectordb_bench-0.0.7.dist-info → vectordb_bench-0.0.9.dist-info}/RECORD +25 -25
  22. {vectordb_bench-0.0.7.dist-info → vectordb_bench-0.0.9.dist-info}/LICENSE +0 -0
  23. {vectordb_bench-0.0.7.dist-info → vectordb_bench-0.0.9.dist-info}/WHEEL +0 -0
  24. {vectordb_bench-0.0.7.dist-info → vectordb_bench-0.0.9.dist-info}/entry_points.txt +0 -0
  25. {vectordb_bench-0.0.7.dist-info → vectordb_bench-0.0.9.dist-info}/top_level.txt +0 -0
@@ -1,25 +1,36 @@
1
1
  """Wrapper around the Pgvector vector database over VectorDB"""
2
2
 
3
- import io
4
3
  import logging
4
+ import pprint
5
5
  from contextlib import contextmanager
6
- from typing import Any
7
- import pandas as pd
8
- import psycopg2
9
- import psycopg2.extras
6
+ from typing import Any, Generator, Optional, Tuple, Sequence
10
7
 
11
- from ..api import VectorDB, DBCaseConfig
8
+ import numpy as np
9
+ import psycopg
10
+ from pgvector.psycopg import register_vector
11
+ from psycopg import Connection, Cursor, sql
12
+
13
+ from ..api import VectorDB
14
+ from .config import PgVectorConfigDict, PgVectorIndexConfig
15
+
16
+ log = logging.getLogger(__name__)
12
17
 
13
- log = logging.getLogger(__name__)
14
18
 
15
19
  class PgVector(VectorDB):
16
- """ Use SQLAlchemy instructions"""
20
+ """Use psycopg instructions"""
21
+
22
+ conn: psycopg.Connection[Any] | None = None
23
+ cursor: psycopg.Cursor[Any] | None = None
24
+
25
+ # TODO add filters support
26
+ _unfiltered_search: sql.Composed
27
+
17
28
  def __init__(
18
29
  self,
19
30
  dim: int,
20
- db_config: dict,
21
- db_case_config: DBCaseConfig,
22
- collection_name: str = "PgVectorCollection",
31
+ db_config: PgVectorConfigDict,
32
+ db_case_config: PgVectorIndexConfig,
33
+ collection_name: str = "pg_vector_collection",
23
34
  drop_old: bool = False,
24
35
  **kwargs,
25
36
  ):
@@ -29,44 +40,89 @@ class PgVector(VectorDB):
29
40
  self.table_name = collection_name
30
41
  self.dim = dim
31
42
 
32
- self._index_name = "pqvector_index"
43
+ self._index_name = "pgvector_index"
33
44
  self._primary_field = "id"
34
45
  self._vector_field = "embedding"
35
46
 
36
47
  # construct basic units
37
- self.conn = psycopg2.connect(**self.db_config)
38
- self.conn.autocommit = False
39
- self.cursor = self.conn.cursor()
40
-
48
+ self.conn, self.cursor = self._create_connection(**self.db_config)
49
+
41
50
  # create vector extension
42
- self.cursor.execute('CREATE EXTENSION IF NOT EXISTS vector')
51
+ self.cursor.execute("CREATE EXTENSION IF NOT EXISTS vector")
43
52
  self.conn.commit()
44
-
45
- if drop_old :
46
- log.info(f"Pgvector client drop table : {self.table_name}")
53
+
54
+ log.info(f"{self.name} config values: {self.db_config}\n{self.case_config}")
55
+ if not any(
56
+ (
57
+ self.case_config.create_index_before_load,
58
+ self.case_config.create_index_after_load,
59
+ )
60
+ ):
61
+ err = f"{self.name} config must create an index using create_index_before_load and/or create_index_after_load"
62
+ log.error(err)
63
+ raise RuntimeError(
64
+ f"{err}\n{pprint.pformat(self.db_config)}\n{pprint.pformat(self.case_config)}"
65
+ )
66
+
67
+ if drop_old:
47
68
  # self.pg_table.drop(pg_engine, checkfirst=True)
48
69
  self._drop_index()
49
70
  self._drop_table()
50
71
  self._create_table(dim)
51
- self._create_index()
52
-
72
+ if self.case_config.create_index_before_load:
73
+ self._create_index()
74
+
53
75
  self.cursor.close()
54
76
  self.conn.close()
55
77
  self.cursor = None
56
78
  self.conn = None
57
79
 
80
+ @staticmethod
81
+ def _create_connection(**kwargs) -> Tuple[Connection, Cursor]:
82
+ conn = psycopg.connect(**kwargs)
83
+ register_vector(conn)
84
+ conn.autocommit = False
85
+ cursor = conn.cursor()
86
+
87
+ assert conn is not None, "Connection is not initialized"
88
+ assert cursor is not None, "Cursor is not initialized"
89
+
90
+ return conn, cursor
91
+
58
92
  @contextmanager
59
- def init(self) -> None:
93
+ def init(self) -> Generator[None, None, None]:
60
94
  """
61
95
  Examples:
62
96
  >>> with self.init():
63
97
  >>> self.insert_embeddings()
64
98
  >>> self.search_embedding()
65
99
  """
66
- self.conn = psycopg2.connect(**self.db_config)
67
- self.conn.autocommit = False
68
- self.cursor = self.conn.cursor()
69
-
100
+
101
+ self.conn, self.cursor = self._create_connection(**self.db_config)
102
+
103
+ # index configuration may have commands defined that we should set during each client session
104
+ session_options: Sequence[dict[str, Any]] = self.case_config.session_param()["session_options"]
105
+
106
+ if len(session_options) > 0:
107
+ for setting in session_options:
108
+ command = sql.SQL("SET {setting_name} " + "= {val};").format(
109
+ setting_name=sql.Identifier(setting['parameter']['setting_name']),
110
+ val=sql.Identifier(str(setting['parameter']['val'])),
111
+ )
112
+ log.debug(command.as_string(self.cursor))
113
+ self.cursor.execute(command)
114
+ self.conn.commit()
115
+
116
+ self._unfiltered_search = sql.Composed(
117
+ [
118
+ sql.SQL("SELECT id FROM public.{} ORDER BY embedding ").format(
119
+ sql.Identifier(self.table_name)
120
+ ),
121
+ sql.SQL(self.case_config.search_param()["metric_fun_op"]),
122
+ sql.SQL(" %s::vector LIMIT %s::int"),
123
+ ]
124
+ )
125
+
70
126
  try:
71
127
  yield
72
128
  finally:
@@ -74,54 +130,170 @@ class PgVector(VectorDB):
74
130
  self.conn.close()
75
131
  self.cursor = None
76
132
  self.conn = None
77
-
133
+
78
134
  def _drop_table(self):
79
135
  assert self.conn is not None, "Connection is not initialized"
80
136
  assert self.cursor is not None, "Cursor is not initialized"
81
-
82
- self.cursor.execute(f'DROP TABLE IF EXISTS public."{self.table_name}"')
137
+ log.info(f"{self.name} client drop table : {self.table_name}")
138
+
139
+ self.cursor.execute(
140
+ sql.SQL("DROP TABLE IF EXISTS public.{table_name}").format(
141
+ table_name=sql.Identifier(self.table_name)
142
+ )
143
+ )
83
144
  self.conn.commit()
84
-
145
+
85
146
  def ready_to_load(self):
86
147
  pass
87
148
 
88
149
  def optimize(self):
89
- pass
90
-
150
+ self._post_insert()
151
+
91
152
  def _post_insert(self):
92
153
  log.info(f"{self.name} post insert before optimize")
93
- self._drop_index()
94
- self._create_index()
154
+ if self.case_config.create_index_after_load:
155
+ self._drop_index()
156
+ self._create_index()
95
157
 
96
- def ready_to_search(self):
97
- pass
98
-
99
158
  def _drop_index(self):
100
159
  assert self.conn is not None, "Connection is not initialized"
101
160
  assert self.cursor is not None, "Cursor is not initialized"
102
-
103
- self.cursor.execute(f'DROP INDEX IF EXISTS "{self._index_name}"')
161
+ log.info(f"{self.name} client drop index : {self._index_name}")
162
+
163
+ drop_index_sql = sql.SQL("DROP INDEX IF EXISTS {index_name}").format(
164
+ index_name=sql.Identifier(self._index_name)
165
+ )
166
+ log.debug(drop_index_sql.as_string(self.cursor))
167
+ self.cursor.execute(drop_index_sql)
104
168
  self.conn.commit()
105
-
169
+
170
+ def _set_parallel_index_build_param(self):
171
+ assert self.conn is not None, "Connection is not initialized"
172
+ assert self.cursor is not None, "Cursor is not initialized"
173
+
174
+ index_param = self.case_config.index_param()
175
+
176
+ if index_param["maintenance_work_mem"] is not None:
177
+ self.cursor.execute(
178
+ sql.SQL("SET maintenance_work_mem TO {};").format(
179
+ index_param["maintenance_work_mem"]
180
+ )
181
+ )
182
+ self.cursor.execute(
183
+ sql.SQL("ALTER USER {} SET maintenance_work_mem TO {};").format(
184
+ sql.Identifier(self.db_config["user"]),
185
+ index_param["maintenance_work_mem"],
186
+ )
187
+ )
188
+ self.conn.commit()
189
+
190
+ if index_param["max_parallel_workers"] is not None:
191
+ self.cursor.execute(
192
+ sql.SQL("SET max_parallel_maintenance_workers TO '{}';").format(
193
+ index_param["max_parallel_workers"]
194
+ )
195
+ )
196
+ self.cursor.execute(
197
+ sql.SQL(
198
+ "ALTER USER {} SET max_parallel_maintenance_workers TO '{}';"
199
+ ).format(
200
+ sql.Identifier(self.db_config["user"]),
201
+ index_param["max_parallel_workers"],
202
+ )
203
+ )
204
+ self.cursor.execute(
205
+ sql.SQL("SET max_parallel_workers TO '{}';").format(
206
+ index_param["max_parallel_workers"]
207
+ )
208
+ )
209
+ self.cursor.execute(
210
+ sql.SQL(
211
+ "ALTER USER {} SET max_parallel_workers TO '{}';"
212
+ ).format(
213
+ sql.Identifier(self.db_config["user"]),
214
+ index_param["max_parallel_workers"],
215
+ )
216
+ )
217
+ self.cursor.execute(
218
+ sql.SQL(
219
+ "ALTER TABLE {} SET (parallel_workers = {});"
220
+ ).format(
221
+ sql.Identifier(self.table_name),
222
+ index_param["max_parallel_workers"],
223
+ )
224
+ )
225
+ self.conn.commit()
226
+
227
+ results = self.cursor.execute(
228
+ sql.SQL("SHOW max_parallel_maintenance_workers;")
229
+ ).fetchall()
230
+ results.extend(
231
+ self.cursor.execute(sql.SQL("SHOW max_parallel_workers;")).fetchall()
232
+ )
233
+ results.extend(
234
+ self.cursor.execute(sql.SQL("SHOW maintenance_work_mem;")).fetchall()
235
+ )
236
+ log.info(f"{self.name} parallel index creation parameters: {results}")
237
+
106
238
  def _create_index(self):
107
239
  assert self.conn is not None, "Connection is not initialized"
108
240
  assert self.cursor is not None, "Cursor is not initialized"
109
-
241
+ log.info(f"{self.name} client create index : {self._index_name}")
242
+
110
243
  index_param = self.case_config.index_param()
111
- self.cursor.execute(f'CREATE INDEX IF NOT EXISTS {self._index_name} ON public."{self.table_name}" USING ivfflat (embedding {index_param["metric"]}) WITH (lists={index_param["lists"]});')
244
+ self._set_parallel_index_build_param()
245
+ options = []
246
+ for option in index_param["index_creation_with_options"]:
247
+ if option['val'] is not None:
248
+ options.append(
249
+ sql.SQL("{option_name} = {val}").format(
250
+ option_name=sql.Identifier(option['option_name']),
251
+ val=sql.Identifier(str(option['val'])),
252
+ )
253
+ )
254
+ if any(options):
255
+ with_clause = sql.SQL("WITH ({});").format(sql.SQL(", ").join(options))
256
+ else:
257
+ with_clause = sql.Composed(())
258
+
259
+ index_create_sql = sql.SQL(
260
+ "CREATE INDEX IF NOT EXISTS {index_name} ON public.{table_name} USING {index_type} (embedding {embedding_metric})"
261
+ ).format(
262
+ index_name=sql.Identifier(self._index_name),
263
+ table_name=sql.Identifier(self.table_name),
264
+ index_type=sql.Identifier(index_param["index_type"]),
265
+ embedding_metric=sql.Identifier(index_param["metric"]),
266
+ )
267
+ index_create_sql_with_with_clause = (
268
+ index_create_sql + with_clause
269
+ ).join(" ")
270
+ log.debug(index_create_sql_with_with_clause.as_string(self.cursor))
271
+ self.cursor.execute(index_create_sql_with_with_clause)
112
272
  self.conn.commit()
113
-
114
- def _create_table(self, dim : int):
273
+
274
+ def _create_table(self, dim: int):
115
275
  assert self.conn is not None, "Connection is not initialized"
116
276
  assert self.cursor is not None, "Cursor is not initialized"
117
-
277
+
118
278
  try:
279
+ log.info(f"{self.name} client create table : {self.table_name}")
280
+
119
281
  # create table
120
- self.cursor.execute(f'CREATE TABLE IF NOT EXISTS public."{self.table_name}" (id BIGINT PRIMARY KEY, embedding vector({dim}));')
121
- self.cursor.execute(f'ALTER TABLE public."{self.table_name}" ALTER COLUMN embedding SET STORAGE PLAIN;')
282
+ self.cursor.execute(
283
+ sql.SQL(
284
+ "CREATE TABLE IF NOT EXISTS public.{table_name} (id BIGINT PRIMARY KEY, embedding vector({dim}));"
285
+ ).format(table_name=sql.Identifier(self.table_name), dim=dim)
286
+ )
287
+ self.cursor.execute(
288
+ sql.SQL(
289
+ "ALTER TABLE public.{table_name} ALTER COLUMN embedding SET STORAGE PLAIN;"
290
+ ).format(table_name=sql.Identifier(self.table_name))
291
+ )
122
292
  self.conn.commit()
123
293
  except Exception as e:
124
- log.warning(f"Failed to create pgvector table: {self.table_name} error: {e}")
294
+ log.warning(
295
+ f"Failed to create pgvector table: {self.table_name} error: {e}"
296
+ )
125
297
  raise e from None
126
298
 
127
299
  def insert_embeddings(
@@ -129,31 +301,35 @@ class PgVector(VectorDB):
129
301
  embeddings: list[list[float]],
130
302
  metadata: list[int],
131
303
  **kwargs: Any,
132
- ) -> (int, Exception):
304
+ ) -> Tuple[int, Optional[Exception]]:
133
305
  assert self.conn is not None, "Connection is not initialized"
134
306
  assert self.cursor is not None, "Cursor is not initialized"
135
307
 
136
308
  try:
137
- items = {
138
- "id": metadata,
139
- "embedding": embeddings
140
- }
141
- df = pd.DataFrame(items)
142
- csv_buffer = io.StringIO()
143
- df.to_csv(csv_buffer, index=False, header=False)
144
- csv_buffer.seek(0)
145
- self.cursor.copy_expert(f"COPY public.\"{self.table_name}\" FROM STDIN WITH (FORMAT CSV)", csv_buffer)
309
+ metadata_arr = np.array(metadata)
310
+ embeddings_arr = np.array(embeddings)
311
+
312
+ with self.cursor.copy(
313
+ sql.SQL("COPY public.{table_name} FROM STDIN (FORMAT BINARY)").format(
314
+ table_name=sql.Identifier(self.table_name)
315
+ )
316
+ ) as copy:
317
+ copy.set_types(["bigint", "vector"])
318
+ for i, row in enumerate(metadata_arr):
319
+ copy.write_row((row, embeddings_arr[i]))
146
320
  self.conn.commit()
147
-
321
+
148
322
  if kwargs.get("last_batch"):
149
323
  self._post_insert()
150
-
324
+
151
325
  return len(metadata), None
152
326
  except Exception as e:
153
- log.warning(f"Failed to insert data into pgvector table ({self.table_name}), error: {e}")
327
+ log.warning(
328
+ f"Failed to insert data into pgvector table ({self.table_name}), error: {e}"
329
+ )
154
330
  return 0, e
155
331
 
156
- def search_embedding(
332
+ def search_embedding(
157
333
  self,
158
334
  query: list[float],
159
335
  k: int = 100,
@@ -163,11 +339,9 @@ class PgVector(VectorDB):
163
339
  assert self.conn is not None, "Connection is not initialized"
164
340
  assert self.cursor is not None, "Cursor is not initialized"
165
341
 
166
- search_param =self.case_config.search_param()
167
- self.cursor.execute(f'SET ivfflat.probes = {search_param["probes"]}')
168
- self.cursor.execute(f"SELECT id FROM public.\"{self.table_name}\" ORDER BY embedding {search_param['metric_fun_op']} '{query}' LIMIT {k};")
169
- self.conn.commit()
170
- result = self.cursor.fetchall()
342
+ # TODO add filters support
343
+ result = self.cursor.execute(
344
+ self._unfiltered_search, (query, k), prepare=True, binary=True
345
+ )
171
346
 
172
- return [int(i[0]) for i in result]
173
-
347
+ return [int(i[0]) for i in result.fetchall()]
@@ -1,18 +1,31 @@
1
1
  from pydantic import BaseModel, SecretStr
2
2
 
3
3
  from ..api import DBConfig, DBCaseConfig, MetricType
4
+ from pydantic import validator
4
5
 
5
-
6
+ # Allowing `api_key` to be left empty, to ensure compatibility with the open-source Qdrant.
6
7
  class QdrantConfig(DBConfig):
7
8
  url: SecretStr
8
9
  api_key: SecretStr
9
10
 
10
11
  def to_dict(self) -> dict:
11
- return {
12
- "url": self.url.get_secret_value(),
13
- "api_key": self.api_key.get_secret_value(),
14
- "prefer_grpc": True,
15
- }
12
+ api_key = self.api_key.get_secret_value()
13
+ if len(api_key) > 0:
14
+ return {
15
+ "url": self.url.get_secret_value(),
16
+ "api_key": self.api_key.get_secret_value(),
17
+ "prefer_grpc": True,
18
+ }
19
+ else:
20
+ return {"url": self.url.get_secret_value(),}
21
+
22
+ @validator("*")
23
+ def not_empty_field(cls, v, field):
24
+ if field.name in ["api_key", "db_label"]:
25
+ return v
26
+ if isinstance(v, (str, SecretStr)) and len(v) == 0:
27
+ raise ValueError("Empty string!")
28
+ return v
16
29
 
17
30
  class QdrantIndexConfig(BaseModel, DBCaseConfig):
18
31
  metric_type: MetricType | None = None
@@ -43,8 +43,7 @@ class QdrantCloud(VectorDB):
43
43
  if drop_old:
44
44
  log.info(f"QdrantCloud client drop_old collection: {self.collection_name}")
45
45
  tmp_client.delete_collection(self.collection_name)
46
-
47
- self._create_collection(dim, tmp_client)
46
+ self._create_collection(dim, tmp_client)
48
47
  tmp_client = None
49
48
 
50
49
  @contextmanager
@@ -110,13 +109,18 @@ class QdrantCloud(VectorDB):
110
109
  ) -> (int, Exception):
111
110
  """Insert embeddings into Milvus. should call self.init() first"""
112
111
  assert self.qdrant_client is not None
112
+ QDRANT_BATCH_SIZE = 500
113
113
  try:
114
114
  # TODO: counts
115
- _ = self.qdrant_client.upsert(
116
- collection_name=self.collection_name,
117
- wait=True,
118
- points=Batch(ids=metadata, payloads=[{self._primary_field: v} for v in metadata], vectors=embeddings)
119
- )
115
+ for offset in range(0, len(embeddings), QDRANT_BATCH_SIZE):
116
+ vectors = embeddings[offset: offset + QDRANT_BATCH_SIZE]
117
+ ids = metadata[offset: offset + QDRANT_BATCH_SIZE]
118
+ payloads=[{self._primary_field: v} for v in ids]
119
+ _ = self.qdrant_client.upsert(
120
+ collection_name=self.collection_name,
121
+ wait=True,
122
+ points=Batch(ids=ids, payloads=payloads, vectors=vectors),
123
+ )
120
124
  except Exception as e:
121
125
  log.info(f"Failed to insert data, {e}")
122
126
  return 0, e
@@ -46,11 +46,9 @@ class SerialInsertRunner:
46
46
  del(emb_np)
47
47
  log.debug(f"batch dataset size: {len(all_embeddings)}, {len(all_metadata)}")
48
48
 
49
- last_batch = self.dataset.data.size - count == len(all_metadata)
50
49
  insert_count, error = self.db.insert_embeddings(
51
50
  embeddings=all_embeddings,
52
51
  metadata=all_metadata,
53
- last_batch=last_batch,
54
52
  )
55
53
  if error is not None:
56
54
  raise error
@@ -140,8 +140,8 @@ class CaseRunner(BaseModel):
140
140
  )
141
141
 
142
142
  self._init_search_runner()
143
- m.recall, m.serial_latency_p99 = self._serial_search()
144
143
  m.qps = self._conc_search()
144
+ m.recall, m.serial_latency_p99 = self._serial_search()
145
145
  except Exception as e:
146
146
  log.warning(f"Failed to run performance case, reason = {e}")
147
147
  traceback.print_exc()
@@ -65,25 +65,28 @@ def caseConfigSetting(st, allCaseConfigs, case, activedDbList):
65
65
  key = "%s-%s-%s" % (db, case, config.label.value)
66
66
  if config.inputType == InputType.Text:
67
67
  caseConfig[config.label] = column.text_input(
68
- config.label.value,
68
+ config.displayLabel if config.displayLabel else config.label.value,
69
69
  key=key,
70
+ help=config.inputHelp,
70
71
  value=config.inputConfig["value"],
71
72
  )
72
73
  elif config.inputType == InputType.Option:
73
74
  caseConfig[config.label] = column.selectbox(
74
- config.label.value,
75
+ config.displayLabel if config.displayLabel else config.label.value,
75
76
  config.inputConfig["options"],
76
77
  key=key,
78
+ help=config.inputHelp,
77
79
  )
78
80
  elif config.inputType == InputType.Number:
79
81
  caseConfig[config.label] = column.number_input(
80
- config.label.value,
82
+ config.displayLabel if config.displayLabel else config.label.value,
81
83
  # format="%d",
82
84
  step=config.inputConfig.get("step", 1),
83
85
  min_value=config.inputConfig["min"],
84
86
  max_value=config.inputConfig["max"],
85
87
  key=key,
86
88
  value=config.inputConfig["value"],
89
+ help=config.inputHelp,
87
90
  )
88
91
  k += 1
89
92
  if k == 0: