@duckedup/nidus 0.1.1 → 0.53.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 +153 -4
- package/dist/index.cjs +312 -16
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +406 -23
- package/dist/index.d.ts +406 -23
- package/dist/index.js +312 -16
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -12,6 +12,10 @@ browser.
|
|
|
12
12
|
npm install @duckedup/nidus
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
+
This package is versioned in lockstep with nidus itself: a given `@duckedup/nidus`
|
|
16
|
+
version is the client for the identically-numbered nidus release. Match the two and the
|
|
17
|
+
wire contract lines up.
|
|
18
|
+
|
|
15
19
|
## Connecting
|
|
16
20
|
|
|
17
21
|
"Local vs remote" is just the base URL — point the client at a local `nidus serve`
|
|
@@ -32,9 +36,9 @@ const db = new NidusClient({
|
|
|
32
36
|
|
|
33
37
|
## Upserting and searching
|
|
34
38
|
|
|
35
|
-
`attrs` accept plain JS values — strings,
|
|
36
|
-
`null` — and are normalized to nidus's typed values for you. (For an explicit
|
|
37
|
-
use the `v.*` helpers.)
|
|
39
|
+
`attrs` accept plain JS values — strings, numbers, booleans, string arrays, `Date`s,
|
|
40
|
+
and `null` — and are normalized to nidus's typed values for you. (For an explicit
|
|
41
|
+
type, use the `v.*` helpers.)
|
|
38
42
|
|
|
39
43
|
```ts
|
|
40
44
|
await db.createCollection("docs");
|
|
@@ -52,10 +56,40 @@ for (const hit of hits) {
|
|
|
52
56
|
}
|
|
53
57
|
```
|
|
54
58
|
|
|
59
|
+
nidus has separate `Int` and `Float` attribute types and compares them same-type only,
|
|
60
|
+
but JS has one `number` and `1.0 === 1` — so a plain number becomes an `Int` when
|
|
61
|
+
`Number.isInteger` says so and a `Float` otherwise. That means a whole-numbered
|
|
62
|
+
measurement lands as an `Int` in whichever records it came out round, and a `Float`
|
|
63
|
+
range filter then skips exactly those. Pin such a field with `v.float`:
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
import { v } from "@duckedup/nidus";
|
|
67
|
+
|
|
68
|
+
await db.upsert("docs", [
|
|
69
|
+
{
|
|
70
|
+
id: "d",
|
|
71
|
+
attrs: {
|
|
72
|
+
score: v.float(1), // a Float even though the value is whole
|
|
73
|
+
ratio: 0.75, // already a Float — not an integer
|
|
74
|
+
year: 2024, // an Int
|
|
75
|
+
seen: new Date(), // a DateTime: a UTC instant, epoch milliseconds
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
]);
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
A `DateTime` carries no timezone and has millisecond resolution; it decodes back to a
|
|
82
|
+
`Date`, so a decoded `attrs` map re-encodes to what it came from. `NaN` and `Infinity`
|
|
83
|
+
throw — JSON has no spelling for them. The Go and Python SDKs have the numeric types JS
|
|
84
|
+
lacks and decide from those instead, so a Python `2.0` or a Go `float64(2)` is a
|
|
85
|
+
`Float` where a bare `2` here is an `Int`.
|
|
86
|
+
|
|
55
87
|
## Filtering
|
|
56
88
|
|
|
57
89
|
Build an AND-filter with the `f.*` helpers. Each predicate is a positive assertion
|
|
58
|
-
about a present attribute (an absent key matches nothing).
|
|
90
|
+
about a present attribute (an absent key matches nothing). Comparisons are same-type
|
|
91
|
+
only, so an operand must encode to the attribute's type — `f.ge("score", v.float(2))`,
|
|
92
|
+
not `f.ge("score", 2)`, for a `Float` attribute.
|
|
59
93
|
|
|
60
94
|
```ts
|
|
61
95
|
import { f } from "@duckedup/nidus";
|
|
@@ -72,10 +106,25 @@ const hits = await db.search({
|
|
|
72
106
|
});
|
|
73
107
|
```
|
|
74
108
|
|
|
109
|
+
Beyond the comparisons there are text predicates — approximate, token-wise, and
|
|
110
|
+
regular-expression matching over a plain attribute (no full-text index required):
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
f.fuzzy("title", "vecter store", 2); // within 2 Levenshtein edits (max 8)
|
|
114
|
+
f.containsAllTokens("body", "vector store"); // both tokens, any order
|
|
115
|
+
f.containsAnyToken("body", "vector store");
|
|
116
|
+
f.containsTokenSequence("body", "vector store"); // as a phrase, in order
|
|
117
|
+
f.regex("path", "src/.*\\.rs"); // anchored at both ends, like f.glob
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
`f.regex` uses Rust's `regex` syntax, not JavaScript's: no backreferences and no
|
|
121
|
+
lookaround. Prefix it with `(?i)` for case-insensitive matching.
|
|
122
|
+
|
|
75
123
|
## Full-text and hybrid search
|
|
76
124
|
|
|
77
125
|
```ts
|
|
78
126
|
await db.setFtsSchema("docs", ["body"]);
|
|
127
|
+
// Per-field tuning: await db.setFtsSchema("docs", [{ field: "body", k1: 1.5 }]);
|
|
79
128
|
|
|
80
129
|
// BM25 text search
|
|
81
130
|
const text = await db.textSearch({ field: "body", query: "vector store", topK: 10 });
|
|
@@ -89,6 +138,106 @@ const hybrid = await db.hybridSearch({
|
|
|
89
138
|
});
|
|
90
139
|
```
|
|
91
140
|
|
|
141
|
+
A query can search several fields at once, each with its own text, by sending `clauses`
|
|
142
|
+
instead of the single field — folded by `combine`, `"Sum"` (a doc hitting title *and*
|
|
143
|
+
body outranks one hitting either) or `"Max"` (a long body cannot out-accumulate a
|
|
144
|
+
precise title match). Weight the two hybrid legs with `vectorWeight`/`textWeight`.
|
|
145
|
+
|
|
146
|
+
```ts
|
|
147
|
+
const hits = await db.textSearch({
|
|
148
|
+
clauses: [
|
|
149
|
+
{ field: "title", query: "rust" },
|
|
150
|
+
{ field: "body", query: "async runtime" },
|
|
151
|
+
],
|
|
152
|
+
combine: "Max",
|
|
153
|
+
topK: 10,
|
|
154
|
+
});
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## Explaining and highlighting a hit
|
|
158
|
+
|
|
159
|
+
`explain` reports what each leg and each matched clause contributed; `highlight` returns
|
|
160
|
+
excerpts of the stored text (so it works even on a field the projection dropped). Both
|
|
161
|
+
land on `hit.annotations`, which is absent unless you asked for one of them.
|
|
162
|
+
|
|
163
|
+
```ts
|
|
164
|
+
const hits = await db.hybridSearch({
|
|
165
|
+
vector: [0.1, 0.2, 0.3],
|
|
166
|
+
field: "body",
|
|
167
|
+
text: "vector store",
|
|
168
|
+
explain: true,
|
|
169
|
+
highlight: true, // or { maxFragments: 3, fragmentChars: 120 }
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
for (const { field, fragments } of hits[0]?.annotations?.highlights ?? []) {
|
|
173
|
+
for (const fragment of fragments) {
|
|
174
|
+
for (const span of fragment.spans) {
|
|
175
|
+
console.log(field, fragment.text.slice(...span));
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
nidus reports a span as a **UTF-8 byte** range, but a JS string is indexed in UTF-16
|
|
182
|
+
code units — so a raw span slices the wrong text out of any non-ASCII excerpt. This SDK
|
|
183
|
+
converts them for you: `fragment.spans` are JS string indices, and `fragment.text.slice`
|
|
184
|
+
is the matched term. If you compare them against the raw HTTP response, expect the
|
|
185
|
+
numbers to differ wherever the excerpt is not ASCII.
|
|
186
|
+
|
|
187
|
+
## Ranking, grouping, ordering, and aggregating
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
// Prefer recent hits: subtract a penalty that halves every `scale` ms of age.
|
|
191
|
+
const recent = await db.search({
|
|
192
|
+
query: [0.1, 0.2, 0.3],
|
|
193
|
+
rankBy: {
|
|
194
|
+
decay: { field: "updated_at", origin: Date.now(), scale: 7 * 86_400_000 },
|
|
195
|
+
},
|
|
196
|
+
// …and keep at most 2 hits from any one file
|
|
197
|
+
limitPer: { field: "path", max: 2 },
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// Sort a listing by an attribute instead of storage order
|
|
201
|
+
await db.list({ orderBy: { field: "updated_at", descending: true } });
|
|
202
|
+
|
|
203
|
+
// Count matches and sum attributes, without reading a single vector
|
|
204
|
+
const { count, sums } = await db.aggregate({
|
|
205
|
+
filter: f.and(f.eq("lang", "rust")),
|
|
206
|
+
sum: ["bytes"],
|
|
207
|
+
});
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Ages are measured back from `origin`, never the wall clock, so the same query against an
|
|
211
|
+
unchanged store ranks the same way twice. The penalty is *subtracted* from the score, so
|
|
212
|
+
it stays meaningful for a metric whose scores are negative or unbounded, and a record
|
|
213
|
+
with no usable timestamp is not penalized at all (`missing` defaults to `1`).
|
|
214
|
+
|
|
215
|
+
## Remembering and recalling (text-native)
|
|
216
|
+
|
|
217
|
+
When the server is started with an embedder (`nidus serve --embed-provider …`), you
|
|
218
|
+
can send **text** and let the server embed it — no need to compute vectors client-side.
|
|
219
|
+
`remember` embeds and upserts; `recall` embeds the query and vector-searches.
|
|
220
|
+
|
|
221
|
+
```ts
|
|
222
|
+
// Embed "the quick brown fox" and store it under id "a"
|
|
223
|
+
await db.remember("notes", "a", "the quick brown fox", { attrs: { tag: "x" } });
|
|
224
|
+
|
|
225
|
+
// Summarize first, then embed the summary (server also needs --summarize-provider).
|
|
226
|
+
// The stored record additionally carries `nidus.summary` and `nidus.source` attrs.
|
|
227
|
+
await db.remember("notes", "b", longArticle, { mode: "summarize" });
|
|
228
|
+
|
|
229
|
+
// Embed the query text and search, best-first (attrs decoded to plain JS values)
|
|
230
|
+
const hits = await db.recall("notes", "quick fox", {
|
|
231
|
+
topK: 5,
|
|
232
|
+
minScore: 0.2,
|
|
233
|
+
filter: f.and(f.eq("tag", "x")),
|
|
234
|
+
});
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
Both throw a `NidusError` with status `400` if the server has no embedder configured
|
|
238
|
+
(the message names `--embed-provider`); `mode: "summarize"` without a summarizer is
|
|
239
|
+
likewise a `400`.
|
|
240
|
+
|
|
92
241
|
## Everything else
|
|
93
242
|
|
|
94
243
|
```ts
|
package/dist/index.cjs
CHANGED
|
@@ -31,6 +31,49 @@ __export(index_exports, {
|
|
|
31
31
|
});
|
|
32
32
|
module.exports = __toCommonJS(index_exports);
|
|
33
33
|
|
|
34
|
+
// src/annotations.ts
|
|
35
|
+
function decodeAnnotations(a) {
|
|
36
|
+
const out = {};
|
|
37
|
+
if (a.vector) out.vector = a.vector;
|
|
38
|
+
if (a.text) out.text = a.text;
|
|
39
|
+
if (a.clauses) out.clauses = a.clauses;
|
|
40
|
+
if (a.highlights) out.highlights = a.highlights.map(decodeHighlight);
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
function decodeHighlight(h) {
|
|
44
|
+
return { field: h.field, fragments: h.fragments.map(decodeFragment) };
|
|
45
|
+
}
|
|
46
|
+
function decodeFragment(fr) {
|
|
47
|
+
return { text: fr.text, spans: toStringIndices(fr.text, fr.spans) };
|
|
48
|
+
}
|
|
49
|
+
function toStringIndices(text, spans) {
|
|
50
|
+
if (spans.length === 0 || isAscii(text)) return spans;
|
|
51
|
+
const index = byteToUnit(text);
|
|
52
|
+
const at = (b) => index[Math.min(Math.max(b, 0), index.length - 1)];
|
|
53
|
+
return spans.map(([start, end]) => [at(start), at(end)]);
|
|
54
|
+
}
|
|
55
|
+
function byteToUnit(text) {
|
|
56
|
+
const index = [];
|
|
57
|
+
let unit = 0;
|
|
58
|
+
for (const ch of text) {
|
|
59
|
+
for (let n = utf8Len(ch.codePointAt(0)); n > 0; n--) index.push(unit);
|
|
60
|
+
unit += ch.length;
|
|
61
|
+
}
|
|
62
|
+
index.push(unit);
|
|
63
|
+
return index;
|
|
64
|
+
}
|
|
65
|
+
function utf8Len(codePoint) {
|
|
66
|
+
if (codePoint < 128) return 1;
|
|
67
|
+
if (codePoint < 2048) return 2;
|
|
68
|
+
return codePoint < 65536 ? 3 : 4;
|
|
69
|
+
}
|
|
70
|
+
function isAscii(text) {
|
|
71
|
+
for (let i = 0; i < text.length; i++) {
|
|
72
|
+
if (text.charCodeAt(i) > 127) return false;
|
|
73
|
+
}
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
|
|
34
77
|
// src/errors.ts
|
|
35
78
|
var NidusError = class extends Error {
|
|
36
79
|
/** The HTTP status code, or `0` for a transport/timeout failure (no response). */
|
|
@@ -59,6 +102,7 @@ var NidusError = class extends Error {
|
|
|
59
102
|
};
|
|
60
103
|
|
|
61
104
|
// src/values.ts
|
|
105
|
+
var TAGS = ["Str", "Int", "Bool", "List", "Float", "DateTime"];
|
|
62
106
|
var v = {
|
|
63
107
|
str: (s) => ({ Str: s }),
|
|
64
108
|
int: (n) => {
|
|
@@ -67,15 +111,34 @@ var v = {
|
|
|
67
111
|
}
|
|
68
112
|
return { Int: n };
|
|
69
113
|
},
|
|
114
|
+
float: (n) => {
|
|
115
|
+
if (typeof n !== "number" || !Number.isFinite(n)) {
|
|
116
|
+
throw new TypeError(`v.float expects a finite number, got ${n}`);
|
|
117
|
+
}
|
|
118
|
+
return { Float: n };
|
|
119
|
+
},
|
|
70
120
|
bool: (b) => ({ Bool: b }),
|
|
71
121
|
list: (items) => ({ List: items }),
|
|
122
|
+
/**
|
|
123
|
+
* A UTC instant, from a `Date` or a raw epoch-millisecond count. Milliseconds is
|
|
124
|
+
* the wire type, so there is no sub-millisecond precision and no timezone.
|
|
125
|
+
*/
|
|
126
|
+
datetime: (when) => {
|
|
127
|
+
const ms = when instanceof Date ? when.getTime() : when;
|
|
128
|
+
if (!Number.isSafeInteger(ms)) {
|
|
129
|
+
throw new TypeError(
|
|
130
|
+
`v.datetime expects a valid Date or epoch ms, got ${when}`
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
return { DateTime: ms };
|
|
134
|
+
},
|
|
72
135
|
/** The explicit `Null` value — set-but-empty, distinct from an absent key. */
|
|
73
136
|
nil: () => "Null"
|
|
74
137
|
};
|
|
75
138
|
function isValue(x) {
|
|
76
139
|
if (x === "Null") return true;
|
|
77
140
|
if (typeof x !== "object" || x === null) return false;
|
|
78
|
-
return
|
|
141
|
+
return TAGS.some((tag) => tag in x);
|
|
79
142
|
}
|
|
80
143
|
function encodeValue(input) {
|
|
81
144
|
if (isValue(input)) return input;
|
|
@@ -86,8 +149,9 @@ function encodeValue(input) {
|
|
|
86
149
|
case "boolean":
|
|
87
150
|
return { Bool: input };
|
|
88
151
|
case "number":
|
|
89
|
-
return v.int(input);
|
|
152
|
+
return Number.isInteger(input) ? v.int(input) : v.float(input);
|
|
90
153
|
case "object":
|
|
154
|
+
if (input instanceof Date) return v.datetime(input);
|
|
91
155
|
if (Array.isArray(input)) {
|
|
92
156
|
if (!input.every((e) => typeof e === "string")) {
|
|
93
157
|
throw new TypeError("a List attribute must contain only strings");
|
|
@@ -112,6 +176,8 @@ function decodeValue(value) {
|
|
|
112
176
|
if ("Int" in value) return value.Int;
|
|
113
177
|
if ("Bool" in value) return value.Bool;
|
|
114
178
|
if ("List" in value) return value.List;
|
|
179
|
+
if ("Float" in value) return value.Float;
|
|
180
|
+
if ("DateTime" in value) return new Date(value.DateTime);
|
|
115
181
|
return value;
|
|
116
182
|
}
|
|
117
183
|
function decodeAttrs(attrs) {
|
|
@@ -231,10 +297,14 @@ var NidusClient = class {
|
|
|
231
297
|
attrs: decodeAttrs(r.attrs)
|
|
232
298
|
}));
|
|
233
299
|
}
|
|
234
|
-
/**
|
|
300
|
+
/**
|
|
301
|
+
* Declare the full-text-indexed attribute fields for a collection. A bare string
|
|
302
|
+
* takes the server's BM25/analyzer defaults; an {@link FtsField} object tunes `k1`,
|
|
303
|
+
* `b`, and the analyzer for that field alone.
|
|
304
|
+
*/
|
|
235
305
|
async setFtsSchema(name, fields) {
|
|
236
306
|
await this.request("POST", `/collections/${enc(name)}/fts-schema`, {
|
|
237
|
-
fields
|
|
307
|
+
fields: fields.map(encodeFtsField)
|
|
238
308
|
});
|
|
239
309
|
}
|
|
240
310
|
// ── Search ──────────────────────────────────────────────────────────────
|
|
@@ -244,32 +314,54 @@ var NidusClient = class {
|
|
|
244
314
|
query: opts.query,
|
|
245
315
|
scope: opts.scope ?? [],
|
|
246
316
|
top_k: opts.topK,
|
|
317
|
+
offset: opts.offset,
|
|
247
318
|
min_score: opts.minScore,
|
|
248
|
-
filter: opts.filter ?? []
|
|
319
|
+
filter: opts.filter ?? [],
|
|
320
|
+
exact: opts.exact,
|
|
321
|
+
include_attributes: opts.includeAttributes,
|
|
322
|
+
exclude_attributes: opts.excludeAttributes,
|
|
323
|
+
rank_by: encodeRankBy(opts.rankBy),
|
|
324
|
+
limit_per: opts.limitPer
|
|
249
325
|
});
|
|
250
326
|
}
|
|
251
|
-
/**
|
|
327
|
+
/**
|
|
328
|
+
* BM25 full-text search over one indexed field, or over a `clauses` list folded by
|
|
329
|
+
* `combine` (`"Sum"` unless said otherwise). Naming the fields both ways is a `400`.
|
|
330
|
+
*/
|
|
252
331
|
textSearch(opts) {
|
|
253
332
|
return this.searchRequest("/text-search", {
|
|
254
|
-
field: opts.field,
|
|
255
|
-
query: opts.query,
|
|
333
|
+
...opts.clauses ? { clauses: opts.clauses, combine: opts.combine } : { field: opts.field, query: opts.query },
|
|
256
334
|
scope: opts.scope ?? [],
|
|
257
335
|
top_k: opts.topK,
|
|
336
|
+
offset: opts.offset,
|
|
258
337
|
min_score: opts.minScore,
|
|
259
|
-
filter: opts.filter ?? []
|
|
338
|
+
filter: opts.filter ?? [],
|
|
339
|
+
explain: opts.explain,
|
|
340
|
+
highlight: encodeHighlight(opts.highlight),
|
|
341
|
+
include_attributes: opts.includeAttributes,
|
|
342
|
+
exclude_attributes: opts.excludeAttributes,
|
|
343
|
+
rank_by: encodeRankBy(opts.rankBy),
|
|
344
|
+
limit_per: opts.limitPer
|
|
260
345
|
});
|
|
261
346
|
}
|
|
262
|
-
/**
|
|
347
|
+
/**
|
|
348
|
+
* Hybrid search: fuse a vector query and a BM25 text query via RRF. The text leg takes
|
|
349
|
+
* the same single-field / `clauses` choice as {@link NidusClient.textSearch}.
|
|
350
|
+
*/
|
|
263
351
|
hybridSearch(opts) {
|
|
264
352
|
return this.searchRequest("/hybrid-search", {
|
|
265
353
|
vector: opts.vector,
|
|
266
|
-
field: opts.field,
|
|
267
|
-
text: opts.text,
|
|
354
|
+
...opts.clauses ? { clauses: opts.clauses, combine: opts.combine } : { field: opts.field, text: opts.text },
|
|
268
355
|
scope: opts.scope ?? [],
|
|
269
356
|
top_k: opts.topK,
|
|
357
|
+
offset: opts.offset,
|
|
270
358
|
filter: opts.filter ?? [],
|
|
271
359
|
rrf_k: opts.rrfK,
|
|
272
|
-
candidates: opts.candidates
|
|
360
|
+
candidates: opts.candidates,
|
|
361
|
+
explain: opts.explain,
|
|
362
|
+
highlight: encodeHighlight(opts.highlight),
|
|
363
|
+
vector_weight: opts.vectorWeight,
|
|
364
|
+
text_weight: opts.textWeight
|
|
273
365
|
});
|
|
274
366
|
}
|
|
275
367
|
/** Metadata-only listing (no vector), paginated by `offset`/`limit`. */
|
|
@@ -278,6 +370,113 @@ var NidusClient = class {
|
|
|
278
370
|
scope: opts.scope ?? [],
|
|
279
371
|
offset: opts.offset,
|
|
280
372
|
limit: opts.limit,
|
|
373
|
+
filter: opts.filter ?? [],
|
|
374
|
+
include_attributes: opts.includeAttributes,
|
|
375
|
+
exclude_attributes: opts.excludeAttributes,
|
|
376
|
+
order_by: opts.orderBy
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Count the records matching a filter and sum the named attributes. Answered from the
|
|
381
|
+
* in-RAM index alone — no record is built and no vector is read.
|
|
382
|
+
*/
|
|
383
|
+
async aggregate(opts = {}) {
|
|
384
|
+
const res = await this.request(
|
|
385
|
+
"POST",
|
|
386
|
+
"/aggregate",
|
|
387
|
+
prune({
|
|
388
|
+
scope: opts.scope ?? [],
|
|
389
|
+
filter: opts.filter ?? [],
|
|
390
|
+
sum: opts.sum ?? [],
|
|
391
|
+
group_by: opts.groupBy
|
|
392
|
+
})
|
|
393
|
+
);
|
|
394
|
+
return {
|
|
395
|
+
count: res.count,
|
|
396
|
+
sums: decodeAttrs(res.sums),
|
|
397
|
+
// Kept absent, not `undefined`, so an ungrouped answer is the shape it always was.
|
|
398
|
+
...res.groups ? {
|
|
399
|
+
groups: res.groups.map((g) => ({
|
|
400
|
+
value: g.value === null ? null : decodeValue(g.value),
|
|
401
|
+
count: g.count,
|
|
402
|
+
sums: decodeAttrs(g.sums)
|
|
403
|
+
}))
|
|
404
|
+
} : {},
|
|
405
|
+
...res.groups_truncated ? { groupsTruncated: true } : {}
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* Answer several vector queries in one round-trip (16 max). Returns one ranking per
|
|
410
|
+
* query in request order, or — with `opts.fuse` — a single array holding the one fused
|
|
411
|
+
* ranking, so the return shape is uniform either way.
|
|
412
|
+
*
|
|
413
|
+
* The server validates the whole batch before running any leg, so a malformed query
|
|
414
|
+
* fails the call rather than returning a partial answer that cannot be told apart.
|
|
415
|
+
*/
|
|
416
|
+
async batchSearch(opts) {
|
|
417
|
+
const body = prune({
|
|
418
|
+
queries: opts.queries.map((q) => ({
|
|
419
|
+
query: q.query,
|
|
420
|
+
scope: q.scope ?? [],
|
|
421
|
+
top_k: q.topK,
|
|
422
|
+
offset: q.offset,
|
|
423
|
+
min_score: q.minScore,
|
|
424
|
+
filter: q.filter ?? [],
|
|
425
|
+
exact: q.exact,
|
|
426
|
+
include_attributes: q.includeAttributes,
|
|
427
|
+
exclude_attributes: q.excludeAttributes,
|
|
428
|
+
rank_by: encodeRankBy(q.rankBy),
|
|
429
|
+
limit_per: q.limitPer
|
|
430
|
+
})),
|
|
431
|
+
fuse: opts.fuse ? prune({
|
|
432
|
+
rrf_k: opts.fuse.rrfK,
|
|
433
|
+
weights: opts.fuse.weights,
|
|
434
|
+
top_k: opts.fuse.topK
|
|
435
|
+
}) : void 0
|
|
436
|
+
});
|
|
437
|
+
const res = await this.request(
|
|
438
|
+
"POST",
|
|
439
|
+
"/search/batch",
|
|
440
|
+
body
|
|
441
|
+
);
|
|
442
|
+
return (res.fused ? [res.fused] : res.results ?? []).map(
|
|
443
|
+
(hits) => hits.map((h) => this.decodeHit(h))
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
// ── Memory (text-native) ──────────────────────────────────────────────────
|
|
447
|
+
//
|
|
448
|
+
// Available only when `nidus serve` was started with an embedder
|
|
449
|
+
// (`--embed-provider …`); otherwise these answer `400`. The server embeds the
|
|
450
|
+
// text/query — the client only sends strings.
|
|
451
|
+
/**
|
|
452
|
+
* Embed `text` and upsert it under `id` in `collection` (idempotent on `id`).
|
|
453
|
+
* With `opts.mode === "summarize"` the server summarizes first, embeds the
|
|
454
|
+
* summary, and stamps `nidus.summary`/`nidus.source` attrs (requires the
|
|
455
|
+
* server to have a summarizer). `opts.attrs` accept plain JS values or `v.*`
|
|
456
|
+
* helpers; they are normalized for you.
|
|
457
|
+
*/
|
|
458
|
+
async remember(collection, id, text, opts = {}) {
|
|
459
|
+
await this.request(
|
|
460
|
+
"POST",
|
|
461
|
+
`/collections/${enc(collection)}/remember`,
|
|
462
|
+
prune({
|
|
463
|
+
id,
|
|
464
|
+
text,
|
|
465
|
+
mode: opts.mode,
|
|
466
|
+
attrs: opts.attrs ? encodeAttrs(opts.attrs) : void 0
|
|
467
|
+
})
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* Embed `query` and vector-search `collection`, best-first (attrs decoded to
|
|
472
|
+
* plain JS values). Refused with a cross-model guard if the collection was
|
|
473
|
+
* written with a different embedder than the server's.
|
|
474
|
+
*/
|
|
475
|
+
recall(collection, query, opts = {}) {
|
|
476
|
+
return this.searchRequest(`/collections/${enc(collection)}/recall`, {
|
|
477
|
+
query,
|
|
478
|
+
top_k: opts.topK,
|
|
479
|
+
min_score: opts.minScore,
|
|
281
480
|
filter: opts.filter ?? []
|
|
282
481
|
});
|
|
283
482
|
}
|
|
@@ -294,12 +493,18 @@ var NidusClient = class {
|
|
|
294
493
|
/** Run a search-family request and decode the resulting hits' attrs. */
|
|
295
494
|
async searchRequest(path, body) {
|
|
296
495
|
const hits = await this.request("POST", path, prune(body));
|
|
297
|
-
return hits.map((h) => (
|
|
496
|
+
return hits.map((h) => this.decodeHit(h));
|
|
497
|
+
}
|
|
498
|
+
/** One wire hit into a {@link Hit}. Shared so every search surface decodes identically. */
|
|
499
|
+
decodeHit(h) {
|
|
500
|
+
return {
|
|
298
501
|
collection: h.collection,
|
|
299
502
|
id: h.id,
|
|
300
503
|
score: h.score,
|
|
301
|
-
attrs: decodeAttrs(h.attrs)
|
|
302
|
-
|
|
504
|
+
attrs: decodeAttrs(h.attrs),
|
|
505
|
+
// Kept absent, not `undefined`, so an unannotated hit is the shape it always was.
|
|
506
|
+
...h.annotations ? { annotations: decodeAnnotations(h.annotations) } : {}
|
|
507
|
+
};
|
|
303
508
|
}
|
|
304
509
|
/** Issue a request and parse a JSON body, mapping a non-2xx to {@link NidusError}. */
|
|
305
510
|
async request(method, path, body) {
|
|
@@ -336,9 +541,42 @@ var NidusClient = class {
|
|
|
336
541
|
}
|
|
337
542
|
}
|
|
338
543
|
};
|
|
544
|
+
function encodeRankBy(rank) {
|
|
545
|
+
if (!rank) return void 0;
|
|
546
|
+
const d = rank.decay;
|
|
547
|
+
return {
|
|
548
|
+
Decay: prune({
|
|
549
|
+
field: d.field,
|
|
550
|
+
origin: d.origin instanceof Date ? d.origin.getTime() : d.origin,
|
|
551
|
+
scale: d.scale,
|
|
552
|
+
decay: d.decay,
|
|
553
|
+
lambda: d.lambda,
|
|
554
|
+
missing: d.missing
|
|
555
|
+
})
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
function encodeHighlight(h) {
|
|
559
|
+
if (h === void 0 || h === false) return void 0;
|
|
560
|
+
if (h === true) return {};
|
|
561
|
+
return prune({
|
|
562
|
+
max_fragments: h.maxFragments,
|
|
563
|
+
fragment_chars: h.fragmentChars
|
|
564
|
+
});
|
|
565
|
+
}
|
|
339
566
|
function enc(name) {
|
|
340
567
|
return encodeURIComponent(name);
|
|
341
568
|
}
|
|
569
|
+
function encodeFtsField(f2) {
|
|
570
|
+
if (typeof f2 === "string") return f2;
|
|
571
|
+
return prune({
|
|
572
|
+
field: f2.field,
|
|
573
|
+
k1: f2.k1,
|
|
574
|
+
b: f2.b,
|
|
575
|
+
language: f2.language,
|
|
576
|
+
ascii_folding: f2.asciiFolding,
|
|
577
|
+
max_token_len: f2.maxTokenLen
|
|
578
|
+
});
|
|
579
|
+
}
|
|
342
580
|
function prune(body) {
|
|
343
581
|
const out = {};
|
|
344
582
|
for (const [k, val] of Object.entries(body)) {
|
|
@@ -367,6 +605,13 @@ var f = {
|
|
|
367
605
|
}),
|
|
368
606
|
/** `attrs[key]` is a `Str` matching the glob pattern (`*`, `?`, `[..]`). */
|
|
369
607
|
glob: (key, pattern) => ({ Glob: [key, pattern] }),
|
|
608
|
+
/**
|
|
609
|
+
* {@link f.glob}, ignoring **ASCII** case on both sides — `"Src/*"` matches
|
|
610
|
+
* `"src/main.rs"`. Non-ASCII is not folded (`É` does not match `é`).
|
|
611
|
+
*/
|
|
612
|
+
iglob: (key, pattern) => ({
|
|
613
|
+
IGlob: [key, pattern]
|
|
614
|
+
}),
|
|
370
615
|
/** `attrs[key]` equals one of `values`. */
|
|
371
616
|
in: (key, values) => ({
|
|
372
617
|
In: [key, values.map(encodeValue)]
|
|
@@ -391,6 +636,56 @@ var f = {
|
|
|
391
636
|
ge: (key, value) => ({
|
|
392
637
|
Ge: [key, encodeValue(value)]
|
|
393
638
|
}),
|
|
639
|
+
/** `attrs[key]` is a `List` containing `value` (whole-element, not substring). */
|
|
640
|
+
contains: (key, value) => ({
|
|
641
|
+
Contains: [key, encodeValue(value)]
|
|
642
|
+
}),
|
|
643
|
+
/** `attrs[key]` is a present `List` not containing `value`. */
|
|
644
|
+
notContains: (key, value) => ({
|
|
645
|
+
NotContains: [key, encodeValue(value)]
|
|
646
|
+
}),
|
|
647
|
+
/** `attrs[key]` is a `List` sharing at least one element with `values`. */
|
|
648
|
+
containsAny: (key, values) => ({
|
|
649
|
+
ContainsAny: [key, values.map(encodeValue)]
|
|
650
|
+
}),
|
|
651
|
+
/** Every sub-predicate holds. `all()` is `true`. */
|
|
652
|
+
all: (...preds) => ({ All: preds }),
|
|
653
|
+
/** At least one sub-predicate holds. `any()` is `false`. */
|
|
654
|
+
any: (...preds) => ({ Any: preds }),
|
|
655
|
+
/**
|
|
656
|
+
* The sub-predicate does not hold. Differs from {@link f.ne} on an absent key:
|
|
657
|
+
* `not(eq(k, v))` matches a record with no `k`, `ne(k, v)` does not.
|
|
658
|
+
*/
|
|
659
|
+
not: (pred) => ({ Not: pred }),
|
|
660
|
+
/**
|
|
661
|
+
* `attrs[key]` is within `maxEdits` Levenshtein edits of `text`, ASCII-case-folded on
|
|
662
|
+
* both sides; a `List` matches if any element does. The only three-element predicate.
|
|
663
|
+
* A `maxEdits` above 8 is refused by the server, not clamped.
|
|
664
|
+
*/
|
|
665
|
+
fuzzy: (key, text, maxEdits) => ({
|
|
666
|
+
Fuzzy: [key, text, maxEdits]
|
|
667
|
+
}),
|
|
668
|
+
/**
|
|
669
|
+
* Every token of `text` appears among `attrs[key]`'s tokens, in any order. Tokens are
|
|
670
|
+
* ASCII-case-folded runs of alphanumerics; a `List` matches if any single element does.
|
|
671
|
+
*/
|
|
672
|
+
containsAllTokens: (key, text) => ({
|
|
673
|
+
ContainsAllTokens: [key, text]
|
|
674
|
+
}),
|
|
675
|
+
/** At least one token of `text` appears among `attrs[key]`'s tokens. Empty never matches. */
|
|
676
|
+
containsAnyToken: (key, text) => ({
|
|
677
|
+
ContainsAnyToken: [key, text]
|
|
678
|
+
}),
|
|
679
|
+
/** `text`'s tokens appear consecutively and in order — a phrase match. */
|
|
680
|
+
containsTokenSequence: (key, text) => ({
|
|
681
|
+
ContainsTokenSequence: [key, text]
|
|
682
|
+
}),
|
|
683
|
+
/**
|
|
684
|
+
* `attrs[key]` matches the regular expression, **anchored at both ends** like
|
|
685
|
+
* {@link f.glob} — `.*` opts back into a substring search, and `(?i)` into case folding.
|
|
686
|
+
* The syntax is Rust's `regex`, not JS's: no backreferences and no lookaround.
|
|
687
|
+
*/
|
|
688
|
+
regex: (key, pattern) => ({ Regex: [key, pattern] }),
|
|
394
689
|
/** Collect predicates into a {@link Filter} (purely sugar — they already AND). */
|
|
395
690
|
and: (...preds) => preds
|
|
396
691
|
};
|
|
@@ -405,6 +700,7 @@ var f = {
|
|
|
405
700
|
f,
|
|
406
701
|
v
|
|
407
702
|
});
|
|
703
|
+
//! Decoding a hit's optional annotations — the opt-in "why did this match".
|
|
408
704
|
//! Error type carrying the HTTP status the server reported.
|
|
409
705
|
//! Ergonomic constructors and decoders for the externally-tagged `Value` wire type.
|
|
410
706
|
//! `NidusClient` — a remote client over the `nidus serve` HTTP API.
|