@zenera/rag 1.1.9 → 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.
Files changed (62) hide show
  1. package/README.md +195 -10
  2. package/dist/command.js +2 -1
  3. package/dist/common/cache.d.ts +66 -0
  4. package/dist/common/cache.js +172 -0
  5. package/dist/common/embedder.d.ts +11 -1
  6. package/dist/common/embedder.js +6 -17
  7. package/dist/common/manifest.d.ts +7 -1
  8. package/dist/common/progress.d.ts +16 -1
  9. package/dist/common/progress.js +35 -3
  10. package/dist/common/prose.d.ts +7 -0
  11. package/dist/common/prose.js +13 -0
  12. package/dist/docs/assemble.d.ts +52 -0
  13. package/dist/docs/assemble.js +127 -0
  14. package/dist/docs/build.d.ts +54 -0
  15. package/dist/docs/build.js +134 -0
  16. package/dist/docs/chunk.d.ts +73 -0
  17. package/dist/docs/chunk.js +586 -0
  18. package/dist/docs/command.d.ts +3 -0
  19. package/dist/docs/command.js +575 -0
  20. package/dist/docs/files.d.ts +94 -0
  21. package/dist/docs/files.js +80 -0
  22. package/dist/docs/index.d.ts +13 -0
  23. package/dist/docs/index.js +13 -0
  24. package/dist/docs/load.d.ts +42 -0
  25. package/dist/docs/load.js +203 -0
  26. package/dist/docs/lookup.d.ts +80 -0
  27. package/dist/docs/lookup.js +147 -0
  28. package/dist/docs/outline.d.ts +11 -0
  29. package/dist/docs/outline.js +55 -0
  30. package/dist/docs/parse-cache.d.ts +20 -0
  31. package/dist/docs/parse-cache.js +60 -0
  32. package/dist/docs/parse-worker.d.ts +13 -0
  33. package/dist/docs/parse-worker.js +21 -0
  34. package/dist/docs/parse.d.ts +95 -0
  35. package/dist/docs/parse.js +372 -0
  36. package/dist/docs/pool.d.ts +27 -0
  37. package/dist/docs/pool.js +133 -0
  38. package/dist/docs/readme.d.ts +6 -0
  39. package/dist/docs/readme.js +142 -0
  40. package/dist/docs/render.d.ts +13 -0
  41. package/dist/docs/render.js +46 -0
  42. package/dist/docs/repl.d.ts +7 -0
  43. package/dist/docs/repl.js +130 -0
  44. package/dist/docs/search.d.ts +92 -0
  45. package/dist/docs/search.js +251 -0
  46. package/dist/docs/store.d.ts +79 -0
  47. package/dist/docs/store.js +214 -0
  48. package/dist/docs/tools.d.ts +10 -0
  49. package/dist/docs/tools.js +300 -0
  50. package/dist/index.d.ts +1 -0
  51. package/dist/index.js +3 -0
  52. package/dist/schema/build.d.ts +16 -2
  53. package/dist/schema/build.js +37 -25
  54. package/dist/schema/command.js +39 -10
  55. package/dist/schema/query.js +1 -0
  56. package/dist/schema/readme.js +6 -2
  57. package/dist/schema/search.d.ts +2 -0
  58. package/dist/schema/search.js +18 -2
  59. package/dist/schema/store.d.ts +13 -3
  60. package/dist/schema/store.js +73 -24
  61. package/dist/schema/tools.js +21 -2
  62. package/package.json +17 -4
package/README.md CHANGED
@@ -1,7 +1,8 @@
1
1
  # @zenera/rag
2
2
 
3
- **An OpenAPI description, indexed as a graph and searched by meaning — for
4
- agents that have to call an API they have not read.**
3
+ **A corpus, indexed and searched by meaning — an OpenAPI description as a
4
+ graph, a pile of markdown as quotable passages — for agents that have to work
5
+ with something they have not read.**
5
6
 
