system HF Staff commited on
Commit
3bbbb17
Β·
verified Β·
1 Parent(s): 4b4ae63

Sync from felladrin/MiniSearch@c75b7b9 (part 2)

Browse files
server/biEncoderService.integration.test.ts CHANGED
@@ -7,16 +7,22 @@
7
  * npx vitest run --config vitest.integration.config.ts biEncoder
8
  */
9
 
 
 
10
  import type { Tokenizer } from "@huggingface/tokenizers";
11
- import { InferenceSession, Tensor } from "onnxruntime-node";
12
- import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
13
  import {
14
- BATCH_ROWS,
15
  getBiEncoderStatus,
16
  scorePassages,
17
  startBiEncoderService,
18
  stopBiEncoderService,
19
  } from "./biEncoderService";
 
 
 
 
 
20
  import { loadOnnxModel } from "./utils/onnxModelLoader";
21
 
22
  const MODEL_HF_REPO =
@@ -178,63 +184,6 @@ describe("biEncoderService batched scoring (#2715)", () => {
178
  await refSession?.release();
179
  });
180
 
181
- it("runs one forward pass per length bucket, not one per passage", async () => {
182
- // Zigzag lengths with the sort's guarantee removed: bucket 1's last
183
- // row (190 words) is longer than bucket 2's last row (25 words), so
184
- // an unsorted pass sets bucket widths from those rows and they come
185
- // out decreasing β€” the non-decreasing-width assertion below then
186
- // fails. With the sort, widths are non-decreasing by construction.
187
- const wordCounts = [
188
- 200, 10, 150, 5, 180, 20, 120, 8, 90, 160, 30, 110, 60, 140, 190, 170, 15,
189
- 130, 70, 25,
190
- ];
191
- const passages = wordCounts.map((n, i) => makePassage(n, i));
192
-
193
- // The typings expose `InferenceSession` as a factory interface and hide
194
- // the class prototype, but at runtime `run` is a prototype method, so a
195
- // prototype spy intercepts the service's already-created session.
196
- const sessionClass = InferenceSession as unknown as {
197
- prototype: { run: InferenceSession["run"] };
198
- };
199
- const runSpy = vi.spyOn(sessionClass.prototype, "run");
200
- await scorePassages("how do alpha bravo passages score", passages);
201
- // Capture before mockRestore: restoring also resets the call history.
202
- const runDims = runSpy.mock.calls.map(
203
- ([feeds]) =>
204
- (feeds as unknown as { input_ids: { dims: number[] } }).input_ids.dims,
205
- );
206
- runSpy.mockRestore();
207
-
208
- // The query rides in the batch, so 21 rows go through ceil(21 / B)
209
- // bucketed runs instead of 21 per-passage runs.
210
- const expectedBuckets = Math.ceil((passages.length + 1) / BATCH_ROWS);
211
- expect(runDims.length).toBe(expectedBuckets);
212
- expect(runDims.length).toBeLessThan(passages.length + 1);
213
-
214
- let totalRows = 0;
215
- for (const dims of runDims) {
216
- expect(dims.length).toBe(2);
217
- expect(dims[0]).toBeLessThanOrEqual(BATCH_ROWS);
218
- totalRows += dims[0];
219
- }
220
- expect(totalRows).toBe(passages.length + 1);
221
-
222
- // Every non-final bucket is full, so it really carries multiple rows.
223
- // Only the final bucket may be short β€” with BATCH_ROWS of 10 or 20 a
224
- // 21-row pool leaves it exactly one row, so the >1 check must not
225
- // depend on the tuning knob.
226
- for (let i = 0; i < runDims.length - 1; i++) {
227
- expect(runDims[i][0]).toBeGreaterThan(1);
228
- }
229
-
230
- // Bucket widths are non-decreasing across runs: the sort by token
231
- // length put the short rows first. Delete the `.sort()` in encodeBatch
232
- // and this fails.
233
- for (let i = 1; i < runDims.length; i++) {
234
- expect(runDims[i][1]).toBeGreaterThanOrEqual(runDims[i - 1][1]);
235
- }
236
- });
237
-
238
  it("keeps batched scores within 1e-5 of the per-text path", async () => {
239
  const wordCounts = [
240
  3, 8, 15, 40, 90, 150, 250, 400, 5, 20, 60, 120, 200, 300, 12, 35, 75,
@@ -310,3 +259,156 @@ describe("biEncoderService batched scoring (#2715)", () => {
310
  expect(isSorted).toBe(false);
311
  });
312
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  * npx vitest run --config vitest.integration.config.ts biEncoder
8
  */
9
 
10
+ import { monitorEventLoopDelay } from "node:perf_hooks";
11
+ import { Worker } from "node:worker_threads";
12
  import type { Tokenizer } from "@huggingface/tokenizers";
13
+ import { type InferenceSession, Tensor } from "onnxruntime-node";
14
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
15
  import {
 
16
  getBiEncoderStatus,
17
  scorePassages,
18
  startBiEncoderService,
19
  stopBiEncoderService,
20
  } from "./biEncoderService";
21
+ import {
22
+ BATCH_ROWS,
23
+ type BiEncoderRequest,
24
+ type BiEncoderResponse,
25
+ } from "./biEncoderWorkerProtocol";
26
  import { loadOnnxModel } from "./utils/onnxModelLoader";
27
 
28
  const MODEL_HF_REPO =
 
184
  await refSession?.release();
185
  });
186
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
187
  it("keeps batched scores within 1e-5 of the per-text path", async () => {
188
  const wordCounts = [
189
  3, 8, 15, 40, 90, 150, 250, 400, 5, 20, 60, 120, 200, 300, 12, 35, 75,
 
259
  expect(isSorted).toBe(false);
260
  });
261
  });
