@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.
@@ -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))
@@ -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
@@ -0,0 +1,2 @@
1
+ export * as Chunking from "./Chunking.js"
2
+ export * as Rank from "./Rank.js"