@zenera/rag 1.1.10 → 1.1.11
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 +41 -0
- package/dist/common/cache.d.ts +66 -0
- package/dist/common/cache.js +172 -0
- package/dist/common/embedder.d.ts +11 -1
- package/dist/common/embedder.js +6 -17
- package/dist/common/manifest.d.ts +7 -1
- package/dist/common/progress.d.ts +16 -1
- package/dist/common/progress.js +35 -3
- package/dist/common/prose.d.ts +7 -0
- package/dist/common/prose.js +13 -0
- package/dist/docs/build.d.ts +22 -2
- package/dist/docs/build.js +52 -26
- package/dist/docs/command.js +56 -10
- package/dist/docs/load.d.ts +18 -4
- package/dist/docs/load.js +54 -63
- package/dist/docs/outline.d.ts +11 -0
- package/dist/docs/outline.js +55 -0
- package/dist/docs/parse-cache.d.ts +20 -0
- package/dist/docs/parse-cache.js +60 -0
- package/dist/docs/parse-worker.d.ts +13 -0
- package/dist/docs/parse-worker.js +21 -0
- package/dist/docs/pool.d.ts +27 -0
- package/dist/docs/pool.js +133 -0
- package/dist/docs/readme.js +23 -3
- package/dist/docs/store.d.ts +25 -1
- package/dist/docs/store.js +59 -16
- package/dist/schema/build.d.ts +16 -2
- package/dist/schema/build.js +37 -25
- package/dist/schema/command.js +36 -10
- package/dist/schema/readme.js +6 -2
- package/dist/schema/store.d.ts +9 -1
- package/dist/schema/store.js +57 -15
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -376,6 +376,47 @@ The copies are a record, not an input: rebuilding reads the files you name, not
|
|
|
376
376
|
the ones in `sources/`. A **document** index has no `--no-sources`, because
|
|
377
377
|
there the copies are what every quoted line is read from.
|
|
378
378
|
|
|
379
|
+
**Vectors are paid for once.** Embedding is the whole cost of a build — the
|
|
380
|
+
reading and the writing are milliseconds, the round trips are minutes, and they
|
|
381
|
+
are the only part anyone is billed for. Almost none of it is new work: editing
|
|
382
|
+
one paragraph re-embeds every other one unchanged, and a build killed at 90%
|
|
383
|
+
starts again from nothing. So the vectors are kept in this machine's shared
|
|
384
|
+
cache, `~/.zenera/neo/cache/vectors/`, keyed by a hash of the exact text that
|
|
385
|
+
produced them together with the model that made them. Re-indexing an unchanged
|
|
386
|
+
corpus embeds nothing; re-indexing after an edit pays for the chunks that
|
|
387
|
+
changed.
|
|
388
|
+
|
|
389
|
+
The store is the machine's rather than the index's, which is the point: the same
|
|
390
|
+
corpus indexed into a second directory costs nothing the second time, and two
|
|
391
|
+
projects that quote the same handbook pay for it once between them.
|
|
392
|
+
|
|
393
|
+
The key is the text, never the chunk's position — inserting a sentence at the
|
|
394
|
+
top of a document shifts every later chunk's ordinal without changing a word of
|
|
395
|
+
it. Two documents that share a paragraph share its vector. Vectors another model
|
|
396
|
+
made are not evicted, they are simply never asked for, because the model is part
|
|
397
|
+
of the key. It is a cache, so every failure in it is a miss and nothing more.
|
|
398
|
+
`--no-cache` ignores it; `--cache-dir <dir>` keeps the work somewhere else.
|
|
399
|
+
|
|
400
|
+
Nothing is ever evicted by a build: what a corpus stops referring to is still
|
|
401
|
+
work somebody paid for. Getting rid of it is [`zen cache`](../cli/README.md)'s
|
|
402
|
+
job — `zen cache ls` to see what is there, `zen cache prune --older-than 30d` to
|
|
403
|
+
drop what has gone unread.
|
|
404
|
+
|
|
405
|
+
**Parsing is spread across cores, and also cached.** With the vectors cached,
|
|
406
|
+
parsing is what is left: reading a document, cutting it into chunks, and
|
|
407
|
+
recording its shape. It is CPU-bound and every document is independent of every
|
|
408
|
+
other, so a **document** index does it on a pool of worker threads — one per
|
|
409
|
+
core, less the one running the build. The pool is skipped for a handful of
|
|
410
|
+
documents, where starting the threads costs more than it saves.
|
|
411
|
+
|
|
412
|
+
The result is cached the same way the vectors are, under
|
|
413
|
+
`~/.zenera/neo/cache/docs-parse/`. The key covers the file's bytes, the name
|
|
414
|
+
stamped on its chunks, and every setting that decides where a chunk ends, so
|
|
415
|
+
changing `--chunk-tokens` misses on all of it and editing one file misses on one
|
|
416
|
+
file. Both caches are governed by the same `--no-cache` and the same
|
|
417
|
+
`--cache-dir`, and both fall back to doing the work when anything about them
|
|
418
|
+
looks wrong.
|
|
419
|
+
|
|
379
420
|
## Notes
|
|
380
421
|
|
|
381
422
|
- Documents are **bundled, not dereferenced**: `#/components/schemas/User` is
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { Embedder } from '@zenera/neo';
|
|
2
|
+
export declare const VECTOR_KIND = "vectors";
|
|
3
|
+
export interface VectorCache {
|
|
4
|
+
/** the vector this text already has, if it has one */
|
|
5
|
+
get(text: string): Float32Array | undefined;
|
|
6
|
+
/** keeps a vector for next time */
|
|
7
|
+
put(text: string, vector: Float32Array): void;
|
|
8
|
+
/** says the entries this build read are still wanted, so age means unused */
|
|
9
|
+
commit(): void;
|
|
10
|
+
/** for a build that failed; entries land as they are paid for, so nothing unwinds */
|
|
11
|
+
abandon(): void;
|
|
12
|
+
readonly hits: number;
|
|
13
|
+
}
|
|
14
|
+
/** A cache that remembers nothing, for `--no-cache`. */
|
|
15
|
+
export declare const NO_CACHE: VectorCache;
|
|
16
|
+
export interface VectorCacheOptions {
|
|
17
|
+
/** the reference as it was typed, which is part of what the vectors mean */
|
|
18
|
+
ref: string;
|
|
19
|
+
/** somewhere other than the shared store */
|
|
20
|
+
dir?: string;
|
|
21
|
+
/** when the caller asked for a width the model does not default to */
|
|
22
|
+
dimensions?: number;
|
|
23
|
+
}
|
|
24
|
+
export declare function openCache(embedder: Embedder, options: VectorCacheOptions): VectorCache;
|
|
25
|
+
export interface CachedEmbedOptions {
|
|
26
|
+
embedder: Embedder;
|
|
27
|
+
cache: VectorCache;
|
|
28
|
+
texts: readonly string[];
|
|
29
|
+
signal?: AbortSignal;
|
|
30
|
+
onProgress?: (done: number, total: number) => void;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Embeds only what the cache does not already have, and hands each request's
|
|
34
|
+
* answer to the cache as it lands rather than at the end, so a build killed
|
|
35
|
+
* half way keeps the half it paid for.
|
|
36
|
+
*
|
|
37
|
+
* Identical texts are embedded once. A corpus repeats itself more than it looks
|
|
38
|
+
* like it does — shared boilerplate, a table copied between two documents — and
|
|
39
|
+
* a duplicate is a whole vector's worth of request for an answer already held.
|
|
40
|
+
*/
|
|
41
|
+
export declare function embedCached(options: CachedEmbedOptions): Promise<Float32Array[]>;
|
|
42
|
+
export interface EmbedStreamOptions<R> {
|
|
43
|
+
embedder: Embedder;
|
|
44
|
+
cache: VectorCache;
|
|
45
|
+
records: readonly R[];
|
|
46
|
+
textOf: (record: R) => string;
|
|
47
|
+
window?: number;
|
|
48
|
+
signal?: AbortSignal;
|
|
49
|
+
/** counted over every record, not over the window being worked on */
|
|
50
|
+
onProgress?: (done: number, total: number) => void;
|
|
51
|
+
onWindow: (records: readonly R[], vectors: readonly Float32Array[]) => Promise<void>;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Embeds in windows and hands each one straight to whatever stores it, so that
|
|
55
|
+
* peak memory is the window rather than the corpus. A corpus of 200k chunks at
|
|
56
|
+
* 3072 dimensions held about 12 GB of vectors this way round; it now holds
|
|
57
|
+
* whatever one window is, however large the corpus gets.
|
|
58
|
+
*
|
|
59
|
+
* Duplicate texts spanning two windows still cost one embedding, because the
|
|
60
|
+
* first window has already written them to the cache by the time the second
|
|
61
|
+
* asks. Under `--no-cache` they cost two, which is what `--no-cache` means.
|
|
62
|
+
*
|
|
63
|
+
* Returns the width the model answered with, for the manifest.
|
|
64
|
+
*/
|
|
65
|
+
export declare function embedStream<R>(options: EmbedStreamOptions<R>): Promise<number>;
|
|
66
|
+
//# sourceMappingURL=cache.d.ts.map
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { Cache, cacheKey } from '@zenera/cli/lib';
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// Not paying twice for the same vector
|
|
4
|
+
//
|
|
5
|
+
// Embedding is the whole cost of a build: the reading and the writing are
|
|
6
|
+
// seconds, the round trips are minutes and the only part anyone is billed for.
|
|
7
|
+
// And almost none of it is new work. Re-indexing a corpus after editing one
|
|
8
|
+
// paragraph re-embeds every other paragraph unchanged, and a build killed at
|
|
9
|
+
// 90% starts again from nothing.
|
|
10
|
+
//
|
|
11
|
+
// The vectors live in the machine's shared cache, so two indexes built from the
|
|
12
|
+
// same corpus — or one corpus indexed twice into different directories — pay
|
|
13
|
+
// for it once between them.
|
|
14
|
+
//
|
|
15
|
+
// The key is the *text*, together with the model that would embed it. Never the
|
|
16
|
+
// chunk's position: inserting a sentence at the top of a document shifts every
|
|
17
|
+
// later chunk's ordinal without changing a word of it, and an ordinal key would
|
|
18
|
+
// miss all of them. Two documents that happen to share a paragraph share its
|
|
19
|
+
// vector; a different model shares nothing, because it asks a different
|
|
20
|
+
// question and so has a different key. That is also why there is nothing here
|
|
21
|
+
// that invalidates anything.
|
|
22
|
+
//
|
|
23
|
+
// It is a cache, so every error is a miss, and the shared store guarantees that
|
|
24
|
+
// much on its own: nothing in this file can fail a build.
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
export const VECTOR_KIND = 'vectors';
|
|
27
|
+
/** Bumped only if what a key means changes; the model is already in the key. */
|
|
28
|
+
const VECTOR_VERSION = 'v1';
|
|
29
|
+
/** A cache that remembers nothing, for `--no-cache`. */
|
|
30
|
+
export const NO_CACHE = {
|
|
31
|
+
get: () => undefined,
|
|
32
|
+
put: () => { },
|
|
33
|
+
commit: () => { },
|
|
34
|
+
abandon: () => { },
|
|
35
|
+
hits: 0,
|
|
36
|
+
};
|
|
37
|
+
export function openCache(embedder, options) {
|
|
38
|
+
return new StoredVectors(new Cache(VECTOR_KIND, { dir: options.dir }), embedder, options);
|
|
39
|
+
}
|
|
40
|
+
class StoredVectors {
|
|
41
|
+
#store;
|
|
42
|
+
#prefix;
|
|
43
|
+
constructor(store, embedder, options) {
|
|
44
|
+
this.#store = store;
|
|
45
|
+
this.#prefix = [VECTOR_VERSION, options.ref, embedder.id, options.dimensions];
|
|
46
|
+
}
|
|
47
|
+
get hits() {
|
|
48
|
+
return this.#store.hits;
|
|
49
|
+
}
|
|
50
|
+
/** Everything the vector is a function of, ending with the text itself. */
|
|
51
|
+
#key(text) {
|
|
52
|
+
return cacheKey(...this.#prefix, text);
|
|
53
|
+
}
|
|
54
|
+
get(text) {
|
|
55
|
+
const found = this.#store.get(this.#key(text));
|
|
56
|
+
if (typeof found !== 'string' || found.length === 0) {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
const bytes = Buffer.from(found, 'base64');
|
|
60
|
+
if (bytes.byteLength === 0 || bytes.byteLength % 4 !== 0) {
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
// Buffer.from can land at any offset in the shared pool, and a
|
|
64
|
+
// Float32Array needs a multiple of four. Slicing copies to its own.
|
|
65
|
+
return new Float32Array(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength));
|
|
66
|
+
}
|
|
67
|
+
put(text, vector) {
|
|
68
|
+
if (vector.length > 0) {
|
|
69
|
+
const bytes = new Uint8Array(vector.buffer, vector.byteOffset, vector.byteLength);
|
|
70
|
+
this.#store.put(this.#key(text), Buffer.from(bytes).toString('base64'));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
commit() {
|
|
74
|
+
this.#store.commit();
|
|
75
|
+
}
|
|
76
|
+
abandon() { }
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Embeds only what the cache does not already have, and hands each request's
|
|
80
|
+
* answer to the cache as it lands rather than at the end, so a build killed
|
|
81
|
+
* half way keeps the half it paid for.
|
|
82
|
+
*
|
|
83
|
+
* Identical texts are embedded once. A corpus repeats itself more than it looks
|
|
84
|
+
* like it does — shared boilerplate, a table copied between two documents — and
|
|
85
|
+
* a duplicate is a whole vector's worth of request for an answer already held.
|
|
86
|
+
*/
|
|
87
|
+
export async function embedCached(options) {
|
|
88
|
+
const { cache, texts } = options;
|
|
89
|
+
const vectors = new Array(texts.length);
|
|
90
|
+
const wanted = new Map();
|
|
91
|
+
for (const [at, text] of texts.entries()) {
|
|
92
|
+
const hit = cache.get(text);
|
|
93
|
+
if (hit) {
|
|
94
|
+
vectors[at] = hit;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
const waiting = wanted.get(text);
|
|
98
|
+
if (waiting) {
|
|
99
|
+
waiting.push(at);
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
wanted.set(text, [at]);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const input = [...wanted.keys()];
|
|
106
|
+
const known = texts.length - [...wanted.values()].reduce((n, at) => n + at.length, 0);
|
|
107
|
+
if (input.length > 0) {
|
|
108
|
+
const response = await options.embedder.embed({
|
|
109
|
+
input,
|
|
110
|
+
taskType: 'document',
|
|
111
|
+
signal: options.signal,
|
|
112
|
+
onSlice: (at, slice) => {
|
|
113
|
+
for (const [i, vector] of slice.entries()) {
|
|
114
|
+
cache.put(input[at + i], Float32Array.from(vector));
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
onProgress: (done) => options.onProgress?.(known + done, texts.length),
|
|
118
|
+
});
|
|
119
|
+
for (const [i, vector] of response.vectors.entries()) {
|
|
120
|
+
const shared = Float32Array.from(vector);
|
|
121
|
+
// `put` rewrites the same bytes, so what a slice already saved costs
|
|
122
|
+
// nothing here. Not every embedder reports slices, and the cache
|
|
123
|
+
// cannot depend on it.
|
|
124
|
+
cache.put(input[i], shared);
|
|
125
|
+
for (const at of wanted.get(input[i])) {
|
|
126
|
+
vectors[at] = shared;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
options.onProgress?.(texts.length, texts.length);
|
|
131
|
+
return vectors;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* How many records are embedded, written and let go of before the next are
|
|
135
|
+
* looked at. At 3072 dimensions a window costs about 250 MB while it is in
|
|
136
|
+
* flight, and the embedder still sees enough texts at once to keep every
|
|
137
|
+
* request slot busy.
|
|
138
|
+
*/
|
|
139
|
+
const WINDOW = 4096;
|
|
140
|
+
/**
|
|
141
|
+
* Embeds in windows and hands each one straight to whatever stores it, so that
|
|
142
|
+
* peak memory is the window rather than the corpus. A corpus of 200k chunks at
|
|
143
|
+
* 3072 dimensions held about 12 GB of vectors this way round; it now holds
|
|
144
|
+
* whatever one window is, however large the corpus gets.
|
|
145
|
+
*
|
|
146
|
+
* Duplicate texts spanning two windows still cost one embedding, because the
|
|
147
|
+
* first window has already written them to the cache by the time the second
|
|
148
|
+
* asks. Under `--no-cache` they cost two, which is what `--no-cache` means.
|
|
149
|
+
*
|
|
150
|
+
* Returns the width the model answered with, for the manifest.
|
|
151
|
+
*/
|
|
152
|
+
export async function embedStream(options) {
|
|
153
|
+
const size = options.window ?? WINDOW;
|
|
154
|
+
const total = options.records.length;
|
|
155
|
+
let dimensions = 0;
|
|
156
|
+
for (let at = 0; at < total; at += size) {
|
|
157
|
+
const window = options.records.slice(at, at + size);
|
|
158
|
+
const vectors = await embedCached({
|
|
159
|
+
embedder: options.embedder,
|
|
160
|
+
cache: options.cache,
|
|
161
|
+
texts: window.map(options.textOf),
|
|
162
|
+
signal: options.signal,
|
|
163
|
+
onProgress: (done) => options.onProgress?.(at + done, total),
|
|
164
|
+
});
|
|
165
|
+
if (dimensions === 0) {
|
|
166
|
+
dimensions = vectors[0]?.length ?? 0;
|
|
167
|
+
}
|
|
168
|
+
await options.onWindow(window, vectors);
|
|
169
|
+
}
|
|
170
|
+
return dimensions;
|
|
171
|
+
}
|
|
172
|
+
//# sourceMappingURL=cache.js.map
|
|
@@ -1,3 +1,13 @@
|
|
|
1
1
|
import { type Embedder } from '@zenera/neo';
|
|
2
|
-
|
|
2
|
+
/**
|
|
3
|
+
* What a shorthand ref cannot say. Both are ceilings on what the embedder would
|
|
4
|
+
* otherwise work out for itself from the model and from what the provider
|
|
5
|
+
* refuses, so leaving them unset is the normal case.
|
|
6
|
+
*/
|
|
7
|
+
export interface EmbedderTuning {
|
|
8
|
+
maxBatch?: number;
|
|
9
|
+
/** narrower vectors, where the model is trained to be truncated */
|
|
10
|
+
dimensions?: number;
|
|
11
|
+
}
|
|
12
|
+
export declare function resolveEmbedder(ref: string | undefined, tuning?: EmbedderTuning): Promise<Embedder>;
|
|
3
13
|
//# sourceMappingURL=embedder.d.ts.map
|
package/dist/common/embedder.js
CHANGED
|
@@ -1,20 +1,6 @@
|
|
|
1
1
|
import { bold, CliError, CURATED, cyan, dim, ensureHome, envNames, form, KeyStore, note, PROVIDERS, table, usageError, } from '@zenera/cli/lib';
|
|
2
|
-
import { createEmbedder } from '@zenera/neo';
|
|
3
|
-
|
|
4
|
-
// Getting an embedder, and saying what the choices are when there is none
|
|
5
|
-
//
|
|
6
|
-
// The keyring is materialised here, and only here. The `zen` frame does not do
|
|
7
|
-
// it, and a command that forgets to looks exactly like a machine with no key:
|
|
8
|
-
// `zen key ls` shows the credential live and the command still says the
|
|
9
|
-
// environment variable is not set. Real env always wins, so a shell that names
|
|
10
|
-
// a key is never overridden by one on disk.
|
|
11
|
-
//
|
|
12
|
-
// Nothing in here is about a particular index. Which model made the vectors is
|
|
13
|
-
// recorded in the manifest and enforced on every later search, so this only has
|
|
14
|
-
// to turn a reference into an embedder — or, given none, into a list worth
|
|
15
|
-
// choosing from.
|
|
16
|
-
// ---------------------------------------------------------------------------
|
|
17
|
-
export async function resolveEmbedder(ref) {
|
|
2
|
+
import { createEmbedder, defaultModels } from '@zenera/neo';
|
|
3
|
+
export async function resolveEmbedder(ref, tuning) {
|
|
18
4
|
ensureHome();
|
|
19
5
|
const keys = await KeyStore.open();
|
|
20
6
|
// Asked before materialising, because materialising is what erases the
|
|
@@ -24,7 +10,10 @@ export async function resolveEmbedder(ref) {
|
|
|
24
10
|
if (!ref) {
|
|
25
11
|
throw choices(keys, fromEnv);
|
|
26
12
|
}
|
|
27
|
-
|
|
13
|
+
if (tuning?.maxBatch === undefined && tuning?.dimensions === undefined) {
|
|
14
|
+
return createEmbedder(ref);
|
|
15
|
+
}
|
|
16
|
+
return createEmbedder({ ...defaultModels.parseEmbedding(ref), ...tuning });
|
|
28
17
|
}
|
|
29
18
|
/**
|
|
30
19
|
* Well-known embedding models per provider, read off the CLI's catalog table so
|
|
@@ -12,11 +12,17 @@ export interface IndexHead {
|
|
|
12
12
|
kind: IndexKind;
|
|
13
13
|
createdAt: string;
|
|
14
14
|
indexer: string;
|
|
15
|
-
/**
|
|
15
|
+
/**
|
|
16
|
+
* `ref` as it was typed, `id` as the embedder answers to it, `dimensions`
|
|
17
|
+
* as the vectors actually came back. `requested` only when a width was
|
|
18
|
+
* asked for out loud: a search has to ask for the same one, and asking for
|
|
19
|
+
* the model's own default is not the same as not asking.
|
|
20
|
+
*/
|
|
16
21
|
embedding: {
|
|
17
22
|
ref: string;
|
|
18
23
|
id: string;
|
|
19
24
|
dimensions: number;
|
|
25
|
+
requested?: number;
|
|
20
26
|
};
|
|
21
27
|
/** whether the table carries an fts index, and whether it carries a vector one */
|
|
22
28
|
indexes: {
|
|
@@ -2,6 +2,11 @@ export declare const LOCK_FILE = ".lock";
|
|
|
2
2
|
export declare const README_FILE = "README.md";
|
|
3
3
|
/** The floor on how often README.md is rewritten. */
|
|
4
4
|
export declare const INTERVAL_MS = 5000;
|
|
5
|
+
/** How long one phase took, by its key rather than its sentence. */
|
|
6
|
+
export interface PhaseTiming {
|
|
7
|
+
name: string;
|
|
8
|
+
ms: number;
|
|
9
|
+
}
|
|
5
10
|
export interface Building<S> {
|
|
6
11
|
documents: readonly string[];
|
|
7
12
|
embedding: string;
|
|
@@ -9,8 +14,14 @@ export interface Building<S> {
|
|
|
9
14
|
now: number;
|
|
10
15
|
/** the current phase, as it should be said out loud */
|
|
11
16
|
step: string;
|
|
17
|
+
/** the current phase's key, for a report that counts different things per phase */
|
|
18
|
+
phase: string;
|
|
12
19
|
done: number;
|
|
13
20
|
total: number;
|
|
21
|
+
/** what the phase is still waiting on, when it is able to say */
|
|
22
|
+
pending: readonly string[];
|
|
23
|
+
/** every phase so far; the last is the one still running */
|
|
24
|
+
timings: readonly PhaseTiming[];
|
|
14
25
|
/** what the documents turned out to hold, once they have been read */
|
|
15
26
|
summary: S | undefined;
|
|
16
27
|
}
|
|
@@ -18,12 +29,14 @@ export interface Completed<M> {
|
|
|
18
29
|
dir: string;
|
|
19
30
|
manifest: M;
|
|
20
31
|
ms: number;
|
|
32
|
+
timings: readonly PhaseTiming[];
|
|
21
33
|
}
|
|
22
34
|
export interface Failed {
|
|
23
35
|
documents: readonly string[];
|
|
24
36
|
step: string;
|
|
25
37
|
reason: unknown;
|
|
26
38
|
started: number;
|
|
39
|
+
timings: readonly PhaseTiming[];
|
|
27
40
|
}
|
|
28
41
|
/** The three states a README can be in, written by whoever knows the subject. */
|
|
29
42
|
export interface Report<S, M> {
|
|
@@ -45,9 +58,11 @@ export interface BuildPlan<S, M, P extends string> {
|
|
|
45
58
|
export interface Journal<S, M, P extends string> {
|
|
46
59
|
phase(name: P): void;
|
|
47
60
|
read(summary: S, total: number): void;
|
|
48
|
-
progress(done: number, total: number): void;
|
|
61
|
+
progress(done: number, total: number, pending?: readonly string[]): void;
|
|
49
62
|
finish(manifest: M): void;
|
|
50
63
|
fail(reason: unknown): void;
|
|
64
|
+
/** what each phase cost, for a caller that wants to say so out loud */
|
|
65
|
+
readonly timings: readonly PhaseTiming[];
|
|
51
66
|
}
|
|
52
67
|
/**
|
|
53
68
|
* Takes the directory, or refuses it. Two builds writing one index would
|
package/dist/common/progress.js
CHANGED
|
@@ -46,8 +46,18 @@ export function beginBuild(plan) {
|
|
|
46
46
|
let summary;
|
|
47
47
|
let done = 0;
|
|
48
48
|
let total = 0;
|
|
49
|
+
let pending = [];
|
|
49
50
|
let wroteAt = 0;
|
|
50
51
|
let closed = false;
|
|
52
|
+
const timings = [];
|
|
53
|
+
let phaseAt = started;
|
|
54
|
+
/** Closes the running phase off. Called on every transition, and at the end. */
|
|
55
|
+
const mark = () => {
|
|
56
|
+
const at = Date.now();
|
|
57
|
+
timings.push({ name: phase, ms: at - phaseAt });
|
|
58
|
+
phaseAt = at;
|
|
59
|
+
};
|
|
60
|
+
const sofar = () => [...timings, { name: phase, ms: Date.now() - phaseAt }];
|
|
51
61
|
const write = (body) => {
|
|
52
62
|
// Through a temp name, so a reader never catches half a file.
|
|
53
63
|
const target = join(plan.dir, README_FILE);
|
|
@@ -62,8 +72,11 @@ export function beginBuild(plan) {
|
|
|
62
72
|
started,
|
|
63
73
|
now: Date.now(),
|
|
64
74
|
step: plan.phases[phase],
|
|
75
|
+
phase,
|
|
65
76
|
done,
|
|
66
77
|
total,
|
|
78
|
+
pending,
|
|
79
|
+
timings: sofar(),
|
|
67
80
|
summary,
|
|
68
81
|
}));
|
|
69
82
|
const maybe = () => {
|
|
@@ -87,7 +100,9 @@ export function beginBuild(plan) {
|
|
|
87
100
|
report();
|
|
88
101
|
return {
|
|
89
102
|
phase(name) {
|
|
103
|
+
mark();
|
|
90
104
|
phase = name;
|
|
105
|
+
pending = [];
|
|
91
106
|
maybe();
|
|
92
107
|
},
|
|
93
108
|
read(seen, count) {
|
|
@@ -95,16 +110,33 @@ export function beginBuild(plan) {
|
|
|
95
110
|
total = count;
|
|
96
111
|
maybe();
|
|
97
112
|
},
|
|
98
|
-
progress(at, of) {
|
|
113
|
+
progress(at, of, waiting) {
|
|
99
114
|
done = at;
|
|
100
115
|
total = of;
|
|
116
|
+
pending = waiting ?? [];
|
|
101
117
|
maybe();
|
|
102
118
|
},
|
|
103
119
|
finish(manifest) {
|
|
104
|
-
|
|
120
|
+
mark();
|
|
121
|
+
close(plan.report.complete({
|
|
122
|
+
dir: plan.dir,
|
|
123
|
+
manifest,
|
|
124
|
+
ms: Date.now() - started,
|
|
125
|
+
timings,
|
|
126
|
+
}));
|
|
105
127
|
},
|
|
106
128
|
fail(reason) {
|
|
107
|
-
|
|
129
|
+
mark();
|
|
130
|
+
close(plan.report.failed({
|
|
131
|
+
documents,
|
|
132
|
+
step: plan.phases[phase],
|
|
133
|
+
reason,
|
|
134
|
+
started,
|
|
135
|
+
timings,
|
|
136
|
+
}));
|
|
137
|
+
},
|
|
138
|
+
get timings() {
|
|
139
|
+
return closed ? timings : sofar();
|
|
108
140
|
},
|
|
109
141
|
};
|
|
110
142
|
}
|
package/dist/common/prose.d.ts
CHANGED
|
@@ -4,6 +4,13 @@ export declare function fields(rows: readonly string[][]): string[];
|
|
|
4
4
|
export declare function grid(headers: readonly string[], rows: readonly (readonly string[])[]): string[];
|
|
5
5
|
export declare function plural(n: number, one: string, many?: string): string;
|
|
6
6
|
export declare function duration(ms: number): string;
|
|
7
|
+
/** Sub-second, because a phase breakdown is read to compare phases against each other. */
|
|
8
|
+
export declare function span(ms: number): string;
|
|
9
|
+
/** Where the time went, which is the only way to know what is worth making faster. */
|
|
10
|
+
export declare const breakdown: (timings: readonly {
|
|
11
|
+
name: string;
|
|
12
|
+
ms: number;
|
|
13
|
+
}[]) => string;
|
|
7
14
|
/** The first line only: a stack trace in a README helps nobody. */
|
|
8
15
|
export declare function message(reason: unknown): string;
|
|
9
16
|
export declare function searched(indexes: {
|
package/dist/common/prose.js
CHANGED
|
@@ -36,6 +36,19 @@ export function duration(ms) {
|
|
|
36
36
|
? `${minutes}m${seconds % 60}s`
|
|
37
37
|
: `${Math.floor(minutes / 60)}h${minutes % 60}m`;
|
|
38
38
|
}
|
|
39
|
+
/** Sub-second, because a phase breakdown is read to compare phases against each other. */
|
|
40
|
+
export function span(ms) {
|
|
41
|
+
if (ms < 1000) {
|
|
42
|
+
return `${Math.round(ms)}ms`;
|
|
43
|
+
}
|
|
44
|
+
if (ms < 60_000) {
|
|
45
|
+
return `${(ms / 1000).toFixed(1)}s`;
|
|
46
|
+
}
|
|
47
|
+
const seconds = Math.round(ms / 1000);
|
|
48
|
+
return `${Math.floor(seconds / 60)}m${String(seconds % 60).padStart(2, '0')}s`;
|
|
49
|
+
}
|
|
50
|
+
/** Where the time went, which is the only way to know what is worth making faster. */
|
|
51
|
+
export const breakdown = (timings) => timings.map((t) => `${t.name} ${span(t.ms)}`).join(' · ');
|
|
39
52
|
/** The first line only: a stack trace in a README helps nobody. */
|
|
40
53
|
export function message(reason) {
|
|
41
54
|
const text = reason instanceof Error ? reason.message : String(reason);
|
package/dist/docs/build.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Embedder } from '@zenera/neo';
|
|
2
|
+
import { type PhaseTiming } from '../common/progress.ts';
|
|
2
3
|
import { type ChunkOptions } from './chunk.ts';
|
|
3
4
|
import { type Counts, type DocRecord, type Manifest } from './files.ts';
|
|
4
5
|
import { type Corpus } from './load.ts';
|
|
@@ -13,12 +14,23 @@ export interface BuildOptions {
|
|
|
13
14
|
embeddingRef?: string;
|
|
14
15
|
/** told the manifest, so a store can say what wrote it */
|
|
15
16
|
indexer: string;
|
|
16
|
-
/** texts sent to the embedder at once */
|
|
17
|
-
batch?: number;
|
|
18
17
|
chunk?: ChunkOptions;
|
|
18
|
+
/**
|
|
19
|
+
* The width asked of the embedder, when one was asked for. Part of the cache
|
|
20
|
+
* key, because a truncated vector is a different vector; left undefined when
|
|
21
|
+
* nobody asked, because that is a different key again from asking for the
|
|
22
|
+
* number the model would have chosen anyway.
|
|
23
|
+
*/
|
|
24
|
+
dimensions?: number;
|
|
25
|
+
/** reuse vectors and parses this machine already has; on by default */
|
|
26
|
+
cache?: boolean;
|
|
27
|
+
/** keep them somewhere other than the shared store */
|
|
28
|
+
cacheDir?: string;
|
|
19
29
|
signal?: AbortSignal;
|
|
20
30
|
/** what the documents turned out to hold, before a vector has been paid for */
|
|
21
31
|
onRead?: (summary: BuildSummary) => void;
|
|
32
|
+
/** documents parsed so far, and what the pool is still on */
|
|
33
|
+
onReading?: (done: number, total: number, pending: readonly string[]) => void;
|
|
22
34
|
onProgress?: (done: number, total: number) => void;
|
|
23
35
|
}
|
|
24
36
|
export interface BuildSummary {
|
|
@@ -29,6 +41,14 @@ export interface BuildSummary {
|
|
|
29
41
|
export interface BuildResult {
|
|
30
42
|
manifest: Manifest;
|
|
31
43
|
chunks: ChunkRecord[];
|
|
44
|
+
/** what each phase cost, so a slow build can say which part was slow */
|
|
45
|
+
timings: readonly PhaseTiming[];
|
|
46
|
+
/** what came out of the shared cache instead of being done again */
|
|
47
|
+
reused: Reused;
|
|
48
|
+
}
|
|
49
|
+
export interface Reused {
|
|
50
|
+
parses: number;
|
|
51
|
+
vectors: number;
|
|
32
52
|
}
|
|
33
53
|
export declare function buildIndex(options: BuildOptions): Promise<BuildResult>;
|
|
34
54
|
//# sourceMappingURL=build.d.ts.map
|