@eltjs/embedding 0.1.3 → 0.2.0

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/README.md CHANGED
@@ -20,16 +20,27 @@ new ComputedDataset({
20
20
  from: [listings],
21
21
  compute: new Embedding({
22
22
  model,
23
+ clock: globalThis,
23
24
  key: 'id',
24
25
  fields: ['title', 'description'],
25
26
  }),
26
27
  });
27
28
  ```
28
29
 
30
+ - `clock` waits out the limit of each model call. An app gives `globalThis`, and a test gives a fake clock.
29
31
  - `key` is the column that identifies a row. It is the key of the output row.
30
32
  - `fields` are the columns whose text makes the vector. The rule joins their text with line breaks. A row with no text in these columns has no vector.
31
33
  - The output column `vector` holds a unit vector of float32 values in the byte order of the platform. This is the form that `VectorSearch` of `@zukhruf/embeddings` reads, so the cosine similarity of two vectors is their dot product.
32
34
  - The identity of each row is the provider and the id of the model. When you change the model, the next refresh makes every vector again.
35
+
36
+ ## Failures
37
+
38
+ A model failure stays on the output row, and the next pass asks again (ADR 0007 of `elt-sqlite`).
39
+
40
+ - When the model answers a call with HTTP 400, the rule splits the call in halves until it finds each text that the model refuses. Only the row of that text fails. An example is a text over the limit of the model.
41
+ - Any other failure fails each row of the call. A split would only send more calls to a model that is down.
42
+ - One call has 120 seconds, with its retries. At that limit, each row of the call fails, and the next batch runs.
43
+ - A stopped refresh fails no row. The next refresh makes those rows.
33
44
  - The rule sends 64 rows to `embedMany` at a time. When the model refuses a call, each row of that call is stored as `failed` with the error, and the next refresh asks again. The load does not fail.
34
45
  - A vector with no usable length, because it is all zeros or holds a value that is not finite, has no unit vector. Its row is stored as `failed` with the error, the other rows of the call stay `computed`, and the next refresh asks again.
35
46
  - To search, declare a view that gives `id` and `vector`, and give its name to `VectorSearch`. A search by publish date, such as `nearest`, also needs `published_at`. A query without a date range does not.
@@ -62,6 +73,7 @@ const searches = prompted(
62
73
 
63
74
  new Embedding({
64
75
  model: documents,
76
+ clock: globalThis,
65
77
  key: 'id',
66
78
  fields: ['title', 'description'],
67
79
  });
@@ -1,5 +1,5 @@
1
1
  import { type EmbeddingModel } from 'ai';
2
- import { type LoadedRow, Rule, type RuleResult } from '@eltjs/elt';
2
+ import { type LoadedRow, type PipelineClock, Rule, type RuleResult } from '@eltjs/elt';
3
3
  export declare class Embedding extends Rule<string, Uint8Array> {
4
4
  #private;
5
5
  readonly key: {
@@ -14,8 +14,9 @@ export declare class Embedding extends Rule<string, Uint8Array> {
14
14
  };
15
15
  readonly batch = 64;
16
16
  readonly concurrency = 1;
17
- constructor({ model, key, fields, }: {
17
+ constructor({ model, clock, key, fields, }: {
18
18
  model: EmbeddingModel;
19
+ clock: Pick<PipelineClock, 'setTimeout' | 'clearTimeout'>;
19
20
  key: string;
20
21
  fields: readonly string[];
21
22
  });
package/dist/embedding.js CHANGED
@@ -1,5 +1,11 @@
1
- import { embedMany } from 'ai';
1
+ import { APICallError, embedMany } from 'ai';
2
2
  import { Rule, errorMessage, } from '@eltjs/elt';
3
+ // How long one call has, its retries included. The AI SDK gives a call no limit,
4
+ // and a model server that never answers takes 3 tries of undici's 300 s header
5
+ // timeout, about 15 minutes for each batch. This is far above a batch of 64
6
+ // short texts on a local model server, and it fails the rows of that batch: the
7
+ // next refresh asks again.
8
+ const callLimitMs = 120_000;
3
9
  // The vector of each loaded row's text, made by an embedding model, so a
4
10
  // vector search finds rows by meaning, as pgai's vectorizer and LanceDB's
5
11
  // embedding functions keep one beside each row. A vector is stored as a unit
@@ -20,13 +26,16 @@ export class Embedding extends Rule {
20
26
  // One call at a time: a local model server answers one call at a time.
21
27
  concurrency = 1;
22
28
  #model;
29
+ #clock;
23
30
  #fields;
24
31
  // key: the column that identifies a row, such as id. fields: the columns
25
32
  // whose text makes the vector, joined by line breaks; a row with none has
26
- // no vector.
27
- constructor({ model, key, fields, }) {
33
+ // no vector. clock waits out the limit of each call (ADR 0019 of elt): an
34
+ // app gives globalThis.
35
+ constructor({ model, clock, key, fields, }) {
28
36
  super();
29
37
  this.#model = model;
38
+ this.#clock = clock;
30
39
  this.key = {
31
40
  name: key,
32
41
  description: `The ${key} of the row the vector is made from.`,
@@ -51,22 +60,46 @@ export class Embedding extends Rule {
51
60
  : `${this.#model.provider}/${this.#model.modelId}`;
52
61
  }
