@zenera/rag 1.1.9 → 1.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +195 -10
  2. package/dist/command.js +2 -1
  3. package/dist/common/cache.d.ts +66 -0
  4. package/dist/common/cache.js +172 -0
  5. package/dist/common/embedder.d.ts +11 -1
  6. package/dist/common/embedder.js +6 -17
  7. package/dist/common/manifest.d.ts +7 -1
  8. package/dist/common/progress.d.ts +16 -1
  9. package/dist/common/progress.js +35 -3
  10. package/dist/common/prose.d.ts +7 -0
  11. package/dist/common/prose.js +13 -0
  12. package/dist/docs/assemble.d.ts +52 -0
  13. package/dist/docs/assemble.js +127 -0
  14. package/dist/docs/build.d.ts +54 -0
  15. package/dist/docs/build.js +134 -0
  16. package/dist/docs/chunk.d.ts +73 -0
  17. package/dist/docs/chunk.js +586 -0
  18. package/dist/docs/command.d.ts +3 -0
  19. package/dist/docs/command.js +575 -0
  20. package/dist/docs/files.d.ts +94 -0
  21. package/dist/docs/files.js +80 -0
  22. package/dist/docs/index.d.ts +13 -0
  23. package/dist/docs/index.js +13 -0
  24. package/dist/docs/load.d.ts +42 -0
  25. package/dist/docs/load.js +203 -0
  26. package/dist/docs/lookup.d.ts +80 -0
  27. package/dist/docs/lookup.js +147 -0
  28. package/dist/docs/outline.d.ts +11 -0
  29. package/dist/docs/outline.js +55 -0
  30. package/dist/docs/parse-cache.d.ts +20 -0
  31. package/dist/docs/parse-cache.js +60 -0
  32. package/dist/docs/parse-worker.d.ts +13 -0
  33. package/dist/docs/parse-worker.js +21 -0
  34. package/dist/docs/parse.d.ts +95 -0
  35. package/dist/docs/parse.js +372 -0
  36. package/dist/docs/pool.d.ts +27 -0
  37. package/dist/docs/pool.js +133 -0
  38. package/dist/docs/readme.d.ts +6 -0
  39. package/dist/docs/readme.js +142 -0
  40. package/dist/docs/render.d.ts +13 -0
  41. package/dist/docs/render.js +46 -0
  42. package/dist/docs/repl.d.ts +7 -0
  43. package/dist/docs/repl.js +130 -0
  44. package/dist/docs/search.d.ts +92 -0
  45. package/dist/docs/search.js +251 -0
  46. package/dist/docs/store.d.ts +79 -0
  47. package/dist/docs/store.js +214 -0
  48. package/dist/docs/tools.d.ts +10 -0
  49. package/dist/docs/tools.js +300 -0
  50. package/dist/index.d.ts +1 -0
  51. package/dist/index.js +3 -0
  52. package/dist/schema/build.d.ts +16 -2
  53. package/dist/schema/build.js +37 -25
  54. package/dist/schema/command.js +39 -10
  55. package/dist/schema/query.js +1 -0
  56. package/dist/schema/readme.js +6 -2
  57. package/dist/schema/search.d.ts +2 -0
  58. package/dist/schema/search.js +18 -2
  59. package/dist/schema/store.d.ts +13 -3
  60. package/dist/schema/store.js +73 -24
  61. package/dist/schema/tools.js +21 -2
  62. package/package.json +17 -4
