@effect-uai/retrieval 0.12.1

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,75 @@
1
+ {
2
+ "name": "@effect-uai/retrieval",
3
+ "version": "0.12.1",
4
+ "description": "Retrieval-pipeline utilities for @effect-uai/core: text chunking, rank fusion, and a Hugging Face tokenizer layer.",
5
+ "keywords": [
6
+ "ai",
7
+ "chunking",
8
+ "effect",
9
+ "rag",
10
+ "retrieval",
11
+ "tokenizer"
12
+ ],
13
+ "homepage": "https://github.com/betalyra/effect-uai/tree/main/packages/retrieval",
14
+ "bugs": {
15
+ "url": "https://github.com/betalyra/effect-uai/issues"
16
+ },
17
+ "license": "MIT",
18
+ "author": "Betalyra",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "https://github.com/betalyra/effect-uai",
22
+ "directory": "packages/retrieval"
23
+ },
24
+ "files": [
25
+ "dist",
26
+ "src",
27
+ "README.md",
28
+ "LICENSE"
29
+ ],
30
+ "type": "module",
31
+ "main": "./dist/index.mjs",
32
+ "types": "./dist/index.d.mts",
33
+ "exports": {
34
+ ".": {
35
+ "types": "./dist/index.d.mts",
36
+ "import": "./dist/index.mjs"
37
+ },
38
+ "./Chunking": {
39
+ "types": "./dist/Chunking.d.mts",
40
+ "import": "./dist/Chunking.mjs"
41
+ },
42
+ "./Rank": {
43
+ "types": "./dist/Rank.d.mts",
44
+ "import": "./dist/Rank.mjs"
45
+ },
46
+ "./HuggingFaceTokenizer": {
47
+ "types": "./dist/HuggingFaceTokenizer.d.mts",
48
+ "import": "./dist/HuggingFaceTokenizer.mjs"
49
+ }
50
+ },
51
+ "publishConfig": {
52
+ "access": "public"
53
+ },
54
+ "devDependencies": {
55
+ "@effect/vitest": "4.0.0-rc.111",
56
+ "@huggingface/tokenizers": "^0.1.3",
57
+ "effect": "4.0.0-rc.111",
58
+ "typescript": "^7.0.2",
59
+ "@effect-uai/core": "0.12.1"
60
+ },
61
+ "peerDependencies": {
62
+ "@effect-uai/core": ">=0.12.0 <1",
63
+ "@huggingface/tokenizers": "^0.1.0",
64
+ "effect": ">=4.0.0-rc.111 <5.0.0"
65
+ },
66
+ "peerDependenciesMeta": {
67
+ "@huggingface/tokenizers": {
68
+ "optional": true
69
+ }
70
+ },
71
+ "scripts": {
72
+ "build": "tsdown",
73
+ "typecheck": "tsc --noEmit"
74
+ }
75
+ }
@@ -0,0 +1,233 @@
1
+ import { Array as Arr, Effect, Layer } from "effect"
2
+ import { describe, expect, expectTypeOf, it } from "vitest"
3
+ import { chunk as chunkFromTag, Chunker } from "@effect-uai/core/Chunker"
4
+ import { Tokenizer } from "@effect-uai/core/Tokenizer"
5
+ import {
6
+ type Chunk,
7
+ fixed,
8
+ layer,
9
+ markdown,
10
+ recursive,
11
+ sentences,
12
+ withTokenizer,
13
+ } from "./Chunking.js"
14
+
15
+ const PROSE = `The Adventure of the Speckled Band
16
+
17
+ On glancing over my notes of the seventy odd cases in which I have during
18
+ the last eight years studied the methods of my friend Sherlock Holmes, I
19
+ find many tragic, some comic, a large number merely strange, but none
20
+ commonplace.
21
+
22
+ It was early in April in the year '83 that I woke one morning to find
23
+ Sherlock Holmes standing, fully dressed, by the side of my bed. He was a
24
+ late riser, as a rule, and as the clock on the mantelpiece showed me that
25
+ it was only a quarter-past seven, I blinked up at him in some surprise.
26
+
27
+ "Very sorry to knock you up, Watson," said he, "but it's the common lot
28
+ this morning. Mrs. Hudson has been knocked up, she retorted upon me, and
29
+ I on you."`
30
+
31
+ const MARKDOWN = `Intro paragraph before any heading.
32
+
33
+ # Install
34
+
35
+ Run the installer.
36
+
37
+ \`\`\`sh
38
+ # this comment is not a heading
39
+ pnpm add example
40
+ \`\`\`
41
+
42
+ ## Usage
43
+
44
+ Call the thing.
45
+
46
+ # License
47
+
48
+ MIT.`
49
+
50
+ const consecutive = <A>(xs: ReadonlyArray<A>): ReadonlyArray<readonly [A, A]> =>
51
+ Arr.zip(xs, Arr.drop(xs, 1))
52
+
53
+ /** The invariant every chunker owes its caller: offsets reproduce the text. */
54
+ const expectProvenance = (input: string, chunks: ReadonlyArray<Chunk>) => {
55
+ expect(chunks.length).toBeGreaterThan(0)
56
+ expect(Arr.map(chunks, (c) => input.slice(c.start, c.end))).toEqual(
57
+ Arr.map(chunks, (c) => c.text),
58
+ )
59
+ expect(Arr.filter(chunks, (c) => c.end <= c.start)).toEqual([])
60
+ }
61
+
62
+ describe.each([
63
+ ["fixed", fixed],
64
+ ["recursive", recursive],
65
+ ["sentences", sentences],
66
+ ["markdown", markdown],
67
+ ])("%s", (_name, chunker) => {
68
+ it("reproduces every chunk from the offsets it reports", () => {
69
+ expectProvenance(PROSE, chunker(PROSE, { targetSize: 40 }))
70
+ expectProvenance(MARKDOWN, chunker(MARKDOWN, { targetSize: 40 }))
71
+ })
72
+
73
+ it("covers the input in order, leaving no content behind", () => {
74
+ const chunks = chunker(PROSE, { targetSize: 40 })
75
+ expect(chunks[0]!.start).toBe(PROSE.indexOf("The"))
76
+ expect(chunks.at(-1)!.end).toBe(PROSE.trimEnd().length)
77
+ expect(Arr.filter(consecutive(chunks), ([a, b]) => b.start <= a.start)).toEqual([])
78
+ })
79
+
80
+ it("returns nothing for blank input", () => {
81
+ expect(chunker("")).toEqual([])
82
+ expect(chunker(" \n\n \t ")).toEqual([])
83
+ })
84
+
85
+ it("advances under heavy overlap instead of repeating a chunk", () => {
86
+ // 75% overlap is where a naive carry loops forever, and an unsplittable
87
+ // run is where it has nothing smaller to fall back on.
88
+ const inputs = [PROSE, MARKDOWN, `Short one. ${"x".repeat(400)} End.`]
89
+ const stalled = Arr.flatMap(inputs, (input) =>
90
+ Arr.filter(
91
+ consecutive(chunker(input, { targetSize: 20, overlap: 15 })),
92
+ ([a, b]) => b.end <= a.end,
93
+ ),
94
+ )
95
+ expect(stalled).toEqual([])
96
+ })
97
+ })
98
+
99
+ describe("recursive", () => {
100
+ it("keeps chunks under the target when the text can be divided", () => {
101
+ const oversized = Arr.filter(
102
+ recursive(PROSE, { targetSize: 40 }),
103
+ (c) => Math.ceil(c.text.length / 4) > 40,
104
+ )
105
+ expect(oversized).toEqual([])
106
+ })
107
+
108
+ it("prefers coarse separators, so every break lands on a paragraph boundary", () => {
109
+ const chunks = recursive(PROSE, { targetSize: 120 })
110
+ expect(chunks.length).toBeGreaterThan(1)
111
+ const midParagraph = Arr.filter(
112
+ Arr.drop(chunks, 1),
113
+ (c) => !/\n\s*\n\s*$/u.test(PROSE.slice(0, c.start)),
114
+ )
115
+ expect(midParagraph).toEqual([])
116
+ })
117
+
118
+ it("falls back to blind windows when nothing divides the text", () => {
119
+ const unbroken = "x".repeat(500)
120
+ const chunks = recursive(unbroken, { targetSize: 25 })
121
+ expect(chunks.length).toBeGreaterThan(4)
122
+ expectProvenance(unbroken, chunks)
123
+ })
124
+ })
125
+
126
+ describe("sentences", () => {
127
+ const FOUR = "One fish. Two fish! Red fish? Blue fish."
128
+
129
+ it("keeps each sentence whole", () => {
130
+ expect(Arr.map(sentences(FOUR, { targetSize: 3 }), (c) => c.text)).toEqual([
131
+ "One fish.",
132
+ "Two fish!",
133
+ "Red fish?",
134
+ "Blue fish.",
135
+ ])
136
+ })
137
+
138
+ it("packs as many sentences as fit", () => {
139
+ expect(Arr.map(sentences(FOUR, { targetSize: 6 }), (c) => c.text)).toEqual([
140
+ "One fish. Two fish!",
141
+ "Red fish? Blue fish.",
142
+ ])
143
+ })
144
+
145
+ it("repeats the trailing sentence when overlap allows one", () => {
146
+ expect(Arr.map(sentences(FOUR, { targetSize: 6, overlap: 3 }), (c) => c.text)).toEqual([
147
+ "One fish. Two fish!",
148
+ "Two fish! Red fish?",
149
+ "Red fish? Blue fish.",
150
+ ])
151
+ })
152
+
153
+ it("cuts a sentence that exceeds the target on its own", () => {
154
+ const long = `Short one. ${"word ".repeat(60)}end. Another short one.`
155
+ const chunks = sentences(long, { targetSize: 20 })
156
+ expect(chunks.length).toBeGreaterThan(3)
157
+ expect(Arr.filter(chunks, (c) => Math.ceil(c.text.length / 4) > 20)).toEqual([])
158
+ })
159
+ })
160
+
161
+ describe("markdown", () => {
162
+ it("starts a chunk at every heading, keeping the heading with its prose", () => {
163
+ const chunks = markdown(MARKDOWN, { targetSize: 512 })
164
+ expect(Arr.map(chunks, (c) => c.text.split("\n")[0])).toEqual([
165
+ "Intro paragraph before any heading.",
166
+ "# Install",
167
+ "## Usage",
168
+ "# License",
169
+ ])
170
+ expect(chunks[1]!.text).toContain("Run the installer.")
171
+ })
172
+
173
+ it("ignores a heading inside a fenced code block", () => {
174
+ expect(markdown(MARKDOWN, { targetSize: 512 })[1]!.text).toContain(
175
+ "# this comment is not a heading",
176
+ )
177
+ })
178
+
179
+ it("splits an oversized section without spanning the next one", () => {
180
+ const chunks = markdown(MARKDOWN, { targetSize: 10 })
181
+ expect(chunks.length).toBeGreaterThan(4)
182
+ const spanning = Arr.filter(chunks, (c) => c.text.split(/^#{1,6} /mu).length > 2)
183
+ expect(spanning).toEqual([])
184
+ })
185
+ })
186
+
187
+ describe("fixed", () => {
188
+ it("cuts blind windows at the calibrated budget", () => {
189
+ const chunks = fixed(PROSE, { targetSize: 25 })
190
+ // targetSize 25 at the default chars/4 measure is a 100-character window.
191
+ expect(chunks[0]!.text).toHaveLength(100)
192
+ expect(chunks[0]!.end).toBe(chunks[1]!.start)
193
+ })
194
+
195
+ it("overlaps consecutive windows", () => {
196
+ const chunks = fixed(PROSE, { targetSize: 25, overlap: 5 })
197
+ expect(chunks[1]!.start).toBe(chunks[0]!.start + 80)
198
+ })
199
+ })
200
+
201
+ // Two ids per word, so a token measure and the chars/4 default disagree.
202
+ const stubTokenizer = Layer.succeed(Tokenizer, {
203
+ encode: (text: string) => Arr.flatMap(text.split(/\s+/u), (w) => (w === "" ? [] : [1, 2])),
204
+ decode: () => "",
205
+ })
206
+
207
+ describe("withTokenizer", () => {
208
+ it("sizes chunks by the tokenizer instead of the character estimate", () => {
209
+ const chunks = Effect.runSync(
210
+ withTokenizer(sentences)(PROSE, { targetSize: 20 }).pipe(Effect.provide(stubTokenizer)),
211
+ )
212
+ expectProvenance(PROSE, chunks)
213
+ // A multi-sentence chunk must respect the budget: 2 tokens per word.
214
+ const overBudget = Arr.filter(
215
+ chunks,
216
+ (c) =>
217
+ c.text.split(/[.!?]/u).length > 2 &&
218
+ Arr.filter(c.text.split(/\s+/u), (w) => w !== "").length * 2 > 20,
219
+ )
220
+ expect(overBudget).toEqual([])
221
+ })
222
+ })
223
+
224
+ describe("layer", () => {
225
+ it("serves a chunker through the core Chunker tag", () => {
226
+ const chunks = Effect.runSync(
227
+ chunkFromTag(PROSE).pipe(Effect.provide(layer(recursive, { targetSize: 120 }))),
228
+ )
229
+ expectTypeOf(chunks).toEqualTypeOf<ReadonlyArray<Chunk>>()
230
+ expect(chunks).toEqual(recursive(PROSE, { targetSize: 120 }))
231
+ expectTypeOf(layer(recursive)).toEqualTypeOf<Layer.Layer<Chunker>>()
232
+ })
233
+ })
@@ -0,0 +1,292 @@
1
+ /**
2
+ * Splitting documents into retrievable passages. The chunkers are pure and
3
+ * synchronous; `layer` lifts one into the core `Chunker` tag so a hosted
4
+ * chunking service can take its place without touching callers.
5
+ *
6
+ * Every chunk carries the offsets it came from, so
7
+ * `input.slice(chunk.start, chunk.end) === chunk.text` always holds. Overlap
8
+ * makes chunks share regions, never lose them.
9
+ *
10
+ * `recursive` is the one to reach for by default. `fixed` ignores structure,
11
+ * `sentences` never cuts mid-sentence, `markdown` follows headers.
12
+ */
13
+ import { Array as Arr, Effect, Layer, Option, pipe, Result } from "effect"
14
+ import { type Chunk, Chunker } from "@effect-uai/core/Chunker"
15
+ import { Tokenizer } from "@effect-uai/core/Tokenizer"
16
+
17
+ export type { Chunk } from "@effect-uai/core/Chunker"
18
+
19
+ /** How chunk size is counted. Chunkers call this on candidate chunks. */
20
+ export type Measure = (text: string) => number
21
+
22
+ export type Options = {
23
+ /** In units of `measure`, approximately tokens with the default. Default 512. */
24
+ readonly targetSize?: number
25
+ /** Units of `measure` repeated from the end of the previous chunk. Default 0. */
26
+ readonly overlap?: number
27
+ /** Default: characters / 4, a rough token estimate for prose. */
28
+ readonly measure?: Measure
29
+ }
30
+
31
+ export type Splitter = (text: string, options?: Options) => ReadonlyArray<Chunk>
32
+
33
+ const approximate: Measure = (text) => Math.ceil(text.length / 4)
34
+
35
+ type Span = { readonly start: number; readonly end: number }
36
+
37
+ type Settings = {
38
+ readonly target: number
39
+ readonly overlap: number
40
+ readonly measure: Measure
41
+ }
42
+
43
+ const settings = (options: Options | undefined): Settings => ({
44
+ target: options?.targetSize ?? 512,
45
+ overlap: options?.overlap ?? 0,
46
+ measure: options?.measure ?? approximate,
47
+ })
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Spans
51
+ // ---------------------------------------------------------------------------
52
+
53
+ const sizeOf = (text: string, span: Span, s: Settings): number =>
54
+ s.measure(text.slice(span.start, span.end))
55
+
56
+ /** Narrow a span to its non-whitespace core; a blank span drops out. */
57
+ const trim =
58
+ (text: string) =>
59
+ (span: Span): Result.Result<Span, void> => {
60
+ const region = text.slice(span.start, span.end)
61
+ const start = span.start + (region.length - region.trimStart().length)
62
+ const end = span.end - (region.length - region.trimEnd().length)
63
+ return start < end ? Result.succeed({ start, end }) : Result.failVoid
64
+ }
65
+
66
+ /** Split a span on every separator match, keeping the trimmed parts between. */
67
+ const splitSpans = (text: string, span: Span, separator: RegExp): ReadonlyArray<Span> => {
68
+ const cuts = Arr.map([...text.slice(span.start, span.end).matchAll(separator)], (m) => ({
69
+ start: span.start + m.index,
70
+ end: span.start + m.index + m[0].length,
71
+ }))
72
+ return pipe(
73
+ Arr.zip(
74
+ [span.start, ...Arr.map(cuts, (c) => c.end)],
75
+ [...Arr.map(cuts, (c) => c.start), span.end],
76
+ ),
77
+ Arr.filterMap(([start, end]) => trim(text)({ start, end })),
78
+ )
79
+ }
80
+
81
+ // Coarse to fine. Closing quotes may trail the sentence punctuation.
82
+ const PARAGRAPH = /\n[ \t]*(?:\n[ \t]*)+/gu
83
+ const LINE = /\n/gu
84
+ const SENTENCE = /(?<=[.!?…][)"'”’]*)\s+/gu
85
+ const WORD = /\s+/gu
86
+ const SEPARATORS: ReadonlyArray<RegExp> = [PARAGRAPH, LINE, SENTENCE, WORD]
87
+
88
+ /** Sentences are split by the time we get here, so oversized ones go to words. */
89
+ const WORD_LEVEL = 3
90
+
91
+ /**
92
+ * `measure` is a black box, so size a character window by calibrating it once
93
+ * over the region instead of probing per chunk.
94
+ */
95
+ const charBudget = (text: string, span: Span, s: Settings): number => {
96
+ const length = span.end - span.start
97
+ const units = sizeOf(text, span, s)
98
+ return Math.max(1, Math.round(units === 0 ? s.target : (s.target * length) / units))
99
+ }
100
+
101
+ const windows = (span: Span, budget: number, stride: number): ReadonlyArray<Span> =>
102
+ Arr.unfold(span.start, (start) =>
103
+ start >= span.end
104
+ ? Option.none()
105
+ : Option.some([
106
+ { start, end: Math.min(start + budget, span.end) },
107
+ // Once a window reaches the end, stop: with overlap the strides that
108
+ // follow would only produce windows contained in this one.
109
+ start + budget >= span.end ? span.end : start + stride,
110
+ ] as const),
111
+ )
112
+
113
+ /** Break a span down until every part fits: coarsest separator first, then blind. */
114
+ const explode = (text: string, span: Span, level: number, s: Settings): ReadonlyArray<Span> => {
115
+ if (sizeOf(text, span, s) <= s.target) return [span]
116
+ const separator = SEPARATORS[level]
117
+ if (separator === undefined) {
118
+ const budget = charBudget(text, span, s)
119
+ return windows(span, budget, budget)
120
+ }
121
+ const parts = splitSpans(text, span, separator)
122
+ // A separator that does not divide the region is no progress; try the next.
123
+ return parts.length <= 1
124
+ ? explode(text, span, level + 1, s)
125
+ : Arr.flatMap(parts, (part) => explode(text, part, level + 1, s))
126
+ }
127
+
128
+ /**
129
+ * The longest suffix of a chunk worth at most `overlap`. Never the whole
130
+ * chunk, or packing would not advance. Relies on `measure` growing with the
131
+ * text it is given, which every sane measure does.
132
+ */
133
+ const carry = (text: string, spans: ReadonlyArray<Span>, s: Settings): ReadonlyArray<Span> => {
134
+ const tail = Arr.drop(spans, 1)
135
+ return s.overlap <= 0
136
+ ? []
137
+ : pipe(
138
+ Arr.last(tail),
139
+ Option.map((end) =>
140
+ Arr.dropWhile(
141
+ tail,
142
+ (span) => sizeOf(text, { start: span.start, end: end.end }, s) > s.overlap,
143
+ ),
144
+ ),
145
+ Option.getOrElse((): ReadonlyArray<Span> => []),
146
+ )
147
+ }
148
+
149
+ /** One chunk covering a group of spans verbatim. An empty group yields none. */
150
+ const toChunk = (text: string, spans: ReadonlyArray<Span>): ReadonlyArray<Chunk> =>
151
+ pipe(
152
+ Option.all([Arr.head(spans), Arr.last(spans)]),
153
+ Option.map(([first, last]): Chunk => ({
154
+ text: text.slice(first.start, last.end),
155
+ start: first.start,
156
+ end: last.end,
157
+ })),
158
+ Option.match({ onNone: (): ReadonlyArray<Chunk> => [], onSome: (chunk) => [chunk] }),
159
+ )
160
+
161
+ type Packing = {
162
+ readonly chunks: ReadonlyArray<Chunk>
163
+ readonly current: ReadonlyArray<Span>
164
+ }
165
+
166
+ /** Greedily fill chunks with consecutive spans, carrying overlap forward. */
167
+ const pack = (text: string, spans: ReadonlyArray<Span>, s: Settings): ReadonlyArray<Chunk> => {
168
+ const packed = Arr.reduce(spans, { chunks: [], current: [] } as Packing, (acc, span) => {
169
+ const first = acc.current[0]
170
+ return first !== undefined && sizeOf(text, { start: first.start, end: span.end }, s) > s.target
171
+ ? {
172
+ chunks: [...acc.chunks, ...toChunk(text, acc.current)],
173
+ current: [...carry(text, acc.current, s), span],
174
+ }
175
+ : { chunks: acc.chunks, current: [...acc.current, span] }
176
+ })
177
+ return [...packed.chunks, ...toChunk(text, packed.current)]
178
+ }
179
+
180
+ /** The input's content as a span, or nothing if it is blank. */
181
+ const content = (text: string): ReadonlyArray<Span> =>
182
+ Arr.filterMap([{ start: 0, end: text.length }], trim(text))
183
+
184
+ // ---------------------------------------------------------------------------
185
+ // Chunkers
186
+ // ---------------------------------------------------------------------------
187
+
188
+ /**
189
+ * Blind character windows, sized by calibrating `measure` over the input. Cuts
190
+ * mid-word and mid-sentence, so reach for it when the input has no structure
191
+ * worth respecting or a hard size ceiling matters more than readable passages.
192
+ */
193
+ export const fixed: Splitter = (text, options) => {
194
+ const s = settings(options)
195
+ return Arr.flatMap(content(text), (span) => {
196
+ const budget = charBudget(text, span, s)
197
+ const stride = budget - Math.min(budget - 1, Math.round((s.overlap * budget) / s.target))
198
+ return Arr.map(windows(span, budget, stride), (w) => ({
199
+ text: text.slice(w.start, w.end),
200
+ start: w.start,
201
+ end: w.end,
202
+ }))
203
+ })
204
+ }
205
+
206
+ /**
207
+ * Split on the coarsest separator that makes the pieces fit (blank line,
208
+ * newline, sentence end, word), then refill up to `targetSize`. The default:
209
+ * it keeps related prose together without knowing anything about the format.
210
+ */
211
+ export const recursive: Splitter = (text, options) => {
212
+ const s = settings(options)
213
+ return Arr.flatMap(content(text), (span) => pack(text, explode(text, span, 0, s), s))
214
+ }
215
+
216
+ /** Pack whole sentences. Only a sentence longer than `targetSize` is cut. */
217
+ export const sentences: Splitter = (text, options) => {
218
+ const s = settings(options)
219
+ return Arr.flatMap(content(text), (span) =>
220
+ pack(
221
+ text,
222
+ Arr.flatMap(splitSpans(text, span, PARAGRAPH), (paragraph) =>
223
+ Arr.flatMap(splitSpans(text, paragraph, SENTENCE), (sentence) =>
224
+ explode(text, sentence, WORD_LEVEL, s),
225
+ ),
226
+ ),
227
+ s,
228
+ ),
229
+ )
230
+ }
231
+
232
+ const HEADER = /^#{1,6}[ \t]+\S.*$/gmu
233
+ const FENCE = /^[ \t]{0,3}(?:```|~~~).*$/gmu
234
+
235
+ /** Fenced code regions, where a `#` line is content rather than a heading. */
236
+ const fenced = (region: string): ReadonlyArray<Span> =>
237
+ pipe(
238
+ Arr.map([...region.matchAll(FENCE)], (m) => m.index),
239
+ Arr.chunksOf(2),
240
+ // An unclosed fence swallows the rest of the document, as a renderer does.
241
+ Arr.map(([open, close]) => ({ start: open, end: close ?? region.length })),
242
+ )
243
+
244
+ /** Spans running from each header to the next, plus any preamble. */
245
+ const sections = (text: string, span: Span): ReadonlyArray<Span> => {
246
+ const region = text.slice(span.start, span.end)
247
+ const code = fenced(region)
248
+ const starts = Arr.dedupe([
249
+ span.start,
250
+ ...pipe(
251
+ [...region.matchAll(HEADER)],
252
+ Arr.filter((m) => !Arr.some(code, (f) => m.index > f.start && m.index < f.end)),
253
+ Arr.map((m) => span.start + m.index),
254
+ ),
255
+ ])
256
+ return Arr.filterMap(starts, (start, i) => trim(text)({ start, end: starts[i + 1] ?? span.end }))
257
+ }
258
+
259
+ /**
260
+ * One chunk per `#`..`######` section, so a heading stays with the prose under
261
+ * it. A section over `targetSize` is split with `recursive` inside its own
262
+ * bounds; a chunk never spans two sections. Headings inside fenced code are
263
+ * ignored; setext (`===` underline) headings are not recognized.
264
+ */
265
+ export const markdown: Splitter = (text, options) => {
266
+ const s = settings(options)
267
+ return Arr.flatMap(content(text), (span) =>
268
+ Arr.flatMap(sections(text, span), (section) => pack(text, explode(text, section, 0, s), s)),
269
+ )
270
+ }
271
+
272
+ // ---------------------------------------------------------------------------
273
+ // Wiring
274
+ // ---------------------------------------------------------------------------
275
+
276
+ /** Serve a chunker through the core `Chunker` tag. */
277
+ export const layer = (chunker: Splitter, options?: Options): Layer.Layer<Chunker> =>
278
+ Layer.succeed(Chunker, { chunk: (text: string) => Effect.succeed(chunker(text, options)) })
279
+
280
+ /**
281
+ * Run a chunker with a real tokenizer as its measure, so `targetSize` and
282
+ * `overlap` count tokens rather than estimate them.
283
+ */
284
+ export const withTokenizer =
285
+ (chunker: Splitter) =>
286
+ (
287
+ text: string,
288
+ options?: Omit<Options, "measure">,
289
+ ): Effect.Effect<ReadonlyArray<Chunk>, never, Tokenizer> =>
290
+ Effect.map(Tokenizer, (tokenizer) =>
291
+ chunker(text, { ...options, measure: (part) => tokenizer.encode(part).length }),
292
+ )