@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.
@@ -1,10 +1,10 @@
1
+ import { embedStream, NO_CACHE, openCache } from "../common/cache.js";
1
2
  import { beginBuild } from "../common/progress.js";
2
3
  import { formatLines } from "./chunk.js";
3
4
  import { INDEX_VERSION, SOURCES_DIR, writeIndex, } from "./files.js";
4
5
  import { loadDocuments } from "./load.js";
5
6
  import { DOCS_REPORT, PHASES } from "./readme.js";
6
- import { writeChunks } from "./store.js";
7
- const DEFAULT_BATCH = 96;
7
+ import { openChunks } from "./store.js";
8
8
  export async function buildIndex(options) {
9
9
  const journal = beginBuild({
10
10
  dir: options.out,
@@ -14,8 +14,25 @@ export async function buildIndex(options) {
14
14
  phases: PHASES,
15
15
  report: DOCS_REPORT,
16
16
  });
17
+ const ref = options.embeddingRef ?? options.embedder.id;
18
+ const cache = options.cache === false
19
+ ? NO_CACHE
20
+ : openCache(options.embedder, {
21
+ ref,
22
+ dir: options.cacheDir,
23
+ dimensions: options.dimensions,
24
+ });
25
+ let writer;
17
26
  try {
18
- const corpus = await loadDocuments(options.files, options.cwd, options.chunk);
27
+ const corpus = await loadDocuments(options.files, options.cwd, {
28
+ chunk: options.chunk,
29
+ cache: options.cache !== false,
30
+ cacheDir: options.cacheDir,
31
+ onProgress: (done, total, pending) => {
32
+ journal.progress(done, total, pending);
33
+ options.onReading?.(done, total, pending);
34
+ },
35
+ });
19
36
  const chunks = recordsOf(corpus);
20
37
  const sources = corpus.docs.map((doc) => ({
21
38
  name: doc.name,
@@ -40,18 +57,20 @@ export async function buildIndex(options) {
40
57
  journal.read(counts, chunks.length);
41
58
  options.onRead?.({ sources, counts, skipped: corpus.skipped });
42
59
  journal.phase('embedding');
43
- const vectors = await embedAll(chunks, options, journal);
60
+ writer = await openChunks(options.out);
61
+ const dimensions = await embedAll(chunks, options, journal, cache, writer);
44
62
  journal.phase('writing');
45
- const written = await writeChunks(options.out, chunks, vectors);
63
+ const written = await writer.finish();
46
64
  const manifest = {
47
65
  version: INDEX_VERSION,
48
66
  kind: 'docs',
49
67
  createdAt: new Date().toISOString(),
50
68
  indexer: options.indexer,
51
69
  embedding: {
52
- ref: options.embeddingRef ?? options.embedder.id,
70
+ ref,
53
71
  id: options.embedder.id,
54
- dimensions: vectors[0]?.length ?? 0,
72
+ dimensions,
73
+ requested: options.dimensions,
55
74
  },
56
75
  sources,
57
76
  counts,
@@ -60,10 +79,18 @@ export async function buildIndex(options) {
60
79
  const outline = { files: corpus.docs.map((doc) => doc.outline) };
61
80
  const documents = Object.fromEntries(corpus.docs.map((doc) => [doc.name, doc.text]));
62
81
  await writeIndex(options.out, { manifest, outline, documents });
82
+ cache.commit();
63
83
  journal.finish(manifest);
64
- return { manifest, chunks };
84
+ return {
85
+ manifest,
86
+ chunks,
87
+ timings: journal.timings,
88
+ reused: { parses: corpus.cached, vectors: cache.hits },
89
+ };
65
90
  }
66
91
  catch (err) {
92
+ writer?.close();
93
+ cache.abandon();
67
94
  journal.fail(err);
68
95
  throw err;
69
96
  }
@@ -86,23 +113,22 @@ function recordsOf(corpus) {
86
113
  tokens: chunk.tokens,
87
114
  })));
88
115
  }
89
- async function embedAll(chunks, options, journal) {
90
- const size = options.batch ?? DEFAULT_BATCH;
91
- const out = [];
92
- for (let at = 0; at < chunks.length; at += size) {
93
- const slice = chunks.slice(at, at + size);
94
- const response = await options.embedder.embed({
95
- input: slice.map((c) => c.embedText),
96
- taskType: 'document',
97
- signal: options.signal,
98
- });
99
- if (response.vectors.length !== slice.length) {
100
- throw new Error(`${options.embedder.id} answered ${response.vectors.length} vectors for ${slice.length} texts`);
101
- }
102
- out.push(...response.vectors.map((v) => Float32Array.from(v)));
103
- journal.progress(out.length, chunks.length);
104
- options.onProgress?.(out.length, chunks.length);
105
- }
106
- return out;
116
+ async function embedAll(chunks, options, journal, cache, writer) {
117
+ // A window at a time, rather than the whole corpus in one call. How many
118
+ // texts fit in a request, and how many requests may be in flight, are still
119
+ // the embedder's to answer it knows the model's caps and it is the one
120
+ // that sees the 429s. What the window decides is only how much is resident.
121
+ return embedStream({
122
+ embedder: options.embedder,
123
+ cache,
124
+ records: chunks,
125
+ textOf: (chunk) => chunk.embedText,
126
+ signal: options.signal,
127
+ onProgress: (done, total) => {
128
+ journal.progress(done, total);
129
+ options.onProgress?.(done, total);
130
+ },
131
+ onWindow: (window, vectors) => writer.add(window, vectors),
132
+ });
107
133
  }
108
134
  //# sourceMappingURL=build.js.map
@@ -1,10 +1,11 @@
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 { PatternError } from "../common/match.js";
7
- import { grid } from "../common/prose.js";
7
+ import { INTERVAL_MS } from "../common/progress.js";
8
+ import { breakdown, grid } from "../common/prose.js";
8
9
  import { assemble, DEFAULT_MAX_LINES } from "./assemble.js";
9
10
  import { buildIndex } from "./build.js";
10
11
  import { CHUNK_KINDS } from "./chunk.js";
@@ -69,8 +70,17 @@ export const command = {
69
70
  ' -o, --out <dir>',
70
71
  dim(`Where the index goes. Default ${DEFAULT_DIR}, or ${DIR_ENV}.`),
71
72
  ],
72
- [' --batch <n>', dim('Texts per embedding request. Default 96.')],
73
+ [' --batch <n>', dim("Texts per embedding request. Default: the model's own cap.")],
74
+ [
75
+ ' --dimensions <n>',
76
+ dim("Narrower vectors, if the model allows it. Default: the model's own width."),
77
+ ],
73
78
  [' --chunk-tokens <n>', dim('Target chunk size. Default 384.')],
79
+ [
80
+ ' --no-cache',
81
+ dim('Parse and embed everything again, ignoring what is already kept.'),
82
+ ],
83
+ [' --cache-dir <dir>', dim('Keep the work somewhere other than the shared cache.')],
74
84
  ]),
75
85
  '',
76
86
  dim(' Every document is copied into the index, so it stays portable and'),
@@ -155,40 +165,58 @@ async function index(args, ctx) {
155
165
  out: { type: 'string', short: 'o' },
156
166
  embedding: { type: 'string' },
157
167
  batch: { type: 'string' },
168
+ dimensions: { type: 'string' },
158
169
  'chunk-tokens': { type: 'string' },
170
+ 'no-cache': { type: 'boolean' },
171
+ 'cache-dir': { type: 'string' },
159
172
  quiet: { type: 'boolean' },
160
173
  }, INDEX_USAGE);
161
174
  if (positionals.length === 0) {
162
175
  throw usageError('no document, directory or pattern given', INDEX_USAGE);
163
176
  }
164
177
  const out = outputDir(ctx.cwd, values.out, DOCS_INDEX);
178
+ const cacheDir = values['cache-dir'] ? resolve(ctx.cwd, values['cache-dir']) : paths.cache();
165
179
  const loud = !values.quiet && !ctx.json;
166
- const chosen = await resolveEmbedder(values.embedding);
180
+ // Undefined when unasked, all the way to the cache key: a width nobody
181
+ // named is not the same key as the width the model happens to default to,
182
+ // and resolving it here would miss every vector already paid for.
183
+ const dimensions = values.dimensions ? count(values.dimensions, '--dimensions') : undefined;
184
+ const chosen = await resolveEmbedder(values.embedding, {
185
+ maxBatch: values.batch ? count(values.batch, '--batch') : undefined,
186
+ dimensions,
187
+ });
167
188
  const started = Date.now();
168
- const { manifest } = await buildIndex({
189
+ const { manifest, timings, reused } = await buildIndex({
169
190
  files: positionals,
170
191
  cwd: ctx.cwd,
171
192
  out,
172
193
  embedder: chosen,
173
194
  embeddingRef: values.embedding,
174
195
  indexer: 'zenera-rag',
175
- batch: values.batch ? count(values.batch, '--batch') : undefined,
176
196
  chunk: values['chunk-tokens']
177
197
  ? { chunkTokens: count(values['chunk-tokens'], '--chunk-tokens') }
178
198
  : undefined,
199
+ dimensions,
200
+ cache: !values['no-cache'],
201
+ cacheDir: values['cache-dir'] ? cacheDir : undefined,
202
+ onReading: loud
203
+ ? throttled((done, total, pending) => note(dim(` parsed ${done}/${total} · ${Math.floor((done / total) * 100)}% · ` +
204
+ `${elapsed(started)}${still(pending)}`)))
205
+ : undefined,
179
206
  onRead: loud
180
207
  ? (summary) => {
181
208
  printSources(summary.sources);
182
209
  for (const skip of summary.skipped) {
183
210
  note(dim(` skipped ${skip.name}: ${skip.reason}`));
184
211
  }
185
- // The first batch takes a while and says nothing while it
186
- // does; this is the line that makes that a wait, not a hang.
212
+ // Embedding is one call now, and a long one; this is the line
213
+ // that makes the wait before the first progress report a wait
214
+ // rather than a hang.
187
215
  note(dim(` embedding ${summary.counts.chunks} chunks with ${chosen.id} …`));
188
216
  }
189
217
  : undefined,
190
218
  onProgress: loud
191
- ? (done, total) => note(dim(` embedded ${done}/${total} · ${Math.round((done / total) * 100)}% · ${elapsed(started)}`))
219
+ ? (done, total) => note(dim(` embedded ${done}/${total} · ${Math.floor((done / total) * 100)}% · ${elapsed(started)}`))
192
220
  : undefined,
193
221
  });
194
222
  if (ctx.json) {
@@ -202,6 +230,10 @@ async function index(args, ctx) {
202
230
  note(` wrote ${bold(String(manifest.counts.chunks))} chunks from ` +
203
231
  `${bold(String(manifest.counts.documents))} document(s) to ${bold(out)}, ` +
204
232
  `embedded with ${manifest.embedding.ref} (${manifest.embedding.dimensions}d)`);
233
+ note(dim(` ${breakdown(timings)}`));
234
+ note(dim(` reused ${reused.parses}/${manifest.counts.documents} parses, ` +
235
+ `${reused.vectors}/${manifest.counts.chunks} vectors` +
236
+ `${values['no-cache'] ? ' (--no-cache)' : ` from ${cacheDir}`}`));
205
237
  const where = out === resolve(ctx.cwd, DEFAULT_DIR) ? '' : ` --dir ${relative(ctx.cwd, out) || out}`;
206
238
  note(dim(` search it: ${cyan(`zen rag docs search${where} "what you are after"`)}`));
207
239
  }
@@ -233,6 +265,18 @@ function elapsed(since) {
233
265
  const seconds = Math.round((Date.now() - since) / 1000);
234
266
  return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m${seconds % 60}s`;
235
267
  }
268
+ /** Reading fires per document, and a line per document is not a narration. */
269
+ function throttled(say) {
270
+ let saidAt = 0;
271
+ return (...args) => {
272
+ if (Date.now() - saidAt >= INTERVAL_MS) {
273
+ saidAt = Date.now();
274
+ say(...args);
275
+ }
276
+ };
277
+ }
278
+ /** Only worth naming when there are few enough to go and look at. */
279
+ const still = (pending) => pending.length > 0 && pending.length <= 2 ? ` · still on ${pending.join(', ')}` : '';
236
280
  const MANY = { type: 'string', multiple: true };
237
281
  async function search(args, ctx) {
238
282
  const { values, positionals } = parse(args, {
@@ -281,7 +325,9 @@ async function search(args, ctx) {
281
325
  const manifest = await readManifest(dir);
282
326
  const ref = values.embedding ?? manifest.embedding.ref;
283
327
  assertSameEmbedding(manifest, ref);
284
- const found = await DocsIndex.open(dir, await resolveEmbedder(ref));
328
+ // A query has to be asked at the width the passages were written at, and
329
+ // the ref alone does not say what that was.
330
+ const found = await DocsIndex.open(dir, await resolveEmbedder(ref, { dimensions: manifest.embedding.requested }));
285
331
  try {
286
332
  if (values.interactive) {
287
333
  await repl(found, query, shape);
@@ -1,6 +1,6 @@
1
- import { type Chunk, type ChunkOptions } from './chunk.ts';
1
+ import type { Chunk, ChunkOptions } from './chunk.ts';
2
2
  import type { FileOutline } from './files.ts';
3
- import { type DocFormat, type ParsedDoc } from './parse.ts';
3
+ import { type DocFormat } from './parse.ts';
4
4
  /** Markdown, and plain text read as paragraphs. Anything else is not a document. */
5
5
  export declare const DOC_EXTENSIONS: readonly [".md", ".markdown", ".txt", ".text"];
6
6
  export interface LoadedDoc {
@@ -11,7 +11,6 @@ export interface LoadedDoc {
11
11
  format: DocFormat;
12
12
  /** the document verbatim, CRLF normalized: what goes into `sources/` */
13
13
  text: string;
14
- parsed: ParsedDoc;
15
14
  chunks: Chunk[];
16
15
  outline: FileOutline;
17
16
  }
@@ -22,7 +21,22 @@ export interface Corpus {
22
21
  name: string;
23
22
  reason: string;
24
23
  }[];
24
+ /** documents whose chunks came from a previous build rather than the parser */
25
+ cached: number;
25
26
  }
26
- export declare function loadDocuments(inputs: readonly string[], cwd: string, options?: ChunkOptions): Promise<Corpus>;
27
+ export interface LoadOptions {
28
+ chunk?: ChunkOptions;
29
+ /** remember what documents parse to; on by default */
30
+ cache?: boolean;
31
+ /** keep the parses somewhere other than the shared store */
32
+ cacheDir?: string;
33
+ onProgress?: (done: number, total: number, pending: readonly string[]) => void;
34
+ }
35
+ /**
36
+ * Reading is cheap and parsing is not, so the two are split: every file is read
37
+ * and hashed here, and only the documents the cache has never seen are handed
38
+ * to the pool. On a rebuild of an unchanged corpus that is none of them.
39
+ */
40
+ export declare function loadDocuments(inputs: readonly string[], cwd: string, options?: LoadOptions): Promise<Corpus>;
27
41
  export declare const formatOf: (path: string) => DocFormat;
28
42
  //# sourceMappingURL=load.d.ts.map
package/dist/docs/load.js CHANGED
@@ -3,8 +3,9 @@ import { createHash } from 'node:crypto';
3
3
  import { readdir, readFile, stat } from 'node:fs/promises';
4
4
  import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
5
5
  import { isGlob, wildcard } from "../common/match.js";
6
- import { chunkDocument } from "./chunk.js";
7
- import { normalize, parseDocument } from "./parse.js";
6
+ import { NO_PARSE_CACHE, openParseCache, parseKey } from "./parse-cache.js";
7
+ import { normalize } from "./parse.js";
8
+ import { parseAll } from "./pool.js";
8
9
  // ---------------------------------------------------------------------------
9
10
  // Finding the documents, and reading them
10
11
  //
@@ -28,6 +29,11 @@ export const DOC_EXTENSIONS = ['.md', '.markdown', '.txt', '.text'];
28
29
  const MAX_BYTES = 16 * 1024 * 1024;
29
30
  /** Enough for a documentation tree; far short of a filesystem. */
30
31
  const MAX_FILES = 20_000;
32
+ /**
33
+ * Reading is cheap and parsing is not, so the two are split: every file is read
34
+ * and hashed here, and only the documents the cache has never seen are handed
35
+ * to the pool. On a rebuild of an unchanged corpus that is none of them.
36
+ */
31
37
  export async function loadDocuments(inputs, cwd, options = {}) {
32
38
  const found = await discover(inputs, cwd);
33
39
  if (found.length === 0) {
@@ -35,7 +41,9 @@ export async function loadDocuments(inputs, cwd, options = {}) {
35
41
  }
36
42
  const root = commonRoot(found);
37
43
  const taken = new Set();
38
- const docs = [];
44
+ const chunk = options.chunk ?? {};
45
+ const cache = options.cache === false ? NO_PARSE_CACHE : openParseCache(options.cacheDir);
46
+ const read = [];
39
47
  const skipped = [];
40
48
  for (const absolute of found) {
41
49
  const name = distinct(nameOf(root, absolute), taken);
@@ -45,23 +53,55 @@ export async function loadDocuments(inputs, cwd, options = {}) {
45
53
  continue;
46
54
  }
47
55
  const raw = await readFile(absolute);
48
- const text = normalize(raw.toString('utf8'));
49
- const format = formatOf(absolute);
50
- const parsed = parseDocument(text, name, format);
51
- const chunks = chunkDocument(parsed, options);
52
- docs.push({
56
+ read.push({
53
57
  name,
54
58
  file: basename(absolute),
55
59
  sha256: createHash('sha256').update(raw).digest('hex'),
56
60
  bytes: raw.byteLength,
57
- format,
58
- text,
59
- parsed,
60
- chunks,
61
- outline: outlineOf(parsed, chunks.length),
61
+ format: formatOf(absolute),
62
+ text: normalize(raw.toString('utf8')),
63
+ key: parseKey(raw, name, chunk),
62
64
  });
63
65
  }
64
- return { docs, skipped };
66
+ const parsed = new Array(read.length);
67
+ const misses = [];
68
+ const missAt = [];
69
+ for (const [at, doc] of read.entries()) {
70
+ const hit = doc.key === undefined ? undefined : cache.get(doc.key);
71
+ if (hit) {
72
+ parsed[at] = hit;
73
+ }
74
+ else {
75
+ missAt.push(at);
76
+ misses.push({ name: doc.name, text: doc.text, format: doc.format });
77
+ }
78
+ }
79
+ const done = read.length - misses.length;
80
+ options.onProgress?.(done, read.length, []);
81
+ const fresh = await parseAll(misses, {
82
+ chunk,
83
+ onProgress: (at, _of, pending) => options.onProgress?.(done + at, read.length, pending),
84
+ });
85
+ for (const [i, result] of fresh.entries()) {
86
+ const at = missAt[i];
87
+ parsed[at] = result;
88
+ const key = read[at].key;
89
+ if (key !== undefined) {
90
+ cache.put(key, result);
91
+ }
92
+ }
93
+ cache.commit();
94
+ const docs = read.map((doc, at) => ({
95
+ name: doc.name,
96
+ file: doc.file,
97
+ sha256: doc.sha256,
98
+ bytes: doc.bytes,
99
+ format: doc.format,
100
+ text: doc.text,
101
+ chunks: parsed[at].chunks,
102
+ outline: parsed[at].outline,
103
+ }));
104
+ return { docs, skipped, cached: cache.hits };
65
105
  }
66
106
  export const formatOf = (path) => ['.txt', '.text'].includes(extname(path).toLowerCase()) ? 'text' : 'markdown';
67
107
  // ---------------------------------------------------------------------------
@@ -160,53 +200,4 @@ function distinct(name, taken) {
160
200
  taken.add(candidate);
161
201
  return candidate;
162
202
  }
163
- // ---------------------------------------------------------------------------
164
- // the outline
165
- // ---------------------------------------------------------------------------
166
- /**
167
- * Headings and tables, with the line each ends on. That end is what makes the
168
- * outline enough on its own: a section runs from its heading to the line before
169
- * the next heading at the same depth or shallower, so scoping a search to a
170
- * section, listing what is in one, or naming the sections a skipped range
171
- * covered are all answerable without reading the document.
172
- */
173
- function outlineOf(doc, chunks) {
174
- const sections = doc.sections.filter((s) => s.line !== undefined);
175
- const headings = sections.map((section, at) => {
176
- const next = sections.findIndex((other, i) => i > at && other.level <= section.level);
177
- const end = next === -1 ? doc.lines.length : sections[next].line - 1;
178
- return {
179
- line: section.line,
180
- end,
181
- level: section.level,
182
- title: section.title,
183
- id: section.id,
184
- path: section.path,
185
- };
186
- });
187
- const tables = doc.blocks
188
- .filter((block) => block.table)
189
- .map((block) => {
190
- const table = block.table;
191
- return {
192
- id: block.id,
193
- path: block.path,
194
- section: block.section.path,
195
- line: block.start,
196
- end: block.end,
197
- columns: table.columns,
198
- rows: table.rows.length,
199
- caption: table.caption,
200
- };
201
- });
202
- return {
203
- name: doc.name,
204
- title: doc.title,
205
- format: doc.format,
206
- lines: doc.lines.length,
207
- chunks,
208
- headings,
209
- tables,
210
- };
211
- }
212
203
  //# sourceMappingURL=load.js.map
@@ -0,0 +1,11 @@
1
+ import type { FileOutline } from './files.ts';
2
+ import type { ParsedDoc } from './parse.ts';
3
+ /**
4
+ * Headings and tables, with the line each ends on. That end is what makes the
5
+ * outline enough on its own: a section runs from its heading to the line before
6
+ * the next heading at the same depth or shallower, so scoping a search to a
7
+ * section, listing what is in one, or naming the sections a skipped range
8
+ * covered are all answerable without reading the document.
9
+ */
10
+ export declare function outlineOf(doc: ParsedDoc, chunks: number): FileOutline;
11
+ //# sourceMappingURL=outline.d.ts.map
@@ -0,0 +1,55 @@
1
+ // ---------------------------------------------------------------------------
2
+ // What a document holds, without the document
3
+ //
4
+ // This lives apart from `load.ts` for one reason: a parse worker needs it, and
5
+ // `load.ts` imports the CLI. A worker that reached for it there would load the
6
+ // whole command layer into every thread to call one pure function. Nothing here
7
+ // may import anything that is not pure.
8
+ // ---------------------------------------------------------------------------
9
+ /**
10
+ * Headings and tables, with the line each ends on. That end is what makes the
11
+ * outline enough on its own: a section runs from its heading to the line before
12
+ * the next heading at the same depth or shallower, so scoping a search to a
13
+ * section, listing what is in one, or naming the sections a skipped range
14
+ * covered are all answerable without reading the document.
15
+ */
16
+ export function outlineOf(doc, chunks) {
17
+ const sections = doc.sections.filter((s) => s.line !== undefined);
18
+ const headings = sections.map((section, at) => {
19
+ const next = sections.findIndex((other, i) => i > at && other.level <= section.level);
20
+ const end = next === -1 ? doc.lines.length : sections[next].line - 1;
21
+ return {
22
+ line: section.line,
23
+ end,
24
+ level: section.level,
25
+ title: section.title,
26
+ id: section.id,
27
+ path: section.path,
28
+ };
29
+ });
30
+ const tables = doc.blocks
31
+ .filter((block) => block.table)
32
+ .map((block) => {
33
+ const table = block.table;
34
+ return {
35
+ id: block.id,
36
+ path: block.path,
37
+ section: block.section.path,
38
+ line: block.start,
39
+ end: block.end,
40
+ columns: table.columns,
41
+ rows: table.rows.length,
42
+ caption: table.caption,
43
+ };
44
+ });
45
+ return {
46
+ name: doc.name,
47
+ title: doc.title,
48
+ format: doc.format,
49
+ lines: doc.lines.length,
50
+ chunks,
51
+ headings,
52
+ tables,
53
+ };
54
+ }
55
+ //# sourceMappingURL=outline.js.map
@@ -0,0 +1,20 @@
1
+ import type { ChunkOptions } from './chunk.ts';
2
+ import type { Parsed } from './pool.ts';
3
+ export declare const PARSE_KIND = "docs-parse";
4
+ /** Bumped when chunking changes shape, which invalidates every entry. */
5
+ export declare const PARSE_VERSION = 1;
6
+ export interface ParseCache {
7
+ get(key: string): Parsed | undefined;
8
+ put(key: string, parsed: Parsed): void;
9
+ commit(): void;
10
+ readonly hits: number;
11
+ }
12
+ export declare const NO_PARSE_CACHE: ParseCache;
13
+ /**
14
+ * What the chunks depend on, and nothing else. `tokenCount` is a function and
15
+ * cannot be hashed; a caller that supplies one gets no cache rather than a key
16
+ * that quietly ignores it.
17
+ */
18
+ export declare function parseKey(bytes: Buffer, name: string, options: ChunkOptions): string | undefined;
19
+ export declare function openParseCache(dir?: string): ParseCache;
20
+ //# sourceMappingURL=parse-cache.d.ts.map
@@ -0,0 +1,60 @@
1
+ import { Cache, cacheKey } from '@zenera/cli/lib';
2
+ import { createHash } from 'node:crypto';
3
+ // ---------------------------------------------------------------------------
4
+ // Not parsing the same document twice
5
+ //
6
+ // Once vectors are cached, parsing is what a rebuild of an unchanged corpus
7
+ // spends all of its time on — the threads make it several times faster, and
8
+ // this makes it nothing at all. A document that has not changed produces
9
+ // exactly the chunks it produced last time, so the chunks are what is kept.
10
+ //
11
+ // The key is the file's bytes together with everything that decides how they
12
+ // are cut: change the document, the chunk settings or this module, and the
13
+ // entry is a miss rather than a wrong answer. It sits in the machine's shared
14
+ // cache next to the vectors, so a corpus indexed into two directories is read
15
+ // once, and it follows the same rule everything there does — every error is a
16
+ // miss and nothing else.
17
+ // ---------------------------------------------------------------------------
18
+ export const PARSE_KIND = 'docs-parse';
19
+ /** Bumped when chunking changes shape, which invalidates every entry. */
20
+ export const PARSE_VERSION = 1;
21
+ export const NO_PARSE_CACHE = {
22
+ get: () => undefined,
23
+ put: () => { },
24
+ commit: () => { },
25
+ hits: 0,
26
+ };
27
+ /**
28
+ * What the chunks depend on, and nothing else. `tokenCount` is a function and
29
+ * cannot be hashed; a caller that supplies one gets no cache rather than a key
30
+ * that quietly ignores it.
31
+ */
32
+ export function parseKey(bytes, name, options) {
33
+ if (typeof options.tokenCount === 'function') {
34
+ return undefined;
35
+ }
36
+ return cacheKey(PARSE_VERSION, name, options.chunkTokens, options.minChunkTokens, options.maxChunkTokens, options.tableSliceTokens, createHash('sha256').update(bytes).digest('hex'));
37
+ }
38
+ export function openParseCache(dir) {
39
+ return new StoredParses(new Cache(PARSE_KIND, { dir }));
40
+ }
41
+ class StoredParses {
42
+ #store;
43
+ constructor(store) {
44
+ this.#store = store;
45
+ }
46
+ get hits() {
47
+ return this.#store.hits;
48
+ }
49
+ get(key) {
50
+ const found = this.#store.get(key);
51
+ return found?.chunks && found.outline ? found : undefined;
52
+ }
53
+ put(key, parsed) {
54
+ this.#store.put(key, { chunks: parsed.chunks, outline: parsed.outline });
55
+ }
56
+ commit() {
57
+ this.#store.commit();
58
+ }
59
+ }
60
+ //# sourceMappingURL=parse-cache.js.map
@@ -0,0 +1,13 @@
1
+ export interface ParseJob {
2
+ at: number;
3
+ name: string;
4
+ text: string;
5
+ format: 'markdown' | 'text';
6
+ }
7
+ export interface ParseDone {
8
+ at: number;
9
+ chunks?: unknown;
10
+ outline?: unknown;
11
+ error?: string;
12
+ }
13
+ //# sourceMappingURL=parse-worker.d.ts.map
@@ -0,0 +1,21 @@
1
+ import { parentPort, workerData } from 'node:worker_threads';
2
+ import { chunkDocument } from "./chunk.js";
3
+ import { outlineOf } from "./outline.js";
4
+ import { parseDocument } from "./parse.js";
5
+ const options = (workerData ?? {});
6
+ parentPort?.on('message', (job) => {
7
+ try {
8
+ const parsed = parseDocument(job.text, job.name, job.format);
9
+ const chunks = chunkDocument(parsed, options);
10
+ parentPort.postMessage({ at: job.at, chunks, outline: outlineOf(parsed, chunks.length) });
11
+ }
12
+ catch (err) {
13
+ // Sent back rather than thrown: an uncaught throw here kills the thread,
14
+ // and the pool would lose the other documents queued behind this one.
15
+ parentPort.postMessage({
16
+ at: job.at,
17
+ error: err instanceof Error ? err.message : String(err),
18
+ });
19
+ }
20
+ });
21
+ //# sourceMappingURL=parse-worker.js.map