@zenera/rag 1.1.21 → 1.1.22
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 +19 -0
- package/dist/common/manifest.d.ts +27 -0
- package/dist/common/manifest.js +47 -1
- package/dist/common/prose.d.ts +7 -0
- package/dist/common/prose.js +16 -0
- package/dist/common/restore.d.ts +30 -0
- package/dist/common/restore.js +65 -0
- package/dist/docs/build.d.ts +4 -0
- package/dist/docs/build.js +22 -0
- package/dist/docs/command.js +134 -6
- package/dist/docs/files.d.ts +10 -2
- package/dist/docs/files.js +1 -2
- package/dist/docs/load.d.ts +7 -0
- package/dist/docs/load.js +1 -1
- package/dist/docs/store.js +1 -1
- package/dist/schema/build.d.ts +2 -0
- package/dist/schema/build.js +1 -0
- package/dist/schema/command.js +127 -6
- package/dist/schema/files.d.ts +1 -2
- package/dist/schema/files.js +1 -2
- package/dist/schema/store.js +1 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -113,6 +113,8 @@ standalone OpenAPI document.
|
|
|
113
113
|
|
|
114
114
|
```
|
|
115
115
|
zen rag schema index <spec...> Read the documents and write a searchable index.
|
|
116
|
+
zen rag schema restore Re-embed an index from its own copies of the documents.
|
|
117
|
+
zen rag schema ready Can this index answer? Exit 0 if it can, 3 if it cannot.
|
|
116
118
|
zen rag schema search Ask it something. --interactive for a prompt.
|
|
117
119
|
zen rag schema list <what> Every method, type or property. No ranking.
|
|
118
120
|
zen rag schema grep <pattern> Every literal match across the whole index.
|
|
@@ -306,6 +308,8 @@ in line numbers and the file can be edited from the answer.
|
|
|
306
308
|
|
|
307
309
|
```
|
|
308
310
|
zen rag docs index <path...> Read the documents and write a searchable index.
|
|
311
|
+
zen rag docs restore Re-embed an index from its own copies of the documents.
|
|
312
|
+
zen rag docs ready Can this index answer? Exit 0 if it can, 3 if it cannot.
|
|
309
313
|
zen rag docs search [text] Ask it something. --interactive for a prompt.
|
|
310
314
|
zen rag docs list <what> Every document, section or table. No ranking.
|
|
311
315
|
zen rag docs grep <pattern> Every matching line, with the section it sits in.
|
|
@@ -471,6 +475,17 @@ references; document indexes preserve the source material used for retrieval.
|
|
|
471
475
|
For documents, search results are always grounded in the original text, with
|
|
472
476
|
source names and line ranges available for inspection and follow-up work.
|
|
473
477
|
|
|
478
|
+
Because the documents travel inside the index, it can be rebuilt from itself.
|
|
479
|
+
Vector files are large and binary, so a project typically commits the index and
|
|
480
|
+
git-ignores `lance/`; a fresh checkout then has an index that looks complete and
|
|
481
|
+
cannot answer. `ready` reports that in an exit code, and `restore` re-embeds
|
|
482
|
+
from the copies already present.
|
|
483
|
+
|
|
484
|
+
```sh
|
|
485
|
+
zen rag docs ready --dir assets/docs-db # exit 0 searchable, 3 not
|
|
486
|
+
zen rag docs restore --dir assets/docs-db # re-embed from sources/
|
|
487
|
+
```
|
|
488
|
+
|
|
474
489
|
<details>
|
|
475
490
|
<summary>Built for project workflows</summary>
|
|
476
491
|
|
|
@@ -493,6 +508,10 @@ zen rag schema index --embedding openai:text-embedding-3-small ./specs
|
|
|
493
508
|
The selected provider stays associated with the index, ensuring search uses a
|
|
494
509
|
compatible representation of its content.
|
|
495
510
|
|
|
511
|
+
To change it later, `restore --embedding <ref>` re-embeds an existing index from
|
|
512
|
+
its own copies and rewrites the manifest, so no source material has to be
|
|
513
|
+
located again.
|
|
514
|
+
|
|
496
515
|
### Efficient rebuilds
|
|
497
516
|
|
|
498
517
|
Rebuild an index as content changes without repeating work for unchanged files
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
export declare const MANIFEST_FILE = "manifest.json";
|
|
2
|
+
/** Where the vectors live, in both kinds of index. */
|
|
3
|
+
export declare const LANCE_DIR = "lance";
|
|
4
|
+
export declare const lancePath: (dir: string) => string;
|
|
2
5
|
export type IndexKind = 'schema' | 'docs';
|
|
3
6
|
/** How to name a kind when something has to be said about it. */
|
|
4
7
|
export declare const SUBJECT: Record<IndexKind, {
|
|
@@ -53,4 +56,28 @@ export declare function readHead<T extends IndexHead>(dir: string, spec: IndexSp
|
|
|
53
56
|
* something anyone should have to remember.
|
|
54
57
|
*/
|
|
55
58
|
export declare function assertSameEmbedding(head: IndexHead, ref: string): void;
|
|
59
|
+
export type IndexState = 'absent' | 'incomplete' | 'ready';
|
|
60
|
+
interface Closable {
|
|
61
|
+
close(): void;
|
|
62
|
+
}
|
|
63
|
+
export interface IndexHealth<T extends IndexHead = IndexHead> {
|
|
64
|
+
dir: string;
|
|
65
|
+
state: IndexState;
|
|
66
|
+
/** absent when there is no manifest to read */
|
|
67
|
+
head?: T;
|
|
68
|
+
/** what is the matter, or what is there, in one line */
|
|
69
|
+
reason: string;
|
|
70
|
+
/** the command that would change the answer; empty when the answer is yes */
|
|
71
|
+
fix: string;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* `open` is the subject's own store opener, so the readiness of an index is
|
|
75
|
+
* decided by the same code a search would use rather than by a second opinion
|
|
76
|
+
* about what a directory ought to contain.
|
|
77
|
+
*
|
|
78
|
+
* A manifest of the wrong kind or the wrong version still throws: those are not
|
|
79
|
+
* states of this index, they are the caller asking about a different one.
|
|
80
|
+
*/
|
|
81
|
+
export declare function inspectIndex<T extends IndexHead>(dir: string, spec: IndexSpec, open: (dir: string) => Promise<Closable>): Promise<IndexHealth<T>>;
|
|
82
|
+
export {};
|
|
56
83
|
//# sourceMappingURL=manifest.d.ts.map
|
package/dist/common/manifest.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { CliError, EXIT } from '@zenera/cli/lib';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
2
3
|
import { readFile } from 'node:fs/promises';
|
|
3
4
|
import { join } from 'node:path';
|
|
4
5
|
// ---------------------------------------------------------------------------
|
|
@@ -15,6 +16,9 @@ import { join } from 'node:path';
|
|
|
15
16
|
// worth spending a field on.
|
|
16
17
|
// ---------------------------------------------------------------------------
|
|
17
18
|
export const MANIFEST_FILE = 'manifest.json';
|
|
19
|
+
/** Where the vectors live, in both kinds of index. */
|
|
20
|
+
export const LANCE_DIR = 'lance';
|
|
21
|
+
export const lancePath = (dir) => join(dir, LANCE_DIR);
|
|
18
22
|
/** How to name a kind when something has to be said about it. */
|
|
19
23
|
export const SUBJECT = {
|
|
20
24
|
schema: { label: 'a schema index', command: 'zen rag schema' },
|
|
@@ -56,7 +60,49 @@ export async function readHead(dir, spec) {
|
|
|
56
60
|
*/
|
|
57
61
|
export function assertSameEmbedding(head, ref) {
|
|
58
62
|
if (ref !== head.embedding.ref && ref !== head.embedding.id) {
|
|
59
|
-
throw new CliError(`this index was built with ${head.embedding.ref}, not ${ref}`, EXIT.invalid, `search it with --embedding ${head.embedding.ref}, or
|
|
63
|
+
throw new CliError(`this index was built with ${head.embedding.ref}, not ${ref}`, EXIT.invalid, `search it with --embedding ${head.embedding.ref}, or move it to ${ref} with ` +
|
|
64
|
+
`\`${SUBJECT[head.kind ?? 'schema'].command} restore --embedding ${ref}\``);
|
|
60
65
|
}
|
|
61
66
|
}
|
|
67
|
+
/**
|
|
68
|
+
* `open` is the subject's own store opener, so the readiness of an index is
|
|
69
|
+
* decided by the same code a search would use rather than by a second opinion
|
|
70
|
+
* about what a directory ought to contain.
|
|
71
|
+
*
|
|
72
|
+
* A manifest of the wrong kind or the wrong version still throws: those are not
|
|
73
|
+
* states of this index, they are the caller asking about a different one.
|
|
74
|
+
*/
|
|
75
|
+
export async function inspectIndex(dir, spec, open) {
|
|
76
|
+
const { command } = SUBJECT[spec.kind];
|
|
77
|
+
if (!existsSync(join(dir, MANIFEST_FILE))) {
|
|
78
|
+
return {
|
|
79
|
+
dir,
|
|
80
|
+
state: 'absent',
|
|
81
|
+
reason: `${dir} does not hold an index`,
|
|
82
|
+
fix: `build one with \`${command} index\``,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
const head = await readHead(dir, spec);
|
|
86
|
+
let store;
|
|
87
|
+
try {
|
|
88
|
+
store = await open(dir);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return {
|
|
92
|
+
dir,
|
|
93
|
+
state: 'incomplete',
|
|
94
|
+
head,
|
|
95
|
+
reason: `${dir} has no vectors — \`${LANCE_DIR}/\` is missing or unreadable`,
|
|
96
|
+
fix: `re-embed them with \`${command} restore --dir ${dir}\``,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
store.close();
|
|
100
|
+
return {
|
|
101
|
+
dir,
|
|
102
|
+
state: 'ready',
|
|
103
|
+
head,
|
|
104
|
+
reason: `${dir} is searchable — ${head.embedding.ref} (${head.embedding.dimensions}d)`,
|
|
105
|
+
fix: '',
|
|
106
|
+
};
|
|
107
|
+
}
|
|
62
108
|
//# sourceMappingURL=manifest.js.map
|
package/dist/common/prose.d.ts
CHANGED
|
@@ -11,6 +11,13 @@ export declare const breakdown: (timings: readonly {
|
|
|
11
11
|
name: string;
|
|
12
12
|
ms: number;
|
|
13
13
|
}[]) => string;
|
|
14
|
+
/**
|
|
15
|
+
* The silence after the last embedded row. Building the full-text and vector
|
|
16
|
+
* indexes is one call that reports nothing until it returns, and on a large
|
|
17
|
+
* corpus that is minutes — so it is announced, or it reads as a hang. Small
|
|
18
|
+
* indexes finish before the line is read, and are not promised a wait.
|
|
19
|
+
*/
|
|
20
|
+
export declare function indexing(rows: number, unit: string): string[];
|
|
14
21
|
/** The first line only: a stack trace in a README helps nobody. */
|
|
15
22
|
export declare function message(reason: unknown): string;
|
|
16
23
|
export declare function searched(indexes: {
|
package/dist/common/prose.js
CHANGED
|
@@ -49,6 +49,22 @@ export function span(ms) {
|
|
|
49
49
|
}
|
|
50
50
|
/** Where the time went, which is the only way to know what is worth making faster. */
|
|
51
51
|
export const breakdown = (timings) => timings.map((t) => `${t.name} ${span(t.ms)}`).join(' · ');
|
|
52
|
+
/**
|
|
53
|
+
* The silence after the last embedded row. Building the full-text and vector
|
|
54
|
+
* indexes is one call that reports nothing until it returns, and on a large
|
|
55
|
+
* corpus that is minutes — so it is announced, or it reads as a hang. Small
|
|
56
|
+
* indexes finish before the line is read, and are not promised a wait.
|
|
57
|
+
*/
|
|
58
|
+
export function indexing(rows, unit) {
|
|
59
|
+
const lines = [`indexing ${rows} ${unit} for search: full text, then vectors …`];
|
|
60
|
+
if (rows >= 50_000) {
|
|
61
|
+
lines.push('this step reports no progress, and at this size it takes several minutes');
|
|
62
|
+
}
|
|
63
|
+
else if (rows >= 5_000) {
|
|
64
|
+
lines.push('this step reports no progress until it is done');
|
|
65
|
+
}
|
|
66
|
+
return lines;
|
|
67
|
+
}
|
|
52
68
|
/** The first line only: a stack trace in a README helps nobody. */
|
|
53
69
|
export function message(reason) {
|
|
54
70
|
const text = reason instanceof Error ? reason.message : String(reason);
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { type Context } from '@zenera/cli/lib';
|
|
2
|
+
import type { IndexHead, IndexHealth } from './manifest.ts';
|
|
3
|
+
export interface Staging {
|
|
4
|
+
/** where the rebuild is written */
|
|
5
|
+
dir: string;
|
|
6
|
+
/** the old index, kept until the new one is in place */
|
|
7
|
+
previous: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function stagingFor(dir: string): Staging;
|
|
10
|
+
/**
|
|
11
|
+
* Two renames, because a rename cannot replace a directory that has anything in
|
|
12
|
+
* it. If the second fails the first is undone, so the window in which neither
|
|
13
|
+
* directory is the index is as short as two syscalls and never ends with one
|
|
14
|
+
* missing.
|
|
15
|
+
*/
|
|
16
|
+
export declare function swapIn(dir: string, staging: Staging): Promise<void>;
|
|
17
|
+
/**
|
|
18
|
+
* The answer to `ready` is the exit code, so that a setup step can be written
|
|
19
|
+
* as `if zen rag docs ready -d "$OUT" --quiet; then …` and never have to parse
|
|
20
|
+
* anything. With `--json` the same answer is a document, and it is printed for
|
|
21
|
+
* both outcomes: a caller that asked for JSON gets JSON whatever the news is.
|
|
22
|
+
*/
|
|
23
|
+
export declare function reportReady(health: IndexHealth, ctx: Context, quiet?: boolean): void;
|
|
24
|
+
/**
|
|
25
|
+
* What a restore is about to do, said before it starts, because the two things
|
|
26
|
+
* worth objecting to — a different model, and chunking that was never recorded
|
|
27
|
+
* — are both cheap to say and expensive to discover afterwards.
|
|
28
|
+
*/
|
|
29
|
+
export declare function announce(head: IndexHead, ref: string, entities: string): void;
|
|
30
|
+
//# sourceMappingURL=restore.d.ts.map
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { CliError, dim, EXIT, json, note } from '@zenera/cli/lib';
|
|
2
|
+
import { rename, rm } from 'node:fs/promises';
|
|
3
|
+
import { basename, dirname, join } from 'node:path';
|
|
4
|
+
export function stagingFor(dir) {
|
|
5
|
+
const parent = dirname(dir);
|
|
6
|
+
const name = basename(dir);
|
|
7
|
+
return {
|
|
8
|
+
dir: join(parent, `.${name}.restoring`),
|
|
9
|
+
previous: join(parent, `.${name}.previous`),
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Two renames, because a rename cannot replace a directory that has anything in
|
|
14
|
+
* it. If the second fails the first is undone, so the window in which neither
|
|
15
|
+
* directory is the index is as short as two syscalls and never ends with one
|
|
16
|
+
* missing.
|
|
17
|
+
*/
|
|
18
|
+
export async function swapIn(dir, staging) {
|
|
19
|
+
await rm(staging.previous, { recursive: true, force: true });
|
|
20
|
+
await rename(dir, staging.previous);
|
|
21
|
+
try {
|
|
22
|
+
await rename(staging.dir, dir);
|
|
23
|
+
}
|
|
24
|
+
catch (err) {
|
|
25
|
+
await rename(staging.previous, dir).catch(() => undefined);
|
|
26
|
+
throw err;
|
|
27
|
+
}
|
|
28
|
+
await rm(staging.previous, { recursive: true, force: true });
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The answer to `ready` is the exit code, so that a setup step can be written
|
|
32
|
+
* as `if zen rag docs ready -d "$OUT" --quiet; then …` and never have to parse
|
|
33
|
+
* anything. With `--json` the same answer is a document, and it is printed for
|
|
34
|
+
* both outcomes: a caller that asked for JSON gets JSON whatever the news is.
|
|
35
|
+
*/
|
|
36
|
+
export function reportReady(health, ctx, quiet = false) {
|
|
37
|
+
if (ctx.json) {
|
|
38
|
+
json({
|
|
39
|
+
ready: health.state === 'ready',
|
|
40
|
+
dir: health.dir,
|
|
41
|
+
state: health.state,
|
|
42
|
+
embedding: health.head?.embedding ?? null,
|
|
43
|
+
reason: health.reason,
|
|
44
|
+
...(health.fix ? { fix: health.fix } : {}),
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
if (health.state !== 'ready') {
|
|
48
|
+
throw new CliError(health.reason, EXIT.invalid, health.fix);
|
|
49
|
+
}
|
|
50
|
+
if (!quiet && !ctx.json) {
|
|
51
|
+
note(dim(` ${health.reason}`));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* What a restore is about to do, said before it starts, because the two things
|
|
56
|
+
* worth objecting to — a different model, and chunking that was never recorded
|
|
57
|
+
* — are both cheap to say and expensive to discover afterwards.
|
|
58
|
+
*/
|
|
59
|
+
export function announce(head, ref, entities) {
|
|
60
|
+
if (ref !== head.embedding.ref) {
|
|
61
|
+
note(dim(` embedder ${head.embedding.ref} → ${ref}; the manifest will be rewritten`));
|
|
62
|
+
}
|
|
63
|
+
note(dim(` re-embedding ${entities} with ${ref} …`));
|
|
64
|
+
}
|
|
65
|
+
//# sourceMappingURL=restore.js.map
|
package/dist/docs/build.d.ts
CHANGED
|
@@ -15,6 +15,8 @@ export interface BuildOptions {
|
|
|
15
15
|
/** told the manifest, so a store can say what wrote it */
|
|
16
16
|
indexer: string;
|
|
17
17
|
chunk?: ChunkOptions;
|
|
18
|
+
/** name the documents relative to this, rather than to their common root */
|
|
19
|
+
root?: string;
|
|
18
20
|
/**
|
|
19
21
|
* The width asked of the embedder, when one was asked for. Part of the cache
|
|
20
22
|
* key, because a truncated vector is a different vector; left undefined when
|
|
@@ -32,6 +34,8 @@ export interface BuildOptions {
|
|
|
32
34
|
/** documents parsed so far, and what the pool is still on */
|
|
33
35
|
onReading?: (done: number, total: number, pending: readonly string[]) => void;
|
|
34
36
|
onProgress?: (done: number, total: number) => void;
|
|
37
|
+
/** every vector is in and the search indexes are about to be built, silently */
|
|
38
|
+
onWriting?: (rows: number) => void;
|
|
35
39
|
}
|
|
36
40
|
export interface BuildSummary {
|
|
37
41
|
sources: DocRecord[];
|
package/dist/docs/build.js
CHANGED
|
@@ -26,6 +26,7 @@ export async function buildIndex(options) {
|
|
|
26
26
|
try {
|
|
27
27
|
const corpus = await loadDocuments(options.files, options.cwd, {
|
|
28
28
|
chunk: options.chunk,
|
|
29
|
+
root: options.root,
|
|
29
30
|
cache: options.cache !== false,
|
|
30
31
|
cacheDir: options.cacheDir,
|
|
31
32
|
onProgress: (done, total, pending) => {
|
|
@@ -60,6 +61,7 @@ export async function buildIndex(options) {
|
|
|
60
61
|
writer = await openChunks(options.out);
|
|
61
62
|
const dimensions = await embedAll(chunks, options, journal, cache, writer);
|
|
62
63
|
journal.phase('writing');
|
|
64
|
+
options.onWriting?.(chunks.length);
|
|
63
65
|
const written = await writer.finish();
|
|
64
66
|
const manifest = {
|
|
65
67
|
version: INDEX_VERSION,
|
|
@@ -74,6 +76,7 @@ export async function buildIndex(options) {
|
|
|
74
76
|
},
|
|
75
77
|
sources,
|
|
76
78
|
counts,
|
|
79
|
+
chunk: settingsOf(options.chunk),
|
|
77
80
|
indexes: { fts: written.fts, vector: written.vector },
|
|
78
81
|
};
|
|
79
82
|
const outline = { files: corpus.docs.map((doc) => doc.outline) };
|
|
@@ -95,6 +98,25 @@ export async function buildIndex(options) {
|
|
|
95
98
|
throw err;
|
|
96
99
|
}
|
|
97
100
|
}
|
|
101
|
+
/**
|
|
102
|
+
* What the manifest keeps of the chunk settings: the numbers, and only the ones
|
|
103
|
+
* actually given. A `tokenCount` function cannot be written down, and a build
|
|
104
|
+
* handed one is a build a restore cannot reproduce from the manifest alone —
|
|
105
|
+
* which the omission says, rather than a recorded default pretending otherwise.
|
|
106
|
+
*/
|
|
107
|
+
function settingsOf(chunk) {
|
|
108
|
+
if (!chunk) {
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
const { chunkTokens, minChunkTokens, maxChunkTokens, tableSliceTokens } = chunk;
|
|
112
|
+
const settings = {
|
|
113
|
+
...(chunkTokens === undefined ? {} : { chunkTokens }),
|
|
114
|
+
...(minChunkTokens === undefined ? {} : { minChunkTokens }),
|
|
115
|
+
...(maxChunkTokens === undefined ? {} : { maxChunkTokens }),
|
|
116
|
+
...(tableSliceTokens === undefined ? {} : { tableSliceTokens }),
|
|
117
|
+
};
|
|
118
|
+
return Object.keys(settings).length > 0 ? settings : undefined;
|
|
119
|
+
}
|
|
98
120
|
/** One row per chunk, with the render set encoded and the document name on it. */
|
|
99
121
|
function recordsOf(corpus) {
|
|
100
122
|
return corpus.docs.flatMap((doc) => doc.chunks.map((chunk) => ({
|
package/dist/docs/command.js
CHANGED
|
@@ -1,20 +1,23 @@
|
|
|
1
1
|
import { bold, CliError, cyan, dim, EXIT, isInteractive, json, note, parse, paths, table, usageError, write, } from '@zenera/cli/lib';
|
|
2
|
-
import {
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { join, relative, resolve } from 'node:path';
|
|
3
4
|
import { resolveEmbedder } from "../common/embedder.js";
|
|
4
5
|
import { locateIndex, outputDir } from "../common/locate.js";
|
|
5
|
-
import { assertSameEmbedding } from "../common/manifest.js";
|
|
6
|
+
import { assertSameEmbedding, inspectIndex } from "../common/manifest.js";
|
|
6
7
|
import { PatternError } from "../common/match.js";
|
|
7
8
|
import { INTERVAL_MS } from "../common/progress.js";
|
|
8
|
-
import { breakdown, grid } from "../common/prose.js";
|
|
9
|
+
import { breakdown, grid, indexing } from "../common/prose.js";
|
|
10
|
+
import { announce, reportReady, stagingFor, swapIn } from "../common/restore.js";
|
|
9
11
|
import { assemble, DEFAULT_MAX_LINES } from "./assemble.js";
|
|
10
12
|
import { buildIndex } from "./build.js";
|
|
11
13
|
import { CHUNK_KINDS } from "./chunk.js";
|
|
12
|
-
import { DOCS_INDEX, readManifest } from "./files.js";
|
|
14
|
+
import { DOCS_INDEX, readManifest, SOURCES_DIR } from "./files.js";
|
|
13
15
|
import { DOC_EXTENSIONS } from "./load.js";
|
|
14
16
|
import { grepLines, listFiles, listSections, listTables, readRange, readSection, } from "./lookup.js";
|
|
15
17
|
import { MATCH_HEADERS, matchRows, renderAssembly } from "./render.js";
|
|
16
18
|
import { repl } from "./repl.js";
|
|
17
19
|
import { DEFAULT_LIMIT, DocsIndex, legsOf, SEARCH_MODES, } from "./search.js";
|
|
20
|
+
import { ChunkStore } from "./store.js";
|
|
18
21
|
const { defaultDir: DEFAULT_DIR, envName: DIR_ENV } = DOCS_INDEX;
|
|
19
22
|
// ---------------------------------------------------------------------------
|
|
20
23
|
// zen rag docs — a pile of documents, as something to search
|
|
@@ -39,8 +42,10 @@ const { defaultDir: DEFAULT_DIR, envName: DIR_ENV } = DOCS_INDEX;
|
|
|
39
42
|
// answer "does the word `deprecated` appear anywhere" — the honest answer is
|
|
40
43
|
// every match or none, and these give it with no model and no credential.
|
|
41
44
|
// ---------------------------------------------------------------------------
|
|
42
|
-
const USAGE = 'zen rag docs <index|search|list|grep|show|stats> [args...]';
|
|
45
|
+
const USAGE = 'zen rag docs <index|restore|ready|search|list|grep|show|stats> [args...]';
|
|
43
46
|
const INDEX_USAGE = 'zen rag docs index --embedding <ref> [--out <dir>] <path...>';
|
|
47
|
+
const RESTORE_USAGE = 'zen rag docs restore [--dir <dir>] [--embedding <ref>]';
|
|
48
|
+
const READY_USAGE = 'zen rag docs ready [--dir <dir>] [--quiet]';
|
|
44
49
|
const SEARCH_USAGE = 'zen rag docs search [--dir <dir>] [query... | --text-query <text> --vector-query <text>]';
|
|
45
50
|
const LIST_USAGE = 'zen rag docs list <files|sections|tables> [--dir <dir>]';
|
|
46
51
|
const GREP_USAGE = 'zen rag docs grep <pattern> [--dir <dir>]';
|
|
@@ -55,6 +60,11 @@ export const command = {
|
|
|
55
60
|
' index <path...>',
|
|
56
61
|
dim('Build or refresh an index before searching changed documents.'),
|
|
57
62
|
],
|
|
63
|
+
[
|
|
64
|
+
' restore',
|
|
65
|
+
dim('Re-embed an index from its own copies, after a clone or a model change.'),
|
|
66
|
+
],
|
|
67
|
+
[' ready', dim('Ask whether an index can answer. Exit 0 if it can, 3 if it cannot.')],
|
|
58
68
|
[
|
|
59
69
|
' search [text]',
|
|
60
70
|
dim('Rank relevant passages with hybrid full-text and vector search.'),
|
|
@@ -101,6 +111,29 @@ export const command = {
|
|
|
101
111
|
dim(' Every document is copied into the index, so it stays portable and'),
|
|
102
112
|
dim(' every quoted line comes from the document rather than a rebuild of it.'),
|
|
103
113
|
'',
|
|
114
|
+
'Restore — the vectors, from the copies the index already holds',
|
|
115
|
+
...table([
|
|
116
|
+
[' -d, --dir <dir>', dim(`Which index. Found from here if unset; see ${DIR_ENV}.`)],
|
|
117
|
+
[' --embedding <ref>', dim('Move to another embedder. Default: the one recorded.')],
|
|
118
|
+
[' --dimensions <n>', dim('Narrower vectors. Default: the width recorded.')],
|
|
119
|
+
[' --chunk-tokens <n>', dim('Re-chunk at this size. Default: the size recorded.')],
|
|
120
|
+
[' --batch <n>', dim("Texts per embedding request. Default: the model's own cap.")],
|
|
121
|
+
[' --no-cache', dim('Embed everything again, ignoring what is already kept.')],
|
|
122
|
+
[' --cache-dir <dir>', dim('Take them from somewhere other than the shared cache.')],
|
|
123
|
+
[' --quiet', dim('No narration.')],
|
|
124
|
+
]),
|
|
125
|
+
'',
|
|
126
|
+
dim(' `lance/` is binary and rebuildable, so it is usually git-ignored while the'),
|
|
127
|
+
dim(' rest of the index is committed. A clone then has an index that looks built'),
|
|
128
|
+
dim(` and cannot answer: ${cyan('ready')} says so, and this puts the vectors back`),
|
|
129
|
+
dim(' without going to find the documents again.'),
|
|
130
|
+
'',
|
|
131
|
+
dim(' It is also how an index changes embedder: --embedding re-embeds everything'),
|
|
132
|
+
dim(' and rewrites the manifest, so later searches ask for the new model.'),
|
|
133
|
+
'',
|
|
134
|
+
dim(' The rebuild is written beside the index and renamed into place, so a failed'),
|
|
135
|
+
dim(' restore leaves the old index intact — and needs room for both meanwhile.'),
|
|
136
|
+
'',
|
|
104
137
|
'Search',
|
|
105
138
|
...table([
|
|
106
139
|
[' <text>', dim('What to look for. One question, not a list of terms.')],
|
|
@@ -177,6 +210,10 @@ export const command = {
|
|
|
177
210
|
switch (name) {
|
|
178
211
|
case 'index':
|
|
179
212
|
return await index(tail, ctx);
|
|
213
|
+
case 'restore':
|
|
214
|
+
return await restore(tail, ctx);
|
|
215
|
+
case 'ready':
|
|
216
|
+
return await ready(tail, ctx);
|
|
180
217
|
case 'search':
|
|
181
218
|
return await search(tail, ctx);
|
|
182
219
|
case 'list':
|
|
@@ -250,6 +287,7 @@ async function index(args, ctx) {
|
|
|
250
287
|
onProgress: loud
|
|
251
288
|
? (done, total) => note(dim(` embedded ${done}/${total} · ${Math.floor((done / total) * 100)}% · ${elapsed(started)}`))
|
|
252
289
|
: undefined,
|
|
290
|
+
onWriting: loud ? (rows) => notes(indexing(rows, 'chunks').map(aside)) : undefined,
|
|
253
291
|
});
|
|
254
292
|
if (ctx.json) {
|
|
255
293
|
json({ out, manifest });
|
|
@@ -269,6 +307,91 @@ async function index(args, ctx) {
|
|
|
269
307
|
const where = out === resolve(ctx.cwd, DEFAULT_DIR) ? '' : ` --dir ${relative(ctx.cwd, out) || out}`;
|
|
270
308
|
note(dim(` search it: ${cyan(`zen rag docs search${where} "what you are after"`)}`));
|
|
271
309
|
}
|
|
310
|
+
async function restore(args, ctx) {
|
|
311
|
+
const { values } = parse(args, {
|
|
312
|
+
dir: { type: 'string', short: 'd' },
|
|
313
|
+
embedding: { type: 'string' },
|
|
314
|
+
batch: { type: 'string' },
|
|
315
|
+
dimensions: { type: 'string' },
|
|
316
|
+
'chunk-tokens': { type: 'string' },
|
|
317
|
+
'no-cache': { type: 'boolean' },
|
|
318
|
+
'cache-dir': { type: 'string' },
|
|
319
|
+
quiet: { type: 'boolean' },
|
|
320
|
+
}, RESTORE_USAGE);
|
|
321
|
+
const dir = indexDir(ctx, values.dir);
|
|
322
|
+
const manifest = await readManifest(dir);
|
|
323
|
+
const sources = join(dir, SOURCES_DIR);
|
|
324
|
+
if (!existsSync(sources)) {
|
|
325
|
+
throw new CliError(`${dir} kept no copy of its documents`, EXIT.invalid, 'build it again from the documents with `zen rag docs index`');
|
|
326
|
+
}
|
|
327
|
+
const loud = !values.quiet && !ctx.json;
|
|
328
|
+
const cacheDir = values['cache-dir'] ? resolve(ctx.cwd, values['cache-dir']) : paths.cache();
|
|
329
|
+
// Every setting defaults to what the index recorded, so a restore that is
|
|
330
|
+
// asked for nothing produces the index it already had, minus the loss.
|
|
331
|
+
const ref = values.embedding ?? manifest.embedding.ref;
|
|
332
|
+
const dimensions = values.dimensions
|
|
333
|
+
? count(values.dimensions, '--dimensions')
|
|
334
|
+
: manifest.embedding.requested;
|
|
335
|
+
const chunk = values['chunk-tokens']
|
|
336
|
+
? { chunkTokens: count(values['chunk-tokens'], '--chunk-tokens') }
|
|
337
|
+
: manifest.chunk;
|
|
338
|
+
if (loud && !chunk) {
|
|
339
|
+
note(dim(' this index records no chunk settings; the defaults are used'));
|
|
340
|
+
}
|
|
341
|
+
const chosen = await resolveEmbedder(ref, {
|
|
342
|
+
maxBatch: values.batch ? count(values.batch, '--batch') : undefined,
|
|
343
|
+
dimensions,
|
|
344
|
+
});
|
|
345
|
+
const started = Date.now();
|
|
346
|
+
if (loud) {
|
|
347
|
+
announce(manifest, ref, `${manifest.counts.chunks} chunks`);
|
|
348
|
+
}
|
|
349
|
+
const staging = stagingFor(dir);
|
|
350
|
+
const built = await buildIndex({
|
|
351
|
+
files: [sources],
|
|
352
|
+
cwd: dir,
|
|
353
|
+
// The names are the identity — chunk ids, --file patterns, what `show`
|
|
354
|
+
// takes — so they are taken from `sources/` rather than from wherever
|
|
355
|
+
// the documents happened to sit on the machine that indexed them.
|
|
356
|
+
root: sources,
|
|
357
|
+
out: staging.dir,
|
|
358
|
+
embedder: chosen,
|
|
359
|
+
embeddingRef: ref,
|
|
360
|
+
// The index is the same index; what rebuilt its vectors is not a new
|
|
361
|
+
// author of it.
|
|
362
|
+
indexer: manifest.indexer,
|
|
363
|
+
chunk,
|
|
364
|
+
dimensions,
|
|
365
|
+
cache: !values['no-cache'],
|
|
366
|
+
cacheDir: values['cache-dir'] ? cacheDir : undefined,
|
|
367
|
+
onProgress: loud
|
|
368
|
+
? (done, total) => note(dim(` embedded ${done}/${total} · ${Math.floor((done / total) * 100)}% · ${elapsed(started)}`))
|
|
369
|
+
: undefined,
|
|
370
|
+
onWriting: loud ? (rows) => notes(indexing(rows, 'chunks').map(aside)) : undefined,
|
|
371
|
+
});
|
|
372
|
+
await swapIn(dir, staging);
|
|
373
|
+
if (ctx.json) {
|
|
374
|
+
json({ dir, manifest: built.manifest });
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
note();
|
|
378
|
+
write(dir);
|
|
379
|
+
note(` restored ${bold(String(built.manifest.counts.chunks))} chunks from ` +
|
|
380
|
+
`${bold(String(built.manifest.counts.documents))} document(s) in ${bold(dir)}, ` +
|
|
381
|
+
`embedded with ${built.manifest.embedding.ref} ` +
|
|
382
|
+
`(${built.manifest.embedding.dimensions}d)`);
|
|
383
|
+
note(dim(` ${breakdown(built.timings)}`));
|
|
384
|
+
note(dim(` reused ${built.reused.vectors}/${built.manifest.counts.chunks} vectors` +
|
|
385
|
+
`${values['no-cache'] ? ' (--no-cache)' : ` from ${cacheDir}`}`));
|
|
386
|
+
}
|
|
387
|
+
// ---------------------------------------------------------------------------
|
|
388
|
+
// ready
|
|
389
|
+
// ---------------------------------------------------------------------------
|
|
390
|
+
async function ready(args, ctx) {
|
|
391
|
+
const { values } = parse(args, { dir: { type: 'string', short: 'd' }, quiet: { type: 'boolean' } }, READY_USAGE);
|
|
392
|
+
const health = await inspectIndex(indexDir(ctx, values.dir), DOCS_INDEX, ChunkStore.open);
|
|
393
|
+
reportReady(health, ctx, values.quiet === true);
|
|
394
|
+
}
|
|
272
395
|
const SOURCE_HEADERS = ['LINES', 'SECTIONS', 'TABLES', 'CHUNKS'];
|
|
273
396
|
function printSources(sources) {
|
|
274
397
|
// A corpus can be thousands of documents and a wall of them is not a report.
|
|
@@ -531,8 +654,9 @@ async function stats(args, ctx) {
|
|
|
531
654
|
const { values } = parse(args, { dir: { type: 'string', short: 'd' } }, 'zen rag docs stats [--dir <dir>]');
|
|
532
655
|
const dir = indexDir(ctx, values.dir);
|
|
533
656
|
const manifest = await readManifest(dir);
|
|
657
|
+
const health = await inspectIndex(dir, DOCS_INDEX, ChunkStore.open);
|
|
534
658
|
if (ctx.json) {
|
|
535
|
-
json(manifest);
|
|
659
|
+
json({ ...manifest, state: health.state });
|
|
536
660
|
return;
|
|
537
661
|
}
|
|
538
662
|
note(bold(dir));
|
|
@@ -544,6 +668,9 @@ async function stats(args, ctx) {
|
|
|
544
668
|
' indexes',
|
|
545
669
|
`fts ${yes(manifest.indexes.fts)} · vector ${yes(manifest.indexes.vector)}`,
|
|
546
670
|
],
|
|
671
|
+
// `stats` reports and never refuses, so a missing store is a row
|
|
672
|
+
// here rather than the error `search` would raise.
|
|
673
|
+
[' vectors', health.state === 'ready' ? 'present' : dim('missing — `restore` them')],
|
|
547
674
|
]));
|
|
548
675
|
printSources(manifest.sources);
|
|
549
676
|
notes(table([
|
|
@@ -619,5 +746,6 @@ function notes(lines) {
|
|
|
619
746
|
note(line);
|
|
620
747
|
}
|
|
621
748
|
}
|
|
749
|
+
const aside = (line) => dim(` ${line}`);
|
|
622
750
|
const yes = (value) => (value ? 'yes' : 'no');
|
|
623
751
|
//# sourceMappingURL=command.js.map
|
package/dist/docs/files.d.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { type IndexHead, type IndexSpec } from '../common/manifest.ts';
|
|
2
|
+
import type { ChunkOptions } from './chunk.ts';
|
|
2
3
|
import type { DocFormat } from './parse.ts';
|
|
3
4
|
export declare const INDEX_VERSION = 1;
|
|
4
5
|
/** How a document index is found, read, and refused. */
|
|
5
6
|
export declare const DOCS_INDEX: IndexSpec;
|
|
6
7
|
export declare const OUTLINE_FILE = "outline.json";
|
|
7
|
-
export declare const LANCE_DIR = "lance";
|
|
8
8
|
export declare const SOURCES_DIR = "sources";
|
|
9
|
+
export { LANCE_DIR, lancePath } from '../common/manifest.ts';
|
|
9
10
|
export interface HeadingRecord {
|
|
10
11
|
/** the heading's own line, 1-based */
|
|
11
12
|
line: number;
|
|
@@ -63,9 +64,17 @@ export interface Counts {
|
|
|
63
64
|
sections: number;
|
|
64
65
|
tables: number;
|
|
65
66
|
}
|
|
67
|
+
/**
|
|
68
|
+
* The chunk settings a build was given, so a restore can cut the documents the
|
|
69
|
+
* same way rather than silently re-chunking them. Numbers only: `tokenCount` is
|
|
70
|
+
* a function and does not survive a manifest, and absent means the defaults.
|
|
71
|
+
*/
|
|
72
|
+
export type ChunkSettings = Omit<ChunkOptions, 'tokenCount'>;
|
|
66
73
|
export interface Manifest extends IndexHead {
|
|
67
74
|
sources: DocRecord[];
|
|
68
75
|
counts: Counts;
|
|
76
|
+
/** absent in indexes built before this was recorded, which means the defaults */
|
|
77
|
+
chunk?: ChunkSettings;
|
|
69
78
|
}
|
|
70
79
|
export interface WrittenIndex {
|
|
71
80
|
manifest: Manifest;
|
|
@@ -78,7 +87,6 @@ export interface OpenDocs {
|
|
|
78
87
|
manifest: Manifest;
|
|
79
88
|
outline: Outline;
|
|
80
89
|
}
|
|
81
|
-
export declare const lancePath: (dir: string) => string;
|
|
82
90
|
export declare function writeIndex(dir: string, index: WrittenIndex): Promise<void>;
|
|
83
91
|
export declare function openIndex(dir: string): Promise<OpenDocs>;
|
|
84
92
|
export declare const readManifest: (dir: string) => Promise<Manifest>;
|
package/dist/docs/files.js
CHANGED
|
@@ -41,9 +41,8 @@ export const DOCS_INDEX = {
|
|
|
41
41
|
envName: 'ZEN_DOCS_DB',
|
|
42
42
|
};
|
|
43
43
|
export const OUTLINE_FILE = 'outline.json';
|
|
44
|
-
export const LANCE_DIR = 'lance';
|
|
45
44
|
export const SOURCES_DIR = 'sources';
|
|
46
|
-
export
|
|
45
|
+
export { LANCE_DIR, lancePath } from "../common/manifest.js";
|
|
47
46
|
export async function writeIndex(dir, index) {
|
|
48
47
|
await mkdir(dir, { recursive: true });
|
|
49
48
|
await writeFile(join(dir, OUTLINE_FILE), JSON.stringify(index.outline));
|
package/dist/docs/load.d.ts
CHANGED
|
@@ -26,6 +26,13 @@ export interface Corpus {
|
|
|
26
26
|
}
|
|
27
27
|
export interface LoadOptions {
|
|
28
28
|
chunk?: ChunkOptions;
|
|
29
|
+
/**
|
|
30
|
+
* Name documents relative to this, instead of relative to the common root of
|
|
31
|
+
* whatever was found. A restore pins it to the index's own `sources/`, so a
|
|
32
|
+
* corpus that is one nested document keeps the name it was indexed under
|
|
33
|
+
* rather than being renamed to its basename by `commonRoot`.
|
|
34
|
+
*/
|
|
35
|
+
root?: string;
|
|
29
36
|
/** remember what documents parse to; on by default */
|
|
30
37
|
cache?: boolean;
|
|
31
38
|
/** keep the parses somewhere other than the shared store */
|
package/dist/docs/load.js
CHANGED
|
@@ -39,7 +39,7 @@ export async function loadDocuments(inputs, cwd, options = {}) {
|
|
|
39
39
|
if (found.length === 0) {
|
|
40
40
|
throw new CliError('nothing to index', EXIT.invalid, `no ${DOC_EXTENSIONS.join(', ')} files were found under ${inputs.join(', ')}`);
|
|
41
41
|
}
|
|
42
|
-
const root = commonRoot(found);
|
|
42
|
+
const root = options.root ? resolve(cwd, options.root) : commonRoot(found);
|
|
43
43
|
const taken = new Set();
|
|
44
44
|
const chunk = options.chunk ?? {};
|
|
45
45
|
const cache = options.cache === false ? NO_PARSE_CACHE : openParseCache(options.cacheDir);
|
package/dist/docs/store.js
CHANGED
|
@@ -107,7 +107,7 @@ export class ChunkStore {
|
|
|
107
107
|
}
|
|
108
108
|
catch {
|
|
109
109
|
db.close();
|
|
110
|
-
throw new CliError(`${dir} holds no searchable table`, EXIT.invalid, 'rebuild it with `zen rag docs index`');
|
|
110
|
+
throw new CliError(`${dir} holds no searchable table`, EXIT.invalid, 'put the vectors back with `zen rag docs restore`, or rebuild it with `zen rag docs index`');
|
|
111
111
|
}
|
|
112
112
|
}
|
|
113
113
|
/** Nearest neighbours. Every row has a vector, so nothing has to be excluded. */
|
package/dist/schema/build.d.ts
CHANGED
|
@@ -27,6 +27,8 @@ export interface BuildOptions {
|
|
|
27
27
|
/** what the documents turned out to hold, before a vector has been paid for */
|
|
28
28
|
onRead?: (summary: BuildSummary) => void;
|
|
29
29
|
onProgress?: (done: number, total: number) => void;
|
|
30
|
+
/** every vector is in and the search indexes are about to be built, silently */
|
|
31
|
+
onWriting?: (rows: number) => void;
|
|
30
32
|
}
|
|
31
33
|
export interface BuildSummary {
|
|
32
34
|
sources: SourceRecord[];
|
package/dist/schema/build.js
CHANGED
|
@@ -45,6 +45,7 @@ export async function buildIndex(options) {
|
|
|
45
45
|
writer = await openStore(options.out);
|
|
46
46
|
const dimensions = await embedAll(entities, options, journal, cache, writer);
|
|
47
47
|
journal.phase('writing');
|
|
48
|
+
options.onWriting?.(entities.length);
|
|
48
49
|
const written = await writer.finish();
|
|
49
50
|
const manifest = {
|
|
50
51
|
version: INDEX_VERSION,
|
package/dist/schema/command.js
CHANGED
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
import { bold, CliError, cyan, dim, EXIT, isInteractive, json, note, parse, paths, table, usageError, write, } from '@zenera/cli/lib';
|
|
2
|
-
import {
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { join, relative, resolve } from 'node:path';
|
|
3
4
|
import { resolveEmbedder } from "../common/embedder.js";
|
|
4
5
|
import { locateIndex, outputDir } from "../common/locate.js";
|
|
5
|
-
import { assertSameEmbedding } from "../common/manifest.js";
|
|
6
|
+
import { assertSameEmbedding, inspectIndex } from "../common/manifest.js";
|
|
6
7
|
import { isGlob, loose, matcher, PatternError, wildcard } from "../common/match.js";
|
|
7
|
-
import { breakdown } from "../common/prose.js";
|
|
8
|
+
import { breakdown, indexing } from "../common/prose.js";
|
|
9
|
+
import { announce, reportReady, stagingFor, swapIn } from "../common/restore.js";
|
|
8
10
|
import { buildIndex } from "./build.js";
|
|
9
|
-
import { openIndex, readManifest, readSource, SCHEMA_INDEX } from "./files.js";
|
|
11
|
+
import { openIndex, readManifest, readSource, SCHEMA_INDEX, SOURCES_DIR, } from "./files.js";
|
|
10
12
|
import { fields, grepNodes, listNodes, propertyCount } from "./lookup.js";
|
|
11
13
|
import { isFormat, present } from "./present.js";
|
|
12
14
|
import { isEmpty, parseQuery, QueryError } from "./query.js";
|
|
13
15
|
import { sourceTag } from "./render.js";
|
|
14
16
|
import { repl } from "./repl.js";
|
|
15
17
|
import { SchemaIndex } from "./search.js";
|
|
18
|
+
import { EntityStore } from "./store.js";
|
|
16
19
|
import { select, stitch } from "./subgraph.js";
|
|
17
20
|
import { chainOf, DEFAULT_TRACE_HOPS, traceNodes } from "./trace.js";
|
|
18
21
|
const { defaultDir: DEFAULT_DIR, envName: DIR_ENV } = SCHEMA_INDEX;
|
|
@@ -37,8 +40,10 @@ const { defaultDir: DEFAULT_DIR, envName: DIR_ENV } = SCHEMA_INDEX;
|
|
|
37
40
|
// that question is every match or none, and these three give it without asking
|
|
38
41
|
// a model or a credential for permission.
|
|
39
42
|
// ---------------------------------------------------------------------------
|
|
40
|
-
const USAGE = 'zen rag schema <index|search|list|grep|trace|show|stats> [spec...]';
|
|
43
|
+
const USAGE = 'zen rag schema <index|restore|ready|search|list|grep|trace|show|stats> [spec...]';
|
|
41
44
|
const INDEX_USAGE = 'zen rag schema index --embedding <ref> [--out <dir>] <spec...>';
|
|
45
|
+
const RESTORE_USAGE = 'zen rag schema restore [--dir <dir>] [--embedding <ref>]';
|
|
46
|
+
const READY_USAGE = 'zen rag schema ready [--dir <dir>] [--quiet]';
|
|
42
47
|
const SEARCH_USAGE = 'zen rag schema search [--dir <dir>] [query...]';
|
|
43
48
|
const LIST_USAGE = 'zen rag schema list <methods|types|properties> [--dir <dir>]';
|
|
44
49
|
const GREP_USAGE = 'zen rag schema grep <pattern> [--dir <dir>]';
|
|
@@ -51,6 +56,8 @@ export const command = {
|
|
|
51
56
|
'Commands',
|
|
52
57
|
...table([
|
|
53
58
|
[' index <spec...>', dim('Read the documents and write a searchable index.')],
|
|
59
|
+
[' restore', dim('Re-embed an index from its own copies of the documents.')],
|
|
60
|
+
[' ready', dim('Ask whether an index can answer. Exit 0 if it can, 3 if it cannot.')],
|
|
54
61
|
[' search', dim('Ask it something. --interactive for a prompt.')],
|
|
55
62
|
[' list <what>', dim('Every method, type or property matching a pattern.')],
|
|
56
63
|
[' grep <pattern>', dim('Every literal match, ranked by nothing.')],
|
|
@@ -82,6 +89,26 @@ export const command = {
|
|
|
82
89
|
[' --cache-dir <dir>', dim('Keep the vectors somewhere other than the shared cache.')],
|
|
83
90
|
]),
|
|
84
91
|
'',
|
|
92
|
+
'Restore — the vectors, from the copies the index already holds',
|
|
93
|
+
...table([
|
|
94
|
+
[' -d, --dir <dir>', dim(`Which index. Found from here if unset; see ${DIR_ENV}.`)],
|
|
95
|
+
[' --embedding <ref>', dim('Move to another embedder. Default: the one recorded.')],
|
|
96
|
+
[' --dimensions <n>', dim('Narrower vectors. Default: the width recorded.')],
|
|
97
|
+
[' --batch <n>', dim("Texts per embedding request. Default: the model's own cap.")],
|
|
98
|
+
[' --no-cache', dim('Embed everything again, ignoring what this machine has.')],
|
|
99
|
+
[' --cache-dir <dir>', dim('Take them from somewhere other than the shared cache.')],
|
|
100
|
+
[' --quiet', dim('No narration.')],
|
|
101
|
+
]),
|
|
102
|
+
'',
|
|
103
|
+
dim(' `lance/` is binary and rebuildable, so it is usually git-ignored while the'),
|
|
104
|
+
dim(' rest of the index is committed. A clone then has an index that looks built'),
|
|
105
|
+
dim(` and cannot answer: ${cyan('ready')} says so, and this puts the vectors back`),
|
|
106
|
+
dim(' without going to find the documents again. It needs an index built with'),
|
|
107
|
+
dim(' sources — one made with --no-sources has nothing to rebuild from.'),
|
|
108
|
+
'',
|
|
109
|
+
dim(' It is also how an index changes embedder: --embedding re-embeds everything'),
|
|
110
|
+
dim(' and rewrites the manifest, so later searches ask for the new model.'),
|
|
111
|
+
'',
|
|
85
112
|
'Search terms (repeatable)',
|
|
86
113
|
...table([
|
|
87
114
|
[' <text>', dim('A bare phrase, the same as --all.')],
|
|
@@ -174,6 +201,10 @@ export const command = {
|
|
|
174
201
|
switch (name) {
|
|
175
202
|
case 'index':
|
|
176
203
|
return await index(tail, ctx);
|
|
204
|
+
case 'restore':
|
|
205
|
+
return await restore(tail, ctx);
|
|
206
|
+
case 'ready':
|
|
207
|
+
return await ready(tail, ctx);
|
|
177
208
|
case 'search':
|
|
178
209
|
return await search(tail, ctx);
|
|
179
210
|
case 'list':
|
|
@@ -239,6 +270,7 @@ async function index(args, ctx) {
|
|
|
239
270
|
onProgress: loud
|
|
240
271
|
? (done, total) => note(dim(` embedded ${done}/${total} · ${Math.floor((done / total) * 100)}% · ${elapsed(started)}`))
|
|
241
272
|
: undefined,
|
|
273
|
+
onWriting: loud ? (rows) => notes(indexing(rows, 'entities').map(aside)) : undefined,
|
|
242
274
|
});
|
|
243
275
|
if (ctx.json) {
|
|
244
276
|
json({ out, manifest });
|
|
@@ -256,6 +288,90 @@ async function index(args, ctx) {
|
|
|
256
288
|
const where = out === resolve(ctx.cwd, DEFAULT_DIR) ? '' : ` --dir ${relative(ctx.cwd, out) || out}`;
|
|
257
289
|
note(dim(` search it: ${cyan(`zen rag schema search${where} --all "what you are after"`)}`));
|
|
258
290
|
}
|
|
291
|
+
async function restore(args, ctx) {
|
|
292
|
+
const { values } = parse(args, {
|
|
293
|
+
dir: { type: 'string', short: 'd' },
|
|
294
|
+
embedding: { type: 'string' },
|
|
295
|
+
batch: { type: 'string' },
|
|
296
|
+
dimensions: { type: 'string' },
|
|
297
|
+
'no-cache': { type: 'boolean' },
|
|
298
|
+
'cache-dir': { type: 'string' },
|
|
299
|
+
quiet: { type: 'boolean' },
|
|
300
|
+
}, RESTORE_USAGE);
|
|
301
|
+
const dir = indexDir(ctx, values.dir);
|
|
302
|
+
const manifest = await readManifest(dir);
|
|
303
|
+
const files = bundled(dir, manifest);
|
|
304
|
+
const loud = !values.quiet && !ctx.json;
|
|
305
|
+
const cacheDir = values['cache-dir'] ? resolve(ctx.cwd, values['cache-dir']) : paths.cache();
|
|
306
|
+
// Every setting defaults to what the index recorded, so a restore that is
|
|
307
|
+
// asked for nothing produces the index it already had, minus the loss.
|
|
308
|
+
const ref = values.embedding ?? manifest.embedding.ref;
|
|
309
|
+
const dimensions = values.dimensions
|
|
310
|
+
? count(values.dimensions, '--dimensions')
|
|
311
|
+
: manifest.embedding.requested;
|
|
312
|
+
const chosen = await resolveEmbedder(ref, {
|
|
313
|
+
maxBatch: values.batch ? count(values.batch, '--batch') : undefined,
|
|
314
|
+
dimensions,
|
|
315
|
+
});
|
|
316
|
+
const started = Date.now();
|
|
317
|
+
if (loud) {
|
|
318
|
+
announce(manifest, ref, `${manifest.counts.entities} entities`);
|
|
319
|
+
}
|
|
320
|
+
const staging = stagingFor(dir);
|
|
321
|
+
const built = await buildIndex({
|
|
322
|
+
files,
|
|
323
|
+
out: staging.dir,
|
|
324
|
+
embedder: chosen,
|
|
325
|
+
embeddingRef: ref,
|
|
326
|
+
// The index is the same index; what rebuilt its vectors is not a new
|
|
327
|
+
// author of it.
|
|
328
|
+
indexer: manifest.indexer,
|
|
329
|
+
sources: true,
|
|
330
|
+
dimensions,
|
|
331
|
+
cache: !values['no-cache'],
|
|
332
|
+
cacheDir: values['cache-dir'] ? cacheDir : undefined,
|
|
333
|
+
onProgress: loud
|
|
334
|
+
? (done, total) => note(dim(` embedded ${done}/${total} · ${Math.floor((done / total) * 100)}% · ${elapsed(started)}`))
|
|
335
|
+
: undefined,
|
|
336
|
+
onWriting: loud ? (rows) => notes(indexing(rows, 'entities').map(aside)) : undefined,
|
|
337
|
+
});
|
|
338
|
+
await swapIn(dir, staging);
|
|
339
|
+
if (ctx.json) {
|
|
340
|
+
json({ dir, manifest: built.manifest });
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
note();
|
|
344
|
+
write(dir);
|
|
345
|
+
note(` restored ${bold(String(built.manifest.counts.entities))} entities in ${bold(dir)}, ` +
|
|
346
|
+
`embedded with ${built.manifest.embedding.ref} ` +
|
|
347
|
+
`(${built.manifest.embedding.dimensions}d)`);
|
|
348
|
+
note(dim(` ${breakdown(built.timings)}`));
|
|
349
|
+
note(dim(` reused ${built.reused}/${built.manifest.counts.entities} vectors` +
|
|
350
|
+
`${values['no-cache'] ? ' (--no-cache)' : ` from ${cacheDir}`}`));
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* The documents to rebuild from, in the order the index recorded them — which
|
|
354
|
+
* is what keeps the slugs the same, since two documents with the same filename
|
|
355
|
+
* are told apart by which was read first.
|
|
356
|
+
*/
|
|
357
|
+
function bundled(dir, manifest) {
|
|
358
|
+
const missing = manifest.sources.filter((s) => !s.path || !existsSync(join(dir, s.path)));
|
|
359
|
+
if (missing.length > 0) {
|
|
360
|
+
throw new CliError(`${dir} has no copy of ${missing.map((s) => s.name).join(', ')}`, EXIT.invalid, manifest.sources.some((s) => s.path)
|
|
361
|
+
? `the ${SOURCES_DIR}/ directory is incomplete; rebuild with \`zen rag schema index\``
|
|
362
|
+
: 'it was built with --no-sources; rebuild it from the documents with `zen rag schema index`');
|
|
363
|
+
}
|
|
364
|
+
return manifest.sources.map((s) => join(dir, s.path));
|
|
365
|
+
}
|
|
366
|
+
// ---------------------------------------------------------------------------
|
|
367
|
+
// ready
|
|
368
|
+
// ---------------------------------------------------------------------------
|
|
369
|
+
async function ready(args, ctx) {
|
|
370
|
+
const { values } = parse(args, { dir: { type: 'string', short: 'd' }, quiet: { type: 'boolean' } }, READY_USAGE);
|
|
371
|
+
const dir = indexDir(ctx, values.dir);
|
|
372
|
+
const health = await inspectIndex(dir, SCHEMA_INDEX, (at) => EntityStore.open(at));
|
|
373
|
+
reportReady(health, ctx, values.quiet === true);
|
|
374
|
+
}
|
|
259
375
|
const HEADERS = ['PATHS', 'OPERATIONS', 'SCHEMAS', 'FIELDS'];
|
|
260
376
|
function elapsed(since) {
|
|
261
377
|
const seconds = Math.round((Date.now() - since) / 1000);
|
|
@@ -805,8 +921,9 @@ async function stats(args, ctx) {
|
|
|
805
921
|
const { values } = parse(args, { dir: { type: 'string', short: 'd' } }, 'zen rag schema stats [--dir <dir>]');
|
|
806
922
|
const dir = indexDir(ctx, values.dir);
|
|
807
923
|
const manifest = await readManifest(dir);
|
|
924
|
+
const health = await inspectIndex(dir, SCHEMA_INDEX, (at) => EntityStore.open(at));
|
|
808
925
|
if (ctx.json) {
|
|
809
|
-
json(manifest);
|
|
926
|
+
json({ ...manifest, state: health.state });
|
|
810
927
|
return;
|
|
811
928
|
}
|
|
812
929
|
note(bold(dir));
|
|
@@ -818,6 +935,9 @@ async function stats(args, ctx) {
|
|
|
818
935
|
' indexes',
|
|
819
936
|
`fts ${yes(manifest.indexes.fts)} · vector ${yes(manifest.indexes.vector)}`,
|
|
820
937
|
],
|
|
938
|
+
// `stats` reports and never refuses, so a missing store is a row
|
|
939
|
+
// here rather than the error `search` would raise.
|
|
940
|
+
[' vectors', health.state === 'ready' ? 'present' : dim('missing — `restore` them')],
|
|
821
941
|
]));
|
|
822
942
|
printSources(manifest.sources);
|
|
823
943
|
notes(table([[' entities', String(manifest.counts.entities)]]).map(dim));
|
|
@@ -827,6 +947,7 @@ function notes(lines) {
|
|
|
827
947
|
note(line);
|
|
828
948
|
}
|
|
829
949
|
}
|
|
950
|
+
const aside = (line) => dim(` ${line}`);
|
|
830
951
|
const yes = (value) => (value ? 'yes' : 'no');
|
|
831
952
|
// ---------------------------------------------------------------------------
|
|
832
953
|
function formatOf(value) {
|
package/dist/schema/files.d.ts
CHANGED
|
@@ -8,8 +8,8 @@ export declare const SCHEMA_INDEX: IndexSpec;
|
|
|
8
8
|
export declare const GRAPH_FILE = "graph.json";
|
|
9
9
|
export declare const SCHEMAS_FILE = "schemas.json";
|
|
10
10
|
export declare const OPERATIONS_FILE = "operations.json";
|
|
11
|
-
export declare const LANCE_DIR = "lance";
|
|
12
11
|
export declare const SOURCES_DIR = "sources";
|
|
12
|
+
export { LANCE_DIR, lancePath } from '../common/manifest.ts';
|
|
13
13
|
export interface SourceRecord {
|
|
14
14
|
/** the document's name within the index: what every entity's `source` says */
|
|
15
15
|
name: string;
|
|
@@ -53,7 +53,6 @@ export interface OpenIndex {
|
|
|
53
53
|
/** the operations, likewise */
|
|
54
54
|
operations(): Promise<Operation[]>;
|
|
55
55
|
}
|
|
56
|
-
export declare const lancePath: (dir: string) => string;
|
|
57
56
|
export declare function writeIndex(dir: string, index: WrittenIndex): Promise<void>;
|
|
58
57
|
export declare function openIndex(dir: string): Promise<OpenIndex>;
|
|
59
58
|
/**
|
package/dist/schema/files.js
CHANGED
|
@@ -31,9 +31,8 @@ export const SCHEMA_INDEX = {
|
|
|
31
31
|
export const GRAPH_FILE = 'graph.json';
|
|
32
32
|
export const SCHEMAS_FILE = 'schemas.json';
|
|
33
33
|
export const OPERATIONS_FILE = 'operations.json';
|
|
34
|
-
export const LANCE_DIR = 'lance';
|
|
35
34
|
export const SOURCES_DIR = 'sources';
|
|
36
|
-
export
|
|
35
|
+
export { LANCE_DIR, lancePath } from "../common/manifest.js";
|
|
37
36
|
export async function writeIndex(dir, index) {
|
|
38
37
|
await mkdir(dir, { recursive: true });
|
|
39
38
|
await writeFile(join(dir, GRAPH_FILE), JSON.stringify(index.graph.export()));
|
package/dist/schema/store.js
CHANGED
|
@@ -100,7 +100,7 @@ export class EntityStore {
|
|
|
100
100
|
}
|
|
101
101
|
catch {
|
|
102
102
|
db.close();
|
|
103
|
-
throw new CliError(`${dir} holds no searchable table`, EXIT.invalid, 'rebuild it with `zen rag schema index`');
|
|
103
|
+
throw new CliError(`${dir} holds no searchable table`, EXIT.invalid, 'put the vectors back with `zen rag schema restore`, or rebuild it with `zen rag schema index`');
|
|
104
104
|
}
|
|
105
105
|
}
|
|
106
106
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zenera/rag",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.22",
|
|
4
4
|
"description": "Retrieval over a corpus: openapi/swagger documents as a searchable graph, markdown and text as searchable passages.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agents",
|
|
@@ -61,8 +61,8 @@
|
|
|
61
61
|
"dependencies": {
|
|
62
62
|
"@apidevtools/swagger-parser": "^12.0.0",
|
|
63
63
|
"@lancedb/lancedb": "^0.38.0",
|
|
64
|
-
"@zenera/cli": "^1.1.
|
|
65
|
-
"@zenera/neo": "^1.1.
|
|
64
|
+
"@zenera/cli": "^1.1.22",
|
|
65
|
+
"@zenera/neo": "^1.1.22",
|
|
66
66
|
"graphology": "^0.26.0",
|
|
67
67
|
"remark-frontmatter": "^5.0.0",
|
|
68
68
|
"remark-gfm": "^4.0.1",
|