@zenera/rag 1.1.9 → 1.1.10

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 (42) hide show
  1. package/README.md +154 -10
  2. package/dist/command.js +2 -1
  3. package/dist/docs/assemble.d.ts +52 -0
  4. package/dist/docs/assemble.js +127 -0
  5. package/dist/docs/build.d.ts +34 -0
  6. package/dist/docs/build.js +108 -0
  7. package/dist/docs/chunk.d.ts +73 -0
  8. package/dist/docs/chunk.js +586 -0
  9. package/dist/docs/command.d.ts +3 -0
  10. package/dist/docs/command.js +529 -0
  11. package/dist/docs/files.d.ts +94 -0
  12. package/dist/docs/files.js +80 -0
  13. package/dist/docs/index.d.ts +13 -0
  14. package/dist/docs/index.js +13 -0
  15. package/dist/docs/load.d.ts +28 -0
  16. package/dist/docs/load.js +212 -0
  17. package/dist/docs/lookup.d.ts +80 -0
  18. package/dist/docs/lookup.js +147 -0
  19. package/dist/docs/parse.d.ts +95 -0
  20. package/dist/docs/parse.js +372 -0
  21. package/dist/docs/readme.d.ts +6 -0
  22. package/dist/docs/readme.js +122 -0
  23. package/dist/docs/render.d.ts +13 -0
  24. package/dist/docs/render.js +46 -0
  25. package/dist/docs/repl.d.ts +7 -0
  26. package/dist/docs/repl.js +130 -0
  27. package/dist/docs/search.d.ts +92 -0
  28. package/dist/docs/search.js +251 -0
  29. package/dist/docs/store.d.ts +55 -0
  30. package/dist/docs/store.js +171 -0
  31. package/dist/docs/tools.d.ts +10 -0
  32. package/dist/docs/tools.js +300 -0
  33. package/dist/index.d.ts +1 -0
  34. package/dist/index.js +3 -0
  35. package/dist/schema/command.js +3 -0
  36. package/dist/schema/query.js +1 -0
  37. package/dist/schema/search.d.ts +2 -0
  38. package/dist/schema/search.js +18 -2
  39. package/dist/schema/store.d.ts +4 -2
  40. package/dist/schema/store.js +16 -9
  41. package/dist/schema/tools.js +21 -2
  42. package/package.json +17 -4
package/README.md CHANGED
@@ -1,7 +1,8 @@
1
1
  # @zenera/rag
2
2
 
3
- **An OpenAPI description, indexed as a graph and searched by meaning — for
4
- agents that have to call an API they have not read.**
3
+ **A corpus, indexed and searched by meaning — an OpenAPI description as a
4
+ graph, a pile of markdown as quotable passages — for agents that have to work
5
+ with something they have not read.**
5
6
 
