Spaces:
Running
Running
Sync from felladrin/MiniSearch@c75b7b9 (part 2)
Browse files- server/biEncoderService.integration.test.ts +162 -60
- server/biEncoderService.ts +116 -217
- server/biEncoderWorker.ts +279 -0
- server/biEncoderWorkerProtocol.ts +49 -0
- server/utils/onnxModelLoader.ts +32 -5
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
|
| 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
|
| 3 |
-
import
|
| 4 |
-
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
const printMessage = createModelLogger(path.basename(import.meta.url));
|
| 7 |
|
| 8 |
-
|
| 9 |
-
|
| 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 |
-
|
| 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 |
-
*
|
| 27 |
-
*
|
| 28 |
-
*
|
| 29 |
-
*
|
| 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 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
let tokenizer: Tokenizer | null = null;
|
| 41 |
-
let padTokenId: number | null = null;
|
| 42 |
|
| 43 |
/**
|
| 44 |
-
*
|
| 45 |
-
* `
|
| 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 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 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 |
-
|
| 116 |
-
|
| 117 |
-
attention_mask: new Tensor("int64", attentionMask, dimensions),
|
| 118 |
-
token_type_ids: new Tensor("int64", tokenTypeIds, dimensions),
|
| 119 |
-
});
|
| 120 |
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
// read the wrong row if the export's layout ever changes.
|
| 126 |
-
const seqStride = last_hidden_state.dims[1];
|
| 127 |
|
| 128 |
-
|
| 129 |
-
|
| 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 |
-
|
| 148 |
-
|
| 149 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 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 |
-
|
| 214 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 215 |
|
| 216 |
isReady = true;
|
| 217 |
printMessage("Bi-encoder service ready!");
|
| 218 |
}
|
| 219 |
|
| 220 |
export async function stopBiEncoderService() {
|
|
|
|
| 221 |
isReady = false;
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 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
|
| 236 |
*/
|
| 237 |
export async function scorePassages(
|
| 238 |
query: string,
|
| 239 |
passages: string[],
|
| 240 |
): Promise<number[]> {
|
| 241 |
-
|
|
|
|
| 242 |
return [];
|
| 243 |
}
|
| 244 |
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 109 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
}
|