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/README.md +91 -0
- package/dist/client.d.ts +258 -0
- package/dist/client.js +431 -0
- package/dist/dcpe.d.ts +57 -0
- package/dist/dcpe.js +336 -0
- package/dist/encryption.d.ts +25 -0
- package/dist/encryption.js +154 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -0
- package/dist/vector.d.ts +26 -0
- package/dist/vector.js +170 -0
- package/package.json +87 -0
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
|
+
}
|