@@ -0,0 +1,300 @@
1
+ import { tool } from '@zenera/neo';
2
+ import { PatternError } from "../common/match.js";
3
+ import { assemble } from "./assemble.js";
4
+ import { CHUNK_KINDS } from "./chunk.js";
5
+ import { grepLines, listFiles, listSections, listTables, readRange, readSection, } from "./lookup.js";
6
+ import { renderAssembly } from "./render.js";
7
+ import { SEARCH_MODES } from "./search.js";
8
+ // ---------------------------------------------------------------------------
9
+ // The same index, given to an agent
10
+ //
11
+ // Four tools over one engine, and only one of them ranks anything. `search_docs`
12
+ // is the way in when the question is vague; the other three are exact, because
13
+ // a model told "no results" by a vector search has learned nothing — a ranking
14
+ // returns the top of a list, so an empty answer and an absent thing look
15
+ // identical.
16
+ //
17
+ // `search_docs` is shaped for the second call rather than the first. The first
18
+ // is always a sentence and always returns too much of the wrong tree; the
19
+ // second is the same sentence with `files: ["nsx_4.2*/api/**"]`, or
20
+ // `section: "Rate limits"`, or `kind: ["table"]` because the answer is a table
21
+ // and not the prose around it. Those are parameters and not separate tools, so
22
+ // narrowing costs one call and not three.
23
+ //
24
+ // Every answer carries line numbers, and `read_docs` takes them. That is the
25
+ // loop the whole thing exists for: find the passage, read around it, then edit
26
+ // the file the passage came from — and a passage that cannot be pointed at is a
27
+ // passage nothing can be done with.
28
+ // ---------------------------------------------------------------------------
29
+ const GROUP = 'docs';
30
+ /** Kept small on purpose: a tool result is prompt, and the model asked for one thing. */
31
+ const DEFAULT_LIMIT = 5;
32
+ const MAX_LIMIT = 25;
33
+ /** A listing is lines rather than passages, so it can afford more of them. */
34
+ const DEFAULT_ROWS = 50;
35
+ const MAX_ROWS = 200;
36
+ /** A ceiling on what one answer may quote, so one long section cannot eat it. */
37
+ const DEFAULT_MAX_LINES = 200;
38
+ const MAX_LINES = 800;
39
+ export function docsTools(index, options = {}) {
40
+ const limitOf = (asked) => clamp(asked ?? options.limit ?? DEFAULT_LIMIT, 1, MAX_LIMIT);
41
+ const linesOf = (asked) => clamp(asked ?? options.maxLines ?? DEFAULT_MAX_LINES, 20, MAX_LINES);
42
+ const searchDocs = tool({
43
+ name: 'search_docs',
44
+ group: GROUP,
45
+ description: 'Searches the documents and answers with the passages that matched, quoted ' +
46
+ 'verbatim with their line numbers and with a marker wherever something between ' +
47
+ 'two passages was left out. When the first answer is from the wrong part of the ' +
48
+ 'corpus, ask again with the same query and a narrowing: `files` for a path ' +
49
+ 'pattern, `section` for a heading, `kind` for tables or code only. Pass the ids ' +
50
+ 'from an earlier answer in exclude_ids to be shown something new instead.',
51
+ parameters: {
52
+ type: 'object',
53
+ properties: {
54
+ query: {
55
+ type: 'string',
56
+ description: 'What is wanted, as a sentence rather than keywords.',
57
+ },
58
+ files: strings('Only documents whose name matches. A glob if it has * or ?, e.g. ' +
59
+ '"guides/**" or "nsx_4.2*/api/**"; otherwise a substring.'),
60
+ exclude_files: strings('Documents to leave out, matched the same way.'),
61
+ section: strings('Only under these headings, and whatever nests inside them. A heading ' +
62
+ 'title, or a structure path from an earlier answer.'),
63
+ kind: {
64
+ type: 'array',
65
+ items: { type: 'string', enum: [...CHUNK_KINDS] },
66
+ description: 'Only these kinds of block. Use ["table"] or ["table_row"] when the ' +
67
+ 'answer is tabular, ["code"] for examples.',
68
+ },
69
+ mode: {
70
+ type: 'string',
71
+ enum: [...SEARCH_MODES],
72
+ description: 'hybrid blends meaning and wording; text is exact wording only, for ' +
73
+ 'an error string or an identifier.',
74
+ },
75
+ exclude_ids: strings('Passage ids already seen, as printed in an earlier answer.'),
76
+ limit: {
77
+ type: 'integer',
78
+ description: `Passages kept. Default ${DEFAULT_LIMIT}, at most ${MAX_LIMIT}.`,
79
+ },
80
+ before: { type: 'integer', description: 'Extra lines quoted before each passage.' },
81
+ after: { type: 'integer', description: 'Extra lines quoted after each passage.' },
82
+ max_lines: {
83
+ type: 'integer',
84
+ description: `A ceiling on the whole answer. Default ${DEFAULT_MAX_LINES}.`,
85
+ },
86
+ },
87
+ required: ['query'],
88
+ additionalProperties: false,
89
+ },
90
+ execute: async (args) => {
91
+ const query = {
92
+ query: args.query,
93
+ files: args.files,
94
+ exclude_files: args.exclude_files,
95
+ section: args.section,
96
+ kinds: args.kind,
97
+ mode: args.mode,
98
+ exclude_ids: args.exclude_ids,
99
+ limit: limitOf(args.limit),
100
+ };
101
+ const result = await guard(() => index.search(query));
102
+ if ('error' in result) {
103
+ return result;
104
+ }
105
+ if (result.files.length === 0) {
106
+ return {
107
+ found: 0,
108
+ hint: 'no document matched `files` — call list_docs to see their names',
109
+ };
110
+ }
111
+ if (result.matches.length === 0) {
112
+ return {
113
+ found: 0,
114
+ scope: { documents: result.files.length, sections: result.sections.length },
115
+ hint: args.section?.length
116
+ ? 'nothing under that section — drop `section` and search the whole document'
117
+ : 'try fewer words, or mode "text" if it is an exact string',
118
+ };
119
+ }
120
+ const excerpt = await assemble(index, result.matches, {
121
+ before: args.before,
122
+ after: args.after,
123
+ maxLines: linesOf(args.max_lines),
124
+ });
125
+ return {
126
+ found: result.matches.length,
127
+ ids: result.matches.map((m) => m.id),
128
+ documents: excerpt.files.map((f) => f.path),
129
+ truncated: excerpt.truncated,
130
+ passages: renderAssembly(excerpt, { colour: false }),
131
+ };
132
+ },
133
+ });
134
+ const listDocs = tool({
135
+ name: 'list_docs',
136
+ group: GROUP,
137
+ description: 'Lists what is in the index — the documents, their headings, or their tables — ' +
138
+ 'without searching or ranking anything. Call it first to learn the document ' +
139
+ "names that `files` patterns are matched against, or to see a document's " +
140
+ 'structure before asking about one part of it.',
141
+ parameters: {
142
+ type: 'object',
143
+ properties: {
144
+ what: {
145
+ type: 'string',
146
+ enum: ['files', 'sections', 'tables'],
147
+ description: 'Default files.',
148
+ },
149
+ files: strings('Only these documents, matched by glob or substring.'),
150
+ section: strings('Only under these headings.'),
151
+ depth: {
152
+ type: 'integer',
153
+ description: 'sections only: the deepest heading level to report.',
154
+ },
155
+ limit: { type: 'integer', description: `Rows kept. Default ${DEFAULT_ROWS}.` },
156
+ },
157
+ additionalProperties: false,
158
+ },
159
+ execute: async (args) => {
160
+ const options = {
161
+ files: args.files,
162
+ section: args.section,
163
+ depth: args.depth,
164
+ limit: clamp(args.limit ?? DEFAULT_ROWS, 1, MAX_ROWS),
165
+ };
166
+ const what = args.what ?? 'files';
167
+ const result = await guard(() => what === 'sections'
168
+ ? listSections(index, options)
169
+ : what === 'tables'
170
+ ? listTables(index, options)
171
+ : listFiles(index, options));
172
+ if ('error' in result) {
173
+ return result;
174
+ }
175
+ return {
176
+ found: result.found,
177
+ truncated: result.truncated,
178
+ [what]: result.rows,
179
+ ...(result.found === 0
180
+ ? { hint: 'nothing matched — widen `files`, or call it with no arguments' }
181
+ : {}),
182
+ };
183
+ },
184
+ });
185
+ const grepDocs = tool({
186
+ name: 'grep_docs',
187
+ group: GROUP,
188
+ description: 'Every line matching a pattern, with the document and line number, and the ' +
189
+ 'section it sits in. Exact, not ranked, and it reports the true total even when ' +
190
+ 'the rows are cut — so unlike search_docs it can answer "does this string appear ' +
191
+ 'anywhere". Reach for it with an identifier, an error message, or a flag name.',
192
+ parameters: {
193
+ type: 'object',
194
+ properties: {
195
+ pattern: {
196
+ type: 'string',
197
+ description: 'A substring, a glob if it has * or ?, or a regex with regex.',
198
+ },
199
+ files: strings('Only these documents.'),
200
+ section: strings('Only under these headings.'),
201
+ regex: { type: 'boolean', description: 'Read the pattern as a regex.' },
202
+ case_sensitive: { type: 'boolean', description: 'Match the capitals too.' },
203
+ limit: { type: 'integer', description: `Lines kept. Default ${DEFAULT_ROWS}.` },
204
+ },
205
+ required: ['pattern'],
206
+ additionalProperties: false,
207
+ },
208
+ execute: async (args) => {
209
+ const result = await guard(() => grepLines(index, args.pattern, {
210
+ files: args.files,
211
+ section: args.section,
212
+ regex: args.regex,
213
+ caseSensitive: args.case_sensitive,
214
+ limit: clamp(args.limit ?? DEFAULT_ROWS, 1, MAX_ROWS),
215
+ }));
216
+ if ('error' in result) {
217
+ return result;
218
+ }
219
+ return {
220
+ found: result.found,
221
+ truncated: result.truncated,
222
+ lines: result.rows,
223
+ ...(result.found === 0 ? { hint: 'nothing matched anywhere in scope' } : {}),
224
+ };
225
+ },
226
+ });
227
+ const readDocs = tool({
228
+ name: 'read_docs',
229
+ group: GROUP,
230
+ description: 'Reads a document verbatim: a whole named section, or a line range as printed by ' +
231
+ 'search_docs or grep_docs. Use it when a passage was found and the lines around ' +
232
+ 'it are needed in full, with nothing omitted and nothing summarised.',
233
+ parameters: {
234
+ type: 'object',
235
+ properties: {
236
+ file: {
237
+ type: 'string',
238
+ description: 'A document name, exactly as list_docs prints it.',
239
+ },
240
+ section: {
241
+ type: 'string',
242
+ description: 'A heading title or structure path. Overrides from/to.',
243
+ },
244
+ from: { type: 'integer', description: 'First line, 1-based. Default 1.' },
245
+ to: { type: 'integer', description: 'Last line. Default the end of the document.' },
246
+ },
247
+ required: ['file'],
248
+ additionalProperties: false,
249
+ },
250
+ execute: async (args) => {
251
+ const file = index.resolveFiles([args.file])[0];
252
+ if (!file) {
253
+ return {
254
+ error: `no document called ${args.file}`,
255
+ hint: 'call list_docs for the names',
256
+ };
257
+ }
258
+ const result = await guard(async () => args.section
259
+ ? await readSection(index, file, args.section)
260
+ : await readRange(index, file, args.from ?? 1, args.to ?? Infinity));
261
+ if ('error' in result) {
262
+ return result;
263
+ }
264
+ // A whole large document is prompt spent on lines nobody asked for.
265
+ const kept = result.lines.slice(0, MAX_LINES);
266
+ return {
267
+ file: result.file,
268
+ start: result.start,
269
+ end: result.start + kept.length - 1,
270
+ total: result.total,
271
+ truncated: kept.length < result.lines.length,
272
+ text: kept.map((line, at) => `${result.start + at} | ${line}`).join('\n'),
273
+ };
274
+ },
275
+ });
276
+ return [searchDocs, listDocs, grepDocs, readDocs];
277
+ }
278
+ // ---------------------------------------------------------------------------
279
+ const strings = (description) => ({
280
+ type: 'array',
281
+ items: { type: 'string' },
282
+ description,
283
+ });
284
+ const clamp = (value, low, high) => Math.min(Math.max(Math.trunc(value), low), high);
285
+ /**
286
+ * A bad pattern is a bad argument, not a failure. A model handed a thrown
287
+ * exception retries the same call; one handed a sentence fixes it.
288
+ */
289
+ async function guard(run) {
290
+ try {
291
+ return await run();
292
+ }
293
+ catch (err) {
294
+ if (err instanceof PatternError) {
295
+ return { error: err.message };
296
+ }
297
+ throw err;
298
+ }
299
+ }
300
+ //# sourceMappingURL=tools.js.map
package/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export * from './common/locate.ts';
2
2
  export * from './common/manifest.ts';
