@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.
- package/README.md +195 -10
- package/dist/command.js +2 -1
- 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/assemble.d.ts +52 -0
- package/dist/docs/assemble.js +127 -0
- package/dist/docs/build.d.ts +54 -0
- package/dist/docs/build.js +134 -0
- package/dist/docs/chunk.d.ts +73 -0
- package/dist/docs/chunk.js +586 -0
- package/dist/docs/command.d.ts +3 -0
- package/dist/docs/command.js +575 -0
- package/dist/docs/files.d.ts +94 -0
- package/dist/docs/files.js +80 -0
- package/dist/docs/index.d.ts +13 -0
- package/dist/docs/index.js +13 -0
- package/dist/docs/load.d.ts +42 -0
- package/dist/docs/load.js +203 -0
- package/dist/docs/lookup.d.ts +80 -0
- package/dist/docs/lookup.js +147 -0
- 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/parse.d.ts +95 -0
- package/dist/docs/parse.js +372 -0
- package/dist/docs/pool.d.ts +27 -0
- package/dist/docs/pool.js +133 -0
- package/dist/docs/readme.d.ts +6 -0
- package/dist/docs/readme.js +142 -0
- package/dist/docs/render.d.ts +13 -0
- package/dist/docs/render.js +46 -0
- package/dist/docs/repl.d.ts +7 -0
- package/dist/docs/repl.js +130 -0
- package/dist/docs/search.d.ts +92 -0
- package/dist/docs/search.js +251 -0
- package/dist/docs/store.d.ts +79 -0
- package/dist/docs/store.js +214 -0
- package/dist/docs/tools.d.ts +10 -0
- package/dist/docs/tools.js +300 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +3 -0
- package/dist/schema/build.d.ts +16 -2
- package/dist/schema/build.js +37 -25
- package/dist/schema/command.js +39 -10
- package/dist/schema/query.js +1 -0
- package/dist/schema/readme.js +6 -2
- package/dist/schema/search.d.ts +2 -0
- package/dist/schema/search.js +18 -2
- package/dist/schema/store.d.ts +13 -3
- package/dist/schema/store.js +73 -24
- package/dist/schema/tools.js +21 -2
- package/package.json +17 -4
package/README.md
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# @zenera/rag
|
|
2
2
|
|
|
3
|
-
**
|
|
4
|
-
|
|
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
|
[](https://github.com/andreyryabov/ZeneraNeo/blob/main/LICENSE)
|
|
7
8
|
[](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
|
-
##
|
|
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`
|
|
123
|
-
if it is set; without that, the nearest index to the
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|