@crewhaus/chunker 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Catalog R12 `chunker` — split documents into retrievable chunks.
3
+ *
4
+ * Three strategies:
5
+ *
6
+ * "fixed" — split into fixed-character windows with optional overlap
7
+ * "semantic" — `Intl.Segmenter` sentence boundaries, packed into windows
8
+ * "markdown" — header-bounded chunks (split at `^#+ ` boundaries)
9
+ *
10
+ * Every strategy preserves the source bytes — `chunk(doc).map(c => c.text).join("")`
11
+ * (mod overlap) reconstructs the doc, modulo any whitespace that
12
+ * sat at chunk boundaries. The `id` field is `<docId>:<index>:<startOffset>`
13
+ * for stable reference back to the source.
14
+ *
15
+ * Layer R12. Pairs with `embedder`, `vector-store`, `pipeline-engine`.
16
+ */
17
+ import { CrewhausError } from "@crewhaus/errors";
18
+ export type ChunkStrategy = "fixed" | "semantic" | "markdown";
19
+ export type ChunkOptions = {
20
+ readonly strategy: ChunkStrategy;
21
+ /** Window size: chars for "fixed", sentences for "semantic", chars for "markdown" sub-chunking. */
22
+ readonly size: number;
23
+ /** Overlap (in the same units as size). Defaults to 0. */
24
+ readonly overlap?: number;
25
+ /** Locale for `Intl.Segmenter` ("semantic" only). Defaults to "en". */
26
+ readonly locale?: string;
27
+ };
28
+ export type Document = {
29
+ /** Stable identifier (URL, path, hash). Used to derive chunk ids. */
30
+ readonly id: string;
31
+ readonly text: string;
32
+ readonly metadata?: Readonly<Record<string, unknown>>;
33
+ };
34
+ export type Chunk = {
35
+ readonly id: string;
36
+ readonly docId: string;
37
+ readonly index: number;
38
+ /** Inclusive byte offset in `doc.text` where the chunk begins. */
39
+ readonly startOffset: number;
40
+ /** Exclusive byte offset where the chunk ends. */
41
+ readonly endOffset: number;
42
+ readonly text: string;
43
+ readonly metadata?: Readonly<Record<string, unknown>>;
44
+ };
45
+ export declare class ChunkerError extends CrewhausError {
46
+ readonly name = "ChunkerError";
47
+ constructor(message: string, cause?: unknown);
48
+ }
49
+ export declare function chunk(doc: Document, opts: ChunkOptions): Chunk[];
package/dist/index.js ADDED
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Catalog R12 `chunker` — split documents into retrievable chunks.
3
+ *
4
+ * Three strategies:
5
+ *
6
+ * "fixed" — split into fixed-character windows with optional overlap
7
+ * "semantic" — `Intl.Segmenter` sentence boundaries, packed into windows
8
+ * "markdown" — header-bounded chunks (split at `^#+ ` boundaries)
9
+ *
10
+ * Every strategy preserves the source bytes — `chunk(doc).map(c => c.text).join("")`
11
+ * (mod overlap) reconstructs the doc, modulo any whitespace that
12
+ * sat at chunk boundaries. The `id` field is `<docId>:<index>:<startOffset>`
13
+ * for stable reference back to the source.
14
+ *
15
+ * Layer R12. Pairs with `embedder`, `vector-store`, `pipeline-engine`.
16
+ */
17
+ import { CrewhausError } from "@crewhaus/errors";
18
+ export class ChunkerError extends CrewhausError {
19
+ name = "ChunkerError";
20
+ constructor(message, cause) {
21
+ super("config", message, cause);
22
+ }
23
+ }
24
+ function makeChunkId(docId, index, start) {
25
+ return `${docId}:${index}:${start}`;
26
+ }
27
+ function chunkFixed(doc, opts) {
28
+ if (opts.size <= 0)
29
+ throw new ChunkerError("size must be positive");
30
+ const overlap = opts.overlap ?? 0;
31
+ if (overlap < 0 || overlap >= opts.size) {
32
+ throw new ChunkerError(`overlap (${overlap}) must be in [0, size) (size=${opts.size})`);
33
+ }
34
+ const out = [];
35
+ const stride = opts.size - overlap;
36
+ let start = 0;
37
+ let index = 0;
38
+ while (start < doc.text.length) {
39
+ const end = Math.min(start + opts.size, doc.text.length);
40
+ out.push({
41
+ id: makeChunkId(doc.id, index, start),
42
+ docId: doc.id,
43
+ index,
44
+ startOffset: start,
45
+ endOffset: end,
46
+ text: doc.text.slice(start, end),
47
+ ...(doc.metadata !== undefined ? { metadata: doc.metadata } : {}),
48
+ });
49
+ if (end >= doc.text.length)
50
+ break;
51
+ start += stride;
52
+ index += 1;
53
+ }
54
+ return out;
55
+ }
56
+ function chunkSemantic(doc, opts) {
57
+ if (opts.size <= 0)
58
+ throw new ChunkerError("size must be positive");
59
+ const overlap = opts.overlap ?? 0;
60
+ // Intl.Segmenter is widely available in modern Node + Bun. An invalid
61
+ // `locale` makes the constructor throw a raw `RangeError`; wrap it so callers
62
+ // see the package's typed `ChunkerError` contract (with the original as cause).
63
+ let segmenter;
64
+ try {
65
+ segmenter = new Intl.Segmenter(opts.locale ?? "en", { granularity: "sentence" });
66
+ }
67
+ catch (cause) {
68
+ throw new ChunkerError(`invalid locale ${JSON.stringify(opts.locale)}`, cause);
69
+ }
70
+ const sentences = [];
71
+ for (const seg of segmenter.segment(doc.text)) {
72
+ if (seg.segment.trim().length === 0)
73
+ continue;
74
+ const start = seg.index;
75
+ const end = start + seg.segment.length;
76
+ sentences.push({ text: seg.segment, start, end });
77
+ }
78
+ if (sentences.length === 0)
79
+ return [];
80
+ const out = [];
81
+ let i = 0;
82
+ let index = 0;
83
+ while (i < sentences.length) {
84
+ const window = sentences.slice(i, Math.min(i + opts.size, sentences.length));
85
+ if (window.length === 0)
86
+ break;
87
+ const startOffset = window[0]?.start ?? 0;
88
+ const endOffset = window[window.length - 1]?.end ?? doc.text.length;
89
+ const text = doc.text.slice(startOffset, endOffset);
90
+ out.push({
91
+ id: makeChunkId(doc.id, index, startOffset),
92
+ docId: doc.id,
93
+ index,
94
+ startOffset,
95
+ endOffset,
96
+ text,
97
+ ...(doc.metadata !== undefined ? { metadata: doc.metadata } : {}),
98
+ });
99
+ index += 1;
100
+ if (i + opts.size >= sentences.length)
101
+ break;
102
+ i += Math.max(1, opts.size - overlap);
103
+ }
104
+ return out;
105
+ }
106
+ function chunkMarkdown(doc, opts) {
107
+ // Split the document into header-bounded sections, then sub-chunk each
108
+ // section with the "fixed" strategy if it's longer than `opts.size`.
109
+ const headerRe = /(^|\n)(#{1,6}\s)/g;
110
+ const sectionRanges = [];
111
+ const matches = [0];
112
+ let m;
113
+ // biome-ignore lint/suspicious/noAssignInExpressions: classical regex iter
114
+ while ((m = headerRe.exec(doc.text)) !== null) {
115
+ const headerStart = m[1] === "\n" ? m.index + 1 : m.index;
116
+ if (headerStart !== 0 && !matches.includes(headerStart))
117
+ matches.push(headerStart);
118
+ }
119
+ matches.push(doc.text.length);
120
+ for (let i = 0; i < matches.length - 1; i += 1) {
121
+ const start = matches[i];
122
+ const end = matches[i + 1];
123
+ if (end > start)
124
+ sectionRanges.push({ start, end });
125
+ }
126
+ const out = [];
127
+ let chunkIndex = 0;
128
+ for (const range of sectionRanges) {
129
+ const sliceLen = range.end - range.start;
130
+ if (sliceLen <= opts.size) {
131
+ out.push({
132
+ id: makeChunkId(doc.id, chunkIndex, range.start),
133
+ docId: doc.id,
134
+ index: chunkIndex,
135
+ startOffset: range.start,
136
+ endOffset: range.end,
137
+ text: doc.text.slice(range.start, range.end),
138
+ ...(doc.metadata !== undefined ? { metadata: doc.metadata } : {}),
139
+ });
140
+ chunkIndex += 1;
141
+ continue;
142
+ }
143
+ // Sub-chunk via fixed strategy on this section.
144
+ const sub = chunkFixed({
145
+ id: doc.id,
146
+ text: doc.text.slice(range.start, range.end),
147
+ ...(doc.metadata !== undefined ? { metadata: doc.metadata } : {}),
148
+ }, opts);
149
+ for (const c of sub) {
150
+ out.push({
151
+ ...c,
152
+ id: makeChunkId(doc.id, chunkIndex, range.start + c.startOffset),
153
+ index: chunkIndex,
154
+ startOffset: range.start + c.startOffset,
155
+ endOffset: range.start + c.endOffset,
156
+ });
157
+ chunkIndex += 1;
158
+ }
159
+ }
160
+ return out;
161
+ }
162
+ export function chunk(doc, opts) {
163
+ switch (opts.strategy) {
164
+ case "fixed":
165
+ return chunkFixed(doc, opts);
166
+ case "semantic":
167
+ return chunkSemantic(doc, opts);
168
+ case "markdown":
169
+ return chunkMarkdown(doc, opts);
170
+ default: {
171
+ const exhaustive = opts.strategy;
172
+ throw new ChunkerError(`unknown strategy "${exhaustive}"`);
173
+ }
174
+ }
175
+ }
package/package.json CHANGED
@@ -1,18 +1,21 @@
1
1
  {
2
2
  "name": "@crewhaus/chunker",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "description": "Document chunking — fixed / semantic (Intl.Segmenter) / markdown strategies",
6
- "main": "src/index.ts",
7
- "types": "src/index.ts",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
8
  "exports": {
9
- ".": "./src/index.ts"
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
10
13
  },
11
14
  "scripts": {
12
15
  "test": "bun test src"
13
16
  },
14
17
  "dependencies": {
15
- "@crewhaus/errors": "0.1.3"
18
+ "@crewhaus/errors": "0.1.5"
16
19
  },
17
20
  "license": "Apache-2.0",
18
21
  "author": {
@@ -32,5 +35,5 @@
32
35
  "publishConfig": {
33
36
  "access": "public"
34
37
  },
35
- "files": ["src", "README.md", "LICENSE", "NOTICE"]
38
+ "files": ["dist", "README.md", "LICENSE", "NOTICE"]
36
39
  }
package/src/index.test.ts DELETED
@@ -1,235 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { ChunkerError, chunk } from "./index";
3
-
4
- describe("fixed strategy", () => {
5
- test("splits a long doc into fixed-size windows", () => {
6
- const text = "0123456789".repeat(20);
7
- const out = chunk({ id: "doc1", text }, { strategy: "fixed", size: 50 });
8
- expect(out.length).toBe(4);
9
- expect(out[0]?.text.length).toBe(50);
10
- expect(out[3]?.text.length).toBe(50);
11
- });
12
-
13
- test("respects overlap when stride < size", () => {
14
- const text = "0123456789".repeat(10); // 100 chars
15
- const out = chunk({ id: "doc", text }, { strategy: "fixed", size: 30, overlap: 10 });
16
- // stride = 20, so chunks start at 0, 20, 40, 60, 80
17
- expect(out.length).toBe(5);
18
- expect(out[0]?.startOffset).toBe(0);
19
- expect(out[1]?.startOffset).toBe(20);
20
- expect(out[4]?.startOffset).toBe(80);
21
- });
22
-
23
- test("rejects overlap >= size", () => {
24
- expect(() =>
25
- chunk({ id: "x", text: "abc" }, { strategy: "fixed", size: 5, overlap: 5 }),
26
- ).toThrow(ChunkerError);
27
- });
28
-
29
- test("preserves source bytes (T9 reconstruction with overlap=0)", () => {
30
- const text = "abcdefghijklmnopqrstuvwxyz";
31
- const out = chunk({ id: "x", text }, { strategy: "fixed", size: 5 });
32
- expect(out.map((c) => c.text).join("")).toBe(text);
33
- });
34
-
35
- test("ids carry docId + index + startOffset", () => {
36
- const out = chunk({ id: "doc99", text: "hello" }, { strategy: "fixed", size: 3 });
37
- expect(out[0]?.id).toBe("doc99:0:0");
38
- expect(out[1]?.id).toBe("doc99:1:3");
39
- });
40
-
41
- test("metadata flows through", () => {
42
- const out = chunk(
43
- { id: "x", text: "abc", metadata: { kind: "doc" } },
44
- { strategy: "fixed", size: 2 },
45
- );
46
- expect(out[0]?.metadata).toEqual({ kind: "doc" });
47
- });
48
- });
49
-
50
- describe("semantic strategy", () => {
51
- test("groups sentences into windows of N sentences", () => {
52
- const text = "Alpha. Bravo. Charlie. Delta. Echo. Foxtrot.";
53
- const out = chunk({ id: "doc", text }, { strategy: "semantic", size: 2 });
54
- // 6 sentences, window 2 → 3 chunks
55
- expect(out.length).toBe(3);
56
- });
57
-
58
- test("empty text yields no chunks", () => {
59
- const out = chunk({ id: "doc", text: "" }, { strategy: "semantic", size: 3 });
60
- expect(out).toEqual([]);
61
- });
62
-
63
- test("whitespace-only text yields no chunks (segments trimmed away)", () => {
64
- const out = chunk({ id: "doc", text: " \n\t " }, { strategy: "semantic", size: 2 });
65
- expect(out).toEqual([]);
66
- });
67
-
68
- test("overlap produces overlapping sentence windows", () => {
69
- const text = "Alpha. Bravo. Charlie. Delta. Echo. Foxtrot.";
70
- const out = chunk({ id: "doc", text }, { strategy: "semantic", size: 2, overlap: 1 });
71
- // stride = max(1, 2 - 1) = 1, so a size-2 window starts at sentences
72
- // 0,1,2,3,4 (the loop breaks once i + size reaches the 6-sentence end): 5 chunks.
73
- expect(out.length).toBe(5);
74
- // Consecutive windows share a sentence (overlap), so each later window
75
- // starts strictly before the previous window's end.
76
- expect(out[1]?.startOffset).toBeLessThan(out[0]?.endOffset ?? -1);
77
- });
78
-
79
- test("overlap >= size still terminates (stride clamped to 1)", () => {
80
- const text = "Alpha. Bravo. Charlie.";
81
- const out = chunk({ id: "doc", text }, { strategy: "semantic", size: 2, overlap: 5 });
82
- // No infinite loop; stride floored at 1 yields one window per sentence
83
- // until the final window reaches the end.
84
- expect(out.length).toBe(2);
85
- });
86
-
87
- test("rejects non-positive size", () => {
88
- expect(() => chunk({ id: "x", text: "A. B." }, { strategy: "semantic", size: 0 })).toThrow(
89
- ChunkerError,
90
- );
91
- });
92
-
93
- test("metadata flows through semantic chunks", () => {
94
- const out = chunk(
95
- { id: "doc", text: "Alpha. Bravo.", metadata: { lang: "en" } },
96
- { strategy: "semantic", size: 1 },
97
- );
98
- expect(out.length).toBe(2);
99
- expect(out[0]?.metadata).toEqual({ lang: "en" });
100
- expect(out[1]?.metadata).toEqual({ lang: "en" });
101
- });
102
-
103
- test("a valid non-default locale is accepted", () => {
104
- const out = chunk(
105
- { id: "doc", text: "Hallo. Welt." },
106
- { strategy: "semantic", size: 1, locale: "de" },
107
- );
108
- expect(out.length).toBe(2);
109
- });
110
-
111
- test("an invalid locale throws ChunkerError (not a raw RangeError)", () => {
112
- let caught: unknown;
113
- try {
114
- chunk(
115
- { id: "doc", text: "Hello. World." },
116
- { strategy: "semantic", size: 2, locale: "not a valid tag!!!" },
117
- );
118
- } catch (e) {
119
- caught = e;
120
- }
121
- expect(caught).toBeInstanceOf(ChunkerError);
122
- // The original RangeError is preserved as the cause for debugging.
123
- expect((caught as Error).cause).toBeInstanceOf(RangeError);
124
- expect((caught as Error).message).toContain("invalid locale");
125
- });
126
-
127
- test("preserves source bytes across a single full window", () => {
128
- const text = "Alpha. Bravo. Charlie.";
129
- const out = chunk({ id: "doc", text }, { strategy: "semantic", size: 10 });
130
- expect(out.length).toBe(1);
131
- const only = out[0];
132
- expect(only).toBeDefined();
133
- // The single window spans from the first sentence start to the last end.
134
- expect(text.slice(only?.startOffset, only?.endOffset)).toBe(only?.text ?? "");
135
- });
136
- });
137
-
138
- describe("markdown strategy", () => {
139
- test("splits at headers", () => {
140
- const md = `# A
141
- para
142
- ## B
143
- para
144
- ### C
145
- para`;
146
- const out = chunk({ id: "doc", text: md }, { strategy: "markdown", size: 100 });
147
- expect(out.length).toBe(3);
148
- expect(out[0]?.text.startsWith("# A")).toBe(true);
149
- expect(out[1]?.text.startsWith("## B")).toBe(true);
150
- expect(out[2]?.text.startsWith("### C")).toBe(true);
151
- });
152
-
153
- test("sub-chunks long sections via the fixed strategy", () => {
154
- const long = "x".repeat(120);
155
- const md = `# Big\n${long}\n## Small\nshort`;
156
- const out = chunk({ id: "doc", text: md }, { strategy: "markdown", size: 50 });
157
- // Section 1 is ~125 chars → sub-chunked into 3 pieces (50/50/25-ish).
158
- // Section 2 is short → 1 piece.
159
- expect(out.length).toBeGreaterThan(2);
160
- });
161
-
162
- test("sub-chunk ids and offsets are remapped to absolute document positions", () => {
163
- const long = "y".repeat(120);
164
- const md = `# Big\n${long}`; // single section, > size → sub-chunked
165
- const out = chunk({ id: "doc", text: md }, { strategy: "markdown", size: 50 });
166
- expect(out.length).toBeGreaterThan(1);
167
- // Indices are contiguous and ids encode absolute startOffset.
168
- out.forEach((c, i) => {
169
- expect(c.index).toBe(i);
170
- expect(c.id).toBe(`doc:${i}:${c.startOffset}`);
171
- // Each chunk's text is exactly the document slice at its absolute offsets.
172
- expect(md.slice(c.startOffset, c.endOffset)).toBe(c.text);
173
- });
174
- expect(out[0]?.startOffset).toBe(0);
175
- });
176
-
177
- test("text before the first header becomes its own leading section", () => {
178
- const md = "preamble line\n# Header\nbody";
179
- const out = chunk({ id: "doc", text: md }, { strategy: "markdown", size: 100 });
180
- expect(out.length).toBe(2);
181
- expect(out[0]?.text.startsWith("preamble")).toBe(true);
182
- expect(out[1]?.text.startsWith("# Header")).toBe(true);
183
- });
184
-
185
- test("a document with no headers yields a single chunk", () => {
186
- const md = "just plain text, no headers here";
187
- const out = chunk({ id: "doc", text: md }, { strategy: "markdown", size: 100 });
188
- expect(out.length).toBe(1);
189
- expect(out[0]?.text).toBe(md);
190
- });
191
-
192
- test("an empty document yields no chunks", () => {
193
- const out = chunk({ id: "doc", text: "" }, { strategy: "markdown", size: 100 });
194
- expect(out).toEqual([]);
195
- });
196
-
197
- test("metadata flows through markdown chunks (both short and sub-chunked paths)", () => {
198
- const long = "z".repeat(120);
199
- const md = `# Big\n${long}\n## Small\nshort`;
200
- const out = chunk(
201
- { id: "doc", text: md, metadata: { src: "wiki" } },
202
- { strategy: "markdown", size: 50 },
203
- );
204
- expect(out.length).toBeGreaterThan(2);
205
- for (const c of out) {
206
- expect(c.metadata).toEqual({ src: "wiki" });
207
- }
208
- });
209
-
210
- test("reconstructs the source bytes by concatenating chunk text (overlap=0)", () => {
211
- const md = "# A\npara one\n## B\npara two\n### C\npara three";
212
- const out = chunk({ id: "doc", text: md }, { strategy: "markdown", size: 100 });
213
- expect(out.map((c) => c.text).join("")).toBe(md);
214
- });
215
- });
216
-
217
- describe("strategy dispatch", () => {
218
- test("an unknown strategy throws ChunkerError", () => {
219
- expect(() =>
220
- // @ts-expect-error — deliberately bypass the typed strategy union
221
- chunk({ id: "x", text: "abc" }, { strategy: "bogus", size: 5 }),
222
- ).toThrow(ChunkerError);
223
- });
224
-
225
- test("the unknown-strategy message names the offending value", () => {
226
- let caught: unknown;
227
- try {
228
- // @ts-expect-error — deliberately bypass the typed strategy union
229
- chunk({ id: "x", text: "abc" }, { strategy: "nope", size: 5 });
230
- } catch (e) {
231
- caught = e;
232
- }
233
- expect((caught as Error).message).toContain("nope");
234
- });
235
- });
package/src/index.ts DELETED
@@ -1,207 +0,0 @@
1
- /**
2
- * Catalog R12 `chunker` — split documents into retrievable chunks.
3
- *
4
- * Three strategies:
5
- *
6
- * "fixed" — split into fixed-character windows with optional overlap
7
- * "semantic" — `Intl.Segmenter` sentence boundaries, packed into windows
8
- * "markdown" — header-bounded chunks (split at `^#+ ` boundaries)
9
- *
10
- * Every strategy preserves the source bytes — `chunk(doc).map(c => c.text).join("")`
11
- * (mod overlap) reconstructs the doc, modulo any whitespace that
12
- * sat at chunk boundaries. The `id` field is `<docId>:<index>:<startOffset>`
13
- * for stable reference back to the source.
14
- *
15
- * Layer R12. Pairs with `embedder`, `vector-store`, `pipeline-engine`.
16
- */
17
-
18
- import { CrewhausError } from "@crewhaus/errors";
19
-
20
- export type ChunkStrategy = "fixed" | "semantic" | "markdown";
21
-
22
- export type ChunkOptions = {
23
- readonly strategy: ChunkStrategy;
24
- /** Window size: chars for "fixed", sentences for "semantic", chars for "markdown" sub-chunking. */
25
- readonly size: number;
26
- /** Overlap (in the same units as size). Defaults to 0. */
27
- readonly overlap?: number;
28
- /** Locale for `Intl.Segmenter` ("semantic" only). Defaults to "en". */
29
- readonly locale?: string;
30
- };
31
-
32
- export type Document = {
33
- /** Stable identifier (URL, path, hash). Used to derive chunk ids. */
34
- readonly id: string;
35
- readonly text: string;
36
- readonly metadata?: Readonly<Record<string, unknown>>;
37
- };
38
-
39
- export type Chunk = {
40
- readonly id: string;
41
- readonly docId: string;
42
- readonly index: number;
43
- /** Inclusive byte offset in `doc.text` where the chunk begins. */
44
- readonly startOffset: number;
45
- /** Exclusive byte offset where the chunk ends. */
46
- readonly endOffset: number;
47
- readonly text: string;
48
- readonly metadata?: Readonly<Record<string, unknown>>;
49
- };
50
-
51
- export class ChunkerError extends CrewhausError {
52
- override readonly name = "ChunkerError";
53
- constructor(message: string, cause?: unknown) {
54
- super("config", message, cause);
55
- }
56
- }
57
-
58
- function makeChunkId(docId: string, index: number, start: number): string {
59
- return `${docId}:${index}:${start}`;
60
- }
61
-
62
- function chunkFixed(doc: Document, opts: ChunkOptions): Chunk[] {
63
- if (opts.size <= 0) throw new ChunkerError("size must be positive");
64
- const overlap = opts.overlap ?? 0;
65
- if (overlap < 0 || overlap >= opts.size) {
66
- throw new ChunkerError(`overlap (${overlap}) must be in [0, size) (size=${opts.size})`);
67
- }
68
- const out: Chunk[] = [];
69
- const stride = opts.size - overlap;
70
- let start = 0;
71
- let index = 0;
72
- while (start < doc.text.length) {
73
- const end = Math.min(start + opts.size, doc.text.length);
74
- out.push({
75
- id: makeChunkId(doc.id, index, start),
76
- docId: doc.id,
77
- index,
78
- startOffset: start,
79
- endOffset: end,
80
- text: doc.text.slice(start, end),
81
- ...(doc.metadata !== undefined ? { metadata: doc.metadata } : {}),
82
- });
83
- if (end >= doc.text.length) break;
84
- start += stride;
85
- index += 1;
86
- }
87
- return out;
88
- }
89
-
90
- function chunkSemantic(doc: Document, opts: ChunkOptions): Chunk[] {
91
- if (opts.size <= 0) throw new ChunkerError("size must be positive");
92
- const overlap = opts.overlap ?? 0;
93
- // Intl.Segmenter is widely available in modern Node + Bun. An invalid
94
- // `locale` makes the constructor throw a raw `RangeError`; wrap it so callers
95
- // see the package's typed `ChunkerError` contract (with the original as cause).
96
- let segmenter: Intl.Segmenter;
97
- try {
98
- segmenter = new Intl.Segmenter(opts.locale ?? "en", { granularity: "sentence" });
99
- } catch (cause) {
100
- throw new ChunkerError(`invalid locale ${JSON.stringify(opts.locale)}`, cause);
101
- }
102
- type Sentence = { readonly text: string; readonly start: number; readonly end: number };
103
- const sentences: Sentence[] = [];
104
- for (const seg of segmenter.segment(doc.text)) {
105
- if (seg.segment.trim().length === 0) continue;
106
- const start = seg.index;
107
- const end = start + seg.segment.length;
108
- sentences.push({ text: seg.segment, start, end });
109
- }
110
- if (sentences.length === 0) return [];
111
- const out: Chunk[] = [];
112
- let i = 0;
113
- let index = 0;
114
- while (i < sentences.length) {
115
- const window = sentences.slice(i, Math.min(i + opts.size, sentences.length));
116
- if (window.length === 0) break;
117
- const startOffset = window[0]?.start ?? 0;
118
- const endOffset = window[window.length - 1]?.end ?? doc.text.length;
119
- const text = doc.text.slice(startOffset, endOffset);
120
- out.push({
121
- id: makeChunkId(doc.id, index, startOffset),
122
- docId: doc.id,
123
- index,
124
- startOffset,
125
- endOffset,
126
- text,
127
- ...(doc.metadata !== undefined ? { metadata: doc.metadata } : {}),
128
- });
129
- index += 1;
130
- if (i + opts.size >= sentences.length) break;
131
- i += Math.max(1, opts.size - overlap);
132
- }
133
- return out;
134
- }
135
-
136
- function chunkMarkdown(doc: Document, opts: ChunkOptions): Chunk[] {
137
- // Split the document into header-bounded sections, then sub-chunk each
138
- // section with the "fixed" strategy if it's longer than `opts.size`.
139
- const headerRe = /(^|\n)(#{1,6}\s)/g;
140
- const sectionRanges: Array<{ start: number; end: number }> = [];
141
- const matches: number[] = [0];
142
- let m: RegExpExecArray | null;
143
- // biome-ignore lint/suspicious/noAssignInExpressions: classical regex iter
144
- while ((m = headerRe.exec(doc.text)) !== null) {
145
- const headerStart = m[1] === "\n" ? m.index + 1 : m.index;
146
- if (headerStart !== 0 && !matches.includes(headerStart)) matches.push(headerStart);
147
- }
148
- matches.push(doc.text.length);
149
- for (let i = 0; i < matches.length - 1; i += 1) {
150
- const start = matches[i] as number;
151
- const end = matches[i + 1] as number;
152
- if (end > start) sectionRanges.push({ start, end });
153
- }
154
- const out: Chunk[] = [];
155
- let chunkIndex = 0;
156
- for (const range of sectionRanges) {
157
- const sliceLen = range.end - range.start;
158
- if (sliceLen <= opts.size) {
159
- out.push({
160
- id: makeChunkId(doc.id, chunkIndex, range.start),
161
- docId: doc.id,
162
- index: chunkIndex,
163
- startOffset: range.start,
164
- endOffset: range.end,
165
- text: doc.text.slice(range.start, range.end),
166
- ...(doc.metadata !== undefined ? { metadata: doc.metadata } : {}),
167
- });
168
- chunkIndex += 1;
169
- continue;
170
- }
171
- // Sub-chunk via fixed strategy on this section.
172
- const sub = chunkFixed(
173
- {
174
- id: doc.id,
175
- text: doc.text.slice(range.start, range.end),
176
- ...(doc.metadata !== undefined ? { metadata: doc.metadata } : {}),
177
- },
178
- opts,
179
- );
180
- for (const c of sub) {
181
- out.push({
182
- ...c,
183
- id: makeChunkId(doc.id, chunkIndex, range.start + c.startOffset),
184
- index: chunkIndex,
185
- startOffset: range.start + c.startOffset,
186
- endOffset: range.start + c.endOffset,
187
- });
188
- chunkIndex += 1;
189
- }
190
- }
191
- return out;
192
- }
193
-
194
- export function chunk(doc: Document, opts: ChunkOptions): Chunk[] {
195
- switch (opts.strategy) {
196
- case "fixed":
197
- return chunkFixed(doc, opts);
198
- case "semantic":
199
- return chunkSemantic(doc, opts);
200
- case "markdown":
201
- return chunkMarkdown(doc, opts);
202
- default: {
203
- const exhaustive: never = opts.strategy;
204
- throw new ChunkerError(`unknown strategy "${exhaustive}"`);
205
- }
206
- }
207
- }