3
3
  export * from './common/match.ts';
4
+ export * as docs from './docs/index.ts';
4
5
  export * from './schema/build.ts';
5
6
  export * from './schema/entities.ts';
6
7
  export * from './schema/files.ts';
package/dist/index.js CHANGED
@@ -1,6 +1,9 @@
1
1
  export * from "./common/locate.js";
2
2
  export * from "./common/manifest.js";
3
3
  export * from "./common/match.js";
4
+ // A namespace rather than a re-export: both subjects have a `Manifest`, a
5
+ // `writeIndex` and a `search`, and flattening them would collide on every one.
6
+ export * as docs from "./docs/index.js";
4
7
  export * from "./schema/build.js";
5
8
  export * from "./schema/entities.js";
6
9
  export * from "./schema/files.js";
@@ -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)',
@@ -99,6 +106,7 @@ export const command = {
99
106
  [' --exclude-method <name>', dim('Drop an operation by name.')],
100
107
  [' --exclude-type <name>', dim('Drop a schema by name.')],
101
108
  [' --exclude-property <name>', dim('Drop a field by name.')],
109
+ [' --source <name>', dim('Only this document, as `stats` names it. Repeatable.')],
102
110
  [' --limit <n>', dim('Seeds kept per term. Default 5.')],
103
111
  [' --max-hops <n>', dim('How far apart two hits may be. Default 3.')],
104
112
  [' --max-nodes <n>', dim('Nodes per result. Default 200.')],
@@ -188,34 +196,48 @@ async function index(args, ctx) {
188
196
  out: { type: 'string', short: 'o' },
189
197
  embedding: { type: 'string' },
190
198
  batch: { type: 'string' },
199
+ dimensions: { type: 'string' },
191
200
  'no-sources': { type: 'boolean' },
201
+ 'no-cache': { type: 'boolean' },
202
+ 'cache-dir': { type: 'string' },
192
203
  quiet: { type: 'boolean' },
193
204
  }, INDEX_USAGE);
