@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.
@@ -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
@@ -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
- const percent = Math.round((state.done / state.total) * 100);
25
- rows.push(['embedded', `${state.done} of ${state.total} · ${percent}%`]);
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,
@@ -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
- export declare function writeChunks(dir: string, rows: readonly ChunkRecord[], vectors: readonly Float32Array[]): Promise<WriteResult>;
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);
@@ -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 writeChunks(dir, rows, vectors) {
34
- if (rows.length === 0) {
35
- throw new CliError('the documents hold nothing to index', EXIT.invalid, 'they are empty, or every one of them is blank');
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
- const db = await connect(lancePath(dir));
38
- // Every column is always populated never null so the Arrow schema is
39
- // inferred from the first row without a declaration to keep in step.
40
- const table = await db.createTable(TABLE, rows.map((row, i) => ({ ...row, vector: vectors[i] })), { mode: 'overwrite' });
41
- await table.createIndex('text', { config: Index.fts() });
42
- await table.createIndex('kind', { config: Index.bitmap() });
43
- for (const column of ['path', 'structurePath']) {
44
- await table.createIndex(column, { config: Index.btree() });
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
- const vector = rows.length >= VECTOR_INDEX_MIN_ROWS;
47
- if (vector) {
48
- await table.createIndex('vector');
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;
@@ -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
@@ -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 { writeStore } from "./store.js";
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
- const vectors = await embedAll(entities, options, journal);
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 writeStore(options.out, entities, vectors);
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: options.embeddingRef ?? options.embedder.id,
55
+ ref,
46
56
  id: options.embedder.id,
47
- dimensions: vectors[0]?.length ?? 0,
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
- const size = options.batch ?? DEFAULT_BATCH;
70
- const out = [];
71
- for (let at = 0; at < entities.length; at += size) {
72
- const slice = entities.slice(at, at + size);
73
- const response = await options.embedder.embed({
74
- input: slice.map((e) => e.text),
75
- taskType: 'document',
76
- signal: options.signal,
77
- });
78
- if (response.vectors.length !== slice.length) {
79
- throw new Error(`${options.embedder.id} answered ${response.vectors.length} vectors for ${slice.length} texts`);
80
- }
81
- out.push(...response.vectors.map((v) => Float32Array.from(v)));
82
- journal.progress(out.length, entities.length);
83
- options.onProgress?.(out.length, entities.length);
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
@@ -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
- ' --batch <n>',
73
- dim('Texts per embedding request, and how often progress prints. Default 96.'),
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
- const chosen = await resolveEmbedder(values.embedding);
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
- // The first batch can take a while and says nothing while it
214
- // does; this is the line that makes that a wait, not a hang.
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.round((done / total) * 100)}% · ${elapsed(started)}`))
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
- const index = await SchemaIndex.open(dir, await resolveEmbedder(ref));
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 });
@@ -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.round((state.done / state.total) * 100);
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)})`,
@@ -18,7 +18,15 @@ export interface WriteResult {
18
18
  fts: boolean;
19
19
  vector: boolean;
20
20
  }
21
- export declare function writeStore(dir: string, rows: readonly EntityRecord[], vectors: readonly Float32Array[]): Promise<WriteResult>;
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[]);