53
62
  async compute(texts, signal) {
63
+ const limit = new AbortController();
64
+ const timer = this.#clock.setTimeout(() => limit.abort(), callLimitMs);
54
65
  try {
55
66
  const { embeddings } = await embedMany({
56
67
  model: this.#model,
57
68
  values: [...texts],
58
- abortSignal: signal,
69
+ abortSignal: signal === undefined
70
+ ? limit.signal
71
+ : AbortSignal.any([signal, limit.signal]),
59
72
  });
60
73
  return embeddings.map(unitVector);
61
74
  }
62
75
  catch (failure) {
63
76
  if (signal?.aborted)
64
77
  return texts.map(() => ({ gone: true }));
78
+ if (limit.signal.aborted) {
79
+ const error = `The model did not answer within ${callLimitMs / 1000} s.`;
80
+ return texts.map(() => ({ error }));
81
+ }
82
+ // A 400 refuses something in the call, such as a text over the model's
83
+ // limit. Halves of the call find the input it refuses, so that failure
84
+ // stays on its own row. Any other failure says nothing about the texts,
85
+ // and a split would only send more calls to a model that is down.
86
+ if (texts.length > 1 &&
87
+ APICallError.isInstance(failure) &&
88
+ failure.statusCode === 400) {
89
+ const half = Math.ceil(texts.length / 2);
90
+ return [
91
+ ...(await this.compute(texts.slice(0, half), signal)),
92
+ ...(await this.compute(texts.slice(half), signal)),
93
+ ];
94
+ }
65
95
  // A model that refuses a call fails each row of it; the next refresh
66
96
  // asks again.
67
97
  const error = errorMessage(failure);
68
98
  return texts.map(() => ({ error }));
69
99
  }
100
+ finally {
101
+ this.#clock.clearTimeout(timer);
102
+ }
70
103
  }
71
104
  }
72
105
  // A vector with no usable length has no unit vector: the division gives NaN,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eltjs/embedding",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
4
4
  "description": "A rule of an elt computed dataset: it gives each loaded row a vector of its text, made by an AI SDK embedding model, so a search can find rows by meaning.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -40,12 +40,12 @@
40
40
  "node": ">=24"
41
41
  },
42
42
  "dependencies": {
43
- "@eltjs/elt": "^0.1.3",
43
+ "@eltjs/elt": "^0.2.0",
44
44
  "ai": "^7.0.165"
45
45
  },
46
46
  "devDependencies": {
47
47
  "@ai-sdk/provider": "^4.0.37",
48
- "@eltjs/elt-sqlite": "^0.1.3",
48
+ "@eltjs/elt-sqlite": "^0.2.0",
49
49
  "@zukhruf/sqlite-open": "^0.8.10",
50
50
  "@zukhruf/testing": "^0.8.10"
51
51
  }