@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/LICENSE +21 -0
- package/README.md +64 -0
- package/dist/Chunking.d.mts +48 -0
- package/dist/Chunking.d.mts.map +1 -0
- package/dist/Chunking.mjs +206 -0
- package/dist/Chunking.mjs.map +1 -0
- package/dist/HuggingFaceTokenizer.d.mts +46 -0
- package/dist/HuggingFaceTokenizer.d.mts.map +1 -0
- package/dist/HuggingFaceTokenizer.mjs +75 -0
- package/dist/HuggingFaceTokenizer.mjs.map +1 -0
- package/dist/Rank.d.mts +22 -0
- package/dist/Rank.d.mts.map +1 -0
- package/dist/Rank.mjs +29 -0
- package/dist/Rank.mjs.map +1 -0
- package/dist/index.d.mts +3 -0
- package/dist/index.mjs +3 -0
- package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
- package/package.json +75 -0
- package/src/Chunking.test.ts +233 -0
- package/src/Chunking.ts +292 -0
- package/src/HuggingFaceTokenizer.ts +119 -0
- package/src/Rank.test.ts +68 -0
- package/src/Rank.ts +38 -0
- package/src/index.ts +2 -0
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
|
+
})
|
package/src/Chunking.ts
ADDED
|
@@ -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
|
+
)
|