Spaces:
Running
Running
File size: 8,259 Bytes
634e30a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 | 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 }));
}
|