@zenera/rag 1.1.8 → 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.
- package/README.md +154 -10
- package/dist/command.js +2 -1
- package/dist/docs/assemble.d.ts +52 -0
- package/dist/docs/assemble.js +127 -0
- package/dist/docs/build.d.ts +34 -0
- package/dist/docs/build.js +108 -0
- package/dist/docs/chunk.d.ts +73 -0
- package/dist/docs/chunk.js +586 -0
- package/dist/docs/command.d.ts +3 -0
- package/dist/docs/command.js +529 -0
- package/dist/docs/files.d.ts +94 -0
- package/dist/docs/files.js +80 -0
- package/dist/docs/index.d.ts +13 -0
- package/dist/docs/index.js +13 -0
- package/dist/docs/load.d.ts +28 -0
- package/dist/docs/load.js +212 -0
- package/dist/docs/lookup.d.ts +80 -0
- package/dist/docs/lookup.js +147 -0
- package/dist/docs/parse.d.ts +95 -0
- package/dist/docs/parse.js +372 -0
- package/dist/docs/readme.d.ts +6 -0
- package/dist/docs/readme.js +122 -0
- package/dist/docs/render.d.ts +13 -0
- package/dist/docs/render.js +46 -0
- package/dist/docs/repl.d.ts +7 -0
- package/dist/docs/repl.js +130 -0
- package/dist/docs/search.d.ts +92 -0
- package/dist/docs/search.js +251 -0
- package/dist/docs/store.d.ts +55 -0
- package/dist/docs/store.js +171 -0
- package/dist/docs/tools.d.ts +10 -0
- package/dist/docs/tools.js +300 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +3 -0
- package/dist/schema/command.js +3 -0
- package/dist/schema/query.js +1 -0
- package/dist/schema/search.d.ts +2 -0
- package/dist/schema/search.js +18 -2
- package/dist/schema/store.d.ts +4 -2
- package/dist/schema/store.js +16 -9
- package/dist/schema/tools.js +21 -2
- package/package.json +17 -4
|
@@ -0,0 +1,586 @@
|
|
|
1
|
+
import { collapse, headingLines, headingPath, } from "./parse.js";
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// Cutting a document into the things that get retrieved
|
|
4
|
+
//
|
|
5
|
+
// A chunk is a SET of lines, not a span of them. It has a contiguous body —
|
|
6
|
+
// the part that actually matched — plus the heading lines above it and any
|
|
7
|
+
// prelude the body cannot be read without: the header row of a table, the
|
|
8
|
+
// opening line of a fence. That set is decided here, at index time, which is
|
|
9
|
+
// why a retrieved table row can never come back without its column names and
|
|
10
|
+
// why there is no prelude-injection step at the far end.
|
|
11
|
+
//
|
|
12
|
+
// Cuts land on block boundaries. There is no sentence segmenter, because once
|
|
13
|
+
// the structure tree exists the block ends are already known and are already
|
|
14
|
+
// safe. A segmenter would earn its keep in exactly one situation — a single
|
|
15
|
+
// block over the budget — and that case is handled by splitting on lines and
|
|
16
|
+
// accepting it, so the design has one soft spot rather than a stage spread
|
|
17
|
+
// across the pipeline for a rare input.
|
|
18
|
+
//
|
|
19
|
+
// Bodies never overlap; text does, slightly. Fixed-size windows over raw text
|
|
20
|
+
// need roughly half of each chunk repeated as damage control, because a blind
|
|
21
|
+
// cut lands mid-sentence. Nothing here cuts blindly, so overlapping bodies
|
|
22
|
+
// would only double the embedding bill and inflate document frequency. What
|
|
23
|
+
// does overlap is a single carried line of text and, for tables, the caption —
|
|
24
|
+
// enough that an answer straddling a boundary can still be found from the
|
|
25
|
+
// later chunk alone, with no extra row and no extra vector.
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
/** Soft target, hard ceiling, and the width at which one table row is too wide. */
|
|
28
|
+
export const CHUNK_TOKENS = 384;
|
|
29
|
+
export const MAX_CHUNK_TOKENS = 512;
|
|
30
|
+
export const TABLE_SLICE_TOKENS = 128;
|
|
31
|
+
/** How much of a table its descriptor stands for when no row of it matched. */
|
|
32
|
+
export const TABLE_PREVIEW_ROWS = 3;
|
|
33
|
+
/**
|
|
34
|
+
* And how many rows may share one. The token budget alone would put a narrow
|
|
35
|
+
* sixteen-row table in a single chunk, so matching one row of it quotes all
|
|
36
|
+
* sixteen — the rows are independent facts, and a reader asking about one is
|
|
37
|
+
* not asking about the other fifteen.
|
|
38
|
+
*/
|
|
39
|
+
export const TABLE_ROWS_PER_CHUNK = 4;
|
|
40
|
+
/**
|
|
41
|
+
* Below this a chunk is merged into its neighbour rather than retrieved alone.
|
|
42
|
+
*
|
|
43
|
+
* BM25 normalises by document length, so a nine-word chunk that happens to
|
|
44
|
+
* contain two query terms outscores a real answer that contains them among a
|
|
45
|
+
* hundred other words. Measured on this repository, a one-line aside about
|
|
46
|
+
* markdown link syntax took full-text rank 0 for "how to create docs index"
|
|
47
|
+
* while the vector leg — correctly — put it 185th. It is not that the chunk is
|
|
48
|
+
* wrong; it is that alone it is not a passage, and a passage is what the
|
|
49
|
+
* lexical index is scoring.
|
|
50
|
+
*/
|
|
51
|
+
export const MIN_CHUNK_TOKENS = 48;
|
|
52
|
+
/**
|
|
53
|
+
* How much of the block before a chunk may carry for continuity. It is one
|
|
54
|
+
* line, which is a whisper until the line is the whole of a README's challenge
|
|
55
|
+
* table on one row: 92kB of badge markup, none of it cited by a line number,
|
|
56
|
+
* outweighing the seven lines the chunk actually stands for by eighty to one.
|
|
57
|
+
*/
|
|
58
|
+
export const CARRY_TOKENS = 32;
|
|
59
|
+
/**
|
|
60
|
+
* Four characters to a token, which is within about 15% for English prose and
|
|
61
|
+
* wrong for CJK, for dense numeric cells and for long identifiers. It cannot
|
|
62
|
+
* cause a request to fail — 512 estimated tokens sits far below any embedding
|
|
63
|
+
* model's limit, so even a threefold underestimate has headroom. Injectable so
|
|
64
|
+
* a real tokenizer is a one-line swap if the corpus ever needs one.
|
|
65
|
+
*/
|
|
66
|
+
export const tokenCount = (text) => Math.ceil(text.length / 4);
|
|
67
|
+
/** The kinds a chunk can be, which is what `--kind` filters on. */
|
|
68
|
+
export const CHUNK_KINDS = [
|
|
69
|
+
'paragraph',
|
|
70
|
+
'list',
|
|
71
|
+
'table',
|
|
72
|
+
'table_row',
|
|
73
|
+
'code',
|
|
74
|
+
'frontmatter',
|
|
75
|
+
'html',
|
|
76
|
+
];
|
|
77
|
+
const PROSE = new Set(['paragraph', 'blockquote']);
|
|
78
|
+
/**
|
|
79
|
+
* What the floor may join. A paragraph and the snippet under it are one
|
|
80
|
+
* thought and read as one; a table or a list has an identity of its own, and a
|
|
81
|
+
* `--kind table` that quietly returned prose would be a worse bargain than a
|
|
82
|
+
* short chunk.
|
|
83
|
+
*/
|
|
84
|
+
const MERGEABLE = new Set(['paragraph', 'code']);
|
|
85
|
+
export function chunkDocument(doc, options = {}) {
|
|
86
|
+
const cut = new Cutter(doc, options);
|
|
87
|
+
return cut.run();
|
|
88
|
+
}
|
|
89
|
+
class Cutter {
|
|
90
|
+
#doc;
|
|
91
|
+
#soft;
|
|
92
|
+
#floor;
|
|
93
|
+
#hard;
|
|
94
|
+
#slice;
|
|
95
|
+
#tok;
|
|
96
|
+
#bodies = [];
|
|
97
|
+
constructor(doc, options) {
|
|
98
|
+
this.#doc = doc;
|
|
99
|
+
this.#soft = options.chunkTokens ?? CHUNK_TOKENS;
|
|
100
|
+
this.#floor = options.minChunkTokens ?? MIN_CHUNK_TOKENS;
|
|
101
|
+
this.#hard = options.maxChunkTokens ?? MAX_CHUNK_TOKENS;
|
|
102
|
+
this.#slice = options.tableSliceTokens ?? TABLE_SLICE_TOKENS;
|
|
103
|
+
this.#tok = options.tokenCount ?? tokenCount;
|
|
104
|
+
}
|
|
105
|
+
run() {
|
|
106
|
+
let run = [];
|
|
107
|
+
const flush = () => {
|
|
108
|
+
if (run.length > 0) {
|
|
109
|
+
this.#prose(run);
|
|
110
|
+
run = [];
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
for (const block of this.#doc.blocks) {
|
|
114
|
+
// A run is prose of one kind under one heading. Anything else ends
|
|
115
|
+
// it, which is what keeps a body from crossing a section boundary.
|
|
116
|
+
if (PROSE.has(block.kind) && block.text) {
|
|
117
|
+
if (run.length > 0 && run[0].section !== block.section) {
|
|
118
|
+
flush();
|
|
119
|
+
}
|
|
120
|
+
run.push(block);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
flush();
|
|
124
|
+
this.#other(block);
|
|
125
|
+
}
|
|
126
|
+
flush();
|
|
127
|
+
return this.#compact().map((body, index) => this.#finish(body, index));
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* The floor, applied once at the end rather than inside each cutter: only
|
|
131
|
+
* here is a body's true neighbour known, since prose, code and lists are
|
|
132
|
+
* emitted by three different paths but land in document order.
|
|
133
|
+
*/
|
|
134
|
+
#compact() {
|
|
135
|
+
const kept = [];
|
|
136
|
+
for (const body of this.#bodies) {
|
|
137
|
+
const last = kept[kept.length - 1];
|
|
138
|
+
if (last && this.#joinable(last, body)) {
|
|
139
|
+
kept[kept.length - 1] = this.#join(last, body);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
kept.push(body);
|
|
143
|
+
}
|
|
144
|
+
return kept;
|
|
145
|
+
}
|
|
146
|
+
/** Neighbours under one heading, at least one of them too small to stand alone. */
|
|
147
|
+
#joinable(left, right) {
|
|
148
|
+
return (left.section === right.section &&
|
|
149
|
+
MERGEABLE.has(left.kind) &&
|
|
150
|
+
MERGEABLE.has(right.kind) &&
|
|
151
|
+
(this.#tok(left.text) < this.#floor || this.#tok(right.text) < this.#floor) &&
|
|
152
|
+
this.#tok(`${left.text}\n${right.text}`) <= this.#soft);
|
|
153
|
+
}
|
|
154
|
+
#join(left, right) {
|
|
155
|
+
const same = left.kind === right.kind;
|
|
156
|
+
return {
|
|
157
|
+
// Prose wins a mixed pair: the words are what the lexical index reads.
|
|
158
|
+
kind: same ? left.kind : 'paragraph',
|
|
159
|
+
id: same && left.id === right.id ? left.id : left.section.id,
|
|
160
|
+
path: same && left.path === right.path ? left.path : left.section.path,
|
|
161
|
+
section: left.section,
|
|
162
|
+
start: Math.min(left.start, right.start),
|
|
163
|
+
end: Math.max(left.end, right.end),
|
|
164
|
+
prelude: [...new Set([...left.prelude, ...right.prelude])].sort((a, b) => a - b),
|
|
165
|
+
preludeText: [left.preludeText, right.preludeText].filter(Boolean).join('\n'),
|
|
166
|
+
text: `${left.text}\n${right.text}`,
|
|
167
|
+
carry: left.carry,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
#other(block) {
|
|
171
|
+
switch (block.kind) {
|
|
172
|
+
case 'list':
|
|
173
|
+
return this.#list(block);
|
|
174
|
+
case 'table':
|
|
175
|
+
return this.#table(block);
|
|
176
|
+
case 'code':
|
|
177
|
+
return this.#code(block);
|
|
178
|
+
case 'frontmatter':
|
|
179
|
+
this.#emit(this.#whole(block, 'frontmatter'));
|
|
180
|
+
return;
|
|
181
|
+
// A sponsors table in raw HTML runs to a thousand lines, which as
|
|
182
|
+
// one chunk is longer than an embedding request may be.
|
|
183
|
+
case 'html':
|
|
184
|
+
this.#oversized(block, 'html');
|
|
185
|
+
return;
|
|
186
|
+
// A heading is never a body of its own: alone it embeds to almost
|
|
187
|
+
// nothing and would compete with the content under it. It reaches
|
|
188
|
+
// results through the breadcrumb and through the line set of every
|
|
189
|
+
// chunk beneath it, so an empty section loses nothing.
|
|
190
|
+
default:
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
// -----------------------------------------------------------------------
|
|
195
|
+
// prose
|
|
196
|
+
// -----------------------------------------------------------------------
|
|
197
|
+
/**
|
|
198
|
+
* The packer, over a whole run rather than one block. A section of ten
|
|
199
|
+
* hundred-token paragraphs becomes about three well-sized chunks instead of
|
|
200
|
+
* ten weak ones, and it takes no extra pass and no extra constant.
|
|
201
|
+
*/
|
|
202
|
+
#prose(run) {
|
|
203
|
+
let acc = [];
|
|
204
|
+
let previous;
|
|
205
|
+
const emit = () => {
|
|
206
|
+
if (acc.length === 0) {
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
previous = this.#body(acc, previous);
|
|
210
|
+
this.#emit(previous);
|
|
211
|
+
acc = [];
|
|
212
|
+
};
|
|
213
|
+
for (const block of run) {
|
|
214
|
+
if (acc.length === 0 && this.#tok(block.text) > this.#soft) {
|
|
215
|
+
this.#oversized(block, 'paragraph');
|
|
216
|
+
// Its tail is not the end of a block, so nothing carries forward.
|
|
217
|
+
previous = undefined;
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
if (this.#tok(proseText(acc) + block.text) > this.#soft) {
|
|
221
|
+
emit();
|
|
222
|
+
}
|
|
223
|
+
acc.push(block);
|
|
224
|
+
}
|
|
225
|
+
emit();
|
|
226
|
+
}
|
|
227
|
+
#body(acc, previous) {
|
|
228
|
+
const first = acc[0];
|
|
229
|
+
const last = acc[acc.length - 1];
|
|
230
|
+
// One block addresses itself; several address what contains them all.
|
|
231
|
+
const single = acc.length === 1;
|
|
232
|
+
return {
|
|
233
|
+
kind: 'paragraph',
|
|
234
|
+
id: single ? first.id : first.section.id,
|
|
235
|
+
path: single ? first.path : first.section.path,
|
|
236
|
+
section: first.section,
|
|
237
|
+
start: first.start,
|
|
238
|
+
end: last.end,
|
|
239
|
+
prelude: [],
|
|
240
|
+
preludeText: '',
|
|
241
|
+
text: proseText(acc),
|
|
242
|
+
carry: previous ? this.#lastLine(previous) : undefined,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
/** Text-level overlap only: never a line number, never a body line. */
|
|
246
|
+
#lastLine(body) {
|
|
247
|
+
const line = collapse(this.#doc.lines[body.end - 1] ?? '');
|
|
248
|
+
return line.slice(0, CARRY_TOKENS * 4) || undefined;
|
|
249
|
+
}
|
|
250
|
+
// -----------------------------------------------------------------------
|
|
251
|
+
// lists, code, tables
|
|
252
|
+
// -----------------------------------------------------------------------
|
|
253
|
+
/**
|
|
254
|
+
* An item and its sub-items are one thought, so they are never split from
|
|
255
|
+
* each other; siblings pack together up to the budget.
|
|
256
|
+
*/
|
|
257
|
+
#list(block) {
|
|
258
|
+
const items = block.items ?? [];
|
|
259
|
+
let acc = [];
|
|
260
|
+
const emit = () => {
|
|
261
|
+
if (acc.length === 0) {
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
const first = acc[0];
|
|
265
|
+
const last = acc[acc.length - 1];
|
|
266
|
+
const single = acc.length === 1;
|
|
267
|
+
this.#emit({
|
|
268
|
+
kind: 'list',
|
|
269
|
+
id: single ? first.id : block.id,
|
|
270
|
+
path: single ? first.path : block.path,
|
|
271
|
+
section: block.section,
|
|
272
|
+
start: first.start,
|
|
273
|
+
end: last.end,
|
|
274
|
+
prelude: [],
|
|
275
|
+
preludeText: '',
|
|
276
|
+
text: acc.map((i) => i.text).join('\n'),
|
|
277
|
+
});
|
|
278
|
+
acc = [];
|
|
279
|
+
};
|
|
280
|
+
for (const item of items) {
|
|
281
|
+
if (acc.length > 0 &&
|
|
282
|
+
this.#tok(acc.map((i) => i.text).join('\n') + item.text) > this.#soft) {
|
|
283
|
+
emit();
|
|
284
|
+
}
|
|
285
|
+
acc.push(item);
|
|
286
|
+
}
|
|
287
|
+
emit();
|
|
288
|
+
}
|
|
289
|
+
#code(block) {
|
|
290
|
+
const opener = collapse(this.#doc.lines[block.start - 1] ?? '');
|
|
291
|
+
if (this.#tok(block.text) <= this.#soft) {
|
|
292
|
+
this.#emit({
|
|
293
|
+
kind: 'code',
|
|
294
|
+
id: block.id,
|
|
295
|
+
path: block.path,
|
|
296
|
+
section: block.section,
|
|
297
|
+
start: block.start,
|
|
298
|
+
end: block.end,
|
|
299
|
+
prelude: [block.start],
|
|
300
|
+
preludeText: opener,
|
|
301
|
+
text: block.text,
|
|
302
|
+
});
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
// Split inside the fence at blank lines: the closest thing a program
|
|
306
|
+
// has to a paragraph break. Both fence lines ride along, or a slice
|
|
307
|
+
// taken from the middle quotes an opening delimiter that never closes.
|
|
308
|
+
for (const span of this.#packLines(block.start + 1, Math.max(block.start, block.end - 1))) {
|
|
309
|
+
this.#emit({
|
|
310
|
+
kind: 'code',
|
|
311
|
+
id: block.id,
|
|
312
|
+
path: block.path,
|
|
313
|
+
section: block.section,
|
|
314
|
+
start: span.start,
|
|
315
|
+
end: span.end,
|
|
316
|
+
prelude: [block.start, block.end],
|
|
317
|
+
preludeText: opener,
|
|
318
|
+
text: this.#slabOf(span.start, span.end),
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
#table(block) {
|
|
323
|
+
const table = block.table;
|
|
324
|
+
if (!table) {
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
const prelude = [table.headerLine, table.separatorLine].filter((line) => line !== undefined);
|
|
328
|
+
const columns = table.columns.filter(Boolean).join(', ');
|
|
329
|
+
const preludeText = columns ? `Columns: ${columns}.` : '';
|
|
330
|
+
// One descriptor per table, however wide. It absorbs the lead-in
|
|
331
|
+
// paragraph — the only sentence in the document that says what the
|
|
332
|
+
// table is about, and otherwise the caption of nothing.
|
|
333
|
+
//
|
|
334
|
+
// Its body is the header and the first few rows. Two rules around
|
|
335
|
+
// nothing say only that a table was here; the rows that answer a
|
|
336
|
+
// question arrive as their own chunks, and what neither reached is
|
|
337
|
+
// left to the omission marker to count.
|
|
338
|
+
const preview = table.rows[Math.min(TABLE_PREVIEW_ROWS, table.rows.length) - 1];
|
|
339
|
+
this.#emit({
|
|
340
|
+
kind: 'table',
|
|
341
|
+
id: block.id,
|
|
342
|
+
path: block.path,
|
|
343
|
+
section: block.section,
|
|
344
|
+
start: table.headerLine,
|
|
345
|
+
end: preview?.line ?? table.separatorLine ?? table.headerLine,
|
|
346
|
+
prelude: [],
|
|
347
|
+
preludeText,
|
|
348
|
+
text: [table.caption, `A table of ${table.rows.length} rows.`]
|
|
349
|
+
.filter(Boolean)
|
|
350
|
+
.join(' '),
|
|
351
|
+
});
|
|
352
|
+
let acc = [];
|
|
353
|
+
const emit = () => {
|
|
354
|
+
if (acc.length === 0) {
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
const first = acc[0];
|
|
358
|
+
const last = acc[acc.length - 1];
|
|
359
|
+
this.#emit({
|
|
360
|
+
kind: 'table_row',
|
|
361
|
+
id: acc.length === 1 ? first.id : block.id,
|
|
362
|
+
path: acc.length === 1 ? first.path : block.path,
|
|
363
|
+
section: block.section,
|
|
364
|
+
start: first.line,
|
|
365
|
+
end: last.line,
|
|
366
|
+
prelude,
|
|
367
|
+
preludeText,
|
|
368
|
+
text: acc.map((r) => r.text).join(' '),
|
|
369
|
+
caption: table.caption || undefined,
|
|
370
|
+
});
|
|
371
|
+
acc = [];
|
|
372
|
+
};
|
|
373
|
+
for (const row of table.rows) {
|
|
374
|
+
const wide = this.#tok(row.text) > this.#slice;
|
|
375
|
+
if (wide) {
|
|
376
|
+
emit();
|
|
377
|
+
this.#slices(block, table, row, prelude, preludeText);
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
if (acc.length > 0 &&
|
|
381
|
+
(acc.length >= TABLE_ROWS_PER_CHUNK ||
|
|
382
|
+
this.#tok(acc.map((r) => r.text).join(' ') + row.text) > this.#soft)) {
|
|
383
|
+
emit();
|
|
384
|
+
}
|
|
385
|
+
acc.push(row);
|
|
386
|
+
}
|
|
387
|
+
emit();
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* One wide row, cut into column groups. Two problems, one cut: a query
|
|
391
|
+
* about one field otherwise competes with a vector averaged over thirty-nine
|
|
392
|
+
* others, and a 400-token row makes every chunk a single row and none of
|
|
393
|
+
* them sharp. The key column rides along in every slice, for the same
|
|
394
|
+
* reason the header does — columns 12 to 18 name nothing on their own.
|
|
395
|
+
*
|
|
396
|
+
* Every slice keeps the row's line numbers and body span, so however many
|
|
397
|
+
* of them match, the row is rendered once.
|
|
398
|
+
*/
|
|
399
|
+
#slices(block, table, row, prelude, preludeText) {
|
|
400
|
+
const key = cellOf(table, row, table.keyColumn);
|
|
401
|
+
const parts = [];
|
|
402
|
+
let acc = [];
|
|
403
|
+
for (let at = 0; at < row.cells.length; at++) {
|
|
404
|
+
if (at === table.keyColumn) {
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
const text = cellOf(table, row, at);
|
|
408
|
+
if (!text) {
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
if (acc.length > 0 && this.#tok([key, ...acc, text].join(' ')) > this.#slice) {
|
|
412
|
+
parts.push([key, ...acc].join(' '));
|
|
413
|
+
acc = [];
|
|
414
|
+
}
|
|
415
|
+
acc.push(text);
|
|
416
|
+
}
|
|
417
|
+
if (acc.length > 0) {
|
|
418
|
+
parts.push([key, ...acc].join(' '));
|
|
419
|
+
}
|
|
420
|
+
for (const text of parts.length > 0 ? parts : [row.text]) {
|
|
421
|
+
this.#emit({
|
|
422
|
+
kind: 'table_row',
|
|
423
|
+
id: row.id,
|
|
424
|
+
path: row.path,
|
|
425
|
+
section: block.section,
|
|
426
|
+
start: row.line,
|
|
427
|
+
end: row.line,
|
|
428
|
+
prelude,
|
|
429
|
+
preludeText,
|
|
430
|
+
text,
|
|
431
|
+
caption: table.caption || undefined,
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
// -----------------------------------------------------------------------
|
|
436
|
+
#whole(block, kind) {
|
|
437
|
+
return {
|
|
438
|
+
kind,
|
|
439
|
+
id: block.id,
|
|
440
|
+
path: block.path,
|
|
441
|
+
section: block.section,
|
|
442
|
+
start: block.start,
|
|
443
|
+
end: block.end,
|
|
444
|
+
prelude: [],
|
|
445
|
+
preludeText: '',
|
|
446
|
+
text: block.text,
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
/** A block over the hard ceiling, cut at line boundaries and nowhere better. */
|
|
450
|
+
#oversized(block, kind) {
|
|
451
|
+
if (this.#tok(block.text) <= this.#hard) {
|
|
452
|
+
this.#emit(this.#whole(block, kind));
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
for (const span of this.#packLines(block.start, block.end)) {
|
|
456
|
+
this.#emit({
|
|
457
|
+
kind,
|
|
458
|
+
id: block.id,
|
|
459
|
+
path: block.path,
|
|
460
|
+
section: block.section,
|
|
461
|
+
start: span.start,
|
|
462
|
+
end: span.end,
|
|
463
|
+
prelude: [],
|
|
464
|
+
preludeText: '',
|
|
465
|
+
text: this.#slabOf(span.start, span.end),
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
/** Line ranges, each the longest that still fits under the hard ceiling. */
|
|
470
|
+
#packLines(from, to) {
|
|
471
|
+
const spans = [];
|
|
472
|
+
let start = from;
|
|
473
|
+
let text = '';
|
|
474
|
+
for (let line = from; line <= to; line++) {
|
|
475
|
+
const next = this.#doc.lines[line - 1] ?? '';
|
|
476
|
+
const merged = text ? `${text}\n${next}` : next;
|
|
477
|
+
if (line > start && this.#tok(merged) > this.#hard) {
|
|
478
|
+
spans.push({ start, end: line - 1 });
|
|
479
|
+
start = line;
|
|
480
|
+
text = next;
|
|
481
|
+
}
|
|
482
|
+
else {
|
|
483
|
+
text = merged;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
if (start <= to) {
|
|
487
|
+
spans.push({ start, end: to });
|
|
488
|
+
}
|
|
489
|
+
return spans;
|
|
490
|
+
}
|
|
491
|
+
#slabOf(start, end) {
|
|
492
|
+
return this.#doc.lines
|
|
493
|
+
.slice(start - 1, end)
|
|
494
|
+
.join('\n')
|
|
495
|
+
.trim();
|
|
496
|
+
}
|
|
497
|
+
#emit(body) {
|
|
498
|
+
if (body.text.trim()) {
|
|
499
|
+
this.#bodies.push(body);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* The two texts diverge in content, not in granularity: same row, same id,
|
|
504
|
+
* one vector and one full-text document. The lexical one is wider because
|
|
505
|
+
* BM25 scores a multi-term query well only when the terms land in the same
|
|
506
|
+
* document; the vector one is tighter because every extra topic pulls the
|
|
507
|
+
* mean-pooled vector toward the middle of the chunk instead of toward
|
|
508
|
+
* anything in it.
|
|
509
|
+
*/
|
|
510
|
+
#finish(body, index) {
|
|
511
|
+
const lines = new Set([...headingLines(body.section), ...body.prelude]);
|
|
512
|
+
for (let line = body.start; line <= body.end; line++) {
|
|
513
|
+
lines.add(line);
|
|
514
|
+
}
|
|
515
|
+
const headings = headingPath(body.section);
|
|
516
|
+
const common = [headings, body.preludeText, body.carry, body.text]
|
|
517
|
+
.filter(Boolean)
|
|
518
|
+
.join('\n');
|
|
519
|
+
return {
|
|
520
|
+
index,
|
|
521
|
+
kind: body.kind,
|
|
522
|
+
structureId: body.id,
|
|
523
|
+
structurePath: body.path,
|
|
524
|
+
headings,
|
|
525
|
+
bodyStart: body.start,
|
|
526
|
+
bodyEnd: body.end,
|
|
527
|
+
lineNumbers: [...lines].sort((a, b) => a - b),
|
|
528
|
+
text: body.caption ? `${common}\n${body.caption}` : common,
|
|
529
|
+
embedText: common,
|
|
530
|
+
tokens: this.#tok(common),
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
// ---------------------------------------------------------------------------
|
|
535
|
+
const proseText = (blocks) => blocks.map((b) => b.text).join('\n');
|
|
536
|
+
const cellOf = (table, row, at) => {
|
|
537
|
+
const value = (row.cells[at] ?? '').trim();
|
|
538
|
+
if (!value) {
|
|
539
|
+
return '';
|
|
540
|
+
}
|
|
541
|
+
const column = table.columns[at];
|
|
542
|
+
return column ? `${column}: ${value}.` : `${value}.`;
|
|
543
|
+
};
|
|
544
|
+
// ---------------------------------------------------------------------------
|
|
545
|
+
// The render set, on the wire
|
|
546
|
+
//
|
|
547
|
+
// A list column would be inferred as list<float64> by LanceDB, which has no
|
|
548
|
+
// declared Arrow schema here to correct it. A run-length string costs nothing,
|
|
549
|
+
// round-trips exactly, and is the one column in the table a person can read
|
|
550
|
+
// with their own eyes when they dump it.
|
|
551
|
+
// ---------------------------------------------------------------------------
|
|
552
|
+
export function formatLines(numbers) {
|
|
553
|
+
const sorted = [...new Set(numbers)].sort((a, b) => a - b);
|
|
554
|
+
const spans = [];
|
|
555
|
+
let at = 0;
|
|
556
|
+
while (at < sorted.length) {
|
|
557
|
+
const start = sorted[at];
|
|
558
|
+
let end = start;
|
|
559
|
+
while (at + 1 < sorted.length && sorted[at + 1] === end + 1) {
|
|
560
|
+
end = sorted[++at];
|
|
561
|
+
}
|
|
562
|
+
spans.push(start === end ? `${start}` : `${start}-${end}`);
|
|
563
|
+
at++;
|
|
564
|
+
}
|
|
565
|
+
return spans.join(',');
|
|
566
|
+
}
|
|
567
|
+
export function parseLines(spec) {
|
|
568
|
+
const out = [];
|
|
569
|
+
for (const span of spec.split(',')) {
|
|
570
|
+
const [from, to] = span.split('-');
|
|
571
|
+
// `Number('')` is 0, which would be a line no document has.
|
|
572
|
+
if (!from) {
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
const start = Number(from);
|
|
576
|
+
if (!Number.isInteger(start)) {
|
|
577
|
+
continue;
|
|
578
|
+
}
|
|
579
|
+
const end = to === undefined ? start : Number(to);
|
|
580
|
+
for (let line = start; line <= (Number.isInteger(end) ? end : start); line++) {
|
|
581
|
+
out.push(line);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
return out;
|
|
585
|
+
}
|
|
586
|
+
//# sourceMappingURL=chunk.js.map
|