@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
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The core `Tokenizer` tag over `@huggingface/tokenizers`, which reads a
|
|
3
|
+
* model's `tokenizer.json` and needs no native build. The package is an
|
|
4
|
+
* optional peer dependency: `pnpm add @huggingface/tokenizers`.
|
|
5
|
+
*
|
|
6
|
+
* Fetching and construction are separate on purpose. `download` gives you
|
|
7
|
+
* plain JSON to keep wherever your application keeps things; `fromDefinition`
|
|
8
|
+
* builds the tokenizer from what you kept. Nothing here touches the filesystem
|
|
9
|
+
* or caches behind your back. `layer` composes the two for scripts happy to
|
|
10
|
+
* download on every run.
|
|
11
|
+
*/
|
|
12
|
+
import { Array as Arr, Effect, Layer, Redacted, Schema } from "effect"
|
|
13
|
+
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
|
|
14
|
+
import { Tokenizer, type TokenizerService } from "@effect-uai/core/Tokenizer"
|
|
15
|
+
import { Tokenizer as HfTokenizer } from "@huggingface/tokenizers"
|
|
16
|
+
|
|
17
|
+
/** The vocabulary could not be fetched, parsed, or loaded. */
|
|
18
|
+
export class TokenizerLoadError extends Schema.TaggedError<TokenizerLoadError>(
|
|
19
|
+
"@effect-uai/retrieval/TokenizerLoadError",
|
|
20
|
+
)("TokenizerLoadError", {
|
|
21
|
+
model: Schema.optional(Schema.String),
|
|
22
|
+
reason: Schema.String,
|
|
23
|
+
cause: Schema.optional(Schema.Unknown),
|
|
24
|
+
}) {}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Everything that defines a tokenizer's behaviour, as published. Plain JSON:
|
|
28
|
+
* write it to disk, a row, or your bundle, and decode it back with this schema.
|
|
29
|
+
*/
|
|
30
|
+
export const Definition = Schema.Struct({
|
|
31
|
+
/** Contents of `tokenizer.json`. */
|
|
32
|
+
tokenizer: Schema.Unknown,
|
|
33
|
+
/** Contents of `tokenizer_config.json`, absent for repos that publish none. */
|
|
34
|
+
config: Schema.optional(Schema.Unknown),
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
export type Definition = typeof Definition.Type
|
|
38
|
+
|
|
39
|
+
export type Options = {
|
|
40
|
+
/** A Hugging Face repo id, e.g. `"jinaai/jina-embeddings-v3"` or `"Xenova/gpt-4o"`. */
|
|
41
|
+
readonly model: string
|
|
42
|
+
/** Branch, tag, or commit. Default `"main"`. */
|
|
43
|
+
readonly revision?: string
|
|
44
|
+
/**
|
|
45
|
+
* A Hugging Face access token. Required for gated repos, where the Hub
|
|
46
|
+
* serves the files only to accounts that have accepted the model's terms
|
|
47
|
+
* (Google's tokenizers among them), and for private ones.
|
|
48
|
+
*/
|
|
49
|
+
readonly token?: Redacted.Redacted
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const fetchJson = (
|
|
53
|
+
options: Options,
|
|
54
|
+
file: string,
|
|
55
|
+
): Effect.Effect<unknown, TokenizerLoadError, HttpClient.HttpClient> =>
|
|
56
|
+
Effect.gen(function* () {
|
|
57
|
+
const client = yield* HttpClient.HttpClient
|
|
58
|
+
const url = `https://huggingface.co/${options.model}/resolve/${options.revision ?? "main"}/${file}`
|
|
59
|
+
const failed = (reason: string) => (cause: unknown) =>
|
|
60
|
+
new TokenizerLoadError({ model: options.model, reason, cause })
|
|
61
|
+
|
|
62
|
+
const request = HttpClientRequest.get(url).pipe(
|
|
63
|
+
options.token === undefined
|
|
64
|
+
? (self) => self
|
|
65
|
+
: HttpClientRequest.bearerToken(Redacted.value(options.token)),
|
|
66
|
+
)
|
|
67
|
+
const response = yield* client
|
|
68
|
+
.execute(request)
|
|
69
|
+
.pipe(Effect.mapError(failed(`could not fetch ${file}`)))
|
|
70
|
+
// 401/403 on a gated repo means no token, or terms not yet accepted.
|
|
71
|
+
return response.status >= 400
|
|
72
|
+
? yield* new TokenizerLoadError({
|
|
73
|
+
model: options.model,
|
|
74
|
+
reason: `${url} returned ${response.status}`,
|
|
75
|
+
})
|
|
76
|
+
: yield* response.json.pipe(Effect.mapError(failed(`${file} is not valid JSON`)))
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Fetch a model's definition from the Hugging Face Hub. Cache the result
|
|
81
|
+
* yourself; a repo without a `tokenizer_config.json` yields `config: undefined`.
|
|
82
|
+
*/
|
|
83
|
+
export const download = (
|
|
84
|
+
options: Options,
|
|
85
|
+
): Effect.Effect<Definition, TokenizerLoadError, HttpClient.HttpClient> =>
|
|
86
|
+
Effect.all({
|
|
87
|
+
tokenizer: fetchJson(options, "tokenizer.json"),
|
|
88
|
+
config: fetchJson(options, "tokenizer_config.json").pipe(Effect.orElseSucceed(() => undefined)),
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
/** Build the tokenizer from a definition you already hold. No IO. */
|
|
92
|
+
export const fromDefinition = (
|
|
93
|
+
definition: Definition,
|
|
94
|
+
): Layer.Layer<Tokenizer, TokenizerLoadError> =>
|
|
95
|
+
Layer.effect(
|
|
96
|
+
Tokenizer,
|
|
97
|
+
Effect.try({
|
|
98
|
+
try: (): TokenizerService => {
|
|
99
|
+
const hf = new HfTokenizer(
|
|
100
|
+
definition.tokenizer as object,
|
|
101
|
+
(definition.config ?? {}) as object,
|
|
102
|
+
)
|
|
103
|
+
return {
|
|
104
|
+
encode: (text) => hf.encode(text).ids,
|
|
105
|
+
// The library rejects an empty array, but `encode("")` returns one and
|
|
106
|
+
// `decode` inverts `encode`. Its signature also wants a mutable array,
|
|
107
|
+
// though it mutates nothing.
|
|
108
|
+
decode: (tokens) => (tokens.length === 0 ? "" : hf.decode(Arr.copy(tokens))),
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
catch: (cause) => new TokenizerLoadError({ reason: "could not load the tokenizer", cause }),
|
|
112
|
+
}),
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
/** Download and build in one step. For scripts; production should cache. */
|
|
116
|
+
export const layer = (
|
|
117
|
+
options: Options,
|
|
118
|
+
): Layer.Layer<Tokenizer, TokenizerLoadError, HttpClient.HttpClient> =>
|
|
119
|
+
Layer.unwrap(Effect.map(download(options), fromDefinition))
|
package/src/Rank.test.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, expect, expectTypeOf, it } from "vitest"
|
|
2
|
+
import { type Fused, rrf } from "./Rank.js"
|
|
3
|
+
|
|
4
|
+
// 1-based rank r in a ranking of weight w contributes w / (k + r).
|
|
5
|
+
const contribution = (rank: number, k = 60, weight = 1) => weight / (k + rank)
|
|
6
|
+
|
|
7
|
+
describe("rrf", () => {
|
|
8
|
+
it("sums contributions across rankings and sorts descending", () => {
|
|
9
|
+
// "b" places 2nd and 1st; "a" places 1st and 3rd; "c" only 2nd.
|
|
10
|
+
const fused = rrf([
|
|
11
|
+
["a", "b"],
|
|
12
|
+
["b", "c", "a"],
|
|
13
|
+
])
|
|
14
|
+
expect(fused.map((f) => f.value)).toEqual(["b", "a", "c"])
|
|
15
|
+
expect(fused[0]?.score).toBeCloseTo(contribution(2) + contribution(1), 12)
|
|
16
|
+
expect(fused[1]?.score).toBeCloseTo(contribution(1) + contribution(3), 12)
|
|
17
|
+
expect(fused[2]?.score).toBeCloseTo(contribution(2), 12)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it("scores an item present in only one ranking from that ranking alone", () => {
|
|
21
|
+
const fused = rrf([["a"], ["b"]])
|
|
22
|
+
expect(fused.map((f) => f.score)).toEqual([contribution(1), contribution(1)])
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it("k damps the head of each list", () => {
|
|
26
|
+
// At k=1 a rank-1 hit is worth 1/2 and a rank-3 hit 1/4, so the single
|
|
27
|
+
// top placement beats the item that places 2nd twice. At k=60 it loses.
|
|
28
|
+
const rankings = [
|
|
29
|
+
["a", "b"],
|
|
30
|
+
["c", "b"],
|
|
31
|
+
]
|
|
32
|
+
expect(rrf(rankings, { k: 1 })[0]?.value).toBe("b")
|
|
33
|
+
expect(rrf(rankings, { k: 1 })[0]?.score).toBeCloseTo(2 * contribution(2, 1), 12)
|
|
34
|
+
expect(rrf(rankings, { k: 0.5 })[0]?.value).toBe("b")
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it("weights scale each ranking's contribution", () => {
|
|
38
|
+
const fused = rrf([["a"], ["b"]], { weights: [3, 1] })
|
|
39
|
+
expect(fused.map((f) => f.value)).toEqual(["a", "b"])
|
|
40
|
+
expect(fused[0]?.score).toBeCloseTo(contribution(1, 60, 3), 12)
|
|
41
|
+
// A missing weight falls back to 1 rather than dropping the ranking.
|
|
42
|
+
expect(rrf([["a"], ["b"]], { weights: [3] })[1]?.score).toBeCloseTo(contribution(1), 12)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it("breaks score ties by first-seen order", () => {
|
|
46
|
+
// Both place 1st in one ranking, so scores are identical.
|
|
47
|
+
const fused = rrf([["b"], ["a"]])
|
|
48
|
+
expect(fused[0]?.score).toBe(fused[1]?.score)
|
|
49
|
+
expect(fused.map((f) => f.value)).toEqual(["b", "a"])
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it("returns empty for no rankings and for empty rankings", () => {
|
|
53
|
+
expect(rrf([])).toEqual([])
|
|
54
|
+
expect(rrf([[], []])).toEqual([])
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it("fuses non-string items by Map identity", () => {
|
|
58
|
+
const fused = rrf([
|
|
59
|
+
[1, 2],
|
|
60
|
+
[2, 1],
|
|
61
|
+
])
|
|
62
|
+
expectTypeOf(fused).toEqualTypeOf<Array<Fused<number>>>()
|
|
63
|
+
expect(fused.map((f) => f.score)).toEqual([
|
|
64
|
+
contribution(1) + contribution(2),
|
|
65
|
+
contribution(2) + contribution(1),
|
|
66
|
+
])
|
|
67
|
+
})
|
|
68
|
+
})
|
package/src/Rank.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rank fusion: merge several ranked lists of the same items into one
|
|
3
|
+
* ranking by position, so retrievers on incomparable score scales
|
|
4
|
+
* (BM25, cosine) can be combined without normalizing them.
|
|
5
|
+
*/
|
|
6
|
+
import { Array as Arr, Order, pipe } from "effect"
|
|
7
|
+
|
|
8
|
+
export type Fused<A> = {
|
|
9
|
+
readonly value: A
|
|
10
|
+
readonly score: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const byScore = Order.mapInput(Order.flip(Order.Number), (f: { readonly score: number }) => f.score)
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Reciprocal rank fusion: `score(v) = Σ weightᵢ / (k + rankᵢ(v))` over
|
|
17
|
+
* 1-based ranks, where a list omitting `v` contributes nothing. `k`
|
|
18
|
+
* defaults to 60, `weights` to 1 per ranking.
|
|
19
|
+
*
|
|
20
|
+
* Items are keyed by `Map` semantics, so fuse ids rather than fresh
|
|
21
|
+
* objects. Sorted descending; ties keep first-seen order.
|
|
22
|
+
*/
|
|
23
|
+
export const rrf = <A>(
|
|
24
|
+
rankings: ReadonlyArray<ReadonlyArray<A>>,
|
|
25
|
+
options?: { readonly k?: number; readonly weights?: ReadonlyArray<number> },
|
|
26
|
+
): Array<Fused<A>> => {
|
|
27
|
+
const k = options?.k ?? 60
|
|
28
|
+
return pipe(
|
|
29
|
+
Arr.reduce(rankings, new Map<A, number>(), (scores, ranking, i) =>
|
|
30
|
+
Arr.reduce(ranking, scores, (acc, value, rank) =>
|
|
31
|
+
acc.set(value, (acc.get(value) ?? 0) + (options?.weights?.[i] ?? 1) / (k + rank + 1)),
|
|
32
|
+
),
|
|
33
|
+
),
|
|
34
|
+
Arr.fromIterable,
|
|
35
|
+
Arr.map(([value, score]): Fused<A> => ({ value, score })),
|
|
36
|
+
Arr.sort(byScore),
|
|
37
|
+
)
|
|
38
|
+
}
|
package/src/index.ts
ADDED