@crewhaus/chunker 0.1.0

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/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@crewhaus/chunker",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Document chunking — fixed / semantic (Intl.Segmenter) / markdown strategies",
6
+ "main": "src/index.ts",
7
+ "types": "src/index.ts",
8
+ "exports": {
9
+ ".": "./src/index.ts"
10
+ },
11
+ "scripts": {
12
+ "test": "bun test src"
13
+ },
14
+ "dependencies": {
15
+ "@crewhaus/errors": "0.0.0"
16
+ },
17
+ "license": "Apache-2.0",
18
+ "author": {
19
+ "name": "Max Meier",
20
+ "email": "max@studiomax.io",
21
+ "url": "https://studiomax.io"
22
+ },
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/crewhaus/factory.git",
26
+ "directory": "packages/chunker"
27
+ },
28
+ "homepage": "https://github.com/crewhaus/factory/tree/main/packages/chunker#readme",
29
+ "bugs": {
30
+ "url": "https://github.com/crewhaus/factory/issues"
31
+ },
32
+ "publishConfig": {
33
+ "access": "restricted"
34
+ },
35
+ "files": [
36
+ "src",
37
+ "README.md",
38
+ "LICENSE",
39
+ "NOTICE"
40
+ ]
41
+ }
@@ -0,0 +1,87 @@
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
+
64
+ describe("markdown strategy", () => {
65
+ test("splits at headers", () => {
66
+ const md = `# A
67
+ para
68
+ ## B
69
+ para
70
+ ### C
71
+ para`;
72
+ const out = chunk({ id: "doc", text: md }, { strategy: "markdown", size: 100 });
73
+ expect(out.length).toBe(3);
74
+ expect(out[0]?.text.startsWith("# A")).toBe(true);
75
+ expect(out[1]?.text.startsWith("## B")).toBe(true);
76
+ expect(out[2]?.text.startsWith("### C")).toBe(true);
77
+ });
78
+
79
+ test("sub-chunks long sections via the fixed strategy", () => {
80
+ const long = "x".repeat(120);
81
+ const md = `# Big\n${long}\n## Small\nshort`;
82
+ const out = chunk({ id: "doc", text: md }, { strategy: "markdown", size: 50 });
83
+ // Section 1 is ~125 chars → sub-chunked into 3 pieces (50/50/25-ish).
84
+ // Section 2 is short → 1 piece.
85
+ expect(out.length).toBeGreaterThan(2);
86
+ });
87
+ });
package/src/index.ts ADDED
@@ -0,0 +1,200 @@
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.
94
+ const segmenter = new Intl.Segmenter(opts.locale ?? "en", { granularity: "sentence" });
95
+ type Sentence = { readonly text: string; readonly start: number; readonly end: number };
96
+ const sentences: Sentence[] = [];
97
+ for (const seg of segmenter.segment(doc.text)) {
98
+ if (seg.segment.trim().length === 0) continue;
99
+ const start = seg.index;
100
+ const end = start + seg.segment.length;
101
+ sentences.push({ text: seg.segment, start, end });
102
+ }
103
+ if (sentences.length === 0) return [];
104
+ const out: Chunk[] = [];
105
+ let i = 0;
106
+ let index = 0;
107
+ while (i < sentences.length) {
108
+ const window = sentences.slice(i, Math.min(i + opts.size, sentences.length));
109
+ if (window.length === 0) break;
110
+ const startOffset = window[0]?.start ?? 0;
111
+ const endOffset = window[window.length - 1]?.end ?? doc.text.length;
112
+ const text = doc.text.slice(startOffset, endOffset);
113
+ out.push({
114
+ id: makeChunkId(doc.id, index, startOffset),
115
+ docId: doc.id,
116
+ index,
117
+ startOffset,
118
+ endOffset,
119
+ text,
120
+ ...(doc.metadata !== undefined ? { metadata: doc.metadata } : {}),
121
+ });
122
+ index += 1;
123
+ if (i + opts.size >= sentences.length) break;
124
+ i += Math.max(1, opts.size - overlap);
125
+ }
126
+ return out;
127
+ }
128
+
129
+ function chunkMarkdown(doc: Document, opts: ChunkOptions): Chunk[] {
130
+ // Split the document into header-bounded sections, then sub-chunk each
131
+ // section with the "fixed" strategy if it's longer than `opts.size`.
132
+ const headerRe = /(^|\n)(#{1,6}\s)/g;
133
+ const sectionRanges: Array<{ start: number; end: number }> = [];
134
+ const matches: number[] = [0];
135
+ let m: RegExpExecArray | null;
136
+ // biome-ignore lint/suspicious/noAssignInExpressions: classical regex iter
137
+ while ((m = headerRe.exec(doc.text)) !== null) {
138
+ const headerStart = m[1] === "\n" ? m.index + 1 : m.index;
139
+ if (headerStart !== 0 && !matches.includes(headerStart)) matches.push(headerStart);
140
+ }
141
+ matches.push(doc.text.length);
142
+ for (let i = 0; i < matches.length - 1; i += 1) {
143
+ const start = matches[i] as number;
144
+ const end = matches[i + 1] as number;
145
+ if (end > start) sectionRanges.push({ start, end });
146
+ }
147
+ const out: Chunk[] = [];
148
+ let chunkIndex = 0;
149
+ for (const range of sectionRanges) {
150
+ const sliceLen = range.end - range.start;
151
+ if (sliceLen <= opts.size) {
152
+ out.push({
153
+ id: makeChunkId(doc.id, chunkIndex, range.start),
154
+ docId: doc.id,
155
+ index: chunkIndex,
156
+ startOffset: range.start,
157
+ endOffset: range.end,
158
+ text: doc.text.slice(range.start, range.end),
159
+ ...(doc.metadata !== undefined ? { metadata: doc.metadata } : {}),
160
+ });
161
+ chunkIndex += 1;
162
+ continue;
163
+ }
164
+ // Sub-chunk via fixed strategy on this section.
165
+ const sub = chunkFixed(
166
+ {
167
+ id: doc.id,
168
+ text: doc.text.slice(range.start, range.end),
169
+ ...(doc.metadata !== undefined ? { metadata: doc.metadata } : {}),
170
+ },
171
+ opts,
172
+ );
173
+ for (const c of sub) {
174
+ out.push({
175
+ ...c,
176
+ id: makeChunkId(doc.id, chunkIndex, range.start + c.startOffset),
177
+ index: chunkIndex,
178
+ startOffset: range.start + c.startOffset,
179
+ endOffset: range.start + c.endOffset,
180
+ });
181
+ chunkIndex += 1;
182
+ }
183
+ }
184
+ return out;
185
+ }
186
+
187
+ export function chunk(doc: Document, opts: ChunkOptions): Chunk[] {
188
+ switch (opts.strategy) {
189
+ case "fixed":
190
+ return chunkFixed(doc, opts);
191
+ case "semantic":
192
+ return chunkSemantic(doc, opts);
193
+ case "markdown":
194
+ return chunkMarkdown(doc, opts);
195
+ default: {
196
+ const exhaustive: never = opts.strategy;
197
+ throw new ChunkerError(`unknown strategy "${exhaustive}"`);
198
+ }
199
+ }
200
+ }