endee
v2.0.0
Published
TypeScript client for encrypted vector database with maximum security and speed
Maintainers
Readme
Endee — TypeScript / JavaScript Client
Endee is a high-performance C++ vector database for AI search, RAG, and hybrid retrieval. A collection holds objects, and each object can carry values for several named, typed fields at once — dense vectors, sparse vectors, and multi-vectors — so a single object can be searched many ways.
- Multi-field objects — one object, many vector fields (dense + sparse + multi-vector).
- Hybrid search — query any subset of fields;
search()returns per-field results, which you can fuse withrerank()(RRF). - Client-side normalization — cosine vectors are L2-normalized for you.
- Full control plane — collections, objects, filters, rebuild/shrink, backups, databases, and tokens.
- TypeScript first — full type definitions and IntelliSense for every call.
- ES modules — native ESM with proper tree-shaking.
Table of contents
- Install
- Quick start
- Connecting & tokens
- Create a collection
- Insert objects (combining vector types)
- Search
- Object operations
- Collection maintenance
- Backups
- Database administration (root token)
- Reference
- Error handling
Install
npm install endeeRequires Node.js ≥ 18. Depends on axios and @msgpack/msgpack.
Quick start
import { Endee } from 'endee';
// A "db token" (db_name:secret) scopes you to one database.
const client = new Endee('my_db:xxxxxxxx');
client.setBaseUrl('http://localhost:8080/api/v2'); // include /api/v2
// 1. Create a collection with one of each field type.
await client.createCollection({
name: 'products',
fields: [
{
name: 'embedding',
type: 'vector',
params: { dimension: 8, space_type: 'cosine', precision: 'int8' },
},
{ name: 'keywords', type: 'sparse', sparse_model: 'default' },
{
name: 'colbert',
type: 'multi_vector',
params: { dimension: 8, space_type: 'cosine', precision: 'int8', pooling: 'mean' },
},
],
});
const collection = await client.getCollection('products');
// 2. Insert an object carrying ALL THREE field types at once.
await collection.upsert([
{
id: 'p1',
meta: { name: 'Wireless Headphones', price: 99 },
filter: { category: 'electronics' },
fields: {
embedding: [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8],
keywords: { indices: [3, 17, 42], values: [0.9, 0.5, 0.2] },
colbert: [Array(8).fill(0.1), Array(8).fill(0.2)],
},
},
]);
// 3. Search the dense field. `results` is keyed by field name.
const res = await collection.search({
fields: { embedding: { query: Array(8).fill(0.2), limit: 5 } },
});
for (const h of res.results.embedding) {
console.log(h.id, h.similarity, h.meta);
}Connecting & tokens
Authentication is always required. The server has no anonymous mode — every request must carry a token, or it is rejected with 401 Unauthorized.
A db token has the form db_name:secret and scopes you to all collection /
object / search work inside one database. (Database administration and minting
tokens require the server's root token — those control-plane operations are
not yet exposed in the JS client; see Reference.)
const client = new Endee('my_db:xxxxxxxx');
client.setBaseUrl('http://localhost:8080/api/v2'); // must point at the /api/v2 root- A token of the form
db_name:secret:regionis parsed automatically — the client targetshttps://<region>.endee.io/api/v2and strips the region from the token it sends. setBaseUrlmust point at the/api/v2root of your server.- A missing/invalid token →
AuthenticationException(401); a read-only token on a write →ForbiddenException(403).
Create a collection
A collection is a set of named, typed fields. Each field is a plain object sent to the server as-is.
| Field type | Required keys | params |
|------------|---------------|----------|
| vector | name, type | dimension, space_type, precision (+ optional M, ef_con) |
| sparse | name, type, sparse_model | — (no params) |
| multi_vector | name, type | dimension, space_type, precision, pooling ("mean"/"max") (+ optional M, ef_con) |
await client.createCollection({
name: 'products',
fields: [
// Dense embedding (single vector per object)
{
name: 'embedding',
type: 'vector',
params: { dimension: 768, space_type: 'cosine', precision: 'int8' },
},
// Sparse / keyword field (BM25-style)
{ name: 'keywords', type: 'sparse', sparse_model: 'default' },
// Multi-vector field (many vectors per object)
{
name: 'colbert',
type: 'multi_vector',
params: { dimension: 768, space_type: 'cosine', precision: 'int8', pooling: 'mean' },
},
],
});space_type:cosine(default),l2,ip.precision:float32,float16,int16(default),int8,int8e,binary.M/ef_con: HNSW build params (optional; sensible defaults applied).- Cosine vectors are L2-normalized by the client before sending.
Other client-level collection calls:
await client.listCollections(); // [{ name: "products", ... }, ...]
const collection = await client.getCollection('products');
await collection.describe(); // { name, fields, created_at, layout_version }
await client.deleteCollection('products');Insert objects (combining vector types)
collection.upsert(objects) takes an array of objects. Each object is:
{
id: '<unique string id>',
meta?: { /* ... */ }, // arbitrary JSON, returned in search results (optional)
filter?: { /* ... */ }, // tag object used by filtered search (optional)
fields?: { // field_name -> value, ONE entry per field you set
'<dense_field>': [/* float, ... */], // dense → array
'<sparse_field>': { indices: [/* ... */], values: [/* ... */] }, // sparse → object
'<multi_field>': [[/* float, ... */], [/* float, ... */]], // multi → array of arrays
},
}Per-field value formats:
| Field type | Value format |
|------------|--------------|
| vector | [0.1, 0.2, ...] — a flat array of floats |
| sparse | { indices: [3, 17], values: [0.9, 0.5] } (also accepts sparse_indices/sparse_values) |
| multi_vector | [[...], [...], ...] — an array of equal-length float arrays |
One object, multiple field types
A single object can populate any combination of the collection's fields in one call. They're stored together under the same id and can be searched independently or together:
await collection.upsert([
{
id: 'p1',
meta: { name: 'Wireless Headphones', price: 99 },
filter: { category: 'electronics' },
fields: {
embedding: [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8], // dense
keywords: { indices: [3, 17, 42], values: [0.9, 0.5, 0.2] }, // sparse
colbert: [
// multi-vector
[0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8],
[0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9],
],
},
},
{
id: 'p2',
meta: { name: 'Running Shoes' },
filter: { category: 'footwear' },
fields: {
// An object may set only SOME fields — here just dense + sparse.
embedding: [0.5, 0.4, 0.3, 0.2, 0.1, 0.05, 0.02, 0.01],
keywords: { indices: [5, 17, 90], values: [0.7, 0.6, 0.1] },
},
},
]);
// → { upserted: 2 }Notes:
- You don't have to fill every field on every object — set whatever subset you have.
upsertis insert-or-replace byid.- Cosine
vector/multi_vectorvalues are normalized client-side before sending; the original norm is stored sogetObjectsreturns the original (pre-normalization) vectors. - Validation is done client-side (fails fast): batch size ≤ 10,000, no duplicate
ids in a batch, dense/multi dims must match the field, sparse
indices/valueslengths must match, filter keys ≤ 128 bytes / string values ≤ 1024 bytes.
Search
collection.search({ fields, filter }) queries one or more fields in a
single request and always returns one ranked list per field, keyed by field
name. To collapse those lists into a single ranked list, fuse them with
rerank().
The unified field shape
Every field is queried with the same object form — { query, limit?, ef_search? }.
There is no bare-value shorthand; query is required. The query value matches
the field's type:
| Field type | query shape |
|------------|-------------|
| vector | [0.2, 0.2, ...] |
| sparse | { indices: [3, 17], values: [0.8, 0.4] } |
| multi_vector | [[...], [...]] |
Each result hit is:
{ id: 'p1', similarity: 0.94, meta: { /* ... */ }, filter: { /* ... */ } }Search results carry
meta/filterbut not the stored vectors. To get the vectors back, usegetObjects.
Per-field limit and ef_search
limit is the max number of hits to return for that field (defaults to
10). It's set per field, so each field draws independently. ef_search tunes
that field's HNSW recall/latency.
// embedding returns up to 35 hits, keywords up to 15 — each independent
await collection.search({
fields: {
embedding: { query: Array(8).fill(0.2), limit: 35, ef_search: 256 },
keywords: { query: { indices: [3, 17], values: [0.8, 0.4] }, limit: 15 },
},
});Single-field search
A single field is just the multi-field shape with one entry. results is keyed
by that field name:
const res = await collection.search({
fields: { embedding: { query: Array(8).fill(0.2), limit: 3 } },
});
res.results.embedding;
// [
// { id: "p1", similarity: 0.97, meta: {...}, filter: {...} },
// { id: "p2", similarity: 0.88, meta: {...}, filter: {...} },
// ]
// sparse
await collection.search({
fields: { keywords: { query: { indices: [3, 17], values: [0.8, 0.4] }, limit: 3 } },
});
// multi-vector
await collection.search({
fields: { colbert: { query: [Array(8).fill(0.1), Array(8).fill(0.2)], limit: 3 } },
});Multi-field search
Query several fields and you get each field's own ranked list, unfused —
results is an object keyed by field name. Merge or display them however you
like, or hand them to rerank().
const res = await collection.search({
fields: {
embedding: { query: [0.2, 0.2, 0.3, 0.3], limit: 2 },
keywords: { query: { indices: [3, 17], values: [0.8, 0.4] }, limit: 2 },
},
});{
"results": {
"embedding": [
{ "id": "p1", "meta": { "name": "Wireless Headphones" },
"filter": { "category": "electronics" }, "similarity": 0.4938 },
{ "id": "p2", "meta": { "name": "Running Shoes" },
"filter": { "category": "footwear" }, "similarity": 0.4505 }
],
"keywords": [
{ "id": "p1", "meta": { "name": "Wireless Headphones" },
"filter": { "category": "electronics" }, "similarity": 0.9200 },
{ "id": "p2", "meta": { "name": "Running Shoes" },
"filter": { "category": "footwear" }, "similarity": 0.5591 }
]
}
}res.results.embedding; // this field's ranked hits
res.results.keywords; // that field's ranked hits
similarityis in each field's own scale (cosine for dense ≈ 0.49, sparse dot for keywords ≈ 0.92) — scores are not comparable across fields. That's exactly why fusion is a separate, opt-in step.
Fusing results with rerank()
rerank({ searchResults, limit, fieldWeights, rrfK }) fuses the per-field lists
from search() into one ranked list using Reciprocal Rank Fusion. It's a
standalone function — run search(), then pass its result to rerank():
import { rerank } from 'endee';
const res = await collection.search({
fields: {
embedding: { query: [0.2, 0.2, 0.3, 0.3], limit: 50 },
keywords: { query: { indices: [3, 17], values: [0.8, 0.4] }, limit: 50 },
colbert: { query: [Array(8).fill(0.1), Array(8).fill(0.2)], limit: 50 },
},
});
const fused = rerank({
searchResults: res,
limit: 10,
// optional: weight each field's contribution (must sum to 1.0; equal by default)
fieldWeights: { embedding: 0.5, keywords: 0.3, colbert: 0.2 },
// optional: RRF rank constant k (default 60)
rrfK: 60,
});{
"results": [
{ "id": "p1", "similarity": 0.0164,
"meta": { "name": "Wireless Headphones" }, "filter": { "category": "electronics" } },
{ "id": "p3", "similarity": 0.0160,
"meta": { "name": "Smart Watch" }, "filter": { "category": "electronics" } },
{ "id": "p2", "similarity": 0.0160,
"meta": { "name": "Running Shoes" }, "filter": { "category": "footwear" } }
]
}searchResults is the response from search(). rrfK is the RRF rank constant
(default 60); a larger value flattens the contribution of top ranks, a smaller
value sharpens it. Each fused hit keeps the meta/filter from its per-field
hit; similarity is replaced with the RRF score (sum of weight / (rrfK + rank)
across the fields it appeared in).
Summary of return shapes
| Call | results shape |
|------|-----------------|
| search(...) (1 or N fields) | Record<field_name, SearchHit[]> |
| rerank({ searchResults, ... }) | SearchHit[] (fused) |
Filtered search
filter applies to any search. It's an array of conditions:
await collection.search({
fields: { embedding: { query: Array(8).fill(0.2), limit: 5 } },
filter: [{ category: { $eq: 'electronics' } }],
});Operators: $eq, $in, $range, $gt, $gte, $lt, $lte (see Reference).
Filter tuning
Two optional params tune the speed/recall trade-off of filtered queries.
They're sent to the server under filter_params and ignored on unfiltered
searches.
await collection.search({
fields: { embedding: { query: Array(8).fill(0.2), limit: 10 } },
filter: [{ category: { $eq: 'rare' } }],
prefilterCardinalityThreshold: 5000, // default 10,000; range 1,000–1,000,000
filterBoostPercentage: 25, // default 0; max 100
});prefilterCardinalityThreshold — the cardinality below which the search
switches from HNSW filtered search to brute-force prefiltering. When very few
vectors match the filter, scanning the matched subset directly is faster and
more accurate than HNSW graph traversal. Raising it makes prefiltering kick
in more often (favors the exhaustive scan); lowering it favors HNSW graph
search (favors speed on large datasets).
filterBoostPercentage — in HNSW filtered search, candidates that fail the
filter are discarded, which can leave fewer than limit results. This expands
the internal candidate pool before filtering to compensate. 0 = no boost,
100 = doubles the pool.
Object operations
// Fetch full stored objects by id (meta, filter, and the stored vectors).
const objs = await collection.getObjects(['p1', 'p2']);
// [
// { id: "p1", meta: {...}, filter: {...},
// vectors: { embedding: [...] }, // dense fields (original, pre-normalization)
// sparses: { keywords: { indices: [...], values: [...] } },
// multi_vectors: { colbert: [[...], [...]] } },
// ...
// ]
// Inspect an object's base-layer (level 0) HNSW links for one dense field.
// `field` must be a dense ANN field (a `vector` field, or a `multi_vector`
// field's pooled index); sparse fields have no HNSW graph. These are the
// graph's pruned, diversity-selected edges — for actual nearest neighbours
// use `search`.
await collection.getNeighborsById('p1', 'embedding');
// { id: "p1", field: "embedding", links: ["p2", "p3"] }
// Delete a single object by id.
await collection.deleteObject('p1'); // { deleted: "p1" }
// Delete every object matching a filter.
await collection.deleteByFilter([{ category: { $eq: 'footwear' } }]); // { deleted: <count> }
// Update only the filter tags on existing objects (no re-upsert of vectors).
await collection.updateFilters([{ id: 'p2', filter: { category: 'sale' } }]); // { updated: <count> }Collection maintenance
await collection.describe(); // { name, fields, created_at, layout_version }
// Rebuild one or more dense fields' HNSW graphs in a single async call.
// Pass a list of field specs; only M/ef_con may change. Sparse fields are skipped server-side.
await collection.rebuild([
{ field: 'embedding', m: 20, efCon: 200 },
{ field: 'colbert', m: 12, efCon: 120 },
]); // { fields_total: 2, total_objects: ..., status: "in_progress" }
// Poll progress; reports the latest rebuilt field plus overall counts.
// Per-database status (one rebuild at a time), like backupStatus/restoreStatus.
await collection.rebuildStatus(); // { field, fields_done, fields_total, percent_complete, status, ... }
// Defragment storage in place.
await collection.shrink(); // { status: "ok", reclaimed_bytes: <int> }Backups
Backups are per-database. Creating one is per-collection and asynchronous.
// Start a backup of a collection (async).
await collection.createBackup('nightly'); // { backup_name: "nightly", status: "in_progress" }
// Poll the job to completion; status is idle/running/completed/failed.
let status = await client.backupStatus(); // { status, phase, percent_complete, error_message, ... }
while (status.status === 'running') {
// ...wait, then re-check
status = await client.backupStatus();
}
await client.listBackups(); // { nightly: { ...metadata }, ... }
await client.backupInfo('nightly'); // metadata for one backup
// Restore a backup into a new collection (async).
await client.restoreBackup('nightly', 'products_restored'); // { status: "running" }
await client.restoreStatus(); // poll like backupStatus() until it finishes
// Move backups around.
await client.downloadBackup('nightly', '/tmp/nightly.tar'); // writes a .tar, returns the path
await client.downloadBackup('nightly', '/tmp/'); // a directory gets /tmp/nightly.tar
await client.uploadBackup('/tmp/nightly.tar'); // streams the .tar in as backup "nightly"
await client.uploadBackup('/tmp/nightly.tar', 'nightly_copy'); // ...or under another name
await client.deleteBackup('nightly');
downloadBackup/uploadBackupuse Node's filesystem (fs); the upload streams the raw tar as the request body. With the root token, pass a thirddbNamearg todownloadBackupto target a specific database's backup.
Database administration (root token)
Database and token administration requires the server's root token (its
NDD_ROOT_TOKEN). Create an admin client with that token:
const admin = new Endee('roottoken');
admin.setBaseUrl('http://localhost:8080/api/v2');The root token administers databases/tokens; it cannot run data ops directly. A db token (which it mints) does the collection/object/search work.
Databases
// Create a database → returns the new db_token.
const res = await admin.createDatabase('my_db', 'scale'); // db_type: starter|pro|scale|enterprise
const dbToken = res.db_token;
await admin.listDatabases(); // [{ db_name, db_type, is_active, created_at }, ...]
await admin.getDatabase('my_db'); // single db info
await admin.setDatabaseType('my_db', 'pro'); // change tier
await admin.deactivateDatabase('my_db'); // block its tokens (data is kept)
await admin.activateDatabase('my_db'); // re-enable
await admin.deleteDatabase('my_db'); // delete db + ALL its dataCollections across databases (admin view)
await admin.listDbCollections('my_db'); // ["products", ...] for one db
await admin.listAllCollections(); // [{ db_name: "my_db", collections: [...] }, ...]
await admin.deleteDbCollection('my_db', 'products');Tokens for any database
// token_type: "rw" (read-write, default) or "r" (read-only).
const t = await admin.createToken('my_db', 'ci-reader', 'r'); // { db_token, ... }
await admin.listTokens('my_db'); // [{ name, token_type, created_at }, ...] (no secrets)
await admin.deleteToken('my_db', 'ci-reader');Self-service tokens (your own database)
Using a db token, you can manage your own database's tokens:
await client.createMyToken('reader', 'r'); // { db_token, ... }
await client.listMyTokens();
await client.deleteMyToken('reader');Server info
await client.health(); // { status, timestamp }
await client.stats(); // { version, uptime, total_requests }Reference
Distance / space types (space_type)
| Value | Meaning |
|-------|---------|
| cosine | Cosine similarity (vectors L2-normalized client-side). Default. |
| l2 | Euclidean distance. |
| ip | Inner product. |
Precisions (precision) — memory/accuracy trade-off
float32, float16, int16 (default), int8, int8e, binary. The
Precision enum is exported for convenience:
import { Precision } from 'endee';
Precision.INT8; // "int8"Token types: rw (read-write), r (read-only).
Filter operators
| Operator | Example | Meaning |
|----------|---------|---------|
| $eq | { category: { $eq: 'news' } } | equals |
| $in | { tag: { $in: ['a', 'b'] } } | in set |
| $range | { price: { $range: [10, 100] } } | inclusive range |
| $gt / $gte | { price: { $gt: 50 } } | greater than / or equal |
| $lt / $lte | { price: { $lte: 100 } } | less than / or equal |
Client-side validation / limits
| Limit | Value |
|-------|-------|
| Objects per upsert | ≤ 10,000 |
| limit (top-k) | 1 … 4096 |
| efSearch | 1 … 1024 |
| Filter key | ≤ 128 bytes |
| Filter string value | ≤ 1024 bytes |
| Vector dimension | must match the field's configured dimension |
Exported TypeScript types
import type {
FieldDefinition,
FieldType,
ObjectInput,
SearchOptions,
SearchHit,
SearchResponse,
RerankOptions,
RerankResponse,
FullObject,
CollectionMetadata,
UpdateFilterEntry,
RebuildFieldSpec,
BackupJobStatus,
DatabaseInfo,
TokenInfo,
DbType,
TokenType,
SpaceType,
Precision,
} from 'endee';Error handling
Non-2xx responses throw typed exceptions:
import {
EndeeException, // base class for all client errors
APIException, // generic 4xx (e.g. bad request)
AuthenticationException, // 401
ForbiddenException, // 403 (e.g. read-only token on a write)
NotFoundException, // 404
ConflictException, // 409 (e.g. duplicate collection)
ServerException, // 5xx
} from 'endee';
try {
await collection.upsert(objects);
} catch (e) {
if (e instanceof NotFoundException) {
console.error('not found:', e.message);
} else if (e instanceof EndeeException) {
// catches all of the above
console.error('request failed:', e.message);
} else {
throw e;
}
}All exception classes are importable from endee: EndeeException (base),
APIException, AuthenticationException, ForbiddenException,
NotFoundException, ConflictException, ServerException,
SubscriptionException.
Client-side input mistakes (bad dimensions, mismatched sparse lengths, oversized
batch, sum-of-weights ≠ 1.0, etc.) throw a plain Error before any network
call.
End-to-end example
See tests/integration_v2.ts — a full walkthrough (create → multi-field upsert →
every search mode → object ops → maintenance → cleanup). Run it against a local
server with a db token:
npx tsx tests/integration_v2.ts --token my_db:xxxx --url http://localhost:8080/api/v2License
MIT
