endee 1.7.1 → 1.8.0-dev.2

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.
package/README.md CHANGED
@@ -1,545 +1,720 @@
1
- # Endee - TypeScript Vector Database Client
2
-
3
- Endee is a TypeScript client for a local vector database designed for maximum speed and efficiency. This package provides full type safety, modern ES module support, and optimized code for rapid Approximate Nearest Neighbor (ANN) searches on vector data.
4
-
5
- ## Key Features
6
-
7
- - **TypeScript First**: Full type safety and IntelliSense support
8
- - **Fast ANN Searches**: Efficient similarity searches on vector data
9
- - **Multiple Distance Metrics**: Support for cosine, L2, and inner product distance metrics
10
- - **Hybrid Indexes**: Support for dense vectors, sparse vectors, and hybrid (dense + sparse) searches
11
- - **Metadata Support**: Attach and search with metadata and filters
12
- - **High Performance**: Optimized for speed and efficiency
13
- - **Modern ES Modules**: Native ES module support with proper tree-shaking
14
-
15
- ## Requirements
16
-
17
- - Node.js >= 18.0.0
18
- - Endee Local server running (see [Quick Start](https://docs.endee.io/quick-start))
19
-
20
- ## Installation
1
+ # Endee TypeScript / JavaScript Client
2
+
3
+ Endee is a high-performance C++ vector database for AI search, RAG, and hybrid
4
+ retrieval. A **collection** holds objects, and each object can carry values for
5
+ several **named, typed fields** at once — dense vectors, sparse vectors, and
6
+ multi-vectors — so a single object can be searched many ways.
7
+
8
+ - **Multi-field objects** one object, many vector fields (dense + sparse + multi-vector).
9
+ - **Hybrid search** query any subset of fields; `search()` returns per-field results, which you can fuse with **`rerank()`** (RRF).
10
+ - **Client-side normalization** cosine vectors are L2-normalized for you.
11
+ - **Full control plane** collections, objects, filters, rebuild/shrink, backups, databases, and tokens.
12
+ - **TypeScript first** full type definitions and IntelliSense for every call.
13
+ - **ES modules** native ESM with proper tree-shaking.
14
+
15
+ ---
16
+
17
+ ## Table of contents
18
+
19
+ 1. [Install](#install)
20
+ 2. [Quick start](#quick-start)
21
+ 3. [Connecting & tokens](#connecting--tokens)
22
+ 4. [Create a collection](#create-a-collection)
23
+ 5. [Insert objects (combining vector types)](#insert-objects-combining-vector-types)
24
+ 6. [Search](#search)
25
+ - [The unified field shape](#the-unified-field-shape)
26
+ - [Per-field `limit` and `ef_search`](#per-field-limit-and-ef_search)
27
+ - [Single field](#single-field-search)
28
+ - [Multi-field](#multi-field-search)
29
+ - [Fusing results with `rerank()`](#fusing-results-with-rerank)
30
+ - [Filters](#filtered-search)
31
+ - [Filter tuning](#filter-tuning)
32
+ 7. [Object operations](#object-operations)
33
+ 8. [Collection maintenance](#collection-maintenance)
34
+ 9. [Backups](#backups)
35
+ 10. [Database administration (root token)](#database-administration-root-token)
36
+ 11. [Reference](#reference)
37
+ 12. [Error handling](#error-handling)
38
+
39
+ ---
40
+
41
+ ## Install
21
42
 
22
43
  ```bash
23
44
  npm install endee
24
45
  ```
25
46
 
26
- ## Quick Start
47
+ Requires Node.js ≥ 18. Depends on `axios` and `@msgpack/msgpack`.
27
48
 
28
- ### Initialize the Client
49
+ ---
29
50
 
30
- The Endee client connects to your local server (defaults to `http://127.0.0.1:8080/api/v1`):
51
+ ## Quick start
31
52
 
32
53
  ```typescript
33
- import { Endee, Precision } from 'endee';
54
+ import { Endee } from 'endee';
55
+
56
+ // A "db token" (db_name:secret) scopes you to one database.
57
+ const client = new Endee('my_db:xxxxxxxx');
58
+ client.setBaseUrl('http://localhost:8080/api/v2'); // include /api/v2
59
+
60
+ // 1. Create a collection with one of each field type.
61
+ await client.createCollection({
62
+ name: 'products',
63
+ fields: [
64
+ {
65
+ name: 'embedding',
66
+ type: 'vector',
67
+ params: { dimension: 8, space_type: 'cosine', precision: 'int8' },
68
+ },
69
+ { name: 'keywords', type: 'sparse', sparse_model: 'default' },
70
+ {
71
+ name: 'colbert',
72
+ type: 'multi_vector',
73
+ params: { dimension: 8, space_type: 'cosine', precision: 'int8', pooling: 'mean' },
74
+ },
75
+ ],
76
+ });
34
77
 
35
- // Connect to local Endee server (defaults to localhost:8080)
36
- const client = new Endee();
37
- ```
78
+ const collection = await client.getCollection('products');
38
79
 
39
- **Using Authentication?** If your server has `NDD_AUTH_TOKEN` set, pass the same token when initializing:
80
+ // 2. Insert an object carrying ALL THREE field types at once.
81
+ await collection.upsert([
82
+ {
83
+ id: 'p1',
84
+ meta: { name: 'Wireless Headphones', price: 99 },
85
+ filter: { category: 'electronics' },
86
+ fields: {
87
+ embedding: [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8],
88
+ keywords: { indices: [3, 17, 42], values: [0.9, 0.5, 0.2] },
89
+ colbert: [Array(8).fill(0.1), Array(8).fill(0.2)],
90
+ },
91
+ },
92
+ ]);
40
93
 
41
- ```typescript
42
- const client = new Endee('your-auth-token');
94
+ // 3. Search the dense field. `results` is keyed by field name.
95
+ const res = await collection.search({
96
+ fields: { embedding: { query: Array(8).fill(0.2), limit: 5 } },
97
+ });
98
+ for (const h of res.results.embedding) {
99
+ console.log(h.id, h.similarity, h.meta);
100
+ }
43
101
  ```
44
102
 
45
- ### Setting a Custom Base URL
103
+ ---
46
104
 
47
- If your server runs on a different port, use `setBaseUrl()`:
105
+ ## Connecting & tokens
48
106
 
49
- ```typescript
50
- const client = new Endee();
107
+ **Authentication is always required.** The server has no anonymous mode — every
108
+ request must carry a token, or it is rejected with **401 Unauthorized**.
51
109
 
52
- // Set custom base URL for non-default port
53
- client.setBaseUrl('http://0.0.0.0:8081/api/v1');
54
- ```
55
-
56
- ### Create a Dense Index
110
+ A **db token** has the form `db_name:secret` and scopes you to all collection /
111
+ object / search work inside one database. (Database administration and minting
112
+ tokens require the server's **root token** — those control-plane operations are
113
+ not yet exposed in the JS client; see [Reference](#reference).)
57
114
 
58
115
  ```typescript
59
- import { Precision } from 'endee';
60
-
61
- await client.createIndex({
62
- name: 'my_vectors',
63
- dimension: 384,
64
- spaceType: 'cosine',
65
- precision: Precision.INT16,
66
- });
116
+ const client = new Endee('my_db:xxxxxxxx');
117
+ client.setBaseUrl('http://localhost:8080/api/v2'); // must point at the /api/v2 root
67
118
  ```
68
119
 
69
- **Dense Index Parameters:**
120
+ - A token of the form `db_name:secret:region` is parsed automatically — the
121
+ client targets `https://<region>.endee.io/api/v2` and strips the region from
122
+ the token it sends.
123
+ - `setBaseUrl` must point at the `/api/v2` root of your server.
124
+ - A missing/invalid token → `AuthenticationException` (401); a read-only token
125
+ on a write → `ForbiddenException` (403).
70
126
 
71
- | Parameter | Description |
72
- |-----------|-------------|
73
- | `name` | Unique name for your index |
74
- | `dimension` | Vector dimensionality (must match your embedding model's output) |
75
- | `spaceType` | Distance metric - `"cosine"`, `"l2"`, or `"ip"` (inner product) |
76
- | `M` | Graph connectivity - higher values increase recall but use more memory (default: 16) |
77
- | `efCon` | Construction-time parameter - higher values improve index quality (default: 128) |
78
- | `precision` | Quantization precision (default: `Precision.INT16`) |
127
+ ---
79
128
 
80
- ### Create a Hybrid Index
129
+ ## Create a collection
81
130
 
82
- Hybrid indexes combine dense vector search with sparse vector search. Add the `sparseDimension` parameter:
131
+ A collection is a set of **named, typed fields**. Each field is a plain object
132
+ sent to the server as-is.
133
+
134
+ | Field type | Required keys | `params` |
135
+ |------------|---------------|----------|
136
+ | `vector` | `name`, `type` | `dimension`, `space_type`, `precision` (+ optional `M`, `ef_con`) |
137
+ | `sparse` | `name`, `type`, `sparse_model` | — (no `params`) |
138
+ | `multi_vector` | `name`, `type` | `dimension`, `space_type`, `precision`, **`pooling`** (`"mean"`/`"max"`) (+ optional `M`, `ef_con`) |
83
139
 
84
140
  ```typescript
85
- await client.createIndex({
86
- name: 'hybrid_index',
87
- dimension: 384, // Dense vector dimension
88
- sparseDimension: 30000, // Sparse vector dimension (vocabulary size)
89
- spaceType: 'cosine',
90
- precision: Precision.INT16,
141
+ await client.createCollection({
142
+ name: 'products',
143
+ fields: [
144
+ // Dense embedding (single vector per object)
145
+ {
146
+ name: 'embedding',
147
+ type: 'vector',
148
+ params: { dimension: 768, space_type: 'cosine', precision: 'int8' },
149
+ },
150
+
151
+ // Sparse / keyword field (BM25-style)
152
+ { name: 'keywords', type: 'sparse', sparse_model: 'default' },
153
+
154
+ // Multi-vector field (many vectors per object)
155
+ {
156
+ name: 'colbert',
157
+ type: 'multi_vector',
158
+ params: { dimension: 768, space_type: 'cosine', precision: 'int8', pooling: 'mean' },
159
+ },
160
+ ],
91
161
  });
92
162
  ```
93
163
 
94
- ### List and Access Indexes
164
+ - `space_type`: `cosine` (default), `l2`, `ip`.
165
+ - `precision`: `float32`, `float16`, `int16` (default), `int8`, `int8e`, `binary`.
166
+ - `M` / `ef_con`: HNSW build params (optional; sensible defaults applied).
167
+ - **Cosine vectors are L2-normalized by the client** before sending.
168
+
169
+ Other client-level collection calls:
95
170
 
96
171
  ```typescript
97
- // List all indexes
98
- const indexes = await client.listIndexes();
172
+ await client.listCollections(); // [{ name: "products", ... }, ...]
173
+ const collection = await client.getCollection('products');
174
+ await collection.describe(); // { name, fields, created_at, layout_version }
175
+ await client.deleteCollection('products');
176
+ ```
177
+
178
+ ---
179
+
180
+ ## Insert objects (combining vector types)
99
181
 
100
- // Get reference to an existing index
101
- const index = await client.getIndex('my_vectors');
182
+ `collection.upsert(objects)` takes an array of objects. Each object is:
102
183
 
103
- // Delete an index
104
- await client.deleteIndex('my_vectors');
184
+ ```typescript
185
+ {
186
+ id: '<unique string id>',
187
+ meta?: { /* ... */ }, // arbitrary JSON, returned in search results (optional)
188
+ filter?: { /* ... */ }, // tag object used by filtered search (optional)
189
+ fields?: { // field_name -> value, ONE entry per field you set
190
+ '<dense_field>': [/* float, ... */], // dense → array
191
+ '<sparse_field>': { indices: [/* ... */], values: [/* ... */] }, // sparse → object
192
+ '<multi_field>': [[/* float, ... */], [/* float, ... */]], // multi → array of arrays
193
+ },
194
+ }
105
195
  ```
106
196
 
107
- ## Upserting Vectors
197
+ Per-field value formats:
108
198
 
109
- The `index.upsert()` method adds or updates vectors in an existing index.
199
+ | Field type | Value format |
200
+ |------------|--------------|
201
+ | `vector` | `[0.1, 0.2, ...]` — a flat array of floats |
202
+ | `sparse` | `{ indices: [3, 17], values: [0.9, 0.5] }` (also accepts `sparse_indices`/`sparse_values`) |
203
+ | `multi_vector` | `[[...], [...], ...]` — an array of equal-length float arrays |
110
204
 
111
- ```typescript
112
- const index = await client.getIndex('my_index');
205
+ ### One object, multiple field types
113
206
 
114
- await index.upsert([
207
+ A single object can populate **any combination** of the collection's fields in
208
+ one call. They're stored together under the same id and can be searched
209
+ independently or together:
210
+
211
+ ```typescript
212
+ await collection.upsert([
115
213
  {
116
- id: 'vec1',
117
- vector: [0.1, 0.2, 0.3 /* ... */],
118
- meta: { title: 'First document' },
119
- filter: { category: 'tech' },
214
+ id: 'p1',
215
+ meta: { name: 'Wireless Headphones', price: 99 },
216
+ filter: { category: 'electronics' },
217
+ fields: {
218
+ embedding: [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8], // dense
219
+ keywords: { indices: [3, 17, 42], values: [0.9, 0.5, 0.2] }, // sparse
220
+ colbert: [
221
+ // multi-vector
222
+ [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8],
223
+ [0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9],
224
+ ],
225
+ },
120
226
  },
121
227
  {
122
- id: 'vec2',
123
- vector: [0.3, 0.4, 0.5 /* ... */],
124
- meta: { title: 'Second document' },
125
- filter: { category: 'science' },
228
+ id: 'p2',
229
+ meta: { name: 'Running Shoes' },
230
+ filter: { category: 'footwear' },
231
+ fields: {
232
+ // An object may set only SOME fields — here just dense + sparse.
233
+ embedding: [0.5, 0.4, 0.3, 0.2, 0.1, 0.05, 0.02, 0.01],
234
+ keywords: { indices: [5, 17, 90], values: [0.7, 0.6, 0.1] },
235
+ },
126
236
  },
127
237
  ]);
238
+ // → { upserted: 2 }
128
239
  ```
129
240
 
130
- **Vector Object Fields:**
241
+ Notes:
131
242
 
132
- | Field | Required | Description |
133
- |-------|----------|-------------|
134
- | `id` | Yes | Unique identifier for the vector |
135
- | `vector` | Yes | Array of floats representing the embedding |
136
- | `meta` | No | Arbitrary metadata object |
137
- | `filter` | No | Key-value pairs for filtering during queries |
243
+ - You don't have to fill every field on every object — set whatever subset you have.
244
+ - `upsert` is insert-or-replace by `id`.
245
+ - Cosine `vector`/`multi_vector` values are normalized client-side before sending;
246
+ the original norm is stored so `getObjects` returns the original (pre-normalization) vectors.
247
+ - Validation is done client-side (fails fast): batch size ≤ 10,000, no duplicate
248
+ ids in a batch, dense/multi dims must match the field, sparse `indices`/`values`
249
+ lengths must match, filter keys ≤ 128 bytes / string values ≤ 1024 bytes.
138
250
 
139
- ## Querying the Index
251
+ ---
140
252
 
141
- The `index.query()` method performs a similarity search.
253
+ ## Search
142
254
 
143
- ```typescript
144
- const results = await index.query({
145
- vector: [0.15, 0.25 /* ... */],
146
- topK: 5,
147
- ef: 128,
148
- includeVectors: true,
149
- });
255
+ `collection.search({ fields, filter })` queries one or more fields **in a
256
+ single request** and **always returns one ranked list per field**, keyed by field
257
+ name. To collapse those lists into a single ranked list, fuse them with
258
+ [`rerank()`](#fusing-results-with-rerank).
150
259
 
151
- for (const item of results) {
152
- console.log(`ID: ${item.id}, Similarity: ${item.similarity}`);
153
- }
154
- ```
260
+ ### The unified field shape
155
261
 
156
- **Query Parameters:**
262
+ Every field is queried with the **same object form** — `{ query, limit?, ef_search? }`.
263
+ There is no bare-value shorthand; `query` is required. The `query` value matches
264
+ the field's type:
157
265
 
158
- | Parameter | Description |
159
- |-----------|-------------|
160
- | `vector` | Query vector (must match index dimension) |
161
- | `topK` | Number of results to return (default: 10, max: 512) |
162
- | `ef` | Search quality parameter (default: 128, max: 1024) |
163
- | `includeVectors` | Include vector data in results (default: false) |
164
- | `filter` | Optional filter criteria (array of filter objects) |
165
- | `sparseIndices` | Sparse vector indices for hybrid search (default: null) |
166
- | `sparseValues` | Sparse vector values for hybrid search (default: null) |
167
- | `prefilterCardinalityThreshold` | Controls when search switches from HNSW filtered search to brute-force prefiltering on the matched subset (default: 10,000, range: 1,000–1,000,000). See [Filter Tuning](#filter-tuning) for details. |
168
- | `filterBoostPercentage` | Expands the internal HNSW candidate pool by this percentage when a filter is active, compensating for filtered-out results (default: 0, range: 0–100). See [Filter Tuning](#filter-tuning) for details. |
266
+ | Field type | `query` shape |
267
+ |------------|-------------|
268
+ | `vector` | `[0.2, 0.2, ...]` |
269
+ | `sparse` | `{ indices: [3, 17], values: [0.8, 0.4] }` |
270
+ | `multi_vector` | `[[...], [...]]` |
169
271
 
170
- ## Filtered Querying
171
-
172
- Use the `filter` parameter to restrict results. All filters are combined with **logical AND**.
272
+ Each result **hit** is:
173
273
 
174
274
  ```typescript
175
- const results = await index.query({
176
- vector: [0.15, 0.25 /* ... */],
177
- topK: 5,
178
- filter: [
179
- { category: { $eq: 'tech' } },
180
- { score: { $range: [80, 100] } },
181
- ],
182
- });
275
+ { id: 'p1', similarity: 0.94, meta: { /* ... */ }, filter: { /* ... */ } }
183
276
  ```
184
277
 
185
- ### Filtering Operators
186
-
187
- | Operator | Description | Example |
188
- |----------|-------------|---------|
189
- | `$eq` | Exact match | `{ status: { $eq: 'published' } }` |
190
- | `$in` | Match any in list | `{ tags: { $in: ['ai', 'ml'] } }` |
191
- | `$range` | Numeric range (inclusive) | `{ score: { $range: [70, 95] } }` |
192
-
193
- > **Note:** The `$range` operator supports values within **[0 - 999]**. Normalize larger values before upserting.
278
+ > Search results carry `meta`/`filter` but **not** the stored vectors. To get the
279
+ > vectors back, use [`getObjects`](#object-operations).
194
280
 
195
- ### Filter Tuning
281
+ ### Per-field `limit` and `ef_search`
196
282
 
197
- When using filtered queries, two optional parameters let you tune the trade-off between search speed and recall:
283
+ `limit` is the **max number of hits to return for that field** (defaults to
284
+ `10`). It's set per field, so each field draws independently. `ef_search` tunes
285
+ that field's HNSW recall/latency.
198
286
 
199
- #### `prefilterCardinalityThreshold`
200
-
201
- Controls when the search strategy switches from **HNSW filtered search** (fast, graph-based) to **brute-force prefiltering** (exhaustive scan on the matched subset).
287
+ ```typescript
288
+ // embedding returns up to 35 hits, keywords up to 15 — each independent
289
+ await collection.search({
290
+ fields: {
291
+ embedding: { query: Array(8).fill(0.2), limit: 35, ef_search: 256 },
292
+ keywords: { query: { indices: [3, 17], values: [0.8, 0.4] }, limit: 15 },
293
+ },
294
+ });
295
+ ```
202
296
 
203
- | Value | Behavior |
204
- |-------|----------|
205
- | `1,000` | Prefilter only for very selective filters — minimum value |
206
- | `10,000` | Prefilter only when the filter matches ≤10,000 vectors **(default)** |
207
- | `1,000,000` | Prefilter for almost all filtered searches — maximum value |
297
+ ### Single-field search
208
298
 
209
- The intuition: when very few vectors match your filter, HNSW may struggle to find enough valid candidates through graph traversal. In that case, scanning the filtered subset directly (prefiltering) is faster and more accurate. Raising the threshold means prefiltering kicks in more often; lowering it favors HNSW graph search.
299
+ A single field is just the multi-field shape with one entry. `results` is keyed
300
+ by that field name:
210
301
 
211
302
  ```typescript
212
- // Only prefilter when filter matches ≤5,000 vectors
213
- const results = await index.query({
214
- vector: [/* ... */],
215
- topK: 10,
216
- filter: [{ category: { $eq: 'rare' } }],
217
- prefilterCardinalityThreshold: 5000,
303
+ const res = await collection.search({
304
+ fields: { embedding: { query: Array(8).fill(0.2), limit: 3 } },
305
+ });
306
+ res.results.embedding;
307
+ // [
308
+ // { id: "p1", similarity: 0.97, meta: {...}, filter: {...} },
309
+ // { id: "p2", similarity: 0.88, meta: {...}, filter: {...} },
310
+ // ]
311
+
312
+ // sparse
313
+ await collection.search({
314
+ fields: { keywords: { query: { indices: [3, 17], values: [0.8, 0.4] }, limit: 3 } },
218
315
  });
219
- ```
220
316
 
221
- #### `filterBoostPercentage`
317
+ // multi-vector
318
+ await collection.search({
319
+ fields: { colbert: { query: [Array(8).fill(0.1), Array(8).fill(0.2)], limit: 3 } },
320
+ });
321
+ ```
222
322
 
223
- When using HNSW filtered search, some candidates explored during graph traversal are discarded by the filter, which can leave you with fewer results than `topK`. `filterBoostPercentage` compensates by expanding the internal candidate pool before filtering is applied.
323
+ ### Multi-field search
224
324
 
225
- - `0` no boost, standard candidate pool size **(default)**
226
- - `20` fetch 20% more candidates internally before applying the filter
227
- - Maximum: `100` (doubles the candidate pool)
325
+ Query several fields and you get **each field's own ranked list, unfused**
326
+ `results` is an object keyed by field name. Merge or display them however you
327
+ like, or hand them to [`rerank()`](#fusing-results-with-rerank).
228
328
 
229
329
  ```typescript
230
- // Fetch 30% more candidates to compensate for aggressive filtering
231
- const results = await index.query({
232
- vector: [/* ... */],
233
- topK: 10,
234
- filter: [{ visibility: { $eq: 'public' } }],
235
- filterBoostPercentage: 30,
330
+ const res = await collection.search({
331
+ fields: {
332
+ embedding: { query: [0.2, 0.2, 0.3, 0.3], limit: 2 },
333
+ keywords: { query: { indices: [3, 17], values: [0.8, 0.4] }, limit: 2 },
334
+ },
236
335
  });
237
336
  ```
238
337
 
239
- #### Using Both Together
338
+ ```json
339
+ {
340
+ "results": {
341
+ "embedding": [
342
+ { "id": "p1", "meta": { "name": "Wireless Headphones" },
343
+ "filter": { "category": "electronics" }, "similarity": 0.4938 },
344
+ { "id": "p2", "meta": { "name": "Running Shoes" },
345
+ "filter": { "category": "footwear" }, "similarity": 0.4505 }
346
+ ],
347
+ "keywords": [
348
+ { "id": "p1", "meta": { "name": "Wireless Headphones" },
349
+ "filter": { "category": "electronics" }, "similarity": 0.9200 },
350
+ { "id": "p2", "meta": { "name": "Running Shoes" },
351
+ "filter": { "category": "footwear" }, "similarity": 0.5591 }
352
+ ]
353
+ }
354
+ }
355
+ ```
240
356
 
241
357
  ```typescript
242
- const results = await index.query({
243
- vector: [/* ... */],
244
- topK: 10,
245
- filter: [{ category: { $eq: 'rare' } }],
246
- prefilterCardinalityThreshold: 5000, // switch to brute-force for small match sets
247
- filterBoostPercentage: 25, // boost candidates for HNSW filtered search
248
- });
358
+ res.results.embedding; // this field's ranked hits
359
+ res.results.keywords; // that field's ranked hits
249
360
  ```
250
361
 
251
- > **Tip:** Start with the defaults (`prefilterCardinalityThreshold: 10,000`, `filterBoostPercentage: 0`). If filtered queries return fewer results than expected, try increasing `filterBoostPercentage`. If filtered queries are slow on selective filters, try lowering `prefilterCardinalityThreshold`. Valid range for the threshold is `1,000–1,000,000`.
362
+ > `similarity` is in **each field's own scale** (cosine for dense 0.49, sparse
363
+ > dot for keywords ≈ 0.92) — scores are **not comparable across fields**. That's
364
+ > exactly why fusion is a separate, opt-in step.
252
365
 
253
- ## Hybrid Search
366
+ ### Fusing results with `rerank()`
254
367
 
255
- ### Upserting Hybrid Vectors
256
-
257
- Provide both dense vectors and sparse representations:
368
+ `rerank(searchResults, { name, limit, fieldWeights, rrfK })` fuses the per-field
369
+ lists from `search()` into **one ranked list** using Reciprocal Rank Fusion. It's
370
+ a standalone function run `search()`, then pass its result to `rerank()`:
258
371
 
259
372
  ```typescript
260
- const index = await client.getIndex('hybrid_index');
373
+ import { rerank } from 'endee';
261
374
 
262
- await index.upsert([
263
- {
264
- id: 'doc1',
265
- vector: [0.1, 0.2 /* ... */], // Dense vector
266
- sparseIndices: [10, 50, 200], // Non-zero term positions
267
- sparseValues: [0.8, 0.5, 0.3], // Weights for each position
268
- meta: { title: 'Document 1' },
269
- },
270
- {
271
- id: 'doc2',
272
- vector: [0.3, 0.4 /* ... */],
273
- sparseIndices: [15, 100, 500],
274
- sparseValues: [0.9, 0.4, 0.6],
275
- meta: { title: 'Document 2' },
375
+ const res = await collection.search({
376
+ fields: {
377
+ embedding: { query: [0.2, 0.2, 0.3, 0.3], limit: 50 },
378
+ keywords: { query: { indices: [3, 17], values: [0.8, 0.4] }, limit: 50 },
379
+ colbert: { query: [Array(8).fill(0.1), Array(8).fill(0.2)], limit: 50 },
276
380
  },
277
- ]);
381
+ });
382
+
383
+ const fused = rerank(res, {
384
+ name: 'rrf',
385
+ limit: 10,
386
+ // optional: weight each field's contribution (must sum to 1.0; equal by default)
387
+ fieldWeights: { embedding: 0.5, keywords: 0.3, colbert: 0.2 },
388
+ // optional: RRF rank constant k (default 60)
389
+ rrfK: 60,
390
+ });
278
391
  ```
279
392
 
280
- **Hybrid Vector Fields:**
393
+ ```json
394
+ {
395
+ "results": [
396
+ { "id": "p1", "similarity": 0.0164,
397
+ "meta": { "name": "Wireless Headphones" }, "filter": { "category": "electronics" } },
398
+ { "id": "p3", "similarity": 0.0160,
399
+ "meta": { "name": "Smart Watch" }, "filter": { "category": "electronics" } },
400
+ { "id": "p2", "similarity": 0.0160,
401
+ "meta": { "name": "Running Shoes" }, "filter": { "category": "footwear" } }
402
+ ]
403
+ }
404
+ ```
405
+
406
+ `name` selects the algorithm (only `"rrf"` is supported today). `rrfK` is the RRF
407
+ rank constant (default `60`); a larger value flattens the contribution of top
408
+ ranks, a smaller value sharpens it. Each fused hit keeps the `meta`/`filter` from
409
+ its per-field hit; `similarity` is replaced with the RRF score (sum of
410
+ `weight / (rrfK + rank)` across the fields it appeared in).
281
411
 
282
- | Field | Required | Description |
283
- |-------|----------|-------------|
284
- | `id` | Yes | Unique identifier |
285
- | `vector` | Yes | Dense embedding vector |
286
- | `sparseIndices` | Yes (hybrid) | Non-zero term positions in sparse vector |
287
- | `sparseValues` | Yes (hybrid) | Weights for each sparse index |
288
- | `meta` | No | Metadata dictionary |
289
- | `filter` | No | Filter fields |
412
+ **Summary of return shapes**
290
413
 
291
- > **Important:** `sparseIndices` and `sparseValues` must have the same length. Values in `sparseIndices` must be within `[0, sparseDimension)`.
414
+ | Call | `results` shape |
415
+ |------|-----------------|
416
+ | `search(...)` (1 or N fields) | `Record<field_name, SearchHit[]>` |
417
+ | `rerank(searchResults, { name: 'rrf', ... })` | `SearchHit[]` (fused) |
292
418
 
293
- ### Querying Hybrid Index
419
+ ### Filtered search
294
420
 
295
- Provide both dense and sparse query vectors:
421
+ `filter` applies to any search. It's an array of conditions:
296
422
 
297
423
  ```typescript
298
- const results = await index.query({
299
- vector: [0.15, 0.25 /* ... */], // Dense query
300
- sparseIndices: [10, 100, 300], // Sparse query positions
301
- sparseValues: [0.7, 0.5, 0.4], // Sparse query weights
302
- topK: 5,
424
+ await collection.search({
425
+ fields: { embedding: { query: Array(8).fill(0.2), limit: 5 } },
426
+ filter: [{ category: { $eq: 'electronics' } }],
303
427
  });
304
-
305
- for (const item of results) {
306
- console.log(`ID: ${item.id}, Similarity: ${item.similarity}`);
307
- }
308
428
  ```
309
429
 
310
- **Hybrid Query Parameters:**
430
+ Operators: `$eq`, `$in`, `$range`, `$gt`, `$gte`, `$lt`, `$lte` (see [Reference](#reference)).
311
431
 
312
- | Parameter | Description |
313
- |-----------|-------------|
314
- | `vector` | Dense query vector |
315
- | `sparseIndices` | Sparse query positions |
316
- | `sparseValues` | Sparse query weights |
317
- | `topK` | Number of results to return |
318
- | `ef` | Search quality parameter (max 1024, default: 128) |
319
- | `includeVectors` | Include vector data in results (default: false) |
320
- | `filter` | Optional filter criteria |
321
- | `prefilterCardinalityThreshold` | Controls when search switches from HNSW filtered search to brute-force prefiltering on the matched subset (default: 10,000, range: 1,000–1,000,000). See [Filter Tuning](#filter-tuning) for details. |
322
- | `filterBoostPercentage` | Expands the internal HNSW candidate pool by this percentage when a filter is active, compensating for filtered-out results (default: 0, range: 0–100). See [Filter Tuning](#filter-tuning) for details. |
432
+ ### Filter tuning
323
433
 
324
- You can also query with:
325
- - **Dense only**: Provide only `vector`
326
- - **Sparse only**: Provide only `sparseIndices` and `sparseValues`
327
- - **Hybrid**: Provide all three for combined results
328
-
329
- ## Updating Filters
330
-
331
- Use `index.updateFilters()` to update the filter fields of existing vectors without re-upserting them.
434
+ Two optional params tune the speed/recall trade-off of **filtered** queries.
435
+ They're sent to the server under `filter_params` and ignored on unfiltered
436
+ searches.
332
437
 
333
438
  ```typescript
334
- await index.updateFilters([
335
- { id: 'vec1', filter: { category: 'ml', score: 95 } },
336
- { id: 'vec2', filter: { category: 'science', score: 80 } },
337
- ]);
439
+ await collection.search({
440
+ fields: { embedding: { query: Array(8).fill(0.2), limit: 10 } },
441
+ filter: [{ category: { $eq: 'rare' } }],
442
+ prefilterCardinalityThreshold: 5000, // default 10,000; range 1,000–1,000,000
443
+ filterBoostPercentage: 25, // default 0; max 100
444
+ });
338
445
  ```
339
446
 
340
- **`UpdateFilterParams` Fields:**
447
+ **`prefilterCardinalityThreshold`** — the cardinality below which the search
448
+ switches from HNSW filtered search to brute-force prefiltering. When very few
449
+ vectors match the filter, scanning the matched subset directly is faster and
450
+ more accurate than HNSW graph traversal. **Raising** it makes prefiltering kick
451
+ in more often (favors the exhaustive scan); **lowering** it favors HNSW graph
452
+ search (favors speed on large datasets).
341
453
 
342
- | Field | Required | Description |
343
- |-------|----------|-------------|
344
- | `id` | Yes | ID of the vector to update |
345
- | `filter` | Yes | New filter object to replace the existing filter |
454
+ **`filterBoostPercentage`** in HNSW filtered search, candidates that fail the
455
+ filter are discarded, which can leave fewer than `limit` results. This expands
456
+ the internal candidate pool before filtering to compensate. `0` = no boost,
457
+ `100` = doubles the pool.
346
458
 
347
- > **Note:** The entire filter object is replaced, not merged.
459
+ ---
348
460
 
349
- ## Deletion Methods
461
+ ## Object operations
350
462
 
351
- ### Delete by ID
352
- Delete vector with a specifc vector id.
353
- ```typescript
354
- await index.deleteVector('vec1');
355
- ```
356
- ### Delete by Filter
357
- Delete all vectors matching specific filters.
358
463
  ```typescript
359
- await index.deleteWithFilter([{'category': {'$eq' : 'tech'}}]);
464
+ // Fetch full stored objects by id (meta, filter, and the stored vectors).
465
+ const objs = await collection.getObjects(['p1', 'p2']);
466
+ // [
467
+ // { id: "p1", meta: {...}, filter: {...},
468
+ // vectors: { embedding: [...] }, // dense fields (original, pre-normalization)
469
+ // sparses: { keywords: { indices: [...], values: [...] } },
470
+ // multi_vectors: { colbert: [[...], [...]] } },
471
+ // ...
472
+ // ]
473
+
474
+ // Delete a single object by id.
475
+ await collection.deleteObject('p1'); // { deleted: "p1" }
476
+
477
+ // Delete every object matching a filter.
478
+ await collection.deleteByFilter([{ category: { $eq: 'footwear' } }]); // { deleted: <count> }
479
+
480
+ // Update only the filter tags on existing objects (no re-upsert of vectors).
481
+ await collection.updateFilters([{ id: 'p2', filter: { category: 'sale' } }]); // { updated: <count> }
360
482
  ```
361
483
 
362
- ### Delete Index
363
- Delete an entire Index.
364
- ```typescript
365
- await client.deleteIndex('my_index');
366
- ```
484
+ ---
367
485
 
368
- > **Warning:** Deletion operations are **irreversible**.
486
+ ## Collection maintenance
369
487
 
370
- ## Additional Operations
488
+ ```typescript
489
+ await collection.describe(); // { name, fields, created_at, layout_version }
371
490
 
372
- ### Get Vector by ID
491
+ // Rebuild a field's HNSW graph with new M / ef_con (async).
492
+ await collection.rebuild({ field: 'embedding', m: 24, efCon: 200 }); // { status: "in_progress", ... }
493
+ await collection.rebuildStatus(); // { percent_complete, ... }
373
494
 
374
- ```typescript
375
- const vector = await index.getVector('vec1');
495
+ // Defragment storage in place.
496
+ await collection.shrink(); // { status: "ok", reclaimed_bytes: <int> }
376
497
  ```
377
498
 
378
- ### Describe Index
499
+ ---
500
+
501
+ ## Backups
502
+
503
+ Backups are per-database. Creating one is per-collection and **asynchronous**.
379
504
 
380
505
  ```typescript
381
- const info = index.describe();
382
- console.log(info);
383
- // { name, spaceType, dimension, sparseDimension, isHybrid, count, precision, M }
384
- ```
506
+ // Start a backup of a collection (async).
507
+ await collection.createBackup('nightly'); // { backup_name: "nightly", status: "in_progress" }
385
508
 
386
- ## Precision Options
509
+ // Poll for completion.
510
+ while ((await client.activeBackup()).active) {
511
+ // ...wait, then re-check
512
+ }
387
513
 
388
- Endee supports different quantization precision levels:
514
+ await client.listBackups(); // { nightly: { ...metadata }, ... }
515
+ await client.backupInfo('nightly'); // metadata for one backup
389
516
 
390
- ```typescript
391
- import { Precision } from 'endee';
517
+ // Restore a backup into a new collection.
518
+ await client.restoreBackup('nightly', 'products_restored');
519
+
520
+ // Move backups around.
521
+ await client.downloadBackup('nightly', '/tmp/nightly.tar'); // writes a .tar, returns the path
522
+ await client.uploadBackup('/tmp/nightly.tar'); // uploads a .tar into this db
392
523
 
393
- Precision.BINARY; // Binary quantization (1-bit) - smallest storage, fastest search
394
- Precision.INT8; // 8-bit integer quantization - balanced performance
395
- Precision.INT16; // 16-bit integer quantization (default) - higher precision
396
- Precision.FLOAT16; // 16-bit floating point - good balance
397
- Precision.FLOAT32; // 32-bit floating point - highest precision
524
+ await client.deleteBackup('nightly');
398
525
  ```
399
526
 
400
- **Choosing Precision:**
527
+ > `downloadBackup` / `uploadBackup` use Node's filesystem (`fs`). With the **root
528
+ > token**, pass a third `dbName` arg to `downloadBackup` to target a specific
529
+ > database's backup.
401
530
 
402
- - `BINARY`: Best for very large datasets where speed and storage are critical
403
- - `INT8` : Recommended for most use cases - good balance of accuracy and performance
404
- - `INT16` (default) : When you need better accuracy than INT8 but less storage than FLOAT32
405
- - `FLOAT16`: Good compromise between precision and storage for embeddings
406
- - `FLOAT32`: When you need maximum precision and storage is not a concern
531
+ ---
407
532
 
408
- ## Complete Example
533
+ ## Database administration (root token)
534
+
535
+ Database and token administration requires the server's **root token** (its
536
+ `NDD_ROOT_TOKEN`). Create an admin client with that token:
409
537
 
410
538
  ```typescript
411
- import { Endee, Precision } from 'endee';
539
+ const admin = new Endee('roottoken');
540
+ admin.setBaseUrl('http://localhost:8080/api/v2');
541
+ ```
412
542
 
413
- // Initialize client
414
- const client = new Endee();
543
+ > The root token administers databases/tokens; it **cannot run data ops directly**.
544
+ > A db token (which it mints) does the collection/object/search work.
415
545
 
416
- // Create a dense index
417
- await client.createIndex({
418
- name: 'documents',
419
- dimension: 384,
420
- spaceType: 'cosine',
421
- precision: Precision.INT16,
422
- });
546
+ ### Databases
423
547
 
424
- // Get the index
425
- const index = await client.getIndex('documents');
548
+ ```typescript
549
+ // Create a database → returns the new db_token.
550
+ const res = await admin.createDatabase('my_db', 'scale'); // db_type: starter|pro|scale|enterprise
551
+ const dbToken = res.db_token;
426
552
 
427
- // Add vectors
428
- await index.upsert([
429
- {
430
- id: 'doc1',
431
- vector: [0.1, 0.2 /* ... 384 dimensions */],
432
- meta: { title: 'First Document' },
433
- filter: { category: 'tech' },
434
- },
435
- {
436
- id: 'doc2',
437
- vector: [0.3, 0.4 /* ... 384 dimensions */],
438
- meta: { title: 'Second Document' },
439
- filter: { category: 'science' },
440
- },
441
- ]);
553
+ await admin.listDatabases(); // [{ db_name, db_type, is_active, created_at }, ...]
554
+ await admin.getDatabase('my_db'); // single db info
442
555
 
443
- // Query the index
444
- const results = await index.query({
445
- vector: [0.15, 0.25 /* ... */],
446
- topK: 5,
447
- });
556
+ await admin.setDatabaseType('my_db', 'pro'); // change tier
557
+ await admin.deactivateDatabase('my_db'); // block its tokens (data is kept)
558
+ await admin.activateDatabase('my_db'); // re-enable
559
+ await admin.deleteDatabase('my_db'); // delete db + ALL its data
560
+ ```
448
561
 
449
- for (const item of results) {
450
- console.log(`ID: ${item.id}, Similarity: ${item.similarity}`);
451
- }
562
+ ### Collections across databases (admin view)
563
+
564
+ ```typescript
565
+ await admin.listDbCollections('my_db'); // ["products", ...] for one db
566
+ await admin.listAllCollections(); // [{ db_name: "my_db", collections: [...] }, ...]
567
+ await admin.deleteDbCollection('my_db', 'products');
452
568
  ```
453
569
 
454
- ## Collections & Objects API
570
+ ### Tokens for any database
455
571
 
456
- Version 1.5+ ships a new, friendlier naming convention alongside the original API. Both names are fully supported — existing code using `Index` / `getVector` / `deleteVector` continues to work without any changes.
572
+ ```typescript
573
+ // token_type: "rw" (read-write, default) or "r" (read-only).
574
+ const t = await admin.createToken('my_db', 'ci-reader', 'r'); // { db_token, ... }
575
+ await admin.listTokens('my_db'); // [{ name, token_type, created_at }, ...] (no secrets)
576
+ await admin.deleteToken('my_db', 'ci-reader');
577
+ ```
457
578
 
458
- | Old name | New name | Notes |
459
- |----------|----------|-------|
460
- | `createIndex` | `createCollection` | Same signature |
461
- | `listIndexes` | `listCollections` | Same signature |
462
- | `deleteIndex` | `deleteCollection` | Same signature |
463
- | `getIndex` | `getCollection` | Returns same object |
464
- | `getVector` | `getObject` | Method on the collection handle |
465
- | `deleteVector` | `deleteObject` | Method on the collection handle |
466
- | `Index` (export) | `Collection` (export) | Same class, two names |
579
+ ### Self-service tokens (your own database)
467
580
 
468
- **Example using the new naming:**
581
+ Using a **db token**, you can manage your own database's tokens:
469
582
 
470
583
  ```typescript
471
- import { Endee, Collection, Precision } from 'endee';
584
+ await client.createMyToken('reader', 'r'); // { db_token, ... }
585
+ await client.listMyTokens();
586
+ await client.deleteMyToken('reader');
587
+ ```
472
588
 
473
- const client = new Endee();
589
+ ### Server info
474
590
 
475
- // Create
476
- await client.createCollection({ name: 'products', dimension: 384, spaceType: 'cosine' });
591
+ ```typescript
592
+ await client.health(); // { status, timestamp }
593
+ await client.stats(); // { version, uptime, total_requests }
594
+ ```
477
595
 
478
- // Get a handle
479
- const collection = await client.getCollection('products');
596
+ ---
480
597
 
481
- // Upsert objects
482
- await collection.upsert([
483
- { id: 'p1', vector: [0.1, 0.2 /* ... */], meta: { title: 'Shoes' } },
484
- ]);
598
+ ## Reference
485
599
 
486
- // Fetch an object by ID
487
- const obj = await collection.getObject('p1');
600
+ **Distance / space types** (`space_type`)
488
601
 
489
- // Delete an object by ID
490
- await collection.deleteObject('p1');
602
+ | Value | Meaning |
603
+ |-------|---------|
604
+ | `cosine` | Cosine similarity (vectors L2-normalized client-side). Default. |
605
+ | `l2` | Euclidean distance. |
606
+ | `ip` | Inner product. |
491
607
 
492
- // Delete the collection
493
- await client.deleteCollection('products');
494
- ```
608
+ **Precisions** (`precision`) memory/accuracy trade-off
495
609
 
496
- The `Collection` export is an alias for `Index` both refer to the same class and have identical methods.
610
+ `float32`, `float16`, `int16` (default), `int8`, `int8e`, `binary`. The
611
+ `Precision` enum is exported for convenience:
497
612
 
498
- ## API Reference
613
+ ```typescript
614
+ import { Precision } from 'endee';
615
+ Precision.INT8; // "int8"
616
+ ```
499
617
 
500
- ### Endee Class
618
+ **Token types**: `rw` (read-write), `r` (read-only).
501
619
 
502
- | Method | Alias | Description |
503
- |--------|-------|-------------|
504
- | `createIndex(options)` | `createCollection(options)` | Create a new index / collection |
505
- | `listIndexes()` | `listCollections()` | List all indexes / collections |
506
- | `deleteIndex(name)` | `deleteCollection(name)` | Delete an index / collection |
507
- | `getIndex(name)` | `getCollection(name)` | Get a handle to an index / collection |
508
- | `setBaseUrl(url)` | — | Set a custom base URL |
620
+ **Filter operators**
509
621
 
510
- ### Index / Collection Class
622
+ | Operator | Example | Meaning |
623
+ |----------|---------|---------|
624
+ | `$eq` | `{ category: { $eq: 'news' } }` | equals |
625
+ | `$in` | `{ tag: { $in: ['a', 'b'] } }` | in set |
626
+ | `$range` | `{ price: { $range: [10, 100] } }` | inclusive range |
627
+ | `$gt` / `$gte` | `{ price: { $gt: 50 } }` | greater than / or equal |
628
+ | `$lt` / `$lte` | `{ price: { $lte: 100 } }` | less than / or equal |
511
629
 
512
- | Method | Alias | Description |
513
- |--------|-------|-------------|
514
- | `upsert(vectors)` | — | Insert or update vectors / objects |
515
- | `query(options)` | — | Search for similar vectors / objects |
516
- | `updateFilters(updates)` | — | Update filter fields on existing vectors by ID |
517
- | `deleteVector(id)` | `deleteObject(id)` | Delete a vector / object by ID |
518
- | `deleteWithFilter(filter)` | — | Delete vectors / objects by filter |
519
- | `getVector(id)` | `getObject(id)` | Get a vector / object by ID |
520
- | `describe()` | — | Get index / collection info |
630
+ **Client-side validation / limits**
521
631
 
522
- ## TypeScript Types
632
+ | Limit | Value |
633
+ |-------|-------|
634
+ | Objects per `upsert` | ≤ 10,000 |
635
+ | `limit` (top-k) | 1 … 4096 |
636
+ | `efSearch` | 1 … 1024 |
637
+ | Filter key | ≤ 128 bytes |
638
+ | Filter string value | ≤ 1024 bytes |
639
+ | Vector dimension | must match the field's configured dimension |
523
640
 
524
- The package includes comprehensive TypeScript types:
641
+ **Exported TypeScript types**
525
642
 
526
643
  ```typescript
527
644
  import type {
528
- VectorItem,
529
- QueryOptions,
530
- QueryResult,
531
- CreateIndexOptions,
532
- IndexDescription,
645
+ FieldDefinition,
646
+ FieldType,
647
+ ObjectInput,
648
+ SearchOptions,
649
+ SearchHit,
650
+ SearchResponse,
651
+ RerankOptions,
652
+ RerankResponse,
653
+ FullObject,
654
+ CollectionMetadata,
655
+ UpdateFilterEntry,
656
+ RebuildOptions,
657
+ DatabaseInfo,
658
+ TokenInfo,
659
+ DbType,
660
+ TokenType,
533
661
  SpaceType,
534
662
  Precision,
535
- UpdateFilterParams,
536
663
  } from 'endee';
537
664
  ```
538
665
 
539
- ## License
666
+ ---
540
667
 
541
- MIT
668
+ ## Error handling
542
669
 
543
- ## Author
670
+ Non-2xx responses throw typed exceptions:
544
671
 
545
- Harshit Raj
672
+ ```typescript
673
+ import {
674
+ EndeeException, // base class for all client errors
675
+ APIException, // generic 4xx (e.g. bad request)
676
+ AuthenticationException, // 401
677
+ ForbiddenException, // 403 (e.g. read-only token on a write)
678
+ NotFoundException, // 404
679
+ ConflictException, // 409 (e.g. duplicate collection)
680
+ ServerException, // 5xx
681
+ } from 'endee';
682
+
683
+ try {
684
+ await collection.upsert(objects);
685
+ } catch (e) {
686
+ if (e instanceof NotFoundException) {
687
+ console.error('not found:', e.message);
688
+ } else if (e instanceof EndeeException) {
689
+ // catches all of the above
690
+ console.error('request failed:', e.message);
691
+ } else {
692
+ throw e;
693
+ }
694
+ }
695
+ ```
696
+
697
+ All exception classes are importable from `endee`: `EndeeException` (base),
698
+ `APIException`, `AuthenticationException`, `ForbiddenException`,
699
+ `NotFoundException`, `ConflictException`, `ServerException`,
700
+ `SubscriptionException`.
701
+
702
+ Client-side input mistakes (bad dimensions, mismatched sparse lengths, oversized
703
+ batch, sum-of-weights ≠ 1.0, etc.) throw a plain `Error` **before** any network
704
+ call.
705
+
706
+ ---
707
+
708
+ ## End-to-end example
709
+
710
+ See `tests/integration_v2.ts` — a full walkthrough (create → multi-field upsert →
711
+ every search mode → object ops → maintenance → cleanup). Run it against a local
712
+ server with a db token:
713
+
714
+ ```bash
715
+ npx tsx tests/integration_v2.ts --token my_db:xxxx --url http://localhost:8080/api/v2
716
+ ```
717
+
718
+ ## License
719
+
720
+ MIT