quiver-client 0.22.0

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/dist/client.js ADDED
@@ -0,0 +1,431 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ //! A small, dependency-free REST client for Quiver.
3
+ //
4
+ // Mirrors the server's HTTP contract (docs/api/rest-grpc.md): collection CRUD,
5
+ // point upsert/delete/get, and filtered k-NN search, plus the per-collection
6
+ // index choice (the memory-frugal `disk_vamana` path). Embeddings are produced
7
+ // by the caller — Quiver is model-agnostic. Uses the global `fetch`, so it runs
8
+ // on Node >= 20 and modern runtimes with no dependencies.
9
+ const DEFAULT_BASE_URL = "http://127.0.0.1:6333";
10
+ const DEFAULT_TIMEOUT_MS = 30_000;
11
+ /** An error from the Quiver server or the transport. `status` is the HTTP code
12
+ * when the failure came from the server, or `undefined` for a transport error. */
13
+ export class QuiverError extends Error {
14
+ status;
15
+ constructor(message, status) {
16
+ super(message);
17
+ this.name = "QuiverError";
18
+ this.status = status;
19
+ }
20
+ }
21
+ /** A synchronous-feeling, promise-based Quiver REST client. */
22
+ export class Client {
23
+ #baseUrl;
24
+ #headers;
25
+ #timeoutMs;
26
+ #fetch;
27
+ constructor(baseUrl = DEFAULT_BASE_URL, opts = {}) {
28
+ this.#baseUrl = baseUrl.replace(/\/+$/, "");
29
+ this.#headers = { "content-type": "application/json" };
30
+ if (opts.apiKey) {
31
+ this.#headers["authorization"] = `Bearer ${opts.apiKey}`;
32
+ }
33
+ this.#timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
34
+ this.#fetch = opts.fetch ?? globalThis.fetch.bind(globalThis);
35
+ }
36
+ // --- collections ---
37
+ /** Create a collection. Rejects with {@link QuiverError} if the name is taken
38
+ * or the index/metric combination is unsupported. */
39
+ async createCollection(name, dim, opts = {}) {
40
+ const body = { name, dim, metric: opts.metric ?? "l2" };
41
+ if (opts.index)
42
+ body["index"] = opts.index;
43
+ if (opts.pqSubspaces !== undefined)
44
+ body["pq_subspaces"] = opts.pqSubspaces;
45
+ if (opts.filterable && opts.filterable.length > 0) {
46
+ body["filterable"] = opts.filterable.map((f) => ({
47
+ path: f.path,
48
+ field_type: f.fieldType ?? "keyword",
49
+ }));
50
+ }
51
+ if (opts.multivector)
52
+ body["multivector"] = true;
53
+ if (opts.vectorEncryption && opts.vectorEncryption !== "none") {
54
+ body["vector_encryption"] = opts.vectorEncryption;
55
+ }
56
+ return toCollection(await this.#json("POST", "/v1/collections", body));
57
+ }
58
+ /** List all collections. */
59
+ async listCollections() {
60
+ const body = (await this.#json("GET", "/v1/collections"));
61
+ return body.map(toCollection);
62
+ }
63
+ /** Fetch one collection's metadata. */
64
+ async getCollection(name) {
65
+ return toCollection(await this.#json("GET", `/v1/collections/${encodeURIComponent(name)}`));
66
+ }
67
+ /** Delete a collection; resolves to whether it existed. */
68
+ async deleteCollection(name) {
69
+ const body = (await this.#json("DELETE", `/v1/collections/${encodeURIComponent(name)}`));
70
+ return Boolean(body.existed);
71
+ }
72
+ // --- points ---
73
+ /** Insert or replace points; resolves to the number upserted. */
74
+ async upsert(collection, points) {
75
+ const body = { points: points.map(pointDict) };
76
+ const res = (await this.#json("POST", `/v1/collections/${encodeURIComponent(collection)}/points`, body));
77
+ return Number(res.upserted ?? 0);
78
+ }
79
+ /** Delete points by id; resolves to the number deleted. */
80
+ async deletePoints(collection, ids) {
81
+ const res = (await this.#json("DELETE", `/v1/collections/${encodeURIComponent(collection)}/points`, { ids }));
82
+ return Number(res.deleted ?? 0);
83
+ }
84
+ /** Fetch a point by id, or `null` if it does not exist. */
85
+ async getPoint(collection, id) {
86
+ const path = `/v1/collections/${encodeURIComponent(collection)}/points/${encodeURIComponent(id)}`;
87
+ const resp = await this.#send("GET", path);
88
+ if (resp.status === 404)
89
+ return null;
90
+ await throwForStatus(resp);
91
+ const body = (await resp.json());
92
+ return { id: body.id, score: 0, payload: body.payload, vector: body.vector };
93
+ }
94
+ /** Search for the `k` nearest points to `vector`, nearest first. */
95
+ async search(collection, vector, opts = {}) {
96
+ const body = {
97
+ vector,
98
+ k: opts.k ?? 10,
99
+ ef_search: opts.efSearch ?? 64,
100
+ with_payload: opts.withPayload ?? true,
101
+ with_vector: opts.withVector ?? false,
102
+ };
103
+ if (opts.filter !== undefined)
104
+ body["filter"] = opts.filter;
105
+ const res = (await this.#json("POST", `/v1/collections/${encodeURIComponent(collection)}/query`, body));
106
+ return (res.matches ?? []).map((m) => ({
107
+ id: m.id,
108
+ score: m.score,
109
+ payload: m.payload,
110
+ vector: m.vector,
111
+ }));
112
+ }
113
+ /** Hybrid search fused with Reciprocal Rank Fusion (ADR-0043/0046). Provide a
114
+ * dense `vector`, a `sparse` query vector, and/or a full-text `queryText` (scored
115
+ * by BM25) — at least one is required. The same payload `filter` applies to every
116
+ * side. */
117
+ async hybridSearch(collection, opts = {}) {
118
+ if (opts.vector === undefined && opts.sparse === undefined && opts.queryText === undefined) {
119
+ throw new QuiverError("hybridSearch requires a dense vector, a sparse vector, or a text query");
120
+ }
121
+ const body = {
122
+ k: opts.k ?? 10,
123
+ ef_search: opts.efSearch ?? 64,
124
+ rrf_k0: opts.rrfK0 ?? 60,
125
+ with_payload: opts.withPayload ?? true,
126
+ with_vector: opts.withVector ?? false,
127
+ };
128
+ if (opts.vector !== undefined)
129
+ body["vector"] = opts.vector;
130
+ if (opts.queryText !== undefined)
131
+ body["query_text"] = opts.queryText;
132
+ if (opts.sparse !== undefined) {
133
+ body["sparse_indices"] = opts.sparse.indices;
134
+ body["sparse_values"] = opts.sparse.values;
135
+ }
136
+ if (opts.filter !== undefined)
137
+ body["filter"] = opts.filter;
138
+ const res = (await this.#json("POST", `/v1/collections/${encodeURIComponent(collection)}/query/hybrid`, body));
139
+ return (res.matches ?? []).map((m) => ({
140
+ id: m.id,
141
+ score: m.score,
142
+ payload: m.payload,
143
+ vector: m.vector,
144
+ }));
145
+ }
146
+ /** Embed each point's `text` server-side and upsert it (ADR-0047); the text is
147
+ * also indexed for BM25. Requires an `[embedding.<collection>]` provider on the
148
+ * server. Resolves to the number upserted. */
149
+ async upsertText(collection, points) {
150
+ const body = {
151
+ points: points.map((p) => ({
152
+ id: p.id,
153
+ text: p.text,
154
+ ...(p.payload !== undefined ? { payload: p.payload } : {}),
155
+ })),
156
+ };
157
+ const res = (await this.#json("POST", `/v1/collections/${encodeURIComponent(collection)}/points:text`, body));
158
+ return Number(res.upserted ?? 0);
159
+ }
160
+ /** Embed `text` server-side and search dense ⊕ BM25, optionally reranking the
161
+ * candidate pool in one call (ADR-0047). Requires an `[embedding.<collection>]`
162
+ * provider (and a `[rerank.<collection>]` provider for `rerank: true`). */
163
+ async searchText(collection, text, opts = {}) {
164
+ const body = {
165
+ text,
166
+ k: opts.k ?? 10,
167
+ ef_search: opts.efSearch ?? 64,
168
+ rrf_k0: opts.rrfK0 ?? 60,
169
+ with_payload: opts.withPayload ?? true,
170
+ with_vector: opts.withVector ?? false,
171
+ rerank: opts.rerank ?? false,
172
+ };
173
+ if (opts.filter !== undefined)
174
+ body["filter"] = opts.filter;
175
+ const res = (await this.#json("POST", `/v1/collections/${encodeURIComponent(collection)}/query/text`, body));
176
+ return (res.matches ?? []).map((m) => ({
177
+ id: m.id,
178
+ score: m.score,
179
+ payload: m.payload,
180
+ vector: m.vector,
181
+ }));
182
+ }
183
+ /** Fetch points without ranking; an optional payload `filter` narrows the set
184
+ * and `limit` bounds it. The retrieval path for `client_side`-encrypted
185
+ * collections (ADR-0032): the server returns the entitled set (each payload
186
+ * carries the sealed vector under `__quiver_vec__`) and you decrypt and rank
187
+ * locally (see {@link searchClientSide}). Also a general list-points call for
188
+ * any collection; returned matches carry `score` 0. */
189
+ async fetch(collection, opts = {}) {
190
+ const body = {
191
+ limit: opts.limit ?? 100,
192
+ with_payload: opts.withPayload ?? true,
193
+ with_vector: opts.withVector ?? false,
194
+ };
195
+ if (opts.filter !== undefined)
196
+ body["filter"] = opts.filter;
197
+ const res = (await this.#json("POST", `/v1/collections/${encodeURIComponent(collection)}/fetch`, body));
198
+ return (res.points ?? []).map((p) => ({
199
+ id: p.id,
200
+ score: 0,
201
+ payload: p.payload,
202
+ vector: p.vector,
203
+ }));
204
+ }
205
+ /** Upsert a large (sync or async) iterable of points in server-friendly
206
+ * batches; resolves to the total upserted. `batch` must stay within the
207
+ * server's `max_batch_size` (ADR-0040, default 1000). `onProgress` is called
208
+ * with the running total after each batch (may be sync or async). Mirrors the
209
+ * Python `upsert_iter`. */
210
+ async upsertIter(collection, points, opts = {}) {
211
+ const batch = opts.batch ?? 500;
212
+ let total = 0;
213
+ let chunk = [];
214
+ const flush = async () => {
215
+ if (chunk.length === 0)
216
+ return;
217
+ total += await this.upsert(collection, chunk);
218
+ chunk = [];
219
+ if (opts.onProgress)
220
+ await opts.onProgress(total);
221
+ };
222
+ // `for await` iterates sync and async iterables alike.
223
+ for await (const p of points) {
224
+ chunk.push(p);
225
+ if (chunk.length >= batch)
226
+ await flush();
227
+ }
228
+ await flush();
229
+ return total;
230
+ }
231
+ /** Yield points page by page, for export / re-embedding. The REST fetch is
232
+ * limit-bounded without a server cursor, so this returns up to `batch` points
233
+ * in one page — pass a narrowing `filter` for large collections (a server-side
234
+ * scroll cursor is a follow-up). Mirrors the Python async `scroll`. */
235
+ async *scroll(collection, opts = {}) {
236
+ const page = await this.fetch(collection, {
237
+ filter: opts.filter,
238
+ limit: opts.batch ?? 500,
239
+ withPayload: opts.withPayload ?? true,
240
+ withVector: opts.withVector ?? false,
241
+ });
242
+ for (const m of page)
243
+ yield m;
244
+ }
245
+ /** Delete every point matching `filter`; resolves to the number deleted.
246
+ * Fetches matching ids (paged by `batch`) and deletes them until none remain —
247
+ * useful for GDPR erasure and re-indexing. Mirrors the Python `delete_by_filter`. */
248
+ async deleteByFilter(collection, filter, opts = {}) {
249
+ const batch = opts.batch ?? 500;
250
+ let total = 0;
251
+ for (;;) {
252
+ const page = await this.fetch(collection, { filter, limit: batch, withPayload: false });
253
+ const ids = page.map((m) => m.id);
254
+ if (ids.length === 0)
255
+ return total;
256
+ total += await this.deletePoints(collection, ids);
257
+ if (ids.length < batch)
258
+ return total;
259
+ }
260
+ }
261
+ /** Take a consistent online snapshot (backup) of the whole database into a
262
+ * server-local directory, which must not already exist (ADR-0050); admin-only.
263
+ * Resolves to the captured manifest version and the file/byte counts. */
264
+ async snapshot(destination) {
265
+ const res = (await this.#json("POST", "/v1/snapshot", { destination }));
266
+ return {
267
+ manifestVersion: Number(res.manifest_version ?? 0),
268
+ files: Number(res.files ?? 0),
269
+ bytes: Number(res.bytes ?? 0),
270
+ };
271
+ }
272
+ /** Nearest-neighbour search over a `client_side`-encrypted collection (ADR-0032),
273
+ * done entirely client-side: {@link fetch} the (optionally filtered) candidate
274
+ * set, decrypt each vector with `cipher` (a `VectorCipher` from
275
+ * `quiver-client/vector`), rank by metric, and return the top `k`. The server
276
+ * never ranks and never sees the key. This mode suits small/medium or
277
+ * pre-filtered collections; `candidateLimit` bounds how many points are fetched
278
+ * before ranking. Each result carries the decrypted `vector` and a `score` under
279
+ * the chosen metric. */
280
+ async searchClientSide(collection, query, cipher, opts = {}) {
281
+ const metric = opts.metric ?? "l2";
282
+ const points = await this.fetch(collection, {
283
+ filter: opts.filter,
284
+ limit: opts.candidateLimit ?? 10000,
285
+ withPayload: true,
286
+ });
287
+ const ranked = points.map((m) => {
288
+ const vector = cipher.open(m.payload);
289
+ const [ordering, score] = clientSideScore(metric, query, vector);
290
+ return { ordering, match: { id: m.id, score, payload: m.payload, vector } };
291
+ });
292
+ ranked.sort((a, b) => a.ordering - b.ordering);
293
+ return ranked.slice(0, opts.k ?? 10).map((r) => r.match);
294
+ }
295
+ // --- documents (multi-vector / late interaction) ---
296
+ /** Insert or replace multi-vector documents; resolves to the number upserted. */
297
+ async upsertDocuments(collection, documents) {
298
+ const body = { documents: documents.map(documentDict) };
299
+ const res = (await this.#json("POST", `/v1/collections/${encodeURIComponent(collection)}/documents`, body));
300
+ return Number(res.upserted ?? 0);
301
+ }
302
+ /** Delete multi-vector documents by id; resolves to the number deleted. */
303
+ async deleteDocuments(collection, ids) {
304
+ const res = (await this.#json("DELETE", `/v1/collections/${encodeURIComponent(collection)}/documents`, { ids }));
305
+ return Number(res.deleted ?? 0);
306
+ }
307
+ /** Rank documents by MaxSim late interaction against the `query` token set. */
308
+ async searchMultiVector(collection, query, opts = {}) {
309
+ const body = {
310
+ query,
311
+ k: opts.k ?? 10,
312
+ ef_search: opts.efSearch ?? 64,
313
+ with_payload: opts.withPayload ?? true,
314
+ with_vector: opts.withVector ?? false,
315
+ };
316
+ if (opts.filter !== undefined)
317
+ body["filter"] = opts.filter;
318
+ const res = (await this.#json("POST", `/v1/collections/${encodeURIComponent(collection)}/documents/query`, body));
319
+ return (res.matches ?? []).map((m) => ({
320
+ id: m.id,
321
+ score: m.score,
322
+ payload: m.payload,
323
+ vectors: m.vectors,
324
+ }));
325
+ }
326
+ // --- health ---
327
+ /** Whether the server's liveness probe succeeds. */
328
+ async healthz() {
329
+ try {
330
+ const resp = await this.#send("GET", "/healthz");
331
+ return resp.ok;
332
+ }
333
+ catch {
334
+ return false;
335
+ }
336
+ }
337
+ // --- internals ---
338
+ async #send(method, path, body) {
339
+ const controller = new AbortController();
340
+ const timer = setTimeout(() => controller.abort(), this.#timeoutMs);
341
+ try {
342
+ return await this.#fetch(`${this.#baseUrl}${path}`, {
343
+ method,
344
+ headers: this.#headers,
345
+ body: body === undefined ? undefined : JSON.stringify(body),
346
+ signal: controller.signal,
347
+ });
348
+ }
349
+ catch (err) {
350
+ throw new QuiverError(`request to ${path} failed: ${String(err)}`);
351
+ }
352
+ finally {
353
+ clearTimeout(timer);
354
+ }
355
+ }
356
+ async #json(method, path, body) {
357
+ const resp = await this.#send(method, path, body);
358
+ await throwForStatus(resp);
359
+ return resp.json();
360
+ }
361
+ }
362
+ function clientSideScore(metric, query, vector) {
363
+ if (metric === "l2") {
364
+ let d = 0;
365
+ for (let i = 0; i < query.length; i++) {
366
+ const diff = (query[i] ?? 0) - (vector[i] ?? 0);
367
+ d += diff * diff;
368
+ }
369
+ return [d, d];
370
+ }
371
+ let dot = 0;
372
+ for (let i = 0; i < query.length; i++)
373
+ dot += (query[i] ?? 0) * (vector[i] ?? 0);
374
+ if (metric === "dot")
375
+ return [-dot, dot];
376
+ let nq = 0;
377
+ let nv = 0;
378
+ for (let i = 0; i < query.length; i++)
379
+ nq += (query[i] ?? 0) ** 2;
380
+ for (let i = 0; i < vector.length; i++)
381
+ nv += (vector[i] ?? 0) ** 2;
382
+ const sim = dot / ((Math.sqrt(nq) || 1) * (Math.sqrt(nv) || 1));
383
+ return [-sim, sim];
384
+ }
385
+ function toCollection(body) {
386
+ const b = body;
387
+ return {
388
+ name: String(b["name"]),
389
+ dim: Number(b["dim"]),
390
+ metric: String(b["metric"]),
391
+ count: Number(b["count"] ?? 0),
392
+ index: b["index"] ?? "hnsw",
393
+ pqSubspaces: b["pq_subspaces"] === undefined ? undefined : Number(b["pq_subspaces"]),
394
+ filterable: Array.isArray(b["filterable"])
395
+ ? b["filterable"].map((f) => ({
396
+ path: String(f["path"]),
397
+ fieldType: f["field_type"] ?? "keyword",
398
+ }))
399
+ : [],
400
+ multivector: Boolean(b["multivector"]),
401
+ vectorEncryption: typeof b["vector_encryption"] === "string"
402
+ ? b["vector_encryption"]
403
+ : "none",
404
+ };
405
+ }
406
+ function documentDict(doc) {
407
+ const out = { id: doc.id, vectors: doc.vectors };
408
+ if (doc.payload !== undefined)
409
+ out["payload"] = doc.payload;
410
+ return out;
411
+ }
412
+ function pointDict(point) {
413
+ const out = { id: point.id, vector: point.vector };
414
+ if (point.payload !== undefined && point.payload !== null)
415
+ out["payload"] = point.payload;
416
+ return out;
417
+ }
418
+ async function throwForStatus(resp) {
419
+ if (resp.status < 400)
420
+ return;
421
+ let detail;
422
+ try {
423
+ const body = (await resp.clone().json());
424
+ detail = body["detail"] ?? body["title"];
425
+ }
426
+ catch {
427
+ detail = undefined;
428
+ }
429
+ const fallback = (await resp.text().catch(() => "")) || `HTTP ${resp.status}`;
430
+ throw new QuiverError(detail ?? fallback, resp.status);
431
+ }
package/dist/dcpe.d.ts ADDED
@@ -0,0 +1,57 @@
1
+ /** DCPE initialisation-vector length in bytes (a 96-bit ChaCha20 nonce). */
2
+ export declare const IV_LEN = 12;
3
+ /** DCPE integrity-tag length in bytes (full HMAC-SHA256 output). */
4
+ export declare const TAG_LEN = 32;
5
+ /** An error from DCPE encryption, decryption, or construction. */
6
+ export declare class DcpeError extends Error {
7
+ constructor(message: string);
8
+ }
9
+ /** A DCPE-encrypted vector: the ciphertext (upserted and searched like any
10
+ * vector), the IV seeding its perturbation, and an HMAC-SHA256 integrity tag. */
11
+ export interface EncryptedVector {
12
+ ciphertext: number[];
13
+ iv: Uint8Array;
14
+ tag: Uint8Array;
15
+ }
16
+ /** A fixed, ordering-preserving global affine normalisation (ADR-0035).
17
+ *
18
+ * Maps a plaintext `m` to `(m - shift) * scale` before encryption, where `shift`
19
+ * is a per-dimension translation and `scale` is a **single** positive scalar. Both
20
+ * steps preserve the L2 distance-comparison ordering (a uniform shift cancels in
21
+ * any difference; a single positive scalar scales every distance by the same
22
+ * factor) and are invertible. Supply it once from a one-time measurement of your
23
+ * corpus and reuse it for the data *and* the queries.
24
+ *
25
+ * Per-axis variance whitening (a different scale per dimension) is anisotropic,
26
+ * re-weights the L2 distance, and so breaks the ordering — it is intentionally not
27
+ * expressible here. See ADR-0035. */
28
+ export declare class Normalization {
29
+ readonly shift: number[];
30
+ readonly scale: number;
31
+ private constructor();
32
+ /** Build a normalisation from a per-dimension shift and a single positive scale. */
33
+ static create(shift: ArrayLike<number>, scale: number): Normalization;
34
+ }
35
+ /** A client-held DCPE key bound to one approximation factor (ADR-0031/0035).
36
+ *
37
+ * Construct one cipher per `(key, approximationFactor[, normalization])` and reuse
38
+ * it; the same factor (and normalisation) must be used for the data and the queries
39
+ * searched against it. */
40
+ export declare class DcpeCipher {
41
+ #private;
42
+ private constructor();
43
+ /** Build a cipher from a raw 256-bit (32-byte) key. */
44
+ static fromBytes(key: Uint8Array, approximationFactor: number, normalization?: Normalization | null): DcpeCipher;
45
+ /** Build a cipher from a 64-character hex-encoded 256-bit key. */
46
+ static fromHex(hex: string, approximationFactor: number, normalization?: Normalization | null): DcpeCipher;
47
+ /** The secret, key-derived scaling factor `s ∈ [1, 2)`. Part of the key. */
48
+ get scale(): number;
49
+ /** The approximation factor this cipher was built with. */
50
+ get approximationFactor(): number;
51
+ /** Encrypt a vector for storage with a fresh random IV. */
52
+ encrypt(vector: ArrayLike<number>): EncryptedVector;
53
+ /** Encrypt a query vector for searching against DCPE-encrypted data. */
54
+ encryptQuery(vector: ArrayLike<number>): number[];
55
+ /** Verify the integrity tag (constant-time) and recover the plaintext. */
56
+ decrypt(sealed: EncryptedVector): number[];
57
+ }