6
7
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/andreyryabov/ZeneraNeo/blob/main/LICENSE)
7
8
  [![Node](https://img.shields.io/badge/node-%E2%89%A524-brightgreen.svg)](https://nodejs.org)
@@ -11,7 +12,23 @@ agents that have to call an API they have not read.**
11
12
  > [`zen`](https://github.com/andreyryabov/ZeneraNeo/blob/main/packages/cli/README.md),
12
13
  > which is also where the credentials already are.
13
14
 
14
- ## Why
15
+ ## Two subjects
16
+
17
+ A subject is a kind of corpus with its own index format, its own verbs and its
18
+ own flags — not a variation on one command, because what `list` means to an API
19
+ description is not what it means to a folder of notes.
20
+
21
+ | Subject | The corpus | The answer |
22
+ | ---------------- | ------------------------- | ---------------------------------------------- |
23
+ | `zen rag schema` | openapi/swagger documents | the connected piece of the API that matched |
24
+ | `zen rag docs` | markdown and plain text | the passages that matched, quoted with numbers |
25
+
26
+ Both are built the same way — [LanceDB](https://lancedb.com) for hybrid vector
27
+
28
+ - full-text retrieval, a manifest that records which embedder made the vectors,
29
+ and exact commands beside the ranking ones that need no credential at all.
30
+
31
+ ## Schema — an API description as a graph
15
32
 
16
33
  A large specification does not fit in a prompt, and the parts of it that answer
17
34
  a question are scattered: the field is on a schema, the schema is on a request
@@ -117,11 +134,75 @@ zen rag schema search --query - --format ts <<'JSON'
117
134
  JSON
118
135
  ```
119
136
 
137
+ ## Docs — markdown as quotable passages
138
+
139
+ The other subject. Point it at files, directories or globs; `.md`, `.markdown`,
140
+ `.txt` and `.text` are read, hidden directories and `node_modules` are not.
141
+
142
+ ```sh
143
+ zen rag docs index --embedding openai:text-embedding-3-small ./docs
144
+ zen rag docs search "how are rate limits counted"
145
+ ```
146
+
147
+ The answer is the documents themselves — the passages that matched, quoted
148
+ verbatim with their line numbers, and a marker wherever something between two
149
+ of them was left out:
150
+
151
+ ```
152
+ ## nsx_4.2.0/api/routing.md — 9 of 148 lines
153
+
154
+ 5 | ## Rate limits
155
+ 7 | Requests are counted per tenant and rejected past the limit.
156
+ ... 12 lines omitted (Retries, Backoff) ...
157
+ 24 | | route | limit | window |
158
+ 25 | | --- | --- | --- |
159
+ 27 | | /api/users | 250 | 1m |
160
+ ```
161
+
162
+ Nobody finds the paragraph they want on the first ask, so **narrowing is the
163
+ interface**, not an afterthought. The second call is the same question inside
164
+ one part of the tree:
165
+
166
+ ```sh
167
+ zen rag docs search --file "nsx_4.2.*/api/**" "rate limit for the users route"
168
+ zen rag docs search --section "Rate limits" --kind table "requests per minute"
169
+ zen rag docs search --mode text "X-RateLimit-Remaining" # exact wording only
170
+ zen rag docs search --interactive # narrow by typing
171
+ ```
172
+
173
+ `--file` is a glob when it has `*` or `?` and a substring otherwise, matched
174
+ against the document's **name**, which is its path relative to the common root
175
+ of everything indexed. That is what keeps
176
+ two releases of the same file apart. `--section` takes a heading title, and
177
+ covers whatever nests inside it. `--kind` takes `paragraph`, `list`, `table`,
178
+ `table_row`, `code`, `frontmatter` or `html`, for when the answer is a table
179
+ and not the prose around it. `-B/-A` widen each passage, `--max-lines` caps the
180
+ whole answer, `--exclude-id` moves on from what was already seen.
181
+
182
+ Tables are indexed twice over: once as a descriptor carrying the caption and
183
+ the column names, and once per row, with the header row travelling alongside so
184
+ the columns are still named wherever a row lands. A row too wide to be one
185
+ chunk is cut into column groups, with the key column repeated in each.
186
+
187
+ And beside all that, the exact half — no embedder, no credential, no network:
188
+
189
+ ```sh
190
+ zen rag docs list files # every document, and what it holds
191
+ zen rag docs list sections --file "api/**" # every heading, with its line span
192
+ zen rag docs list tables # every table, with its columns
193
+ zen rag docs grep "Retry-After" # every matching line, and its section
194
+ zen rag docs show api/routing.md --section "Rate limits"
195
+ zen rag docs show api/routing.md --lines 40-80
196
+ ```
197
+
198
+ `grep` reports `found` as the true total even when `--limit` cuts the rows, so
199
+ unlike a search it can answer whether a string appears at all.
200
+
120
201
  ## Which index
121
202
 
122
- Every reading command takes `-d, --dir`. Without one, `$ZEN_SCHEMA_DB` is used
123
- if it is set; without that, the nearest index to the working directory is found
124
- and named on stderr as it is used.
203
+ Every reading command takes `-d, --dir`. Without one, `$ZEN_SCHEMA_DB` or
204
+ `$ZEN_DOCS_DB` is used if it is set; without that, the nearest index to the
205
+ working directory is found and named on stderr as it is used.
125
206
 
126
207
  Nearest means what it says: this directory, then a short way down into it, then
127
208
  up a level and again, stopping at your home directory. What is looked for is a
@@ -131,7 +212,10 @@ the same. `schema-db` is only the name a new one is given.
131
212
 
132
213
  Two indexes the same distance away is a question, not a tie to break, and it is
133
214
  refused: the wrong index does not fail, it answers confidently about a
134
- different API. Name one with `--dir`, or set `ZEN_SCHEMA_DB`.
215
+ different API. Name one with `--dir`, or set the environment variable.
216
+
217
+ The search is scoped by kind, so a `docs` index and a `schema` index can sit in
218
+ the same tree without either shadowing the other.
135
219
 
136
220
  ## Commands
137
221
 
@@ -147,8 +231,8 @@ zen rag schema stats What is in an index, and what built it.
147
231
 
148
232
  Search terms are one flag each — `--all`, `--method`, `--type`, `--input-type`,
149
233
  `--output-type`, `--property`, `--input-property`, `--output-property` — shaped
150
- by `--direction`, `--method-type`, `--limit`, `--max-hops`, `--max-nodes` and
151
- the four `--exclude-*` filters, and rendered by `--format text | mermaid |
234
+ by `--direction`, `--method-type`, `--limit`, `--max-hops`, `--max-nodes`,
235
+ `--source` and the four `--exclude-*` filters, and rendered by `--format text | mermaid |
152
236
  mermaid-flowchart | ts | openapi`. `zen help rag` prints the full table.
153
237
 
154
238
  `list` and `grep` share `--name`, `--path`, `--regex`, `--case-sensitive`,
@@ -167,6 +251,23 @@ only what was named instead of its neighbourhood.
167
251
  zen rag schema grep token --ids-only | xargs zen rag schema show --format ts
168
252
  ```
169
253
 
254
+ And for documents:
255
+
256
+ ```
257
+ zen rag docs index <path...> Read the documents and write a searchable index.
258
+ zen rag docs search [text] Ask it something. --interactive for a prompt.
259
+ zen rag docs list <what> Every document, section or table. No ranking.
260
+ zen rag docs grep <pattern> Every matching line, with the section it sits in.
261
+ zen rag docs show <file> A document, a section of one, or a line range.
262
+ zen rag docs stats What is in an index, and what built it.
263
+ ```
264
+
265
+ Search takes the question as a bare phrase, narrowed by `--file`,
266
+ `--exclude-file`, `--section`, `--kind` and `--mode`, shaped by `--limit`,
267
+ `-B/--before`, `-A/--after` and `--max-lines`, and moved along by
268
+ `--exclude-id`. `list` and `grep` share `--file`, `--section`, `--regex`,
269
+ `--case-sensitive` and `--limit`.
270
+
170
271
  ## From an agent
171
272
 
172
273
  ```ts
@@ -201,6 +302,28 @@ need the word explained again, it needs the list of types that have one.
201
302
  `trace_api` is the step after both: a field is of no use until the call that
202
303
  carries it is known.
203
304
 
305
+ Documents come with four, in the group `docs`, selectable as `docs:*`:
306
+
307
+ ```ts
308
+ import { docs } from '@zenera/rag';
309
+
310
+ const index = await docs.DocsIndex.open('./docs-db', embedder);
311
+ const project = await loadProject('./my-project', { tools: docs.docsTools(index) });
312
+ ```
313
+
314
+ | Tool | For |
315
+ | ------------- | ------------------------------------------------------------ |
316
+ | `search_docs` | the passages that match, quoted with their line numbers |
317
+ | `list_docs` | the documents, their headings, or their tables — no search |
318
+ | `grep_docs` | every matching line, counted in full — no search |
319
+ | `read_docs` | a section or a line range, verbatim and with nothing omitted |
320
+
321
+ Same division, same reason. `search_docs` is the way in when the question is
322
+ vague; `grep_docs` is how "it is not in here" can actually be concluded. Every
323
+ answer carries line numbers and `read_docs` takes them, which is the loop the
324
+ subject exists for: find the passage, read around it, then edit the file the
325
+ passage came from.
326
+
204
327
  ## What an index is
205
328
 
206
329
  ```
@@ -217,6 +340,21 @@ schema-db/
217
340
  The manifest records which embedder made the vectors, and a search with a
218
341
  different one is refused rather than answered with noise.
219
342
 
343
+ A document index is the same idea with a different middle:
344
+
345
+ ```
346
+ docs-db/
347
+ ├── README.md what this index holds — a live progress report while it builds
348
+ ├── manifest.json written last — its absence means "not indexed"
349
+ ├── outline.json every heading and table, with the lines they cover
350
+ ├── sources/ the documents themselves, verbatim — where the quotes come from
351
+ └── lance/ one table: a row per chunk, two texts, one vector
352
+ ```
353
+
354
+ There the copies are not a record but the answer: a search returns line ranges
355
+ and the lines are read back out of `sources/`, so what is quoted is the document
356
+ rather than a reconstruction of it.
357
+
220
358
  Indexing a large document is minutes of silence, so the directory says what is
221
359
  happening to it. `README.md` appears first as a progress report — the documents,
222
360
  the embedder, the step, how many entities have been embedded of how many, and
@@ -235,7 +373,8 @@ so an index built here is read under a name this machine never sees. `--no-sourc
235
373
  leaves the copies out, for an index that will never travel.
236
374
 
237
375
  The copies are a record, not an input: rebuilding reads the files you name, not
238
- the ones in `sources/`.
376
+ the ones in `sources/`. A **document** index has no `--no-sources`, because
377
+ there the copies are what every quoted line is read from.
239
378
 
240
379
  ## Notes
241
380
 
@@ -251,6 +390,11 @@ the ones in `sources/`.
251
390
  - Filters reaching the store are **closed enums only**. Exclusion lists are
252
391
  applied in JavaScript afterwards, so nothing a model wrote ever reaches a SQL
253
392
  predicate.
393
+ - A document chunk knows **exactly which lines** of the original it stands for,
394
+ headings and table headers included. That is what makes an answer quotable,
395
+ and what lets the next question be phrased in line numbers.
396
+ - Plain text is read as paragraphs and given **no invented headings**: a `.txt`
397
+ file has one section, which is the document.
254
398
 
255
399
  ## The rest of the family
256
400
 
package/dist/command.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { bold, cyan, dim, table, usageError, write, } from '@zenera/cli/lib';
2
+ import { command as docs } from "./docs/command.js";
2
3
  import { command as schema } from "./schema/command.js";
3
4
  // ---------------------------------------------------------------------------
4
5
  // zen rag — retrieval, by subject
@@ -18,7 +19,7 @@ import { command as schema } from "./schema/command.js";
18
19
  // `help <subject>` is a verb rather than a flag because `--help` never gets
19
20
  // here: the frame lifts it out of the arguments and answers with this page.
20
21
  // ---------------------------------------------------------------------------
21
- const SUBJECTS = { schema };
22
+ const SUBJECTS = { schema, docs };
22
23
  const USAGE = 'zen rag <subject> <command> [args...]';
23
24
  export const command = {
24
25
  summary: 'Retrieval over a corpus: index it, then ask it something.',
@@ -0,0 +1,52 @@
1
+ import type { DocsIndex, Match } from './search.ts';
2
+ export interface AssembleOptions {
3
+ /** extra lines quoted before each matching body */
4
+ before?: number;
5
+ after?: number;
6
+ /** a ceiling on the whole answer, so one long section cannot eat it */
7
+ maxLines?: number;
8
+ /** two ranges closer than this are joined rather than marked */
9
+ mergeGap?: number;
10
+ }
11
+ export interface Segment {
12
+ start: number;
13
+ end: number;
14
+ /** the lines themselves, verbatim */
15
+ lines: string[];
16
+ }
17
+ export interface Omission {
18
+ start: number;
19
+ end: number;
20
+ count: number;
21
+ /** the headings the skipped lines covered, so the gap has a name */
22
+ sections: string[];
23
+ }
24
+ export type Piece = ({
25
+ type: 'segment';
26
+ } & Segment) | ({
27
+ type: 'omission';
28
+ } & Omission);
29
+ export interface Excerpt {
30
+ path: string;
31
+ title: string;
32
+ score: number;
33
+ /** what landed in this document, best first */
34
+ matches: Match[];
35
+ pieces: Piece[];
36
+ /** lines actually quoted */
37
+ shown: number;
38
+ /** the document's length, so a reader knows what fraction this is */
39
+ lines: number;
40
+ }
41
+ export interface Assembly {
42
+ files: Excerpt[];
43
+ shown: number;
44
+ /** true when the line budget cut something that had matched */
45
+ truncated: boolean;
46
+ }
47
+ export declare const DEFAULT_BEFORE = 0;
48
+ export declare const DEFAULT_AFTER = 0;
49
+ export declare const DEFAULT_MAX_LINES = 400;
50
+ export declare const DEFAULT_MERGE_GAP = 3;
51
+ export declare function assemble(index: DocsIndex, matches: readonly Match[], options?: AssembleOptions): Promise<Assembly>;
52
+ //# sourceMappingURL=assemble.d.ts.map
@@ -0,0 +1,127 @@
1
+ export const DEFAULT_BEFORE = 0;
2
+ export const DEFAULT_AFTER = 0;
3
+ export const DEFAULT_MAX_LINES = 400;
4
+ export const DEFAULT_MERGE_GAP = 3;
5
+ export async function assemble(index, matches, options = {}) {
6
+ const before = options.before ?? DEFAULT_BEFORE;
7
+ const after = options.after ?? DEFAULT_AFTER;
8
+ const gap = options.mergeGap ?? DEFAULT_MERGE_GAP;
9
+ let budget = options.maxLines ?? DEFAULT_MAX_LINES;
10
+ const files = [];
11
+ let truncated = false;
12
+ let shown = 0;
13
+ for (const [path, group] of byFile(matches)) {
14
+ const outline = index.file(path);
15
+ const source = await index.lines(path);
16
+ const total = outline?.lines ?? source.length;
17
+ const wanted = ranges(lineSet(group, before, after, total), gap);
18
+ const pieces = [];
19
+ let quoted = 0;
20
+ let last;
21
+ for (const range of wanted) {
22
+ if (budget <= 0) {
23
+ truncated = true;
24
+ break;
25
+ }
26
+ const end = Math.min(range.end, range.start + budget - 1);
27
+ if (last) {
28
+ pieces.push(omission(last.end + 1, range.start - 1, outline?.headings ?? []));
29
+ }
30
+ pieces.push({
31
+ type: 'segment',
32
+ start: range.start,
33
+ end,
34
+ lines: source.slice(range.start - 1, end),
35
+ });
36
+ const count = end - range.start + 1;
37
+ quoted += count;
38
+ budget -= count;
39
+ if (end < range.end) {
40
+ truncated = true;
41
+ }
42
+ last = { start: range.start, end };
43
+ }
44
+ if (last && last.end < total) {
45
+ pieces.push(omission(last.end + 1, total, outline?.headings ?? []));
46
+ }
47
+ if (pieces.length === 0) {
48
+ continue;
49
+ }
50
+ shown += quoted;
51
+ files.push({
52
+ path,
53
+ title: outline?.title ?? path,
54
+ score: group[0].score,
55
+ matches: group,
56
+ pieces,
57
+ shown: quoted,
58
+ lines: total,
59
+ });
60
+ }
61
+ return { files, shown, truncated };
62
+ }
63
+ // ---------------------------------------------------------------------------
64
+ /** Documents in the order their best match placed, matches within them likewise. */
65
+ function byFile(matches) {
66
+ const groups = new Map();
67
+ for (const item of matches) {
68
+ const group = groups.get(item.path);
69
+ if (group) {
70
+ group.push(item);
71
+ }
72
+ else {
73
+ groups.set(item.path, [item]);
74
+ }
75
+ }
76
+ return [...groups.entries()];
77
+ }
78
+ /**
79
+ * Every line the answer wants. A chunk carries its own render set — its
80
+ * headings, a table's header row, the line its body started on — and `before`
81
+ * and `after` widen the body only, so context is padding around the match and
82
+ * never around a heading quoted from elsewhere in the file.
83
+ */
84
+ function lineSet(matches, before, after, total) {
85
+ const wanted = new Set();
86
+ const add = (line) => {
87
+ if (line >= 1 && line <= total) {
88
+ wanted.add(line);
89
+ }
90
+ };
91
+ for (const item of matches) {
92
+ for (const line of item.lineNumbers) {
93
+ add(line);
94
+ }
95
+ for (let line = item.bodyStart - before; line <= item.bodyEnd + after; line++) {
96
+ add(line);
97
+ }
98
+ }
99
+ return wanted;
100
+ }
101
+ /** Contiguous runs, with runs closer together than `gap` joined into one. */
102
+ function ranges(wanted, gap) {
103
+ const sorted = [...wanted].sort((a, b) => a - b);
104
+ const out = [];
105
+ for (const line of sorted) {
106
+ const last = out.at(-1);
107
+ if (last && line - last.end <= gap + 1) {
108
+ last.end = line;
109
+ }
110
+ else {
111
+ out.push({ start: line, end: line });
112
+ }
113
+ }
114
+ return out;
115
+ }
116
+ function omission(start, end, headings) {
117
+ const named = headings.filter((h) => h.line >= start && h.line <= end).map((h) => h.title);
118
+ return {
119
+ type: 'omission',
120
+ start,
121
+ end,
122
+ count: end - start + 1,
123
+ // A gap covering thirty headings is a gap; naming all of them is noise.
124
+ sections: named.slice(0, 6),
125
+ };
126
+ }
127
+ //# sourceMappingURL=assemble.js.map
@@ -0,0 +1,34 @@
1
+ import type { Embedder } from '@zenera/neo';
2
+ import { type ChunkOptions } from './chunk.ts';
3
+ import { type Counts, type DocRecord, type Manifest } from './files.ts';
4
+ import { type Corpus } from './load.ts';
5
+ import { type ChunkRecord } from './store.ts';
6
+ export interface BuildOptions {
7
+ /** files, directories or patterns, as they were named */
8
+ files: readonly string[];
9
+ cwd: string;
10
+ out: string;
11
+ embedder: Embedder;
12
+ /** the reference as it was written, which is what a later search will type */
13
+ embeddingRef?: string;
14
+ /** told the manifest, so a store can say what wrote it */
15
+ indexer: string;
16
+ /** texts sent to the embedder at once */
17
+ batch?: number;
18
+ chunk?: ChunkOptions;
19
+ signal?: AbortSignal;
20
+ /** what the documents turned out to hold, before a vector has been paid for */
21
+ onRead?: (summary: BuildSummary) => void;
22
+ onProgress?: (done: number, total: number) => void;
23
+ }
24
+ export interface BuildSummary {
25
+ sources: DocRecord[];
26
+ counts: Counts;
27
+ skipped: Corpus['skipped'];
28
+ }
29
+ export interface BuildResult {
30
+ manifest: Manifest;
31
+ chunks: ChunkRecord[];
32
+ }
33
+ export declare function buildIndex(options: BuildOptions): Promise<BuildResult>;
34
+ //# sourceMappingURL=build.d.ts.map
@@ -0,0 +1,108 @@
1
+ import { beginBuild } from "../common/progress.js";
2
+ import { formatLines } from "./chunk.js";
3
+ import { INDEX_VERSION, SOURCES_DIR, writeIndex, } from "./files.js";
4
+ import { loadDocuments } from "./load.js";
5
+ import { DOCS_REPORT, PHASES } from "./readme.js";
6
+ import { writeChunks } from "./store.js";
7
+ const DEFAULT_BATCH = 96;
8
+ export async function buildIndex(options) {
9
+ const journal = beginBuild({
10
+ dir: options.out,
11
+ files: options.files,
12
+ embedding: options.embeddingRef ?? options.embedder.id,
13
+ indexer: options.indexer,
14
+ phases: PHASES,
15
+ report: DOCS_REPORT,
16
+ });
17
+ try {
18
+ const corpus = await loadDocuments(options.files, options.cwd, options.chunk);
19
+ const chunks = recordsOf(corpus);
20
+ const sources = corpus.docs.map((doc) => ({
21
+ name: doc.name,
22
+ file: doc.file,
23
+ path: `${SOURCES_DIR}/${doc.name}`,
24
+ sha256: doc.sha256,
25
+ format: doc.format,
26
+ title: doc.outline.title,
27
+ bytes: doc.bytes,
28
+ lines: doc.outline.lines,
29
+ sections: doc.outline.headings.length,
30
+ tables: doc.outline.tables.length,
31
+ chunks: doc.chunks.length,
32
+ }));
33
+ const counts = {
34
+ documents: sources.length,
35
+ chunks: chunks.length,
36
+ lines: sources.reduce((n, s) => n + s.lines, 0),
37
+ sections: sources.reduce((n, s) => n + s.sections, 0),
38
+ tables: sources.reduce((n, s) => n + s.tables, 0),
39
+ };
40
+ journal.read(counts, chunks.length);
41
+ options.onRead?.({ sources, counts, skipped: corpus.skipped });
42
+ journal.phase('embedding');
43
+ const vectors = await embedAll(chunks, options, journal);
44
+ journal.phase('writing');
45
+ const written = await writeChunks(options.out, chunks, vectors);
46
+ const manifest = {
47
+ version: INDEX_VERSION,
48
+ kind: 'docs',
49
+ createdAt: new Date().toISOString(),
50
+ indexer: options.indexer,
51
+ embedding: {
52
+ ref: options.embeddingRef ?? options.embedder.id,
53
+ id: options.embedder.id,
54
+ dimensions: vectors[0]?.length ?? 0,
55
+ },
56
+ sources,
57
+ counts,
58
+ indexes: { fts: written.fts, vector: written.vector },
59
+ };
60
+ const outline = { files: corpus.docs.map((doc) => doc.outline) };
61
+ const documents = Object.fromEntries(corpus.docs.map((doc) => [doc.name, doc.text]));
62
+ await writeIndex(options.out, { manifest, outline, documents });
63
+ journal.finish(manifest);
64
+ return { manifest, chunks };
65
+ }
66
+ catch (err) {
67
+ journal.fail(err);
68
+ throw err;
69
+ }
70
+ }
71
+ /** One row per chunk, with the render set encoded and the document name on it. */
72
+ function recordsOf(corpus) {
73
+ return corpus.docs.flatMap((doc) => doc.chunks.map((chunk) => ({
74
+ id: `${doc.name}#c${chunk.index}`,
75
+ path: doc.name,
76
+ ordinal: chunk.index,
77
+ kind: chunk.kind,
78
+ text: chunk.text,
79
+ embedText: chunk.embedText,
80
+ lineSpec: formatLines(chunk.lineNumbers),
81
+ bodyStart: chunk.bodyStart,
82
+ bodyEnd: chunk.bodyEnd,
83
+ structureId: chunk.structureId,
84
+ structurePath: chunk.structurePath,
85
+ headings: chunk.headings,
86
+ tokens: chunk.tokens,
87
+ })));
88
+ }
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;
107
+ }
108
+ //# sourceMappingURL=build.js.map
@@ -0,0 +1,73 @@
1
+ import { type ParsedDoc } from './parse.ts';
2
+ /** Soft target, hard ceiling, and the width at which one table row is too wide. */
3
+ export declare const CHUNK_TOKENS = 384;
4
+ export declare const MAX_CHUNK_TOKENS = 512;
5
+ export declare const TABLE_SLICE_TOKENS = 128;
6
+ /** How much of a table its descriptor stands for when no row of it matched. */
7
+ export declare const TABLE_PREVIEW_ROWS = 3;
8
+ /**
9
+ * And how many rows may share one. The token budget alone would put a narrow
10
+ * sixteen-row table in a single chunk, so matching one row of it quotes all
11
+ * sixteen — the rows are independent facts, and a reader asking about one is
12
+ * not asking about the other fifteen.
13
+ */
14
+ export declare const TABLE_ROWS_PER_CHUNK = 4;
15
+ /**
16
+ * Below this a chunk is merged into its neighbour rather than retrieved alone.
17
+ *
18
+ * BM25 normalises by document length, so a nine-word chunk that happens to
19
+ * contain two query terms outscores a real answer that contains them among a
20
+ * hundred other words. Measured on this repository, a one-line aside about
21
+ * markdown link syntax took full-text rank 0 for "how to create docs index"
22
+ * while the vector leg — correctly — put it 185th. It is not that the chunk is
23
+ * wrong; it is that alone it is not a passage, and a passage is what the
24
+ * lexical index is scoring.
25
+ */
26
+ export declare const MIN_CHUNK_TOKENS = 48;
27
+ /**
28
+ * How much of the block before a chunk may carry for continuity. It is one
29
+ * line, which is a whisper until the line is the whole of a README's challenge
30
+ * table on one row: 92kB of badge markup, none of it cited by a line number,
31
+ * outweighing the seven lines the chunk actually stands for by eighty to one.
32
+ */
33
+ export declare const CARRY_TOKENS = 32;
34
+ /**
35
+ * Four characters to a token, which is within about 15% for English prose and
36
+ * wrong for CJK, for dense numeric cells and for long identifiers. It cannot
37
+ * cause a request to fail — 512 estimated tokens sits far below any embedding
38
+ * model's limit, so even a threefold underestimate has headroom. Injectable so
39
+ * a real tokenizer is a one-line swap if the corpus ever needs one.
40
+ */
41
+ export declare const tokenCount: (text: string) => number;
42
+ /** The kinds a chunk can be, which is what `--kind` filters on. */
43
+ export declare const CHUNK_KINDS: readonly ["paragraph", "list", "table", "table_row", "code", "frontmatter", "html"];
44
+ export type ChunkKind = (typeof CHUNK_KINDS)[number];
45
+ export interface ChunkOptions {
46
+ chunkTokens?: number;
47
+ minChunkTokens?: number;
48
+ maxChunkTokens?: number;
49
+ tableSliceTokens?: number;
50
+ tokenCount?: (text: string) => number;
51
+ }
52
+ export interface Chunk {
53
+ index: number;
54
+ kind: ChunkKind;
55
+ /** the innermost structure node containing the whole body */
56
+ structureId: string;
57
+ structurePath: string;
58
+ /** the breadcrumb, from the document name down to the nearest heading */
59
+ headings: string;
60
+ bodyStart: number;
61
+ bodyEnd: number;
62
+ /** everything that gets rendered: headings, prelude and body, sorted */
63
+ lineNumbers: number[];
64
+ /** the full-text document — wider */
65
+ text: string;
66
+ /** the vector's source — tighter */
67
+ embedText: string;
68
+ tokens: number;
69
+ }
70
+ export declare function chunkDocument(doc: ParsedDoc, options?: ChunkOptions): Chunk[];
71
+ export declare function formatLines(numbers: readonly number[]): string;
72
+ export declare function parseLines(spec: string): number[];
73
+ //# sourceMappingURL=chunk.d.ts.map