MiniSearch / server /biEncoderService.integration.test.ts
system's picture
system HF Staff
Sync from felladrin/MiniSearch@c75b7b9 (part 2)
3bbbb17 verified
Raw
History Blame Contribute Delete
13.8 kB
// @vitest-environment node
/**
* Exercises the real bi-encoder end to end. Downloads ~460MB on first run, so
* it is excluded from the default suite:
*
* npx vitest run --config vitest.integration.config.ts biEncoder
*/
import { monitorEventLoopDelay } from "node:perf_hooks";
import { Worker } from "node:worker_threads";
import type { Tokenizer } from "@huggingface/tokenizers";
import { type InferenceSession, Tensor } from "onnxruntime-node";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
getBiEncoderStatus,
scorePassages,
startBiEncoderService,
stopBiEncoderService,
} from "./biEncoderService";
import {
BATCH_ROWS,
type BiEncoderRequest,
type BiEncoderResponse,
} from "./biEncoderWorkerProtocol";
import { loadOnnxModel } from "./utils/onnxModelLoader";
const MODEL_HF_REPO =
"sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2";
const MODEL_HF_FILE = "onnx/model.onnx";
const MAX_SEQUENCE_LENGTH = 256;
const WORDS =
"alpha bravo charlie delta echo foxtrot golf hotel india juliet kilo lima mike november oscar papa quebec romeo sierra tango uniform victor whiskey xray yankee zulu".split(
" ",
);
/** Deterministic passage of `wordCount` words, distinct per `seed`. */
function makePassage(wordCount: number, seed: number): string {
const words: string[] = [];
for (let i = 0; i < wordCount; i++) {
words.push(WORDS[(i * 7 + seed * 13) % WORDS.length]);
}
return words.join(" ");
}
/**
* The per-text path this change replaced: one `session.run()` per text,
* `[1, L]` tensors, mean pooling over the tokenizer's own mask. Kept here as
* the reference the batched path must match.
*/
async function encodePerText(
refSession: InferenceSession,
refTokenizer: Tokenizer,
text: string,
): Promise<Float32Array> {
const { ids, attention_mask } = refTokenizer.encode(text);
const truncatedIds = ids.slice(0, MAX_SEQUENCE_LENGTH);
const truncatedMask = attention_mask.slice(0, MAX_SEQUENCE_LENGTH);
const length = truncatedIds.length;
const dimensions = [1, length];
const { last_hidden_state } = await refSession.run({
input_ids: new Tensor(
"int64",
BigInt64Array.from(truncatedIds, BigInt),
dimensions,
),
attention_mask: new Tensor(
"int64",
BigInt64Array.from(truncatedMask, BigInt),
dimensions,
),
token_type_ids: new Tensor("int64", new BigInt64Array(length), dimensions),
});
const embedding = last_hidden_state.data as Float32Array;
const dim = last_hidden_state.dims[2];
const pooled = new Float32Array(dim);
let count = 0;
for (let t = 0; t < length; t++) {
if (truncatedMask[t] === 0) continue;
const offset = t * dim;
for (let d = 0; d < dim; d++) {
pooled[d] += embedding[offset + d];
}
count++;
}
if (count > 0) {
for (let d = 0; d < dim; d++) {
pooled[d] /= count;
}
}
let norm = 0;
for (let d = 0; d < dim; d++) {
norm += pooled[d] * pooled[d];
}
norm = Math.sqrt(norm);
if (norm > 0) {
for (let d = 0; d < dim; d++) {
pooled[d] /= norm;
}
}
return pooled;
}
function dot(a: Float32Array, b: Float32Array): number {
let sum = 0;
for (let d = 0; d < a.length; d++) {
sum += a[d] * b[d];
}
return sum;
}
describe("biEncoderService", () => {
beforeAll(async () => {
await startBiEncoderService();
});
afterAll(async () => {
await stopBiEncoderService();
});
it("reports itself ready", async () => {
expect(await getBiEncoderStatus()).toBe(true);
});
it("scores a relevant passage above an unrelated one", async () => {
const [relevant, unrelated] = await scorePassages(
"how to bake sourdough bread at home",
[
"Mix the starter with flour and water, let the dough rise overnight, then bake it in a hot Dutch oven.",
"Antarctica has no permanent residents and its ice sheet holds most of the planet's fresh water.",
],
);
expect(relevant).toBeGreaterThan(unrelated);
expect(relevant).toBeGreaterThan(0.3);
});
it("matches across languages", async () => {
const [portuguese, unrelated] = await scorePassages(
"what is the capital of France",
[
"A capital da FranΓ§a Γ© Paris, situada Γ s margens do rio Sena.",
"Bubble sort repeatedly swaps adjacent elements until the list is ordered.",
],
);
expect(portuguese).toBeGreaterThan(unrelated);
});
it("returns one score per passage", async () => {
const scores = await scorePassages("query", ["one", "two", "three"]);
expect(scores).toHaveLength(3);
for (const score of scores) {
expect(score).toBeGreaterThanOrEqual(-1.001);
expect(score).toBeLessThanOrEqual(1.001);
}
});
});
describe("biEncoderService batched scoring (#2715)", () => {
let refSession: InferenceSession;
let refTokenizer: Tokenizer;
beforeAll(async () => {
// The first describe's afterAll stopped the service; these tests need it
// running again, alongside a second session used as the per-text
// reference.
await startBiEncoderService();
const loaded = await loadOnnxModel(MODEL_HF_REPO, MODEL_HF_FILE);
refSession = loaded.session;
refTokenizer = loaded.tokenizer;
});
afterAll(async () => {
await stopBiEncoderService();
await refSession?.release();
});
it("keeps batched scores within 1e-5 of the per-text path", async () => {
const wordCounts = [
3, 8, 15, 40, 90, 150, 250, 400, 5, 20, 60, 120, 200, 300, 12, 35, 75,
175, 350, 7, 45, 110, 230, 9,
];
const passages = wordCounts.map((n, i) => makePassage(n, i + 100));
const query = "alpha bravo charlie delta echo";
const batchedScores = await scorePassages(query, passages);
const queryEmbedding = await encodePerText(refSession, refTokenizer, query);
let maxAbsDiff = 0;
for (let i = 0; i < passages.length; i++) {
const passageEmbedding = await encodePerText(
refSession,
refTokenizer,
passages[i],
);
const referenceScore = dot(queryEmbedding, passageEmbedding);
maxAbsDiff = Math.max(
maxAbsDiff,
Math.abs(batchedScores[i] - referenceScore),
);
expect(Math.abs(batchedScores[i] - referenceScore)).toBeLessThan(1e-5);
}
// Sanity: the fixture actually exercised the comparison rather than
// matching an all-zero result β€” real magnitude and real spread. An
// all-zero or all-equal score vector fails here.
const maxAbsScore = Math.max(...batchedScores.map((s) => Math.abs(s)));
expect(maxAbsScore).toBeGreaterThan(0.1);
expect(new Set(batchedScores).size).toBeGreaterThan(1);
expect(maxAbsDiff).toBeLessThan(1e-5);
});
it("returns scores in the original passage order, not the length-sorted order", async () => {
// Deliberately out of length order: long, short, medium, short, long...
const passages = [
makePassage(220, 1),
makePassage(4, 2),
makePassage(60, 3),
makePassage(6, 4),
makePassage(180, 5),
makePassage(40, 6),
makePassage(5, 7),
makePassage(140, 8),
makePassage(3, 9),
makePassage(300, 10),
];
const query = "whiskey xray yankee zulu";
const batchedScores = await scorePassages(query, passages);
const queryEmbedding = await encodePerText(refSession, refTokenizer, query);
for (let i = 0; i < passages.length; i++) {
const passageEmbedding = await encodePerText(
refSession,
refTokenizer,
passages[i],
);
const referenceScore = dot(queryEmbedding, passageEmbedding);
// A permutation of the length-sorted order would miss by far more
// than the padding tolerance.
expect(Math.abs(batchedScores[i] - referenceScore)).toBeLessThan(1e-5);
}
// The fixture is non-monotonic in length, so the sorted order differs
// from the input order and the check above is a real order test.
const lengths = passages.map((p) => p.length);
const isSorted = lengths.every(
(len, i) => i === 0 || lengths[i - 1] <= len,
);
expect(isSorted).toBe(false);
});
});
/**
* Runs one scoring request against a worker of its own and returns its reply.
*
* The session lives in the worker now, so a `session.run` spy on this thread
* would see nothing. The worker reports the dims of each forward pass instead,
* which is what the bucketing assertions read.
*/
async function scoreInOwnWorker(
query: string,
passages: string[],
): Promise<Extract<BiEncoderResponse, { type: "scores" }>> {
const worker = new Worker(new URL("./biEncoderWorker.ts", import.meta.url));
try {
await new Promise<void>((resolve, reject) => {
worker.once("message", (message: BiEncoderResponse) => {
if (message.type === "ready") resolve();
else reject(new Error(`Expected "ready", got "${message.type}"`));
});
worker.once("error", reject);
});
return await new Promise((resolve, reject) => {
worker.once("message", (message: BiEncoderResponse) => {
if (message.type === "scores") resolve(message);
else if (message.type === "failed") reject(new Error(message.message));
});
worker.once("error", reject);
const request: BiEncoderRequest = {
type: "score",
id: 1,
query,
passages,
};
worker.postMessage(request);
});
} finally {
await worker.terminate();
}
}
describe("biEncoderWorker bucketing (#2715, #2730)", () => {
it("runs one forward pass per length bucket, not one per passage", async () => {
// Zigzag lengths with the sort's guarantee removed: bucket 1's last
// row (190 words) is longer than bucket 2's last row (25 words), so
// an unsorted pass sets bucket widths from those rows and they come
// out decreasing β€” the non-decreasing-width assertion below then
// fails. With the sort, widths are non-decreasing by construction.
const wordCounts = [
200, 10, 150, 5, 180, 20, 120, 8, 90, 160, 30, 110, 60, 140, 190, 170, 15,
130, 70, 25,
];
const passages = wordCounts.map((n, i) => makePassage(n, i));
const { scores, runDimensions } = await scoreInOwnWorker(
"how do alpha bravo passages score",
passages,
);
expect(scores).toHaveLength(passages.length);
// The query rides in the batch, so 21 rows go through ceil(21 / B)
// bucketed runs instead of 21 per-passage runs.
const expectedBuckets = Math.ceil((passages.length + 1) / BATCH_ROWS);
expect(runDimensions.length).toBe(expectedBuckets);
expect(runDimensions.length).toBeLessThan(passages.length + 1);
let totalRows = 0;
for (const dims of runDimensions) {
expect(dims.length).toBe(2);
expect(dims[0]).toBeLessThanOrEqual(BATCH_ROWS);
totalRows += dims[0];
}
expect(totalRows).toBe(passages.length + 1);
// Every non-final bucket is full, so it really carries multiple rows.
// Only the final bucket may be short β€” with BATCH_ROWS of 10 or 20 a
// 21-row pool leaves it exactly one row, so the >1 check must not
// depend on the tuning knob.
for (let i = 0; i < runDimensions.length - 1; i++) {
expect(runDimensions[i][0]).toBeGreaterThan(1);
}
// Bucket widths are non-decreasing across runs: the sort by token
// length put the short rows first. Delete the `.sort()` in encodeBatch
// and this fails.
for (let i = 1; i < runDimensions.length; i++) {
expect(runDimensions[i][1]).toBeGreaterThanOrEqual(
runDimensions[i - 1][1],
);
}
});
});
describe("biEncoderService main-thread cost (#2730)", () => {
beforeAll(async () => {
await startBiEncoderService();
});
afterAll(async () => {
await stopBiEncoderService();
});
/**
* The acceptance measurement from #2730. On the main thread the same pass
* blocked for ~815 ms (x86_64, 32 logical cores); with the session in the
* worker it measures ~1.5 ms. The bar is left at the issue's 20 ms so the
* test reports a regression rather than host-to-host noise.
*/
it("keeps the main-thread event-loop block under 20 ms for a 200-passage pass", async () => {
const passages = Array.from({ length: 200 }, (_, i) =>
makePassage(5 + ((i * 37) % 300), i),
);
const query = "how do alpha bravo passages score";
// One untimed pass, so the worker's lazily allocated arenas are not
// charged to the measurement.
await scorePassages(query, passages.slice(0, 20));
const histogram = monitorEventLoopDelay({ resolution: 1 });
histogram.enable();
const scores = await scorePassages(query, passages);
histogram.disable();
expect(scores).toHaveLength(passages.length);
expect(histogram.max / 1e6).toBeLessThan(20);
});
});
describe("biEncoderService when the worker goes away (#2730)", () => {
it("answers in-flight scoring with empty scores and reports not ready", async () => {
await startBiEncoderService();
expect(await getBiEncoderStatus()).toBe(true);
const passages = Array.from({ length: 200 }, (_, i) =>
makePassage(5 + ((i * 37) % 300), i),
);
// Fired but not awaited: the worker is taken away underneath it. An
// unanswered request would hang the page-content read that made it, so
// it has to come back empty, which is the signal to rank lexically.
const pending = scorePassages(
"how do alpha bravo passages score",
passages,
);
await stopBiEncoderService();
await expect(pending).resolves.toEqual([]);
expect(await getBiEncoderStatus()).toBe(false);
expect(await scorePassages("query", ["one"])).toEqual([]);
});
});