262
+
263
+ /**
264
+ * Runs one scoring request against a worker of its own and returns its reply.
265
+ *
266
+ * The session lives in the worker now, so a `session.run` spy on this thread
267
+ * would see nothing. The worker reports the dims of each forward pass instead,
268
+ * which is what the bucketing assertions read.
269
+ */
270
+ async function scoreInOwnWorker(
271
+ query: string,
272
+ passages: string[],
273
+ ): Promise<Extract<BiEncoderResponse, { type: "scores" }>> {
274
+ const worker = new Worker(new URL("./biEncoderWorker.ts", import.meta.url));
275
+
276
+ try {
277
+ await new Promise<void>((resolve, reject) => {
278
+ worker.once("message", (message: BiEncoderResponse) => {
279
+ if (message.type === "ready") resolve();
280
+ else reject(new Error(`Expected "ready", got "${message.type}"`));
281
+ });
282
+ worker.once("error", reject);
283
+ });
284
+
285
+ return await new Promise((resolve, reject) => {
286
+ worker.once("message", (message: BiEncoderResponse) => {
287
+ if (message.type === "scores") resolve(message);
288
+ else if (message.type === "failed") reject(new Error(message.message));
289
+ });
290
+ worker.once("error", reject);
291
+
292
+ const request: BiEncoderRequest = {
293
+ type: "score",
294
+ id: 1,
295
+ query,
296
+ passages,
297
+ };
298
+ worker.postMessage(request);
299
+ });
300
+ } finally {
301
+ await worker.terminate();
302
+ }
303
+ }
304
+
305
+ describe("biEncoderWorker bucketing (#2715, #2730)", () => {
306
+ it("runs one forward pass per length bucket, not one per passage", async () => {
307
+ // Zigzag lengths with the sort's guarantee removed: bucket 1's last
308
+ // row (190 words) is longer than bucket 2's last row (25 words), so
309
+ // an unsorted pass sets bucket widths from those rows and they come
310
+ // out decreasing β€” the non-decreasing-width assertion below then
311
+ // fails. With the sort, widths are non-decreasing by construction.
312
+ const wordCounts = [
313
+ 200, 10, 150, 5, 180, 20, 120, 8, 90, 160, 30, 110, 60, 140, 190, 170, 15,
314
+ 130, 70, 25,
315
+ ];
316
+ const passages = wordCounts.map((n, i) => makePassage(n, i));
317
+
318
+ const { scores, runDimensions } = await scoreInOwnWorker(
319
+ "how do alpha bravo passages score",
320
+ passages,
321
+ );
322
+
323
+ expect(scores).toHaveLength(passages.length);
324
+
325
+ // The query rides in the batch, so 21 rows go through ceil(21 / B)
326
+ // bucketed runs instead of 21 per-passage runs.
327
+ const expectedBuckets = Math.ceil((passages.length + 1) / BATCH_ROWS);
328
+ expect(runDimensions.length).toBe(expectedBuckets);
329
+ expect(runDimensions.length).toBeLessThan(passages.length + 1);
330
+
331
+ let totalRows = 0;
332
+ for (const dims of runDimensions) {
333
+ expect(dims.length).toBe(2);
334
+ expect(dims[0]).toBeLessThanOrEqual(BATCH_ROWS);
335
+ totalRows += dims[0];
336
+ }
337
+ expect(totalRows).toBe(passages.length + 1);
338
+
339
+ // Every non-final bucket is full, so it really carries multiple rows.
340
+ // Only the final bucket may be short β€” with BATCH_ROWS of 10 or 20 a
341
+ // 21-row pool leaves it exactly one row, so the >1 check must not
342
+ // depend on the tuning knob.
343
+ for (let i = 0; i < runDimensions.length - 1; i++) {
344
+ expect(runDimensions[i][0]).toBeGreaterThan(1);
345
+ }
346
+
347
+ // Bucket widths are non-decreasing across runs: the sort by token
348
+ // length put the short rows first. Delete the `.sort()` in encodeBatch
349
+ // and this fails.
350
+ for (let i = 1; i < runDimensions.length; i++) {
351
+ expect(runDimensions[i][1]).toBeGreaterThanOrEqual(
352
+ runDimensions[i - 1][1],
353
+ );
354
+ }
355
+ });
356
+ });
357
+
358
+ describe("biEncoderService main-thread cost (#2730)", () => {
359
+ beforeAll(async () => {
360
+ await startBiEncoderService();
361
+ });
362
+
363
+ afterAll(async () => {
364
+ await stopBiEncoderService();
365
+ });
366
+
367
+ /**
368
+ * The acceptance measurement from #2730. On the main thread the same pass
369
+ * blocked for ~815 ms (x86_64, 32 logical cores); with the session in the
370
+ * worker it measures ~1.5 ms. The bar is left at the issue's 20 ms so the
371
+ * test reports a regression rather than host-to-host noise.
372
+ */
373
+ it("keeps the main-thread event-loop block under 20 ms for a 200-passage pass", async () => {
374
+ const passages = Array.from({ length: 200 }, (_, i) =>
375
+ makePassage(5 + ((i * 37) % 300), i),
376
+ );
377
+ const query = "how do alpha bravo passages score";
378
+
379
+ // One untimed pass, so the worker's lazily allocated arenas are not
380
+ // charged to the measurement.
381
+ await scorePassages(query, passages.slice(0, 20));
382
+
383
+ const histogram = monitorEventLoopDelay({ resolution: 1 });
384
+ histogram.enable();
385
+ const scores = await scorePassages(query, passages);
386
+ histogram.disable();
387
+
388
+ expect(scores).toHaveLength(passages.length);
389
+ expect(histogram.max / 1e6).toBeLessThan(20);
390
+ });
391
+ });
392
+
393
+ describe("biEncoderService when the worker goes away (#2730)", () => {
394
+ it("answers in-flight scoring with empty scores and reports not ready", async () => {
395
+ await startBiEncoderService();
396
+ expect(await getBiEncoderStatus()).toBe(true);
397
+
398
+ const passages = Array.from({ length: 200 }, (_, i) =>
399
+ makePassage(5 + ((i * 37) % 300), i),
400
+ );
401
+ // Fired but not awaited: the worker is taken away underneath it. An
402
+ // unanswered request would hang the page-content read that made it, so
403
+ // it has to come back empty, which is the signal to rank lexically.
404
+ const pending = scorePassages(
405
+ "how do alpha bravo passages score",
406
+ passages,
407
+ );
408
+ await stopBiEncoderService();
409
+
410
+ await expect(pending).resolves.toEqual([]);
411
+ expect(await getBiEncoderStatus()).toBe(false);
412
+ expect(await scorePassages("query", ["one"])).toEqual([]);
413
+ });
414
+ });
server/biEncoderService.ts CHANGED
@@ -1,229 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
1
  import path from "node:path";