194
205
  if (positionals.length === 0) {
195
206
  throw usageError('no document given', INDEX_USAGE);
196
207
  }
197
208
  const out = outputDir(ctx.cwd, values.out, SCHEMA_INDEX);
209
+ const cacheDir = values['cache-dir'] ? resolve(ctx.cwd, values['cache-dir']) : paths.cache();
198
210
  const loud = !values.quiet && !ctx.json;
199
- 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
+ });
200
219
  const started = Date.now();
201
- const { manifest } = await buildIndex({
220
+ const { manifest, timings, reused } = await buildIndex({
202
221
  files: positionals.map((file) => resolve(ctx.cwd, file)),
203
222
  out,
204
223
  embedder: chosen,
205
224
  embeddingRef: values.embedding,
206
225
  indexer: 'zenera-rag',
207
- batch: values.batch ? count(values.batch, '--batch') : undefined,
208
226
  sources: !values['no-sources'],
227
+ dimensions,
228
+ cache: !values['no-cache'],
229
+ cacheDir: values['cache-dir'] ? cacheDir : undefined,
209
230
  onRead: loud
210
231
  ? (summary) => {
211
232
  printSources(summary.sources);
212
- // The first batch can take a while and says nothing while it
213
- // 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.
214
236
  note(dim(` embedding ${summary.counts.entities} entities with ${chosen.id} …`));
215
237
  }
216
238
  : undefined,
217
239
  onProgress: loud
218
- ? (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)}`))
219
241
  : undefined,
220
242
  });
221
243
  if (ctx.json) {
@@ -228,6 +250,9 @@ async function index(args, ctx) {
228
250
  write(out);
229
251
  note(` wrote ${bold(String(manifest.counts.entities))} entities to ${bold(out)}, ` +
230
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}`}`));
231
256
  const where = out === resolve(ctx.cwd, DEFAULT_DIR) ? '' : ` --dir ${relative(ctx.cwd, out) || out}`;
232
257
  note(dim(` search it: ${cyan(`zen rag schema search${where} --all "what you are after"`)}`));
233
258
  }
@@ -282,6 +307,7 @@ const SEARCH_OPTIONS = {
282
307
  'exclude-method': MANY,
283
308
  'exclude-type': MANY,
284
309
  'exclude-property': MANY,
310
+ source: MANY,
285
311
  limit: { type: 'string' },
286
312
  'max-hops': { type: 'string' },
287
313
  'max-nodes': { type: 'string' },
@@ -313,7 +339,9 @@ async function search(args, ctx) {
313
339
  const manifest = await readManifest(dir);
314
340
  const ref = values.embedding ?? manifest.embedding.ref;
315
341
  assertSameEmbedding(manifest, ref);
316
- 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 }));
317
345
  try {
318
346
  if (values.interactive) {
319
347
  await repl(index, query, { format, ...options });
@@ -362,6 +390,7 @@ function fromFlags(values, positionals = []) {
362
390
  put('exclude_methods', values['exclude-method']);
363
391
  put('exclude_types', values['exclude-type']);
364
392
  put('exclude_properties', values['exclude-property']);
393
+ put('sources', values.source);
365
394
  put('direction', values.direction);
366
395
  put('method_type', values['method-type']);
367
396
  put('limit', values.limit && count(values.limit, '--limit'));
@@ -19,6 +19,7 @@ const LISTS = [
19
19
  'exclude_methods',
20
20
  'exclude_types',
21
21
  'exclude_properties',
22
+ 'sources',
22
23
  ];
23
24
  const NUMBERS = ['limit', 'max_hops', 'max_nodes'];
24
25
  const DIRECTIONS = ['input', 'output', 'any'];
@@ -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)})`,
@@ -24,6 +24,8 @@ export interface SchemaQuery {
24
24
  exclude_methods?: readonly string[];
25
25
  exclude_types?: readonly string[];
26
26
  exclude_properties?: readonly string[];
27
+ /** document names, as `stats` prints them; any one of them is enough */
28
+ sources?: readonly string[];
27
29
  /** seeds kept per query string */
28
30
  limit?: number;
29
31
  max_hops?: number;