@zenera/rag 1.1.10 → 1.1.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -0
- package/dist/common/cache.d.ts +66 -0
- package/dist/common/cache.js +172 -0
- package/dist/common/embedder.d.ts +11 -1
- package/dist/common/embedder.js +6 -17
- package/dist/common/manifest.d.ts +7 -1
- package/dist/common/progress.d.ts +16 -1
- package/dist/common/progress.js +35 -3
- package/dist/common/prose.d.ts +7 -0
- package/dist/common/prose.js +13 -0
- package/dist/docs/build.d.ts +22 -2
- package/dist/docs/build.js +52 -26
- package/dist/docs/command.js +56 -10
- package/dist/docs/load.d.ts +18 -4
- package/dist/docs/load.js +54 -63
- package/dist/docs/outline.d.ts +11 -0
- package/dist/docs/outline.js +55 -0
- package/dist/docs/parse-cache.d.ts +20 -0
- package/dist/docs/parse-cache.js +60 -0
- package/dist/docs/parse-worker.d.ts +13 -0
- package/dist/docs/parse-worker.js +21 -0
- package/dist/docs/pool.d.ts +27 -0
- package/dist/docs/pool.js +133 -0
- package/dist/docs/readme.js +23 -3
- package/dist/docs/store.d.ts +25 -1
- package/dist/docs/store.js +59 -16
- package/dist/schema/build.d.ts +16 -2
- package/dist/schema/build.js +37 -25
- package/dist/schema/command.js +36 -10
- package/dist/schema/readme.js +6 -2
- package/dist/schema/store.d.ts +9 -1
- package/dist/schema/store.js +57 -15
- package/package.json +3 -3
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type Chunk, type ChunkOptions } from './chunk.ts';
|
|
2
|
+
import type { FileOutline } from './files.ts';
|
|
3
|
+
export interface ParseInput {
|
|
4
|
+
name: string;
|
|
5
|
+
text: string;
|
|
6
|
+
format: 'markdown' | 'text';
|
|
7
|
+
}
|
|
8
|
+
export interface Parsed {
|
|
9
|
+
chunks: Chunk[];
|
|
10
|
+
outline: FileOutline;
|
|
11
|
+
}
|
|
12
|
+
/** One short of the machine, so the thread doing everything else keeps a core. */
|
|
13
|
+
export declare const poolSize: () => number;
|
|
14
|
+
export interface ParseAllOptions {
|
|
15
|
+
chunk?: ChunkOptions;
|
|
16
|
+
/** how many threads to allow; 1 keeps everything here */
|
|
17
|
+
workers?: number;
|
|
18
|
+
onProgress?: (done: number, total: number, pending: readonly string[]) => void;
|
|
19
|
+
}
|
|
20
|
+
export declare function parseAll(inputs: readonly ParseInput[], options?: ParseAllOptions): Promise<Parsed[]>;
|
|
21
|
+
/**
|
|
22
|
+
* The threaded path on its own. `parseAll` falls back to this thread when the
|
|
23
|
+
* workers cannot run, which is right for a build and useless for a test: a
|
|
24
|
+
* worker that never starts would look exactly like a fast one. Tests call this.
|
|
25
|
+
*/
|
|
26
|
+
export declare function parseThreaded(inputs: readonly ParseInput[], chunk: ChunkOptions, size: number, onProgress?: (done: number, total: number, pending: readonly string[]) => void): Promise<Parsed[]>;
|
|
27
|
+
//# sourceMappingURL=pool.d.ts.map
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { availableParallelism } from 'node:os';
|
|
2
|
+
import { Worker } from 'node:worker_threads';
|
|
3
|
+
import { chunkDocument } from "./chunk.js";
|
|
4
|
+
import { outlineOf } from "./outline.js";
|
|
5
|
+
import { parseDocument } from "./parse.js";
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Parsing several documents at once
|
|
8
|
+
//
|
|
9
|
+
// Remark is synchronous and holds the thread for as long as a document takes,
|
|
10
|
+
// so a corpus is parsed strictly one file at a time no matter how many cores
|
|
11
|
+
// are idle. A pool fixes that and nothing else: the work is pure, the inputs are
|
|
12
|
+
// independent, and the answers are placed by index, so the result is the same
|
|
13
|
+
// list in the same order that one thread would have produced.
|
|
14
|
+
//
|
|
15
|
+
// It is not always the faster choice. Starting a thread costs more than parsing
|
|
16
|
+
// a small document, so a handful of files are done here; and a caller that
|
|
17
|
+
// supplied its own `tokenCount` cannot be helped at all, because a function is
|
|
18
|
+
// not something `postMessage` can clone. Every one of those paths runs the same
|
|
19
|
+
// three functions in this thread instead, which is why the fallback is safe
|
|
20
|
+
// rather than merely convenient.
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
/** Below this, starting threads costs more than the parsing they would save. */
|
|
23
|
+
const MIN_DOCUMENTS = 8;
|
|
24
|
+
/** More than this and the threads contend for memory bandwidth, not for work. */
|
|
25
|
+
const MAX_WORKERS = 8;
|
|
26
|
+
/** One short of the machine, so the thread doing everything else keeps a core. */
|
|
27
|
+
export const poolSize = () => Math.max(1, Math.min(MAX_WORKERS, availableParallelism() - 1));
|
|
28
|
+
const EXTENSION = import.meta.url.endsWith('.ts') ? '.ts' : '.js';
|
|
29
|
+
// Built from `import.meta.url` because the extension changes on the way to
|
|
30
|
+
// `dist/`, and `rewriteRelativeImportExtensions` does not touch string literals.
|
|
31
|
+
const WORKER = new URL(`./parse-worker${EXTENSION}`, import.meta.url);
|
|
32
|
+
/** How many documents run between yields, so a long parse still narrates. */
|
|
33
|
+
const YIELD_EVERY = 32;
|
|
34
|
+
export async function parseAll(inputs, options = {}) {
|
|
35
|
+
const chunk = options.chunk ?? {};
|
|
36
|
+
const workers = Math.min(options.workers ?? poolSize(), inputs.length);
|
|
37
|
+
const out = new Array(inputs.length);
|
|
38
|
+
if (workers > 1 && inputs.length >= MIN_DOCUMENTS && typeof chunk.tokenCount !== 'function') {
|
|
39
|
+
try {
|
|
40
|
+
await threaded(out, inputs, chunk, workers, options.onProgress);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// A thread that would not start, or died. Whatever it did finish is
|
|
44
|
+
// in `out` already and is not parsed again; `here` fills the rest.
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return here(out, inputs, chunk, options.onProgress);
|
|
48
|
+
}
|
|
49
|
+
async function here(out, inputs, chunk, onProgress) {
|
|
50
|
+
let done = out.reduce((n, parsed) => (parsed ? n + 1 : n), 0);
|
|
51
|
+
for (const [at, input] of inputs.entries()) {
|
|
52
|
+
if (out[at]) {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const parsed = parseDocument(input.text, input.name, input.format);
|
|
56
|
+
const chunks = chunkDocument(parsed, chunk);
|
|
57
|
+
out[at] = { chunks, outline: outlineOf(parsed, chunks.length) };
|
|
58
|
+
onProgress?.(++done, inputs.length, [input.name]);
|
|
59
|
+
if (done % YIELD_EVERY === 0) {
|
|
60
|
+
// Remark is synchronous, so a corpus parsed here would hold the loop
|
|
61
|
+
// for minutes and freeze the very report saying it is still working.
|
|
62
|
+
await new Promise((resume) => setImmediate(resume));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* The threaded path on its own. `parseAll` falls back to this thread when the
|
|
69
|
+
* workers cannot run, which is right for a build and useless for a test: a
|
|
70
|
+
* worker that never starts would look exactly like a fast one. Tests call this.
|
|
71
|
+
*/
|
|
72
|
+
export async function parseThreaded(inputs, chunk, size, onProgress) {
|
|
73
|
+
const out = new Array(inputs.length);
|
|
74
|
+
await threaded(out, inputs, chunk, size, onProgress);
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
async function threaded(out, inputs, chunk, size, onProgress) {
|
|
78
|
+
const workers = Array.from({ length: size }, () => new Worker(WORKER, { workerData: chunk }));
|
|
79
|
+
// What each thread is on, so a corpus with two pathological documents in it
|
|
80
|
+
// can name them instead of looking stalled a hair short of the total.
|
|
81
|
+
const busy = new Map();
|
|
82
|
+
let next = 0;
|
|
83
|
+
let done = 0;
|
|
84
|
+
const pump = async (worker) => {
|
|
85
|
+
while (next < inputs.length) {
|
|
86
|
+
const at = next++;
|
|
87
|
+
busy.set(worker, inputs[at].name);
|
|
88
|
+
out[at] = await ask(worker, { at, ...inputs[at] });
|
|
89
|
+
busy.delete(worker);
|
|
90
|
+
onProgress?.(++done, inputs.length, [...busy.values()]);
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
try {
|
|
94
|
+
await Promise.all(workers.map(pump));
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
await Promise.all(workers.map((worker) => worker.terminate()));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function ask(worker, job) {
|
|
101
|
+
return new Promise((resolve, reject) => {
|
|
102
|
+
const done = () => {
|
|
103
|
+
worker.off('message', onMessage);
|
|
104
|
+
worker.off('error', onError);
|
|
105
|
+
worker.off('exit', onExit);
|
|
106
|
+
};
|
|
107
|
+
const onMessage = (reply) => {
|
|
108
|
+
if (reply.at !== job.at) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
done();
|
|
112
|
+
if (reply.error !== undefined) {
|
|
113
|
+
reject(new Error(`${job.name}: ${reply.error}`));
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
resolve({ chunks: reply.chunks, outline: reply.outline });
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
const onError = (err) => {
|
|
120
|
+
done();
|
|
121
|
+
reject(err);
|
|
122
|
+
};
|
|
123
|
+
const onExit = (code) => {
|
|
124
|
+
done();
|
|
125
|
+
reject(new Error(`parse worker stopped with code ${code}`));
|
|
126
|
+
};
|
|
127
|
+
worker.on('message', onMessage);
|
|
128
|
+
worker.on('error', onError);
|
|
129
|
+
worker.on('exit', onExit);
|
|
130
|
+
worker.postMessage(job);
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
//# sourceMappingURL=pool.js.map
|
package/dist/docs/readme.js
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
import { basename } from 'node:path';
|
|
2
2
|
import { INTERVAL_MS, } from "../common/progress.js";
|
|
3
|
-
import { duration, fields, grid, message, plural, searched } from "../common/prose.js";
|
|
3
|
+
import { breakdown, duration, fields, grid, message, plural, searched } from "../common/prose.js";
|
|
4
4
|
export const PHASES = {
|
|
5
5
|
reading: 'reading the documents and cutting them into chunks',
|
|
6
6
|
embedding: 'embedding',
|
|
7
7
|
writing: 'writing the store',
|
|
8
8
|
};
|
|
9
|
+
/** What the running count is counting, which is not the same in every phase. */
|
|
10
|
+
const COUNTING = {
|
|
11
|
+
reading: 'parsed',
|
|
12
|
+
embedding: 'embedded',
|
|
13
|
+
};
|
|
9
14
|
export const DOCS_REPORT = { building, complete, failed };
|
|
10
15
|
function building(state) {
|
|
11
16
|
const rows = [
|
|
@@ -21,9 +26,18 @@ function building(state) {
|
|
|
21
26
|
rows.push(['found', counted(state.summary)]);
|
|
22
27
|
}
|
|
23
28
|
if (state.total > 0) {
|
|
24
|
-
|
|
25
|
-
|
|
29
|
+
// Rounded down: 13619 of 13621 is not 100%, and a report that says it is
|
|
30
|
+
// turns two slow documents into a build that looks hung.
|
|
31
|
+
const percent = Math.floor((state.done / state.total) * 100);
|
|
32
|
+
rows.push([
|
|
33
|
+
COUNTING[state.phase] ?? 'done',
|
|
34
|
+
`${state.done} of ${state.total} · ${percent}%`,
|
|
35
|
+
]);
|
|
36
|
+
}
|
|
37
|
+
if (state.pending.length > 0) {
|
|
38
|
+
rows.push(['still on', waiting(state.pending)]);
|
|
26
39
|
}
|
|
40
|
+
rows.push(['timing', breakdown(state.timings)]);
|
|
27
41
|
rows.push(['updated', new Date(state.now).toISOString()]);
|
|
28
42
|
return [
|
|
29
43
|
'# Document index — being built',
|
|
@@ -57,6 +71,8 @@ function complete(state) {
|
|
|
57
71
|
`${counted(manifest.counts)},`,
|
|
58
72
|
`${searched(manifest.indexes)}.`,
|
|
59
73
|
'',
|
|
74
|
+
`Time: ${breakdown(state.timings)}.`,
|
|
75
|
+
'',
|
|
60
76
|
'## Files',
|
|
61
77
|
'',
|
|
62
78
|
...fields([
|
|
@@ -92,6 +108,7 @@ function failed(state) {
|
|
|
92
108
|
['step', state.step],
|
|
93
109
|
['reason', message(state.reason)],
|
|
94
110
|
['started', new Date(state.started).toISOString()],
|
|
111
|
+
['timing', breakdown(state.timings)],
|
|
95
112
|
[
|
|
96
113
|
'failed',
|
|
97
114
|
`${new Date().toISOString()} (after ${duration(Date.now() - state.started)})`,
|
|
@@ -101,6 +118,9 @@ function failed(state) {
|
|
|
101
118
|
].join('\n');
|
|
102
119
|
}
|
|
103
120
|
// ---------------------------------------------------------------------------
|
|
121
|
+
/** Names are only worth reading once there are few enough to act on. */
|
|
122
|
+
const MOST_NAMES = 3;
|
|
123
|
+
const waiting = (pending) => pending.length <= MOST_NAMES ? pending.join(', ') : `${pending.length} documents`;
|
|
104
124
|
const HEADERS = ['document', 'format', 'lines', 'sections', 'tables', 'chunks'];
|
|
105
125
|
const documentTable = (sources) => grid(HEADERS, sources.map((s) => [
|
|
106
126
|
s.name,
|
package/dist/docs/store.d.ts
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
1
|
import { type Connection, type Table } from '@lancedb/lancedb';
|
|
2
|
+
/**
|
|
3
|
+
* Rows per write.
|
|
4
|
+
*
|
|
5
|
+
* Arrow addresses a batch's buffers with 32-bit offsets, so one batch carrying
|
|
6
|
+
* more than 2 GiB does not error — it panics inside the reader, on a thread
|
|
7
|
+
* whose panic never reaches this one. At 3072 dimensions the vector column
|
|
8
|
+
* alone crosses that at about 175k rows, which a documentation tree reaches.
|
|
9
|
+
*/
|
|
10
|
+
export declare const WRITE_BATCH = 8192;
|
|
2
11
|
export interface ChunkRecord {
|
|
3
12
|
/** `${path}#c${ordinal}` — the fusion key, and stable across compaction */
|
|
4
13
|
id: string;
|
|
@@ -37,7 +46,22 @@ export interface WriteResult {
|
|
|
37
46
|
fts: boolean;
|
|
38
47
|
vector: boolean;
|
|
39
48
|
}
|
|
40
|
-
|
|
49
|
+
/**
|
|
50
|
+
* A table built a window at a time.
|
|
51
|
+
*
|
|
52
|
+
* The corpus used to be embedded into one array and handed over in one call,
|
|
53
|
+
* which made peak memory a function of corpus size and, past 2 GiB of vectors,
|
|
54
|
+
* a panic. Rows go in as they are paid for instead, so what is resident is one
|
|
55
|
+
* window rather than all of it.
|
|
56
|
+
*/
|
|
57
|
+
export interface ChunkWriter {
|
|
58
|
+
add(rows: readonly ChunkRecord[], vectors: readonly Float32Array[]): Promise<void>;
|
|
59
|
+
/** Builds the indexes and closes. Throws if nothing was ever added. */
|
|
60
|
+
finish(): Promise<WriteResult>;
|
|
61
|
+
/** For a build that failed, so the connection does not outlive it. */
|
|
62
|
+
close(): void;
|
|
63
|
+
}
|
|
64
|
+
export declare function openChunks(dir: string): Promise<ChunkWriter>;
|
|
41
65
|
export declare class ChunkStore {
|
|
42
66
|
#private;
|
|
43
67
|
constructor(db: Connection, table: Table);
|
package/dist/docs/store.js
CHANGED
|
@@ -27,28 +27,71 @@ import { lancePath } from "./files.js";
|
|
|
27
27
|
const TABLE = 'chunks';
|
|
28
28
|
/** Below this an IVF index has nothing to train on, and a flat scan is faster. */
|
|
29
29
|
const VECTOR_INDEX_MIN_ROWS = 2000;
|
|
30
|
+
/**
|
|
31
|
+
* Rows per write.
|
|
32
|
+
*
|
|
33
|
+
* Arrow addresses a batch's buffers with 32-bit offsets, so one batch carrying
|
|
34
|
+
* more than 2 GiB does not error — it panics inside the reader, on a thread
|
|
35
|
+
* whose panic never reaches this one. At 3072 dimensions the vector column
|
|
36
|
+
* alone crosses that at about 175k rows, which a documentation tree reaches.
|
|
37
|
+
*/
|
|
38
|
+
export const WRITE_BATCH = 8192;
|
|
30
39
|
const KINDS = new Set(CHUNK_KINDS);
|
|
31
40
|
/** What may go into a string literal in a predicate, and nothing else. */
|
|
32
41
|
const SAFE = /^[\w.:/ -]+$/;
|
|
33
|
-
export async function
|
|
34
|
-
|
|
35
|
-
|
|
42
|
+
export async function openChunks(dir) {
|
|
43
|
+
return new Writer(await connect(lancePath(dir)));
|
|
44
|
+
}
|
|
45
|
+
class Writer {
|
|
46
|
+
#db;
|
|
47
|
+
#table;
|
|
48
|
+
#rows = 0;
|
|
49
|
+
constructor(db) {
|
|
50
|
+
this.#db = db;
|
|
36
51
|
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
52
|
+
async add(rows, vectors) {
|
|
53
|
+
for (let from = 0; from < rows.length; from += WRITE_BATCH) {
|
|
54
|
+
const batch = rows.slice(from, from + WRITE_BATCH).map((row, i) => ({
|
|
55
|
+
...row,
|
|
56
|
+
vector: vectors[from + i],
|
|
57
|
+
}));
|
|
58
|
+
if (this.#table) {
|
|
59
|
+
await this.#table.add(batch);
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
// Every column is always populated — never null — so the Arrow
|
|
63
|
+
// schema is inferred from the first row without a declaration.
|
|
64
|
+
this.#table = await this.#db.createTable(TABLE, batch, { mode: 'overwrite' });
|
|
65
|
+
}
|
|
66
|
+
this.#rows += batch.length;
|
|
67
|
+
}
|
|
45
68
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
69
|
+
async finish() {
|
|
70
|
+
const table = this.#table;
|
|
71
|
+
if (!table) {
|
|
72
|
+
throw new CliError('the documents hold nothing to index', EXIT.invalid, 'they are empty, or every one of them is blank');
|
|
73
|
+
}
|
|
74
|
+
// A writer that panicked did not reject, so a short table is the only
|
|
75
|
+
// evidence left that the rows never landed.
|
|
76
|
+
const stored = await table.countRows();
|
|
77
|
+
if (stored !== this.#rows) {
|
|
78
|
+
throw new CliError(`the table holds ${stored} of ${this.#rows} chunks`, EXIT.failed, 'the write did not finish, and a partial index answers as if it were whole');
|
|
79
|
+
}
|
|
80
|
+
await table.createIndex('text', { config: Index.fts() });
|
|
81
|
+
await table.createIndex('kind', { config: Index.bitmap() });
|
|
82
|
+
for (const column of ['path', 'structurePath']) {
|
|
83
|
+
await table.createIndex(column, { config: Index.btree() });
|
|
84
|
+
}
|
|
85
|
+
const vector = this.#rows >= VECTOR_INDEX_MIN_ROWS;
|
|
86
|
+
if (vector) {
|
|
87
|
+
await table.createIndex('vector');
|
|
88
|
+
}
|
|
89
|
+
this.#db.close();
|
|
90
|
+
return { rows: this.#rows, fts: true, vector };
|
|
91
|
+
}
|
|
92
|
+
close() {
|
|
93
|
+
this.#db.close();
|
|
49
94
|
}
|
|
50
|
-
db.close();
|
|
51
|
-
return { rows: rows.length, fts: true, vector };
|
|
52
95
|
}
|
|
53
96
|
export class ChunkStore {
|
|
54
97
|
#db;
|
package/dist/schema/build.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Embedder } from '@zenera/neo';
|
|
2
|
+
import { type PhaseTiming } from '../common/progress.ts';
|
|
2
3
|
import { type EntityRecord } from './entities.ts';
|
|
3
4
|
import { type Counts, type Manifest, type SourceRecord } from './files.ts';
|
|
4
5
|
export interface BuildOptions {
|
|
@@ -9,10 +10,19 @@ export interface BuildOptions {
|
|
|
9
10
|
embeddingRef?: string;
|
|
10
11
|
/** told the manifest, so a store can say what wrote it */
|
|
11
12
|
indexer: string;
|
|
12
|
-
/** texts sent to the embedder at once */
|
|
13
|
-
batch?: number;
|
|
14
13
|
/** keep a bundled copy of each document in the index. On by default. */
|
|
15
14
|
sources?: boolean;
|
|
15
|
+
/**
|
|
16
|
+
* The width asked of the embedder, when one was asked for. Part of the cache
|
|
17
|
+
* key, because a truncated vector is a different vector; left undefined when
|
|
18
|
+
* nobody asked, because that is a different key again from asking for the
|
|
19
|
+
* number the model would have chosen anyway.
|
|
20
|
+
*/
|
|
21
|
+
dimensions?: number;
|
|
22
|
+
/** reuse vectors this machine already has; on by default */
|
|
23
|
+
cache?: boolean;
|
|
24
|
+
/** keep them somewhere other than the shared store */
|
|
25
|
+
cacheDir?: string;
|
|
16
26
|
signal?: AbortSignal;
|
|
17
27
|
/** what the documents turned out to hold, before a vector has been paid for */
|
|
18
28
|
onRead?: (summary: BuildSummary) => void;
|
|
@@ -25,6 +35,10 @@ export interface BuildSummary {
|
|
|
25
35
|
export interface BuildResult {
|
|
26
36
|
manifest: Manifest;
|
|
27
37
|
entities: EntityRecord[];
|
|
38
|
+
/** what each phase cost, so a slow build can say which part was slow */
|
|
39
|
+
timings: readonly PhaseTiming[];
|
|
40
|
+
/** vectors that came out of the shared cache instead of being paid for again */
|
|
41
|
+
reused: number;
|
|
28
42
|
}
|
|
29
43
|
export declare function buildIndex(options: BuildOptions): Promise<BuildResult>;
|
|
30
44
|
//# sourceMappingURL=build.d.ts.map
|
package/dist/schema/build.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
+
import { embedStream, NO_CACHE, openCache } from "../common/cache.js";
|
|
1
2
|
import { beginBuild } from "../common/progress.js";
|
|
2
3
|
import { toEntities } from "./entities.js";
|
|
3
4
|
import { INDEX_VERSION, SOURCES_DIR, writeIndex, } from "./files.js";
|
|
4
5
|
import { buildGraph } from "./graph.js";
|
|
5
6
|
import { PHASES, SCHEMA_REPORT } from "./readme.js";
|
|
6
7
|
import { loadSpecs } from "./spec.js";
|
|
7
|
-
import {
|
|
8
|
-
const DEFAULT_BATCH = 96;
|
|
8
|
+
import { openStore } from "./store.js";
|
|
9
9
|
export async function buildIndex(options) {
|
|
10
10
|
const journal = beginBuild({
|
|
11
11
|
dir: options.out,
|
|
@@ -15,6 +15,15 @@ export async function buildIndex(options) {
|
|
|
15
15
|
phases: PHASES,
|
|
16
16
|
report: SCHEMA_REPORT,
|
|
17
17
|
});
|
|
18
|
+
const ref = options.embeddingRef ?? options.embedder.id;
|
|
19
|
+
const cache = options.cache === false
|
|
20
|
+
? NO_CACHE
|
|
21
|
+
: openCache(options.embedder, {
|
|
22
|
+
ref,
|
|
23
|
+
dir: options.cacheDir,
|
|
24
|
+
dimensions: options.dimensions,
|
|
25
|
+
});
|
|
26
|
+
let writer;
|
|
18
27
|
try {
|
|
19
28
|
const corpus = await loadSpecs(options.files);
|
|
20
29
|
journal.phase('graph');
|
|
@@ -33,18 +42,20 @@ export async function buildIndex(options) {
|
|
|
33
42
|
journal.read(summary.counts, summary.counts.entities);
|
|
34
43
|
options.onRead?.(summary);
|
|
35
44
|
journal.phase('embedding');
|
|
36
|
-
|
|
45
|
+
writer = await openStore(options.out);
|
|
46
|
+
const dimensions = await embedAll(entities, options, journal, cache, writer);
|
|
37
47
|
journal.phase('writing');
|
|
38
|
-
const written = await
|
|
48
|
+
const written = await writer.finish();
|
|
39
49
|
const manifest = {
|
|
40
50
|
version: INDEX_VERSION,
|
|
41
51
|
kind: 'schema',
|
|
42
52
|
createdAt: new Date().toISOString(),
|
|
43
53
|
indexer: options.indexer,
|
|
44
54
|
embedding: {
|
|
45
|
-
ref
|
|
55
|
+
ref,
|
|
46
56
|
id: options.embedder.id,
|
|
47
|
-
dimensions
|
|
57
|
+
dimensions,
|
|
58
|
+
requested: options.dimensions,
|
|
48
59
|
},
|
|
49
60
|
sources: summary.sources,
|
|
50
61
|
counts: summary.counts,
|
|
@@ -57,32 +68,33 @@ export async function buildIndex(options) {
|
|
|
57
68
|
operations: corpus.operations,
|
|
58
69
|
documents: keep ? corpus.documents : {},
|
|
59
70
|
});
|
|
71
|
+
cache.commit();
|
|
60
72
|
journal.finish(manifest);
|
|
61
|
-
return { manifest, entities };
|
|
73
|
+
return { manifest, entities, timings: journal.timings, reused: cache.hits };
|
|
62
74
|
}
|
|
63
75
|
catch (err) {
|
|
76
|
+
writer?.close();
|
|
77
|
+
cache.abandon();
|
|
64
78
|
journal.fail(err);
|
|
65
79
|
throw err;
|
|
66
80
|
}
|
|
67
81
|
}
|
|
68
|
-
async function embedAll(entities, options, journal) {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
}
|
|
85
|
-
return out;
|
|
82
|
+
async function embedAll(entities, options, journal, cache, writer) {
|
|
83
|
+
// A window at a time. How many texts fit in a request, and how many
|
|
84
|
+
// requests may be in flight, are still the embedder's to answer — it knows
|
|
85
|
+
// the model's caps and it is the one that sees the 429s.
|
|
86
|
+
return embedStream({
|
|
87
|
+
embedder: options.embedder,
|
|
88
|
+
cache,
|
|
89
|
+
records: entities,
|
|
90
|
+
textOf: (entity) => entity.text,
|
|
91
|
+
signal: options.signal,
|
|
92
|
+
onProgress: (done, total) => {
|
|
93
|
+
journal.progress(done, total);
|
|
94
|
+
options.onProgress?.(done, total);
|
|
95
|
+
},
|
|
96
|
+
onWindow: (window, vectors) => writer.add(window, vectors),
|
|
97
|
+
});
|
|
86
98
|
}
|
|
87
99
|
/**
|
|
88
100
|
* Counted off the entities rather than the corpus, so a schema the document
|
package/dist/schema/command.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { bold, CliError, cyan, dim, EXIT, isInteractive, json, note, parse, table, usageError, write, } from '@zenera/cli/lib';
|
|
1
|
+
import { bold, CliError, cyan, dim, EXIT, isInteractive, json, note, parse, paths, table, usageError, write, } from '@zenera/cli/lib';
|
|
2
2
|
import { relative, resolve } from 'node:path';
|
|
3
3
|
import { resolveEmbedder } from "../common/embedder.js";
|
|
4
4
|
import { locateIndex, outputDir } from "../common/locate.js";
|
|
5
5
|
import { assertSameEmbedding } from "../common/manifest.js";
|
|
6
6
|
import { isGlob, loose, matcher, PatternError, wildcard } from "../common/match.js";
|
|
7
|
+
import { breakdown } from "../common/prose.js";
|
|
7
8
|
import { buildIndex } from "./build.js";
|
|
8
9
|
import { openIndex, readManifest, readSource, SCHEMA_INDEX } from "./files.js";
|
|
9
10
|
import { fields, grepNodes, listNodes, propertyCount } from "./lookup.js";
|
|
@@ -68,11 +69,17 @@ export const command = {
|
|
|
68
69
|
' -o, --out <dir>',
|
|
69
70
|
dim(`Where the index goes. Default ${DEFAULT_DIR}, or ${DIR_ENV}.`),
|
|
70
71
|
],
|
|
72
|
+
[' --batch <n>', dim("Texts per embedding request. Default: the model's own cap.")],
|
|
71
73
|
[
|
|
72
|
-
' --
|
|
73
|
-
dim(
|
|
74
|
+
' --dimensions <n>',
|
|
75
|
+
dim("Narrower vectors, if the model allows it. Default: the model's own width."),
|
|
74
76
|
],
|
|
75
77
|
[' --no-sources', dim('Do not keep a copy of each document in the index.')],
|
|
78
|
+
[
|
|
79
|
+
' --no-cache',
|
|
80
|
+
dim('Embed everything again, ignoring vectors this machine already has.'),
|
|
81
|
+
],
|
|
82
|
+
[' --cache-dir <dir>', dim('Keep the vectors somewhere other than the shared cache.')],
|
|
76
83
|
]),
|
|
77
84
|
'',
|
|
78
85
|
'Search terms (repeatable)',
|
|
@@ -189,34 +196,48 @@ async function index(args, ctx) {
|
|
|
189
196
|
out: { type: 'string', short: 'o' },
|
|
190
197
|
embedding: { type: 'string' },
|
|
191
198
|
batch: { type: 'string' },
|
|
199
|
+
dimensions: { type: 'string' },
|
|
192
200
|
'no-sources': { type: 'boolean' },
|
|
201
|
+
'no-cache': { type: 'boolean' },
|
|
202
|
+
'cache-dir': { type: 'string' },
|
|
193
203
|
quiet: { type: 'boolean' },
|
|
194
204
|
}, INDEX_USAGE);
|
|
195
205
|
if (positionals.length === 0) {
|
|
196
206
|
throw usageError('no document given', INDEX_USAGE);
|
|
197
207
|
}
|
|
198
208
|
const out = outputDir(ctx.cwd, values.out, SCHEMA_INDEX);
|
|
209
|
+
const cacheDir = values['cache-dir'] ? resolve(ctx.cwd, values['cache-dir']) : paths.cache();
|
|
199
210
|
const loud = !values.quiet && !ctx.json;
|
|
200
|
-
|
|
211
|
+
// Undefined when unasked, all the way to the cache key: a width nobody
|
|
212
|
+
// named is not the same key as the width the model happens to default to,
|
|
213
|
+
// and resolving it here would miss every vector already paid for.
|
|
214
|
+
const dimensions = values.dimensions ? count(values.dimensions, '--dimensions') : undefined;
|
|
215
|
+
const chosen = await resolveEmbedder(values.embedding, {
|
|
216
|
+
maxBatch: values.batch ? count(values.batch, '--batch') : undefined,
|
|
217
|
+
dimensions,
|
|
218
|
+
});
|
|
201
219
|
const started = Date.now();
|
|
202
|
-
const { manifest } = await buildIndex({
|
|
220
|
+
const { manifest, timings, reused } = await buildIndex({
|
|
203
221
|
files: positionals.map((file) => resolve(ctx.cwd, file)),
|
|
204
222
|
out,
|
|
205
223
|
embedder: chosen,
|
|
206
224
|
embeddingRef: values.embedding,
|
|
207
225
|
indexer: 'zenera-rag',
|
|
208
|
-
batch: values.batch ? count(values.batch, '--batch') : undefined,
|
|
209
226
|
sources: !values['no-sources'],
|
|
227
|
+
dimensions,
|
|
228
|
+
cache: !values['no-cache'],
|
|
229
|
+
cacheDir: values['cache-dir'] ? cacheDir : undefined,
|
|
210
230
|
onRead: loud
|
|
211
231
|
? (summary) => {
|
|
212
232
|
printSources(summary.sources);
|
|
213
|
-
//
|
|
214
|
-
//
|
|
233
|
+
// Embedding is one call now, and a long one; this is the line
|
|
234
|
+
// that makes the wait before the first progress report a wait
|
|
235
|
+
// rather than a hang.
|
|
215
236
|
note(dim(` embedding ${summary.counts.entities} entities with ${chosen.id} …`));
|
|
216
237
|
}
|
|
217
238
|
: undefined,
|
|
218
239
|
onProgress: loud
|
|
219
|
-
? (done, total) => note(dim(` embedded ${done}/${total} · ${Math.
|
|
240
|
+
? (done, total) => note(dim(` embedded ${done}/${total} · ${Math.floor((done / total) * 100)}% · ${elapsed(started)}`))
|
|
220
241
|
: undefined,
|
|
221
242
|
});
|
|
222
243
|
if (ctx.json) {
|
|
@@ -229,6 +250,9 @@ async function index(args, ctx) {
|
|
|
229
250
|
write(out);
|
|
230
251
|
note(` wrote ${bold(String(manifest.counts.entities))} entities to ${bold(out)}, ` +
|
|
231
252
|
`embedded with ${manifest.embedding.ref} (${manifest.embedding.dimensions}d)`);
|
|
253
|
+
note(dim(` ${breakdown(timings)}`));
|
|
254
|
+
note(dim(` reused ${reused}/${manifest.counts.entities} vectors` +
|
|
255
|
+
`${values['no-cache'] ? ' (--no-cache)' : ` from ${cacheDir}`}`));
|
|
232
256
|
const where = out === resolve(ctx.cwd, DEFAULT_DIR) ? '' : ` --dir ${relative(ctx.cwd, out) || out}`;
|
|
233
257
|
note(dim(` search it: ${cyan(`zen rag schema search${where} --all "what you are after"`)}`));
|
|
234
258
|
}
|
|
@@ -315,7 +339,9 @@ async function search(args, ctx) {
|
|
|
315
339
|
const manifest = await readManifest(dir);
|
|
316
340
|
const ref = values.embedding ?? manifest.embedding.ref;
|
|
317
341
|
assertSameEmbedding(manifest, ref);
|
|
318
|
-
|
|
342
|
+
// A query has to be asked at the width the entities were written at, and
|
|
343
|
+
// the ref alone does not say what that was.
|
|
344
|
+
const index = await SchemaIndex.open(dir, await resolveEmbedder(ref, { dimensions: manifest.embedding.requested }));
|
|
319
345
|
try {
|
|
320
346
|
if (values.interactive) {
|
|
321
347
|
await repl(index, query, { format, ...options });
|
package/dist/schema/readme.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { basename } from 'node:path';
|
|
2
2
|
import { INTERVAL_MS } from "../common/progress.js";
|
|
3
|
-
import { duration, fields, grid, message, plural, searched } from "../common/prose.js";
|
|
3
|
+
import { breakdown, duration, fields, grid, message, plural, searched } from "../common/prose.js";
|
|
4
4
|
export const PHASES = {
|
|
5
5
|
reading: 'reading the documents',
|
|
6
6
|
graph: 'building the graph',
|
|
@@ -22,9 +22,10 @@ function building(state) {
|
|
|
22
22
|
rows.push(['found', entities(state.summary)]);
|
|
23
23
|
}
|
|
24
24
|
if (state.total > 0) {
|
|
25
|
-
const percent = Math.
|
|
25
|
+
const percent = Math.floor((state.done / state.total) * 100);
|
|
26
26
|
rows.push(['embedded', `${state.done} of ${state.total} · ${percent}%`]);
|
|
27
27
|
}
|
|
28
|
+
rows.push(['timing', breakdown(state.timings)]);
|
|
28
29
|
rows.push(['updated', new Date(state.now).toISOString()]);
|
|
29
30
|
return [
|
|
30
31
|
'# Schema index — being built',
|
|
@@ -60,6 +61,8 @@ function complete(state) {
|
|
|
60
61
|
`${entities(manifest.counts)},`,
|
|
61
62
|
`${searched(manifest.indexes)}.`,
|
|
62
63
|
'',
|
|
64
|
+
`Time: ${breakdown(state.timings)}.`,
|
|
65
|
+
'',
|
|
63
66
|
'## Files',
|
|
64
67
|
'',
|
|
65
68
|
...fields([
|
|
@@ -97,6 +100,7 @@ function failed(state) {
|
|
|
97
100
|
['step', state.step],
|
|
98
101
|
['reason', message(state.reason)],
|
|
99
102
|
['started', new Date(state.started).toISOString()],
|
|
103
|
+
['timing', breakdown(state.timings)],
|
|
100
104
|
[
|
|
101
105
|
'failed',
|
|
102
106
|
`${new Date().toISOString()} (after ${duration(Date.now() - state.started)})`,
|
package/dist/schema/store.d.ts
CHANGED
|
@@ -18,7 +18,15 @@ export interface WriteResult {
|
|
|
18
18
|
fts: boolean;
|
|
19
19
|
vector: boolean;
|
|
20
20
|
}
|
|
21
|
-
|
|
21
|
+
/** A table built a window at a time, so peak memory is not a function of size. */
|
|
22
|
+
export interface EntityWriter {
|
|
23
|
+
add(rows: readonly EntityRecord[], vectors: readonly Float32Array[]): Promise<void>;
|
|
24
|
+
/** Builds the indexes and closes. Throws if nothing was ever added. */
|
|
25
|
+
finish(): Promise<WriteResult>;
|
|
26
|
+
/** For a build that failed, so the connection does not outlive it. */
|
|
27
|
+
close(): void;
|
|
28
|
+
}
|
|
29
|
+
export declare function openStore(dir: string): Promise<EntityWriter>;
|
|
22
30
|
export declare class EntityStore {
|
|
23
31
|
#private;
|
|
24
32
|
constructor(db: Connection, table: Table, sources?: readonly string[]);
|