2
- import type { Tokenizer } from "@huggingface/tokenizers";
3
- import { type InferenceSession, Tensor } from "onnxruntime-node";
4
- import { createModelLogger, loadOnnxModel } from "./utils/onnxModelLoader.ts";
 
 
 
5
 
6
  const printMessage = createModelLogger(path.basename(import.meta.url));
7
 
8
- const MODEL_HF_REPO =
9
- "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2";
10
-
11
- /**
12
- * The ONNX export. ~450 MB, multilingual (50+ languages), 384-dimensional
13
- * embeddings. CPU cost depends on passage length and pool size; the page-content
14
- * selector limits dense scoring to 256 passages. See docs/page-content.md.
15
- */
16
- const MODEL_HF_FILE = "onnx/model.onnx";
17
 
18
- /**
19
- * Maximum tokens per encoding. The model was trained with a 256-token limit;
20
- * passages longer than that are truncated from the end, which is where the
21
- * passage content sits after the query prefix.
22
- */
23
- const MAX_SEQUENCE_LENGTH = 256;
24
 
25
  /**
26
- * Rows per batched forward pass. The old chunk of 64 wrapped one-text-per-call
27
- * runs and bought no concurrency; now that a call really carries its rows, B
28
- * trades padding waste and cache pressure against per-call setup. B=64 measured
29
- * slower than the per-text path; the sweep cannot separate the two plausible
30
- * causes β€” a 64-row bucket spans a wider length range, so padding waste
31
- * grows, and at L=256 with 12 heads the attention score tensor alone is
32
- * ~192 MiB live, which pressures cache. B=16 (~48 MiB) measured fastest on
33
- * a 200-passage pool on this host (x86_64, 32 cpus): 1.88x wall time and a
34
- * 223ms worst event-loop block against 1560ms for the per-text path.
35
  */
36
- export const BATCH_ROWS = 16;
37
-
38
- let isReady = false;
39
- let session: InferenceSession | null = null;
40
- let tokenizer: Tokenizer | null = null;
41
- let padTokenId: number | null = null;
42
 
43
  /**
44
- * Encodes a batch of texts into normalized embedding vectors with one
45
- * `session.run()` per length bucket.
46
- *
47
- * Rows are sorted by token length and cut into buckets of at most
48
- * `BATCH_ROWS`, so each batch pads to its own near-uniform width instead of
49
- * the global max: attention is O(L^2), and padding a mixed-length chunk out
50
- * to 256 can multiply the FLOPs several-fold and come out slower than the
51
- * per-text path.
52
- *
53
- * Right-padding with the real pad id under an attention mask is score-safe for
54
- * this fp32 export: it has no per-tensor dynamic quantization scale, so a
55
- * padded row cannot move another row's scale, and the real tokens' hidden
56
- * states are bit-identical to encoding the text alone. (The cross-encoder is
57
- * dynamically quantized and cannot batch for that reason; see the note in
58
- * `rerankerService.ts`.)
59
- *
60
- * Buckets run one after another with a plain `await` β€” deliberately NOT a
61
- * `Promise.all` across buckets. Each await hands the event loop a turn
62
- * between buckets, which is where the block-length win comes from. Do not
63
- * "optimize" the buckets back into a `Promise.all`: onnxruntime-node runs
64
- * inference synchronously on the JS thread regardless, so it would add no
65
- * concurrency while taking away those between-bucket turns.
66
  */
67
- async function encodeBatch(
68
- activeSession: InferenceSession,
69
- loadedTokenizer: Tokenizer,
70
- activePadTokenId: number,
71
- texts: string[],
72
- ): Promise<Float32Array[]> {
73
- const tokenized = texts
74
- .map((text, index) => {
75
- const { ids, attention_mask } = loadedTokenizer.encode(text);
76
- const slicedIds = ids.slice(0, MAX_SEQUENCE_LENGTH);
77
- const slicedMask = attention_mask.slice(0, MAX_SEQUENCE_LENGTH);
78
- // The real-token length comes from the mask's leading 1s, not from
79
- // trusting every returned id as real. At @huggingface/tokenizers
80
- // 0.2.0 the mask is all 1s, so this is a no-op today; it keeps the
81
- // row correct if a future tokenizer pads or marks truncation in the
82
- // mask (the cached tokenizer.json already declares a padding
83
- // strategy with pad_id 1).
84
- const firstPad = slicedMask.indexOf(0);
85
- const realLength = firstPad === -1 ? slicedIds.length : firstPad;
86
- return { index, ids: slicedIds.slice(0, realLength) };
87
- })
88
- .sort((a, b) => a.ids.length - b.ids.length);
89
-
90
- const embeddings: Float32Array[] = new Array(texts.length);
91
-
92
- for (let start = 0; start < tokenized.length; start += BATCH_ROWS) {
93
- const rows = tokenized.slice(start, start + BATCH_ROWS);
94
- // Sorted ascending, so the last row sets the bucket width.
95
- const bucketLength = rows[rows.length - 1].ids.length;
96
- const dimensions = [rows.length, bucketLength];
97
-
98
- const inputIds = new BigInt64Array(rows.length * bucketLength).fill(
99
- BigInt(activePadTokenId),
100
- );
101
- const attentionMask = new BigInt64Array(rows.length * bucketLength);
102
- // The export declares `token_type_ids` and ONNX Runtime refuses to run
103
- // with a declared input missing. Every text is one segment, so zeros.
104
- const tokenTypeIds = new BigInt64Array(rows.length * bucketLength);
105
-
106
- for (let row = 0; row < rows.length; row++) {
107
- const base = row * bucketLength;
108
- const { ids } = rows[row];
109
- for (let t = 0; t < ids.length; t++) {
110
- inputIds[base + t] = BigInt(ids[t]);
111
- attentionMask[base + t] = 1n;
112
- }
113
- }
114
 
115
- const { last_hidden_state } = await activeSession.run({
116
- input_ids: new Tensor("int64", inputIds, dimensions),
117
- attention_mask: new Tensor("int64", attentionMask, dimensions),
118
- token_type_ids: new Tensor("int64", tokenTypeIds, dimensions),
119
- });
120
 
121
- const hidden = last_hidden_state.data as Float32Array;
122
- const dim = last_hidden_state.dims[2];
123
- // Take the row stride from the output tensor, not from the bucket
124
- // length we asked for: same value today, but the pooling can never
125
- // read the wrong row if the export's layout ever changes.
126
- const seqStride = last_hidden_state.dims[1];
127
 
128
- for (let row = 0; row < rows.length; row++) {
129
- // Right-padding keeps every real token inside `ids.length`, so
130
- // pooling over that count never touches a pad. (Checking the mask
131
- // here instead would have to compare against 0n: BigInt64Array
132
- // entries are never `=== 0`.)
133
- const rowLength = rows[row].ids.length;
134
- const pooled = new Float32Array(dim);
135
- for (let t = 0; t < rowLength; t++) {
136
- const offset = (row * seqStride + t) * dim;
137
- for (let d = 0; d < dim; d++) {
138
- pooled[d] += hidden[offset + d];
139
- }
140
- }
141
- if (rowLength > 0) {
142
- for (let d = 0; d < dim; d++) {
143
- pooled[d] /= rowLength;
144
- }
145
- }
146
 
147
- // L2 normalize.
148
- let norm = 0;
149
- for (let d = 0; d < dim; d++) {
150
- norm += pooled[d] * pooled[d];
151
- }
152
- norm = Math.sqrt(norm);
153
- if (norm > 0) {
154
- for (let d = 0; d < dim; d++) {
155
- pooled[d] /= norm;
156
- }
157
- }
158
 
159
- embeddings[rows[row].index] = pooled;
 
 
 
160
  }
161
- }
162
-
163
- return embeddings;
164
- }
165
-
166
- /**
167
- * Encodes a single text into a normalized embedding vector.
168
- */
169
- async function encode(
170
- activeSession: InferenceSession,
171
- loadedTokenizer: Tokenizer,
172
- activePadTokenId: number,
173
- text: string,
174
- ): Promise<Float32Array> {
175
- const [embedding] = await encodeBatch(
176
- activeSession,
177
- loadedTokenizer,
178
- activePadTokenId,
179
- [text],
180
- );
181
- return embedding;
182
- }
183
 
