MiniSearch / server /rerankerService.ts
github-actions[bot]
Sync from https://github.com/felladrin/MiniSearch
9509f5b
Raw
History Blame Contribute Delete
8.26 kB
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { Tokenizer } from "@huggingface/tokenizers";
import debug from "debug";
import { InferenceSession, Tensor } from "onnxruntime-node";
import { downloadFileFromHuggingFaceRepository } from "./downloadFileFromHuggingFaceRepository.ts";
const fileName = path.basename(import.meta.url);
const printMessage = debug(fileName);
printMessage.enabled = true;
const MODEL_HF_REPO = "cross-encoder/mmarco-mMiniLMv2-L12-H384-v1";
/**
* The dynamically quantized export. The repository ships one build per CPU
* kernel family (`qint8_arm64`, `qint8_avx512`, `qint8_avx512_vnni`,
* `quint8_avx2`) from the same weights; this one is the portable choice, because
* unsigned activations sidestep the signed-int8 saturation that x64 without VNNI
* has to work around, and it measured no slower than the arm64 build on arm64.
* Quantization costs nothing measurable: 0.7992 against 0.7973 nDCG@10 for fp32
* on 240 MIRACL queries, for a quarter of the download and half the latency.
*/
const MODEL_HF_FILE = "onnx/model_quint8_avx2.onnx";
const TOKENIZER_HF_FILE = "tokenizer.json";
const TOKENIZER_CONFIG_HF_FILE = "tokenizer_config.json";
/**
* Hard ceiling rather than a tuning knob: the model has 514 learned position
* embeddings, two of which XLM-RoBERTa reserves, so a 513-token pair fails
* outright with `indices element out of data bounds` at the position-embedding
* gather. Still more generous than what shipped before #2260, which cut
* documents to 512 characters upstream.
*/
const MAX_SEQUENCE_LENGTH = 512;
let isReady = false;
let session: InferenceSession | null = null;
let tokenizer: Tokenizer | null = null;
/**
* Sanitizes Unicode surrogate pairs in input string
* @param input - String to sanitize
* @returns Sanitized string with valid Unicode surrogates
*/
export function sanitizeUnicodeSurrogates(input: string) {
let output = "";
for (let i = 0; i < input.length; i += 1) {
const codeUnit = input.charCodeAt(i);
if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) {
const nextCodeUnit =
i + 1 < input.length ? input.charCodeAt(i + 1) : undefined;
if (
nextCodeUnit !== undefined &&
nextCodeUnit >= 0xdc00 &&
nextCodeUnit <= 0xdfff
) {
output += input[i];
output += input[i + 1];
i += 1;
} else {
output += "\ufffd";
}
continue;
}
if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) {
output += "\ufffd";
continue;
}
output += input[i];
}
return output;
}
function resolveModelPath(hfRepoFile: string) {
return path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"models",
MODEL_HF_REPO,
hfRepoFile,
);
}
async function ensureFileExists(hfRepoFile: string) {
const localPath = resolveModelPath(hfRepoFile);
await downloadFileFromHuggingFaceRepository(
MODEL_HF_REPO,
hfRepoFile,
localPath,
);
return localPath;
}
/**
* Runs on the CPU, with nothing to configure. A dynamically quantized graph is
* the wrong shape for the WebGPU provider, which has no kernels for the integer
* matmuls and shuttles every one of them back to the CPU: 812ms against 172ms
* for the same work, with scores drifting by up to 1.15 and reordering results.
* `coreml` is out for the same reason it was before, being slower than CPU on
* dynamic shapes. The architecture is logged because it selects the quantized
* kernel, which is the part that varies between hosts.
*/
function createSession(modelPath: string) {
printMessage(
`Loading model on CPU (arch: ${process.arch}, platform: ${process.platform})...`,
);
return InferenceSession.create(modelPath, {
executionProviders: ["cpu"],
// Errors only. ONNX Runtime otherwise warns on every startup that it
// assigned shape operators to CPU, which is expected and not actionable.
logSeverityLevel: 3,
});
}
export async function startRerankerService() {
printMessage("Preparing model...");
const [modelPath, tokenizerPath, tokenizerConfigPath] = await Promise.all([
ensureFileExists(MODEL_HF_FILE),
ensureFileExists(TOKENIZER_HF_FILE),
ensureFileExists(TOKENIZER_CONFIG_HF_FILE),
]);
tokenizer = new Tokenizer(
JSON.parse(fs.readFileSync(tokenizerPath, "utf8")),
JSON.parse(fs.readFileSync(tokenizerConfigPath, "utf8")),
);
session = await createSession(modelPath);
await score("test", ["test document"]);
isReady = true;
printMessage("Service ready!");
}
export async function stopRerankerService() {
isReady = false;
const currentSession = session;
session = null;
tokenizer = null;
await currentSession?.release();
}
export async function getRerankerStatus() {
return isReady;
}
/**
* Scores one pair on its own. Documents are deliberately not batched: this graph
* quantizes activations dynamically, deriving the scale from each tensor's own
* range, and padding rows out to a shared width puts the pad positions inside
* that range even though the attention mask excludes them from attention. A
* document's score then depends on which documents happen to sit beside it,
* which moved logits by up to 1.29 and reordered 2 of 10 fixtures. One pair per
* call has no padding to begin with, and it also holds the event loop for 13ms
* at a time instead of 50ms, for about 12% more wall time on two threads.
*/
async function scoreDocument(
activeSession: InferenceSession,
ids: number[],
): Promise<number> {
const dimensions = [1, ids.length];
const { logits } = await activeSession.run({
input_ids: new Tensor("int64", BigInt64Array.from(ids, BigInt), dimensions),
attention_mask: new Tensor(
"int64",
new BigInt64Array(ids.length).fill(1n),
dimensions,
),
});
return Number((logits.data as Float32Array)[0]);
}
/**
* Caps an encoded cross-encoder pair at `maxLength` tokens by dropping tokens
* from the end, which is where the document is: the sequence is `<s> query </s>
* </s> document </s>`, so the query sits at the front and survives. The final
* separator is carried over to the new end so the model still receives a
* well-formed pair. This mirrors the tokenizer's `only_second` truncation, which
* the JS package does not implement. The query segment is not read off
* `token_type_ids`, because XLM-RoBERTa has a `type_vocab_size` of 1 and emits
* zeros for the whole sequence.
*/
export function truncatePairTokens(ids: number[], maxLength: number): number[] {
if (ids.length <= maxLength) {
return ids;
}
return [...ids.slice(0, maxLength - 1), ids[ids.length - 1]];
}
/**
* Returns the cross-encoder's raw relevance logit per document. Deliberately
* not squashed through sigmoid: the standard-deviation filter in
* rankSearchResults is calibrated against this scale.
*/
async function score(query: string, documents: string[]) {
if (!session || !tokenizer) {
throw new Error("Reranker model is not loaded");
}
const activeSession = session;
const loadedTokenizer = tokenizer;
const scores: number[] = [];
for (const document of documents) {
const { ids } = loadedTokenizer.encode(query, { text_pair: document });
scores.push(
await scoreDocument(
activeSession,
truncatePairTokens(ids, MAX_SEQUENCE_LENGTH),
),
);
}
return scores;
}
export async function rerank(query: string, documents: string[]) {
if (!documents || documents.length === 0) {
return [];
}
if (!isReady) {
throw new Error("Reranker service is not ready");
}
const sanitizedQuery = sanitizeUnicodeSurrogates(query);
const sanitizedDocuments = documents.map(sanitizeUnicodeSurrogates);
if (sanitizedQuery !== query) {
printMessage(
"Rerank query contained invalid Unicode surrogates; sanitized",
);
}
if (sanitizedDocuments.some((doc, index) => doc !== documents[index])) {
printMessage(
"One or more rerank documents contained invalid Unicode surrogates; sanitized",
);
}
const scores = await score(sanitizedQuery, sanitizedDocuments);
return scores.map((relevance_score, index) => ({ index, relevance_score }));
}