@crewhaus/chunker 0.1.4 → 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.
- package/dist/index.d.ts +49 -0
- package/dist/index.js +175 -0
- package/package.json +9 -6
- package/src/index.test.ts +0 -235
- package/src/index.ts +0 -207
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
"version": "0.1.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Document chunking — fixed / semantic (Intl.Segmenter) / markdown strategies",
|
|
6
|
-
"main": "
|
|
7
|
-
"types": "
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
8
|
"exports": {
|
|
9
|
-
".":
|
|
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.
|
|
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": ["
|
|
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
|
-
}
|