184
- /**
185
- * Computes cosine similarity between a query embedding and passage embeddings.
186
- * Both are assumed to be L2-normalized, so cosine similarity = dot product.
187
- */
188
- function cosineSimilarities(
189
- query: Float32Array,
190
- passages: Float32Array[],
191
- ): number[] {
192
- return passages.map((passage) => {
193
- let sum = 0;
194
- for (let d = 0; d < query.length; d++) {
195
- sum += query[d] * passage[d];
196
- }
197
- return sum;
198
  });
199
- }
200
 
201
- export async function startBiEncoderService() {
202
- printMessage("Preparing bi-encoder model...");
203
- const loaded = await loadOnnxModel(MODEL_HF_REPO, MODEL_HF_FILE);
204
- if (loaded.padTokenId === null) {
205
- throw new Error(
206
- `The bi-encoder tokenizer (${MODEL_HF_REPO}) declares no pad_token; batched scoring needs the real pad id and will not assume one`,
207
- );
208
- }
209
- session = loaded.session;
210
- tokenizer = loaded.tokenizer;
211
- padTokenId = loaded.padTokenId;
212
 
213
- // Warm up with a test encoding.
214
- await encode(session, tokenizer, padTokenId, "test query");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
215
 
216
  isReady = true;
217
  printMessage("Bi-encoder service ready!");
218
  }
219
 
220
  export async function stopBiEncoderService() {
 
221
  isReady = false;
222
- const currentSession = session;
223
- session = null;
224
- tokenizer = null;
225
- padTokenId = null;
226
- await currentSession?.release();
227
  }
228
 