6
7
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/andreyryabov/ZeneraNeo/blob/main/LICENSE)
7
8
  [![Node](https://img.shields.io/badge/node-%E2%89%A524-brightgreen.svg)](https://nodejs.org)
@@ -11,7 +12,23 @@ agents that have to call an API they have not read.**
11
12
  > [`zen`](https://github.com/andreyryabov/ZeneraNeo/blob/main/packages/cli/README.md),
12
13
  > which is also where the credentials already are.
13
14
 
14
- ## Why
15
+ ## Two subjects
16
+
17
+ A subject is a kind of corpus with its own index format, its own verbs and its
18
+ own flags — not a variation on one command, because what `list` means to an API
19
+ description is not what it means to a folder of notes.
20
+
21
+ | Subject | The corpus | The answer |
22
+ | ---------------- | ------------------------- | ---------------------------------------------- |
23
+ | `zen rag schema` | openapi/swagger documents | the connected piece of the API that matched |
24
+ | `zen rag docs` | markdown and plain text | the passages that matched, quoted with numbers |
25
+
26
+ Both are built the same way — [LanceDB](https://lancedb.com) for hybrid vector
27
+
28
+ - full-text retrieval, a manifest that records which embedder made the vectors,
29
+ and exact commands beside the ranking ones that need no credential at all.
30
+
31
+ ## Schema — an API description as a graph
15
32
 
16
33
  A large specification does not fit in a prompt, and the parts of it that answer
17
34
  a question are scattered: the field is on a schema, the schema is on a request
@@ -117,11 +134,75 @@ zen rag schema search --query - --format ts <<'JSON'
117
134
  JSON
118
135
  ```
119
136
 
137
+ ## Docs — markdown as quotable passages
138
+
139
+ The other subject. Point it at files, directories or globs; `.md`, `.markdown`,
140
+ `.txt` and `.text` are read, hidden directories and `node_modules` are not.
141
+
142
+ ```sh
143
+ zen rag docs index --embedding openai:text-embedding-3-small ./docs
144
+ zen rag docs search "how are rate limits counted"
145
+ ```
146
+
147
+ The answer is the documents themselves — the passages that matched, quoted
148
+ verbatim with their line numbers, and a marker wherever something between two
149
+ of them was left out:
150
+
151
+ ```
152
+ ## nsx_4.2.0/api/routing.md — 9 of 148 lines
153
+
154
+ 5 | ## Rate limits
155
+ 7 | Requests are counted per tenant and rejected past the limit.
156
+ ... 12 lines omitted (Retries, Backoff) ...
157
+ 24 | | route | limit | window |
158
+ 25 | | --- | --- | --- |
159
+ 27 | | /api/users | 250 | 1m |
160
+ ```
161
+
162
+ Nobody finds the paragraph they want on the first ask, so **narrowing is the
163
+ interface**, not an afterthought. The second call is the same question inside
164
+ one part of the tree:
165
+
166
+ ```sh
167
+ zen rag docs search --file "nsx_4.2.*/api/**" "rate limit for the users route"
168
+ zen rag docs search --section "Rate limits" --kind table "requests per minute"
169
+ zen rag docs search --mode text "X-RateLimit-Remaining" # exact wording only
170
+ zen rag docs search --interactive # narrow by typing
171
+ ```
172
+
173
+ `--file` is a glob when it has `*` or `?` and a substring otherwise, matched
174
+ against the document's **name**, which is its path relative to the common root
175
+ of everything indexed. That is what keeps
176
+ two releases of the same file apart. `--section` takes a heading title, and
177
+ covers whatever nests inside it. `--kind` takes `paragraph`, `list`, `table`,
178
+ `table_row`, `code`, `frontmatter` or `html`, for when the answer is a table
179
+ and not the prose around it. `-B/-A` widen each passage, `--max-lines` caps the
180
+ whole answer, `--exclude-id` moves on from what was already seen.
181
+
182
+ Tables are indexed twice over: once as a descriptor carrying the caption and
183
+ the column names, and once per row, with the header row travelling alongside so
184
+ the columns are still named wherever a row lands. A row too wide to be one
185
+ chunk is cut into column groups, with the key column repeated in each.
186
+
187
+ And beside all that, the exact half — no embedder, no credential, no network:
188
+
189
+ ```sh
190
+ zen rag docs list files # every document, and what it holds
191
+ zen rag docs list sections --file "api/**" # every heading, with its line span
192
+ zen rag docs list tables # every table, with its columns
193
+ zen rag docs grep "Retry-After" # every matching line, and its section
194
+ zen rag docs show api/routing.md --section "Rate limits"
195
+ zen rag docs show api/routing.md --lines 40-80
196
+ ```
197
+
198
+ `grep` reports `found` as the true total even when `--limit` cuts the rows, so
199
+ unlike a search it can answer whether a string appears at all.
200
+
120
201
  ## Which index
121
202
 
122
- Every reading command takes `-d, --dir`. Without one, `$ZEN_SCHEMA_DB` is used
123
- if it is set; without that, the nearest index to the working directory is found
124
- and named on stderr as it is used.
203
+ Every reading command takes `-d, --dir`. Without one, `$ZEN_SCHEMA_DB` or
204
+ `$ZEN_DOCS_DB` is used if it is set; without that, the nearest index to the
205
+ working directory is found and named on stderr as it is used.
125
206
 
126
207
  Nearest means what it says: this directory, then a short way down into it, then
127
208
  up a level and again, stopping at your home directory. What is looked for is a
@@ -131,7 +212,10 @@ the same. `schema-db` is only the name a new one is given.
131
212
 
132
213
  Two indexes the same distance away is a question, not a tie to break, and it is
133
214
  refused: the wrong index does not fail, it answers confidently about a
134
- different API. Name one with `--dir`, or set `ZEN_SCHEMA_DB`.
215
+ different API. Name one with `--dir`, or set the environment variable.
216
+
217
+ The search is scoped by kind, so a `docs` index and a `schema` index can sit in
218
+ the same tree without either shadowing the other.
135
219
 
136
220
  ## Commands
137
221
 
@@ -147,8 +231,8 @@ zen rag schema stats What is in an index, and what built it.
147
231
 
148
232
  Search terms are one flag each — `--all`, `--method`, `--type`, `--input-type`,
149
233
  `--output-type`, `--property`, `--input-property`, `--output-property` — shaped
150
- by `--direction`, `--method-type`, `--limit`, `--max-hops`, `--max-nodes` and
151
- the four `--exclude-*` filters, and rendered by `--format text | mermaid |
234
+ by `--direction`, `--method-type`, `--limit`, `--max-hops`, `--max-nodes`,
235
+ `--source` and the four `--exclude-*` filters, and rendered by `--format text | mermaid |
152
236
  mermaid-flowchart | ts | openapi`. `zen help rag` prints the full table.
153
237
 
154
238
  `list` and `grep` share `--name`, `--path`, `--regex`, `--case-sensitive`,
@@ -167,6 +251,23 @@ only what was named instead of its neighbourhood.
167
251
  zen rag schema grep token --ids-only | xargs zen rag schema show --format ts
168
252
  ```
169
253
 
254
+ And for documents:
255
+
256
+ ```
257
+ zen rag docs index <path...> Read the documents and write a searchable index.
258
+ zen rag docs search [text] Ask it something. --interactive for a prompt.
259
+ zen rag docs list <what> Every document, section or table. No ranking.
260
+ zen rag docs grep <pattern> Every matching line, with the section it sits in.
261
+ zen rag docs show <file> A document, a section of one, or a line range.
262
+ zen rag docs stats What is in an index, and what built it.
263
+ ```
264
+
265
+ Search takes the question as a bare phrase, narrowed by `--file`,
266
+ `--exclude-file`, `--section`, `--kind` and `--mode`, shaped by `--limit`,
267
+ `-B/--before`, `-A/--after` and `--max-lines`, and moved along by
268
+ `--exclude-id`. `list` and `grep` share `--file`, `--section`, `--regex`,
269
+ `--case-sensitive` and `--limit`.
270
+
170
271
  ## From an agent
171
272
 
172
273
  ```ts
@@ -201,6 +302,28 @@ need the word explained again, it needs the list of types that have one.
201
302
  `trace_api` is the step after both: a field is of no use until the call that
202
303
  carries it is known.
203
304
 
305
+ Documents come with four, in the group `docs`, selectable as `docs:*`:
306
+
307
+ ```ts
308
+ import { docs } from '@zenera/rag';
309
+
310
+ const index = await docs.DocsIndex.open('./docs-db', embedder);
311
+ const project = await loadProject('./my-project', { tools: docs.docsTools(index) });
312
+ ```
313
+
314
+ | Tool | For |
315
+ | ------------- | ------------------------------------------------------------ |
316
+ | `search_docs` | the passages that match, quoted with their line numbers |
317
+ | `list_docs` | the documents, their headings, or their tables — no search |
318
+ | `grep_docs` | every matching line, counted in full — no search |
319
+ | `read_docs` | a section or a line range, verbatim and with nothing omitted |
320
+
321
+ Same division, same reason. `search_docs` is the way in when the question is
322
+ vague; `grep_docs` is how "it is not in here" can actually be concluded. Every
323
+ answer carries line numbers and `read_docs` takes them, which is the loop the
324
+ subject exists for: find the passage, read around it, then edit the file the
325
+ passage came from.
326
+
204
327
  ## What an index is
205
328
 
206
329
  ```
@@ -217,6 +340,21 @@ schema-db/
217
340
  The manifest records which embedder made the vectors, and a search with a
218
341
  different one is refused rather than answered with noise.
219
342
 
343
+ A document index is the same idea with a different middle:
344
+
345
+ ```
346
+ docs-db/
347
+ ├── README.md what this index holds — a live progress report while it builds
348
+ ├── manifest.json written last — its absence means "not indexed"
349
+ ├── outline.json every heading and table, with the lines they cover
350
+ ├── sources/ the documents themselves, verbatim — where the quotes come from
351
+ └── lance/ one table: a row per chunk, two texts, one vector
352
+ ```
353
+
354
+ There the copies are not a record but the answer: a search returns line ranges
355
+ and the lines are read back out of `sources/`, so what is quoted is the document
356
+ rather than a reconstruction of it.
357
+
220
358
  Indexing a large document is minutes of silence, so the directory says what is
221
359
  happening to it. `README.md` appears first as a progress report — the documents,
222
360
  the embedder, the step, how many entities have been embedded of how many, and
@@ -235,7 +373,49 @@ so an index built here is read under a name this machine never sees. `--no-sourc
235
373
  leaves the copies out, for an index that will never travel.
236
374
 
237
375
  The copies are a record, not an input: rebuilding reads the files you name, not
238
- the ones in `sources/`.
376
+ the ones in `sources/`. A **document** index has no `--no-sources`, because
377
+ there the copies are what every quoted line is read from.
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.
239
419
 
240
420
  ## Notes
241
421
 
@@ -251,6 +431,11 @@ the ones in `sources/`.
251
431
  - Filters reaching the store are **closed enums only**. Exclusion lists are
252
432
  applied in JavaScript afterwards, so nothing a model wrote ever reaches a SQL
253
433
  predicate.
434
+ - A document chunk knows **exactly which lines** of the original it stands for,
435
+ headings and table headers included. That is what makes an answer quotable,
436
+ and what lets the next question be phrased in line numbers.
437
+ - Plain text is read as paragraphs and given **no invented headings**: a `.txt`
438
+ file has one section, which is the document.
254
439
 
255
440
  ## The rest of the family
256
441
 
package/dist/command.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { bold, cyan, dim, table, usageError, write, } from '@zenera/cli/lib';
2
+ import { command as docs } from "./docs/command.js";
2
3
  import { command as schema } from "./schema/command.js";
3
4
  // ---------------------------------------------------------------------------
4
5
  // zen rag — retrieval, by subject
@@ -18,7 +19,7 @@ import { command as schema } from "./schema/command.js";
18
19
  // `help <subject>` is a verb rather than a flag because `--help` never gets
19
20
  // here: the frame lifts it out of the arguments and answers with this page.
20
21
  // ---------------------------------------------------------------------------
21
- const SUBJECTS = { schema };
22
+ const SUBJECTS = { schema, docs };
22
23
  const USAGE = 'zen rag <subject> <command> [args...]';
23
24
  export const command = {
24
25
  summary: 'Retrieval over a corpus: index it, then ask it something.',
@@ -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
- export declare function resolveEmbedder(ref: string | undefined): Promise<Embedder>;
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
@@ -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
- return createEmbedder(ref);
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
- /** `ref` as it was typed, `id` as the embedder answers to it */
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