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 }));
}