229
  export async function getBiEncoderStatus() {
@@ -232,31 +127,35 @@ export async function getBiEncoderStatus() {
232
 
233
  /**
234
  * Returns dense (semantic) scores for passages given a query.
235
- * Falls back to empty array when the model is not loaded.
236
  */
237
  export async function scorePassages(
238
  query: string,
239
  passages: string[],
240
  ): Promise<number[]> {
241
- if (!session || !tokenizer || padTokenId === null || passages.length === 0) {
 
242
  return [];
243
  }
244
 
245
- const activeSession = session;
246
- const loadedTokenizer = tokenizer;
247
- const activePadTokenId = padTokenId;
248
-
249
- // The query rides in the same batched pass as the passages instead of
250
- // running alone, saving one forward pass. Its position in the result is
251
- // guaranteed by the index-based restore inside encodeBatch
252
- // (`embeddings[rows[row].index]`), not by where the length sort happens
253
- // to place it, so it stays correct whatever the bucketing does.
254
- const [queryEmbedding, ...passageEmbeddings] = await encodeBatch(
255
- activeSession,
256
- loadedTokenizer,
257
- activePadTokenId,
258
- [query, ...passages],
259
- );
260
-
261
- return cosineSimilarities(queryEmbedding, passageEmbeddings);
 
 
 
262
  }
 
1
+ /**
2
+ * The main thread's side of the bi-encoder. It owns no model and runs no
3
+ * inference: it spawns `biEncoderWorker.ts`, forwards scoring requests to it
4
+ * and matches the replies back to their callers.
5
+ *
6
+ * The four exported functions keep the signatures they had when the session
7
+ * lived here, so no caller changed. What changed is that a scoring pass no
8
+ * longer stops the event loop: a 200-passage pool blocked the main thread for
9
+ * ~815 ms before and stays under 20 ms now (see docs/page-content.md).
10
+ */
11
+
12
  import path from "node:path";
13
+ import { Worker } from "node:worker_threads";
14
+ import type {
15
+ BiEncoderRequest,
16
+ BiEncoderResponse,
17
+ } from "./biEncoderWorkerProtocol.ts";
18
+ import { createModelLogger } from "./utils/onnxModelLoader.ts";
19
 
20
  const printMessage = createModelLogger(path.basename(import.meta.url));
21
 
22
+ let worker: Worker | null = null;
23
+ let isReady = false;
24
+ let nextRequestId = 0;
 
 
 
 
 
 
25
 
26
+ /** Resolvers for requests the worker has not answered yet, keyed by their id. */
27
+ const pendingScores = new Map<number, (scores: number[]) => void>();
 
 
 
 
28
 
29
  /**
30
+ * Answers every in-flight request with empty scores. Empty is the signal
31
+ * `pageContentService` already reads as "dense scoring is unavailable, rank
32
+ * lexically", so a worker that dies degrades the ranking instead of leaving
33
+ * page-content reads waiting for a reply that is never coming.
 
 
 
 
 
34
  */
35
+ function resolvePendingEmpty() {
36
+ for (const resolve of pendingScores.values()) resolve([]);
37
+ pendingScores.clear();
38
+ }
 
 
39
 
40
  /**
41
+ * Drops a worker that died. Guarded on identity because a terminated worker's
42
+ * `exit` still arrives after `startBiEncoderService` has installed its
43
+ * replacement, and that late event must not mark the new one dead.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  */
45
+ function forgetWorker(deadWorker: Worker) {
46
+ if (worker !== deadWorker) return;
47
+ worker = null;
48
+ isReady = false;
49
+ resolvePendingEmpty();
50
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
 
52
+ export async function startBiEncoderService() {
53
+ if (worker) await stopBiEncoderService();
 
 
 
54
 
55
+ const biEncoderWorker = new Worker(
56
+ new URL("./biEncoderWorker.ts", import.meta.url),
57
+ );
58
+ worker = biEncoderWorker;
 
 
59
 
60
+ biEncoderWorker.on("message", (message: BiEncoderResponse) => {
61
+ if (message.type === "ready") return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
 
63
+ const resolve = pendingScores.get(message.id);
64
+ if (!resolve) return;
65
+ pendingScores.delete(message.id);
 
 
 
 
 
 
 
 
66
 
67
+ if (message.type === "failed") {
68
+ printMessage(`Bi-encoder scoring failed: ${message.message}`);
69
+ resolve([]);
70
+ return;
71
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
 
73
+ resolve(message.scores);
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  });
 
75
 
76
+ biEncoderWorker.on("error", (error) => {
77
+ printMessage(`Bi-encoder worker failed: ${error.message}`);
78
+ forgetWorker(biEncoderWorker);
79
+ });
 
 
 
 
 
 
 
80
 
81
+ biEncoderWorker.on("exit", () => forgetWorker(biEncoderWorker));
82
+
83
+ await new Promise<void>((resolve, reject) => {
84
+ const stopListening = () => {
85
+ biEncoderWorker.off("message", onMessage);
86
+ biEncoderWorker.off("error", onError);
87
+ biEncoderWorker.off("exit", onExit);
88
+ };
89
+ const onMessage = (message: BiEncoderResponse) => {
90
+ if (message.type !== "ready") return;
91
+ stopListening();
92
+ resolve();
93
+ };
94
+ const onError = (error: Error) => {
95
+ stopListening();
96
+ reject(error);
97
+ };
98
+ const onExit = (code: number) => {
99
+ stopListening();
100
+ reject(
101
+ new Error(
102
+ `The bi-encoder worker exited with code ${code} before it was ready`,
103
+ ),
104
+ );
105
+ };
106
+
107
+ biEncoderWorker.on("message", onMessage);
108
+ biEncoderWorker.on("error", onError);
109
+ biEncoderWorker.on("exit", onExit);
110
+ });
111
 
112
  isReady = true;
113
  printMessage("Bi-encoder service ready!");
114
  }
115
 
116
  export async function stopBiEncoderService() {
117
+ const runningWorker = worker;
118
  isReady = false;
119
+ worker = null;
120
+ resolvePendingEmpty();
121
+ await runningWorker?.terminate();
 
 
122
  }
123
 
124
  export async function getBiEncoderStatus() {
 
127
 
128
  /**
129
  * Returns dense (semantic) scores for passages given a query.
130
+ * Falls back to empty array when the worker is not running.
131
  */
132
  export async function scorePassages(
133
  query: string,
134
  passages: string[],
135
  ): Promise<number[]> {
136
+ const runningWorker = worker;
137
+ if (!runningWorker || !isReady || passages.length === 0) {
138
  return [];
139
  }
140
 
141
+ // Ids are per-process and monotonic because the service can have several
142
+ // scoring passes in flight at once: two concurrent page-content reads each
143
+ // send their own request, and the replies can come back in either order.
144
+ const id = nextRequestId++;
145
+
146
+ return new Promise<number[]>((resolve) => {
147
+ pendingScores.set(id, resolve);
148
+
149
+ const request: BiEncoderRequest = { type: "score", id, query, passages };
150
+ try {
151
+ runningWorker.postMessage(request);
152
+ } catch (error) {
153
+ // The worker was terminated between the check above and this send.
154
+ pendingScores.delete(id);
155
+ printMessage(
156
+ `Could not reach the bi-encoder worker: ${error instanceof Error ? error.message : String(error)}`,
157
+ );
158
+ resolve([]);
159
+ }
160
+ });
161
  }
server/biEncoderWorker.ts ADDED
@@ -0,0 +1,279 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * The bi-encoder's ONNX session, running off the main thread.
3
+ *
4
+ * `onnxruntime-node` runs inference synchronously on whichever JS thread calls
5
+ * it, so scoring a 200-passage pool used to stop the server's event loop for
6
+ * hundreds of milliseconds β€” no other request could be served while a single
7
+ * page-content read was ranking its passages. Here that work blocks only this
8
+ * thread. The model is loaded here for the same reason: the ~450 MB of weights
9
+ * must live in the worker's memory, not the main thread's.
10
+ *
11
+ * Node loads this file directly (type stripping), so keep it to syntax that
12
+ * erases: no enums, no parameter properties, no namespaces.
13
+ */
14
+
15
+ import { availableParallelism } from "node:os";
16
+ import path from "node:path";
17
+ import { parentPort } from "node:worker_threads";
18
+ import type { Tokenizer } from "@huggingface/tokenizers";
19
+ import { type InferenceSession, Tensor } from "onnxruntime-node";
20
+ import {
21
+ BATCH_ROWS,
22
+ type BiEncoderRequest,
23
+ type BiEncoderResponse,
24
+ } from "./biEncoderWorkerProtocol.ts";
25
+ import { createModelLogger, loadOnnxModel } from "./utils/onnxModelLoader.ts";
26
+
27
+ const printMessage = createModelLogger(path.basename(import.meta.url));
28
+
29
+ const MODEL_HF_REPO =
30
+ "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2";
31
+
32
+ /**
33
+ * The ONNX export. ~450 MB, multilingual (50+ languages), 384-dimensional
34
+ * embeddings. CPU cost depends on passage length and pool size; the page-content
35
+ * selector limits dense scoring to 256 passages. See docs/page-content.md.
36
+ */
37
+ const MODEL_HF_FILE = "onnx/model.onnx";
38
+
39
+ /**
40
+ * Maximum tokens per encoding. The model was trained with a 256-token limit;
41
+ * passages longer than that are truncated from the end, which is where the
42
+ * passage content sits after the query prefix.
43
+ */
44
+ const MAX_SEQUENCE_LENGTH = 256;
45
+
46
+ /**
47
+ * Half the reported parallelism, which is the physical core count on the
48
+ * hyperthreaded x86 hosts this runs on, and at least one thread everywhere
49
+ * else.
50
+ *
51
+ * ONNX Runtime otherwise sizes its intra-op pool from the logical core count
52
+ * and its threads spin before yielding, so it oversubscribes every core and
53
+ * leaves nothing for the thread that has to answer HTTP. Measured on a
54
+ * 200-passage pool on this host (x86_64, 32 logical cores), worst main-thread
55
+ * event-loop delay against wall time for the pass:
56
+ *
57
+ * ORT default (32): 31-38 ms, 7.1 s
58
+ * 16: 1-4 ms, 2.6 s
59
+ * 8: 1-2 ms, 4.2 s
60
+ * 4: 1-1 ms, 5.7 s
61
+ *
62
+ * Capping is not a trade here: 16 was both the quietest and the fastest, and
63
+ * the oversubscribed default was the slowest setting measured.
64
+ */
65
+ const INTRA_OP_THREADS = Math.max(1, Math.floor(availableParallelism() / 2));
66
+
67
+ interface EncodeResult {
68
+ embeddings: Float32Array[];
69
+ /** `input_ids` dims of each forward pass, in run order. */
70
+ runDimensions: number[][];
71
+ }
72
+
73
+ /**
74
+ * Encodes a batch of texts into normalized embedding vectors with one
75
+ * `session.run()` per length bucket.
76
+ *
77
+ * Rows are sorted by token length and cut into buckets of at most
78
+ * `BATCH_ROWS`, so each batch pads to its own near-uniform width instead of
79
+ * the global max: attention is O(L^2), and padding a mixed-length chunk out
80
+ * to 256 can multiply the FLOPs several-fold and come out slower than the
81
+ * per-text path.
82
+ *
83
+ * Right-padding with the real pad id under an attention mask is score-safe for
84
+ * this fp32 export: it has no per-tensor dynamic quantization scale, so a
85
+ * padded row cannot move another row's scale, and the real tokens' hidden
86
+ * states are bit-identical to encoding the text alone. (The cross-encoder is
87
+ * dynamically quantized and cannot batch for that reason; see the note in
88
+ * `rerankerService.ts`.)
89
+ *
90
+ * Buckets run one after another with a plain `await` β€” deliberately NOT a
91
+ * `Promise.all` across buckets. Each await hands this thread's event loop a
92
+ * turn between buckets, which is what lets two overlapping scoring requests
93
+ * interleave instead of one waiting out the other. Do not "optimize" the
94
+ * buckets back into a `Promise.all`: onnxruntime-node runs inference
95
+ * synchronously on the calling thread regardless, so it would add no
96
+ * concurrency while taking away those between-bucket turns.
97
+ */
98
+ async function encodeBatch(
99
+ activeSession: InferenceSession,
100
+ loadedTokenizer: Tokenizer,
101
+ activePadTokenId: number,
102
+ texts: string[],
103
+ ): Promise<EncodeResult> {
104
+ const tokenized = texts
105
+ .map((text, index) => {
106
+ const { ids, attention_mask } = loadedTokenizer.encode(text);
107
+ const slicedIds = ids.slice(0, MAX_SEQUENCE_LENGTH);
108
+ const slicedMask = attention_mask.slice(0, MAX_SEQUENCE_LENGTH);
109
+ // The real-token length comes from the mask's leading 1s, not from
110
+ // trusting every returned id as real. At @huggingface/tokenizers
111
+ // 0.2.0 the mask is all 1s, so this is a no-op today; it keeps the
112
+ // row correct if a future tokenizer pads or marks truncation in the
113
+ // mask (the cached tokenizer.json already declares a padding
114
+ // strategy with pad_id 1).
115
+ const firstPad = slicedMask.indexOf(0);
116
+ const realLength = firstPad === -1 ? slicedIds.length : firstPad;
117
+ return { index, ids: slicedIds.slice(0, realLength) };
118
+ })
119
+ .sort((a, b) => a.ids.length - b.ids.length);
120
+
121
+ const embeddings: Float32Array[] = new Array(texts.length);
122
+ const runDimensions: number[][] = [];
123
+
124
+ for (let start = 0; start < tokenized.length; start += BATCH_ROWS) {
125
+ const rows = tokenized.slice(start, start + BATCH_ROWS);
126
+ // Sorted ascending, so the last row sets the bucket width.
127
+ const bucketLength = rows[rows.length - 1].ids.length;
128
+ const dimensions = [rows.length, bucketLength];
129
+
130
+ const inputIds = new BigInt64Array(rows.length * bucketLength).fill(
131
+ BigInt(activePadTokenId),
132
+ );
133
+ const attentionMask = new BigInt64Array(rows.length * bucketLength);
134
+ // The export declares `token_type_ids` and ONNX Runtime refuses to run
135
+ // with a declared input missing. Every text is one segment, so zeros.
136
+ const tokenTypeIds = new BigInt64Array(rows.length * bucketLength);
137
+
138
+ for (let row = 0; row < rows.length; row++) {
139
+ const base = row * bucketLength;
140
+ const { ids } = rows[row];
141
+ for (let t = 0; t < ids.length; t++) {
142
+ inputIds[base + t] = BigInt(ids[t]);
143
+ attentionMask[base + t] = 1n;
144
+ }
145
+ }
146
+
147
+ runDimensions.push(dimensions);
148
+
149
+ const { last_hidden_state } = await activeSession.run({
150
+ input_ids: new Tensor("int64", inputIds, dimensions),
151
+ attention_mask: new Tensor("int64", attentionMask, dimensions),
152
+ token_type_ids: new Tensor("int64", tokenTypeIds, dimensions),
153
+ });
154
+
155
+ const hidden = last_hidden_state.data as Float32Array;
156
+ const dim = last_hidden_state.dims[2];
157
+ // Take the row stride from the output tensor, not from the bucket
158
+ // length we asked for: same value today, but the pooling can never
159
+ // read the wrong row if the export's layout ever changes.
160
+ const seqStride = last_hidden_state.dims[1];
161
+
162
+ for (let row = 0; row < rows.length; row++) {
163
+ // Right-padding keeps every real token inside `ids.length`, so
164
+ // pooling over that count never touches a pad. (Checking the mask
165
+ // here instead would have to compare against 0n: BigInt64Array
166
+ // entries are never `=== 0`.)
167
+ const rowLength = rows[row].ids.length;
168
+ const pooled = new Float32Array(dim);
169
+ for (let t = 0; t < rowLength; t++) {
170
+ const offset = (row * seqStride + t) * dim;
171
+ for (let d = 0; d < dim; d++) {
172
+ pooled[d] += hidden[offset + d];
173
+ }
174
+ }
175
+ if (rowLength > 0) {
176
+ for (let d = 0; d < dim; d++) {
177
+ pooled[d] /= rowLength;
178
+ }
179
+ }
180
+
181
+ // L2 normalize.
182
+ let norm = 0;
183
+ for (let d = 0; d < dim; d++) {
184
+ norm += pooled[d] * pooled[d];
185
+ }
186
+ norm = Math.sqrt(norm);
187
+ if (norm > 0) {
188
+ for (let d = 0; d < dim; d++) {
189
+ pooled[d] /= norm;
190
+ }
191
+ }
192
+
193
+ embeddings[rows[row].index] = pooled;
194
+ }
195
+ }
196
+
197
+ return { embeddings, runDimensions };
198
+ }
199
+
200
+ /**
201
+ * Computes cosine similarity between a query embedding and passage embeddings.
202
+ * Both are assumed to be L2-normalized, so cosine similarity = dot product.
203
+ */
204
+ function cosineSimilarities(
205
+ query: Float32Array,
206
+ passages: Float32Array[],
207
+ ): number[] {
208
+ return passages.map((passage) => {
209
+ let sum = 0;
210
+ for (let d = 0; d < query.length; d++) {
211
+ sum += query[d] * passage[d];
212
+ }
213
+ return sum;
214
+ });
215
+ }
216
+
217
+ if (!parentPort) {
218
+ throw new Error(
219
+ "biEncoderWorker.ts is a worker entry point and must be started with node:worker_threads",
220
+ );
221
+ }
222
+
223
+ const port = parentPort;
224
+
225
+ printMessage("Preparing bi-encoder model...");
226
+ const loaded = await loadOnnxModel(MODEL_HF_REPO, MODEL_HF_FILE, {
227
+ sessionOptions: { intraOpNumThreads: INTRA_OP_THREADS },
228
+ });
229
+ if (loaded.padTokenId === null) {
230
+ throw new Error(
231
+ `The bi-encoder tokenizer (${MODEL_HF_REPO}) declares no pad_token; batched scoring needs the real pad id and will not assume one`,
232
+ );
233
+ }
234
+ const session = loaded.session;
235
+ const tokenizer = loaded.tokenizer;
236
+ const padTokenId = loaded.padTokenId;
237
+
238
+ // Warm up with a test encoding, so the first real request does not pay for
239
+ // the lazily allocated arenas of the first run.
240
+ await encodeBatch(session, tokenizer, padTokenId, ["test query"]);
241
+
242
+ port.on("message", async (request: BiEncoderRequest) => {
243
+ if (request.type !== "score") return;
244
+
245
+ try {
246
+ // The query rides in the same batched pass as the passages instead of
247
+ // running alone, saving one forward pass. Its position in the result is
248
+ // guaranteed by the index-based restore inside encodeBatch
249
+ // (`embeddings[rows[row].index]`), not by where the length sort happens
250
+ // to place it, so it stays correct whatever the bucketing does.
251
+ const { embeddings, runDimensions } = await encodeBatch(
252
+ session,
253
+ tokenizer,
254
+ padTokenId,
255
+ [request.query, ...request.passages],
256
+ );
257
+ const [queryEmbedding, ...passageEmbeddings] = embeddings;
258
+
259
+ const response: BiEncoderResponse = {
260
+ type: "scores",
261
+ id: request.id,
262
+ scores: cosineSimilarities(queryEmbedding, passageEmbeddings),
263
+ runDimensions,
264
+ };
265
+ port.postMessage(response);
266
+ } catch (error) {
267
+ // The caller falls back to lexical ranking on empty scores, so one bad
268
+ // request must not take the worker down with it.
269
+ const response: BiEncoderResponse = {
270
+ type: "failed",
271
+ id: request.id,
272
+ message: error instanceof Error ? error.message : String(error),
273
+ };
274
+ port.postMessage(response);
275
+ }
276
+ });
277
+
278
+ const ready: BiEncoderResponse = { type: "ready" };
279
+ port.postMessage(ready);
server/biEncoderWorkerProtocol.ts ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * The messages `biEncoderService.ts` and `biEncoderWorker.ts` exchange, kept in
3
+ * their own module so the main thread can name them without importing the
4
+ * worker: importing that module would load the ~450 MB model into the very
5
+ * thread the worker exists to keep free.
6
+ *
7
+ * Only plain data crosses the boundary. Embeddings stay inside the worker and
8
+ * the cosine step runs there, so a scoring pass sends strings out and brings
9
+ * `number[]` back rather than copying 384 floats per passage.
10
+ */
11
+
12
+ /**
13
+ * Rows per batched forward pass. The old chunk of 64 wrapped one-text-per-call
14
+ * runs and bought no concurrency; now that a call really carries its rows, B
15
+ * trades padding waste and cache pressure against per-call setup. B=64 measured
16
+ * slower than the per-text path; the sweep cannot separate the two plausible
17
+ * causes β€” a 64-row bucket spans a wider length range, so padding waste
18
+ * grows, and at L=256 with 12 heads the attention score tensor alone is
19
+ * ~192 MiB live, which pressures cache. B=16 (~48 MiB) measured fastest on
20
+ * a 200-passage pool on this host (x86_64, 32 cpus): 1.88x wall time and a
21
+ * 223ms worst event-loop block against 1560ms for the per-text path.
22
+ */
23
+ export const BATCH_ROWS = 16;
24
+
25
+ /** Scoring work sent to the worker. `id` correlates the reply. */
26
+ export interface BiEncoderRequest {
27
+ type: "score";
28
+ id: number;
29
+ query: string;
30
+ passages: string[];
31
+ }
32
+
33
+ export type BiEncoderResponse =
34
+ /** The model is loaded and warmed up; the worker accepts scoring now. */
35
+ | { type: "ready" }
36
+ | {
37
+ type: "scores";
38
+ id: number;
39
+ scores: number[];
40
+ /**
41
+ * The `input_ids` dims of each forward pass this request ran, in run
42
+ * order. The session no longer lives on the main thread, so this is the
43
+ * only way a test can assert on the bucketing; it is a few pairs of
44
+ * numbers per request.
45
+ */
46
+ runDimensions: number[][];
47
+ }
48
+ /** Scoring threw inside the worker. The service reads this as empty scores. */
49
+ | { type: "failed"; id: number; message: string };
server/utils/onnxModelLoader.ts CHANGED
@@ -23,6 +23,21 @@ export function createModelLogger(moduleName: string) {
23
  return printMessage;
24
  }
25
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  function resolveModelPath(modelRepo: string, hfRepoFile: string) {
27
  return path.resolve(SERVER_DIR, "models", modelRepo, hfRepoFile);
28
  }
@@ -44,13 +59,22 @@ async function ensureModelFileExists(modelRepo: string, hfRepoFile: string) {
44
  // quiet: ONNX Runtime otherwise warns that it assigned shape operators to CPU,
45
  // which is expected and not actionable. The architecture is logged because it
46
  // selects the quantized kernel, which is the part that varies between hosts.
47
- function createOnnxSession(modelRepo: string, modelPath: string) {
 
 
 
 
48
  printMessage(
49
- `Creating CPU session for ${modelRepo} (arch: ${process.arch}, platform: ${process.platform})...`,
 
 
 
 
50
  );
51
  return InferenceSession.create(modelPath, {
52
  executionProviders: ["cpu"],
53
  logSeverityLevel: 3,
 
54
  });
55
  }
56
 
@@ -105,8 +129,11 @@ function resolvePadTokenId(
105
  export async function loadOnnxModel(
106
  modelRepo: string,
107
  modelFile: string,
108
- tokenizerFile = "tokenizer.json",
109
- tokenizerConfigFile = "tokenizer_config.json",
 
 
 
110
  ): Promise<{
111
  session: InferenceSession;
112
  tokenizer: Tokenizer;
@@ -126,7 +153,7 @@ export async function loadOnnxModel(
126
  tokenizerConfig,
127
  );
128
  const padTokenId = resolvePadTokenId(tokenizer, tokenizerConfig);
129
- const session = await createOnnxSession(modelRepo, modelPath);
130
 
131
  return { session, tokenizer, padTokenId };
132
  }
 
23
  return printMessage;
24
  }
25
 
26
+ /**
27
+ * Session knobs a caller may override. Left unset, ONNX Runtime picks its own
28
+ * defaults, which is what every caller but the bi-encoder worker wants.
29
+ */
30
+ interface OnnxSessionOptions {
31
+ intraOpNumThreads?: number;
32
+ }
33
+
34
+ export interface LoadOnnxModelOptions {
35
+ /** Tokenizer file names, for a repo that does not use the standard ones. */
36
+ tokenizerFile?: string;
37
+ tokenizerConfigFile?: string;
38
+ sessionOptions?: OnnxSessionOptions;
39
+ }
40
+
41
  function resolveModelPath(modelRepo: string, hfRepoFile: string) {
42
  return path.resolve(SERVER_DIR, "models", modelRepo, hfRepoFile);
43
  }
 
59
  // quiet: ONNX Runtime otherwise warns that it assigned shape operators to CPU,
60
  // which is expected and not actionable. The architecture is logged because it
61
  // selects the quantized kernel, which is the part that varies between hosts.
62
+ function createOnnxSession(
63
+ modelRepo: string,
64
+ modelPath: string,
65
+ sessionOptions: OnnxSessionOptions = {},
66
+ ) {
67
  printMessage(
68
+ `Creating CPU session for ${modelRepo} (arch: ${process.arch}, platform: ${process.platform}${
69
+ sessionOptions.intraOpNumThreads === undefined
70
+ ? ""
71
+ : `, intra-op threads: ${sessionOptions.intraOpNumThreads}`
72
+ })...`,
73
  );
74
  return InferenceSession.create(modelPath, {
75
  executionProviders: ["cpu"],
76
  logSeverityLevel: 3,
77
+ ...sessionOptions,
78
  });
79
  }
80
 
 
129
  export async function loadOnnxModel(
130
  modelRepo: string,
131
  modelFile: string,
132
+ {
133
+ tokenizerFile = "tokenizer.json",
134
+ tokenizerConfigFile = "tokenizer_config.json",
135
+ sessionOptions = {},
136
+ }: LoadOnnxModelOptions = {},
137
  ): Promise<{
138
  session: InferenceSession;
139
  tokenizer: Tokenizer;
 
153
  tokenizerConfig,
154
  );
155
  const padTokenId = resolvePadTokenId(tokenizer, tokenizerConfig);
156
+ const session = await createOnnxSession(modelRepo, modelPath, sessionOptions);
157
 
158
  return { session, tokenizer, padTokenId };
159
  }