endee 1.7.1-dev.3 → 1.8.0-dev.1

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,698 @@
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 `weight` and `ef_search`](#per-field-weight-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
+ 7. [Object operations](#object-operations)
32
+ 8. [Collection maintenance](#collection-maintenance)
33
+ 9. [Backups](#backups)
34
+ 10. [Database administration (root token)](#database-administration-root-token)
35
+ 11. [Reference](#reference)
36
+ 12. [Error handling](#error-handling)
37
+
38
+ ---
39
+
40
+ ## Install
21
41
 
22
42
  ```bash
23
43
  npm install endee
24
44
  ```
25
45
 
26
- ## Quick Start
46
+ Requires Node.js ≥ 18. Depends on `axios` and `@msgpack/msgpack`.
27
47
 
28
- ### Initialize the Client
48
+ ---
29
49
 
30
- The Endee client connects to your local server (defaults to `http://127.0.0.1:8080/api/v1`):
50
+ ## Quick start
31
51
 
32
52
  ```typescript
33
- import { Endee, Precision } from 'endee';
53
+ import { Endee } from 'endee';
54
+
55
+ // A "db token" (db_name:secret) scopes you to one database.
56
+ const client = new Endee('my_db:xxxxxxxx');
57
+ client.setBaseUrl('http://localhost:8080/api/v2'); // include /api/v2
58
+
59
+ // 1. Create a collection with one of each field type.
60
+ await client.createCollection({
61
+ name: 'products',
62
+ fields: [
63
+ {
64
+ name: 'embedding',
65
+ type: 'vector',
66
+ params: { dimension: 8, space_type: 'cosine', precision: 'int8' },
67
+ },
68
+ { name: 'keywords', type: 'sparse', sparse_model: 'default' },
69
+ {
70
+ name: 'colbert',
71
+ type: 'multi_vector',
72
+ params: { dimension: 8, space_type: 'cosine', precision: 'int8', pooling: 'mean' },
73
+ },
74
+ ],
75
+ });
34
76
 
35
- // Connect to local Endee server (defaults to localhost:8080)
36
- const client = new Endee();
37
- ```
77
+ const collection = await client.getCollection('products');
38
78
 
39
- **Using Authentication?** If your server has `NDD_AUTH_TOKEN` set, pass the same token when initializing:
79
+ // 2. Insert an object carrying ALL THREE field types at once.
80
+ await collection.upsert([
81
+ {
82
+ id: 'p1',
83
+ meta: { name: 'Wireless Headphones', price: 99 },
84
+ filter: { category: 'electronics' },
85
+ fields: {
86
+ embedding: [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8],
87
+ keywords: { indices: [3, 17, 42], values: [0.9, 0.5, 0.2] },
88
+ colbert: [Array(8).fill(0.1), Array(8).fill(0.2)],
89
+ },
90
+ },
91
+ ]);
40
92
 
41
- ```typescript
42
- const client = new Endee('your-auth-token');
93
+ // 3. Search the dense field. `results` is keyed by field name.
94
+ const res = await collection.search({
95
+ fields: { embedding: { query: Array(8).fill(0.2) } },
96
+ 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();
51
-
52
- // Set custom base URL for non-default port
53
- client.setBaseUrl('http://0.0.0.0:8081/api/v1');
54
- ```
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**.
55
109
 
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).
126
+
127
+ ---
70
128
 
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`) |
129
+ ## Create a collection
79
130
 
80
- ### Create a Hybrid Index
131
+ A collection is a set of **named, typed fields**. Each field is a plain object
132
+ sent to the server as-is.
81
133
 
82
- Hybrid indexes combine dense vector search with sparse vector search. Add the `sparseDimension` parameter:
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
+ ---
99
179
 
100
- // Get reference to an existing index
101
- const index = await client.getIndex('my_vectors');
180
+ ## Insert objects (combining vector types)
102
181
 
103
- // Delete an index
104
- await client.deleteIndex('my_vectors');
182
+ `collection.upsert(objects)` takes an array of objects. Each object is:
183
+
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
206
+
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:
113
210
 
114
- await index.upsert([
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
- });
150
-
151
- for (const item of results) {
152
- console.log(`ID: ${item.id}, Similarity: ${item.similarity}`);
153
- }
154
- ```
255
+ `collection.search({ fields, limit, 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).
155
259
 
156
- **Query Parameters:**
260
+ ### The unified field shape
157
261
 
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. |
262
+ Every field is queried with the **same object form** — `{ query, weight?, ef_search? }`.
263
+ There is no bare-value shorthand; `query` is required. The `query` value matches
264
+ the field's type:
169
265
 
170
- ## Filtered Querying
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` | `[[...], [...]]` |
171
271
 
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 `weight` 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
+ `weight` is the **fraction of the request `limit` to draw from that field**: the
284
+ effective per-field limit is `ceil(weight * limit)`. Omit `weight` to use the full
285
+ `limit`. `ef_search` tunes 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
+ // limit 50 → embedding fetches ceil(0.7*50)=35, keywords fetches ceil(0.3*50)=15
289
+ await collection.search({
290
+ fields: {
291
+ embedding: { query: Array(8).fill(0.2), weight: 0.7, ef_search: 256 },
292
+ keywords: { query: { indices: [3, 17], values: [0.8, 0.4] }, weight: 0.3 },
293
+ },
294
+ limit: 50,
295
+ });
296
+ ```
202
297
 
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 |
298
+ ### Single-field search
208
299
 
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.
300
+ A single field is just the multi-field shape with one entry. `results` is keyed
301
+ by that field name:
210
302
 
211
303
  ```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,
304
+ const res = await collection.search({
305
+ fields: { embedding: { query: Array(8).fill(0.2) } },
306
+ limit: 3,
307
+ });
308
+ res.results.embedding;
309
+ // [
310
+ // { id: "p1", similarity: 0.97, meta: {...}, filter: {...} },
311
+ // { id: "p2", similarity: 0.88, meta: {...}, filter: {...} },
312
+ // ]
313
+
314
+ // sparse
315
+ await collection.search({
316
+ fields: { keywords: { query: { indices: [3, 17], values: [0.8, 0.4] } } },
317
+ limit: 3,
218
318
  });
219
- ```
220
319
 
221
- #### `filterBoostPercentage`
320
+ // multi-vector
321
+ await collection.search({
322
+ fields: { colbert: { query: [Array(8).fill(0.1), Array(8).fill(0.2)] } },
323
+ limit: 3,
324
+ });
325
+ ```
222
326
 
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.
327
+ ### Multi-field search
224
328
 
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)
329
+ Query several fields and you get **each field's own ranked list, unfused**
330
+ `results` is an object keyed by field name. Merge or display them however you
331
+ like, or hand them to [`rerank()`](#fusing-results-with-rerank).
228
332
 
229
333
  ```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,
334
+ const res = await collection.search({
335
+ fields: {
336
+ embedding: { query: [0.2, 0.2, 0.3, 0.3] },
337
+ keywords: { query: { indices: [3, 17], values: [0.8, 0.4] } },
338
+ },
339
+ limit: 2,
236
340
  });
237
341
  ```
238
342
 
239
- #### Using Both Together
343
+ ```json
344
+ {
345
+ "results": {
346
+ "embedding": [
347
+ { "id": "p1", "meta": { "name": "Wireless Headphones" },
348
+ "filter": { "category": "electronics" }, "similarity": 0.4938 },
349
+ { "id": "p2", "meta": { "name": "Running Shoes" },
350
+ "filter": { "category": "footwear" }, "similarity": 0.4505 }
351
+ ],
352
+ "keywords": [
353
+ { "id": "p1", "meta": { "name": "Wireless Headphones" },
354
+ "filter": { "category": "electronics" }, "similarity": 0.9200 },
355
+ { "id": "p2", "meta": { "name": "Running Shoes" },
356
+ "filter": { "category": "footwear" }, "similarity": 0.5591 }
357
+ ]
358
+ }
359
+ }
360
+ ```
240
361
 
241
362
  ```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
- });
363
+ res.results.embedding; // this field's ranked hits
364
+ res.results.keywords; // that field's ranked hits
249
365
  ```
250
366
 
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`.
252
-
253
- ## Hybrid Search
367
+ > `similarity` is in **each field's own scale** (cosine for dense 0.49, sparse
368
+ > dot for keywords ≈ 0.92) — scores are **not comparable across fields**. That's
369
+ > exactly why fusion is a separate, opt-in step.
254
370
 
255
- ### Upserting Hybrid Vectors
371
+ ### Fusing results with `rerank()`
256
372
 
257
- Provide both dense vectors and sparse representations:
373
+ `rerank(searchResults, { name, limit, fieldWeights })` fuses the per-field lists
374
+ from `search()` into **one ranked list** using Reciprocal Rank Fusion. It's a
375
+ standalone function — run `search()`, then pass its result to `rerank()`:
258
376
 
259
377
  ```typescript
260
- const index = await client.getIndex('hybrid_index');
378
+ import { rerank } from 'endee';
261
379
 
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' },
380
+ const res = await collection.search({
381
+ fields: {
382
+ embedding: { query: [0.2, 0.2, 0.3, 0.3] },
383
+ keywords: { query: { indices: [3, 17], values: [0.8, 0.4] } },
384
+ colbert: { query: [Array(8).fill(0.1), Array(8).fill(0.2)] },
276
385
  },
277
- ]);
386
+ limit: 50,
387
+ });
388
+
389
+ const fused = rerank(res, {
390
+ name: 'rrf',
391
+ limit: 10,
392
+ // optional: weight each field's contribution (must sum to 1.0; equal by default)
393
+ fieldWeights: { embedding: 0.5, keywords: 0.3, colbert: 0.2 },
394
+ // optional: RRF rank constant k (default 60)
395
+ rrfK: 60,
396
+ });
278
397
  ```
279
398
 
280
- **Hybrid Vector Fields:**
399
+ ```json
400
+ {
401
+ "results": [
402
+ { "id": "p1", "similarity": 0.0164,
403
+ "meta": { "name": "Wireless Headphones" }, "filter": { "category": "electronics" } },
404
+ { "id": "p3", "similarity": 0.0160,
405
+ "meta": { "name": "Smart Watch" }, "filter": { "category": "electronics" } },
406
+ { "id": "p2", "similarity": 0.0160,
407
+ "meta": { "name": "Running Shoes" }, "filter": { "category": "footwear" } }
408
+ ]
409
+ }
410
+ ```
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
+ `name` selects the algorithm (only `"rrf"` is supported today). Each fused hit
413
+ keeps the `meta`/`filter` from its per-field hit; `similarity` is replaced with
414
+ the RRF score (sum of `weight / (rrfK + rank)` across the fields it appeared in).
290
415
 
291
- > **Important:** `sparseIndices` and `sparseValues` must have the same length. Values in `sparseIndices` must be within `[0, sparseDimension)`.
416
+ **Summary of return shapes**
292
417
 
293
- ### Querying Hybrid Index
418
+ | Call | `results` shape |
419
+ |------|-----------------|
420
+ | `search(...)` (1 or N fields) | `Record<field_name, SearchHit[]>` |
421
+ | `rerank(searchResults, { name: 'rrf', ... })` | `SearchHit[]` (fused) |
294
422
 
295
- Provide both dense and sparse query vectors:
423
+ ### Filtered search
424
+
425
+ `filter` applies to any search. It's an array of conditions:
296
426
 
297
427
  ```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,
428
+ await collection.search({
429
+ fields: { embedding: { query: Array(8).fill(0.2) } },
430
+ limit: 5,
431
+ filter: [{ category: { $eq: 'electronics' } }],
303
432
  });
304
-
305
- for (const item of results) {
306
- console.log(`ID: ${item.id}, Similarity: ${item.similarity}`);
307
- }
308
433
  ```
309
434
 
310
- **Hybrid Query Parameters:**
311
-
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. |
435
+ Operators: `$eq`, `$in`, `$range`, `$gt`, `$gte`, `$lt`, `$lte` (see [Reference](#reference)).
323
436
 
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
437
+ ---
328
438
 
329
- ## Updating Filters
330
-
331
- Use `index.updateFilters()` to update the filter fields of existing vectors without re-upserting them.
439
+ ## Object operations
332
440
 
333
441
  ```typescript
334
- await index.updateFilters([
335
- { id: 'vec1', filter: { category: 'ml', score: 95 } },
336
- { id: 'vec2', filter: { category: 'science', score: 80 } },
337
- ]);
442
+ // Fetch full stored objects by id (meta, filter, and the stored vectors).
443
+ const objs = await collection.getObjects(['p1', 'p2']);
444
+ // [
445
+ // { id: "p1", meta: {...}, filter: {...},
446
+ // vectors: { embedding: [...] }, // dense fields (original, pre-normalization)
447
+ // sparses: { keywords: { indices: [...], values: [...] } },
448
+ // multi_vectors: { colbert: [[...], [...]] } },
449
+ // ...
450
+ // ]
451
+
452
+ // Delete a single object by id.
453
+ await collection.deleteObject('p1'); // { deleted: "p1" }
454
+
455
+ // Delete every object matching a filter.
456
+ await collection.deleteByFilter([{ category: { $eq: 'footwear' } }]); // { deleted: <count> }
457
+
458
+ // Update only the filter tags on existing objects (no re-upsert of vectors).
459
+ await collection.updateFilters([{ id: 'p2', filter: { category: 'sale' } }]); // { updated: <count> }
338
460
  ```
339
461
 
340
- **`UpdateFilterParams` Fields:**
462
+ ---
341
463
 
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 |
464
+ ## Collection maintenance
346
465
 
347
- > **Note:** The entire filter object is replaced, not merged.
466
+ ```typescript
467
+ await collection.describe(); // { name, fields, created_at, layout_version }
348
468
 
349
- ## Deletion Methods
469
+ // Rebuild a field's HNSW graph with new M / ef_con (async).
470
+ await collection.rebuild({ field: 'embedding', m: 24, efCon: 200 }); // { status: "in_progress", ... }
471
+ await collection.rebuildStatus(); // { percent_complete, ... }
350
472
 
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
- ```typescript
359
- await index.deleteWithFilter([{'category': {'$eq' : 'tech'}}]);
473
+ // Defragment storage in place.
474
+ await collection.shrink(); // { status: "ok", reclaimed_bytes: <int> }
360
475
  ```
361
476
 
362
- ### Delete Index
363
- Delete an entire Index.
477
+ ---
478
+
479
+ ## Backups
480
+
481
+ Backups are per-database. Creating one is per-collection and **asynchronous**.
482
+
364
483
  ```typescript
365
- await client.deleteIndex('my_index');
366
- ```
484
+ // Start a backup of a collection (async).
485
+ await collection.createBackup('nightly'); // { backup_name: "nightly", status: "in_progress" }
367
486
 
368
- > **Warning:** Deletion operations are **irreversible**.
487
+ // Poll for completion.
488
+ while ((await client.activeBackup()).active) {
489
+ // ...wait, then re-check
490
+ }
369
491
 
370
- ## Additional Operations
492
+ await client.listBackups(); // { nightly: { ...metadata }, ... }
493
+ await client.backupInfo('nightly'); // metadata for one backup
371
494
 
372
- ### Get Vector by ID
495
+ // Restore a backup into a new collection.
496
+ await client.restoreBackup('nightly', 'products_restored');
373
497
 
374
- ```typescript
375
- const vector = await index.getVector('vec1');
498
+ // Move backups around.
499
+ await client.downloadBackup('nightly', '/tmp/nightly.tar'); // writes a .tar, returns the path
500
+ await client.uploadBackup('/tmp/nightly.tar'); // uploads a .tar into this db
501
+
502
+ await client.deleteBackup('nightly');
376
503
  ```
377
504
 
378
- ### Describe Index
505
+ > `downloadBackup` / `uploadBackup` use Node's filesystem (`fs`). With the **root
506
+ > token**, pass a third `dbName` arg to `downloadBackup` to target a specific
507
+ > database's backup.
379
508
 
380
- ```typescript
381
- const info = index.describe();
382
- console.log(info);
383
- // { name, spaceType, dimension, sparseDimension, isHybrid, count, precision, M }
384
- ```
509
+ ---
385
510
 
386
- ## Precision Options
511
+ ## Database administration (root token)
387
512
 
388
- Endee supports different quantization precision levels:
513
+ Database and token administration requires the server's **root token** (its
514
+ `NDD_ROOT_TOKEN`). Create an admin client with that token:
389
515
 
390
516
  ```typescript
391
- import { Precision } from 'endee';
392
-
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
517
+ const admin = new Endee('roottoken');
518
+ admin.setBaseUrl('http://localhost:8080/api/v2');
398
519
  ```
399
520
 
400
- **Choosing Precision:**
521
+ > The root token administers databases/tokens; it **cannot run data ops directly**.
522
+ > A db token (which it mints) does the collection/object/search work.
401
523
 
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
407
-
408
- ## Complete Example
524
+ ### Databases
409
525
 
410
526
  ```typescript
411
- import { Endee, Precision } from 'endee';
527
+ // Create a database returns the new db_token.
528
+ const res = await admin.createDatabase('my_db', 'scale'); // db_type: starter|pro|scale|enterprise
529
+ const dbToken = res.db_token;
412
530
 
413
- // Initialize client
414
- const client = new Endee();
531
+ await admin.listDatabases(); // [{ db_name, db_type, is_active, created_at }, ...]
532
+ await admin.getDatabase('my_db'); // single db info
415
533
 
416
- // Create a dense index
417
- await client.createIndex({
418
- name: 'documents',
419
- dimension: 384,
420
- spaceType: 'cosine',
421
- precision: Precision.INT16,
422
- });
534
+ await admin.setDatabaseType('my_db', 'pro'); // change tier
535
+ await admin.deactivateDatabase('my_db'); // block its tokens (data is kept)
536
+ await admin.activateDatabase('my_db'); // re-enable
537
+ await admin.deleteDatabase('my_db'); // delete db + ALL its data
538
+ ```
423
539
 
424
- // Get the index
425
- const index = await client.getIndex('documents');
540
+ ### Collections across databases (admin view)
426
541
 
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
- ]);
542
+ ```typescript
543
+ await admin.listDbCollections('my_db'); // ["products", ...] for one db
544
+ await admin.listAllCollections(); // [{ db_name: "my_db", collections: [...] }, ...]
545
+ await admin.deleteDbCollection('my_db', 'products');
546
+ ```
442
547
 
443
- // Query the index
444
- const results = await index.query({
445
- vector: [0.15, 0.25 /* ... */],
446
- topK: 5,
447
- });
548
+ ### Tokens for any database
448
549
 
449
- for (const item of results) {
450
- console.log(`ID: ${item.id}, Similarity: ${item.similarity}`);
451
- }
550
+ ```typescript
551
+ // token_type: "rw" (read-write, default) or "r" (read-only).
552
+ const t = await admin.createToken('my_db', 'ci-reader', 'r'); // { db_token, ... }
553
+ await admin.listTokens('my_db'); // [{ name, token_type, created_at }, ...] (no secrets)
554
+ await admin.deleteToken('my_db', 'ci-reader');
452
555
  ```
453
556
 
454
- ## Collections & Objects API
557
+ ### Self-service tokens (your own database)
455
558
 
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.
559
+ Using a **db token**, you can manage your own database's tokens:
457
560
 
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 |
561
+ ```typescript
562
+ await client.createMyToken('reader', 'r'); // { db_token, ... }
563
+ await client.listMyTokens();
564
+ await client.deleteMyToken('reader');
565
+ ```
467
566
 
468
- **Example using the new naming:**
567
+ ### Server info
469
568
 
470
569
  ```typescript
471
- import { Endee, Collection, Precision } from 'endee';
570
+ await client.health(); // { status, timestamp }
571
+ await client.stats(); // { version, uptime, total_requests }
572
+ ```
472
573
 
473
- const client = new Endee();
574
+ ---
474
575
 
475
- // Create
476
- await client.createCollection({ name: 'products', dimension: 384, spaceType: 'cosine' });
576
+ ## Reference
477
577
 
478
- // Get a handle
479
- const collection = await client.getCollection('products');
578
+ **Distance / space types** (`space_type`)
480
579
 
481
- // Upsert objects
482
- await collection.upsert([
483
- { id: 'p1', vector: [0.1, 0.2 /* ... */], meta: { title: 'Shoes' } },
484
- ]);
580
+ | Value | Meaning |
581
+ |-------|---------|
582
+ | `cosine` | Cosine similarity (vectors L2-normalized client-side). Default. |
583
+ | `l2` | Euclidean distance. |
584
+ | `ip` | Inner product. |
485
585
 
486
- // Fetch an object by ID
487
- const obj = await collection.getObject('p1');
586
+ **Precisions** (`precision`) memory/accuracy trade-off
488
587
 
489
- // Delete an object by ID
490
- await collection.deleteObject('p1');
588
+ `float32`, `float16`, `int16` (default), `int8`, `int8e`, `binary`. The
589
+ `Precision` enum is exported for convenience:
491
590
 
492
- // Delete the collection
493
- await client.deleteCollection('products');
591
+ ```typescript
592
+ import { Precision } from 'endee';
593
+ Precision.INT8; // "int8"
494
594
  ```
495
595
 
496
- The `Collection` export is an alias for `Index` — both refer to the same class and have identical methods.
497
-
498
- ## API Reference
596
+ **Token types**: `rw` (read-write), `r` (read-only).
499
597
 
500
- ### Endee Class
598
+ **Filter operators**
501
599
 
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 |
600
+ | Operator | Example | Meaning |
601
+ |----------|---------|---------|
602
+ | `$eq` | `{ category: { $eq: 'news' } }` | equals |
603
+ | `$in` | `{ tag: { $in: ['a', 'b'] } }` | in set |
604
+ | `$range` | `{ price: { $range: [10, 100] } }` | inclusive range |
605
+ | `$gt` / `$gte` | `{ price: { $gt: 50 } }` | greater than / or equal |
606
+ | `$lt` / `$lte` | `{ price: { $lte: 100 } }` | less than / or equal |
509
607
 
510
- ### Index / Collection Class
608
+ **Client-side validation / limits**
511
609
 
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 |
610
+ | Limit | Value |
611
+ |-------|-------|
612
+ | Objects per `upsert` | 10,000 |
613
+ | `limit` (top-k) | 1 4096 |
614
+ | `efSearch` | 1 1024 |
615
+ | Filter key | 128 bytes |
616
+ | Filter string value | 1024 bytes |
617
+ | Vector dimension | must match the field's configured dimension |
521
618
 
522
- ## TypeScript Types
523
-
524
- The package includes comprehensive TypeScript types:
619
+ **Exported TypeScript types**
525
620
 
526
621
  ```typescript
527
622
  import type {
528
- VectorItem,
529
- QueryOptions,
530
- QueryResult,
531
- CreateIndexOptions,
532
- IndexDescription,
623
+ FieldDefinition,
624
+ FieldType,
625
+ ObjectInput,
626
+ SearchOptions,
627
+ SearchHit,
628
+ SearchResponse,
629
+ RerankOptions,
630
+ RerankResponse,
631
+ FullObject,
632
+ CollectionMetadata,
633
+ UpdateFilterEntry,
634
+ RebuildOptions,
635
+ DatabaseInfo,
636
+ TokenInfo,
637
+ DbType,
638
+ TokenType,
533
639
  SpaceType,
534
640
  Precision,
535
- UpdateFilterParams,
536
641
  } from 'endee';
537
642
  ```
538
643
 
539
- ## License
644
+ ---
540
645
 
541
- MIT
646
+ ## Error handling
542
647
 
543
- ## Author
648
+ Non-2xx responses throw typed exceptions:
544
649
 
545
- Harshit Raj
650
+ ```typescript
651
+ import {
652
+ EndeeException, // base class for all client errors
653
+ APIException, // generic 4xx (e.g. bad request)
654
+ AuthenticationException, // 401
655
+ ForbiddenException, // 403 (e.g. read-only token on a write)
656
+ NotFoundException, // 404
657
+ ConflictException, // 409 (e.g. duplicate collection)
658
+ ServerException, // 5xx
659
+ } from 'endee';
660
+
661
+ try {
662
+ await collection.upsert(objects);
663
+ } catch (e) {
664
+ if (e instanceof NotFoundException) {
665
+ console.error('not found:', e.message);
666
+ } else if (e instanceof EndeeException) {
667
+ // catches all of the above
668
+ console.error('request failed:', e.message);
669
+ } else {
670
+ throw e;
671
+ }
672
+ }
673
+ ```
674
+
675
+ All exception classes are importable from `endee`: `EndeeException` (base),
676
+ `APIException`, `AuthenticationException`, `ForbiddenException`,
677
+ `NotFoundException`, `ConflictException`, `ServerException`,
678
+ `SubscriptionException`.
679
+
680
+ Client-side input mistakes (bad dimensions, mismatched sparse lengths, oversized
681
+ batch, sum-of-weights ≠ 1.0, etc.) throw a plain `Error` **before** any network
682
+ call.
683
+
684
+ ---
685
+
686
+ ## End-to-end example
687
+
688
+ See `tests/integration_v2.ts` — a full walkthrough (create → multi-field upsert →
689
+ every search mode → object ops → maintenance → cleanup). Run it against a local
690
+ server with a db token:
691
+
692
+ ```bash
693
+ npx tsx tests/integration_v2.ts --token my_db:xxxx --url http://localhost:8080/api/v2
694
+ ```
695
+
696
+ ## License
697
+
698
+ MIT