File size: 4,924 Bytes
3bbbb17
 
 
 
 
 
 
 
 
 
 
6c3af4e
3bbbb17
 
 
 
 
 
6c3af4e
 
 
3bbbb17
 
 
6c3af4e
3bbbb17
 
6c3af4e
6dcf170
3bbbb17
 
 
 
6dcf170
3bbbb17
 
 
 
6c3af4e
 
3bbbb17
 
 
6c3af4e
3bbbb17
 
 
 
 
 
6c3af4e
3bbbb17
 
6dcf170
3bbbb17
 
 
 
6dcf170
3bbbb17
 
6dcf170
3bbbb17
 
 
6dcf170
3bbbb17
 
 
 
6c3af4e
 
3bbbb17
6c3af4e
 
3bbbb17
 
 
 
6c3af4e
3bbbb17
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6c3af4e
 
 
 
 
 
3bbbb17
6c3af4e
3bbbb17
 
 
6c3af4e
 
 
 
 
 
 
 
3bbbb17
6c3af4e
 
 
 
 
3bbbb17
 
6c3af4e
 
 
3bbbb17
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6c3af4e
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
/**
 * The main thread's side of the bi-encoder. It owns no model and runs no
 * inference: it spawns `biEncoderWorker.ts`, forwards scoring requests to it
 * and matches the replies back to their callers.
 *
 * The four exported functions keep the signatures they had when the session
 * lived here, so no caller changed. What changed is that a scoring pass no
 * longer stops the event loop: a 200-passage pool blocked the main thread for
 * ~815 ms before and stays under 20 ms now (see docs/page-content.md).
 */

import path from "node:path";
import { Worker } from "node:worker_threads";
import type {
  BiEncoderRequest,
  BiEncoderResponse,
} from "./biEncoderWorkerProtocol.ts";
import { createModelLogger } from "./utils/onnxModelLoader.ts";

const printMessage = createModelLogger(path.basename(import.meta.url));

let worker: Worker | null = null;
let isReady = false;
let nextRequestId = 0;

/** Resolvers for requests the worker has not answered yet, keyed by their id. */
const pendingScores = new Map<number, (scores: number[]) => void>();

/**
 * Answers every in-flight request with empty scores. Empty is the signal
 * `pageContentService` already reads as "dense scoring is unavailable, rank
 * lexically", so a worker that dies degrades the ranking instead of leaving
 * page-content reads waiting for a reply that is never coming.
 */
function resolvePendingEmpty() {
  for (const resolve of pendingScores.values()) resolve([]);
  pendingScores.clear();
}

/**
 * Drops a worker that died. Guarded on identity because a terminated worker's
 * `exit` still arrives after `startBiEncoderService` has installed its
 * replacement, and that late event must not mark the new one dead.
 */
function forgetWorker(deadWorker: Worker) {
  if (worker !== deadWorker) return;
  worker = null;
  isReady = false;
  resolvePendingEmpty();
}

export async function startBiEncoderService() {
  if (worker) await stopBiEncoderService();

  const biEncoderWorker = new Worker(
    new URL("./biEncoderWorker.ts", import.meta.url),
  );
  worker = biEncoderWorker;

  biEncoderWorker.on("message", (message: BiEncoderResponse) => {
    if (message.type === "ready") return;

    const resolve = pendingScores.get(message.id);
    if (!resolve) return;
    pendingScores.delete(message.id);

    if (message.type === "failed") {
      printMessage(`Bi-encoder scoring failed: ${message.message}`);
      resolve([]);
      return;
    }

    resolve(message.scores);
  });

  biEncoderWorker.on("error", (error) => {
    printMessage(`Bi-encoder worker failed: ${error.message}`);
    forgetWorker(biEncoderWorker);
  });

  biEncoderWorker.on("exit", () => forgetWorker(biEncoderWorker));

  await new Promise<void>((resolve, reject) => {
    const stopListening = () => {
      biEncoderWorker.off("message", onMessage);
      biEncoderWorker.off("error", onError);
      biEncoderWorker.off("exit", onExit);
    };
    const onMessage = (message: BiEncoderResponse) => {
      if (message.type !== "ready") return;
      stopListening();
      resolve();
    };
    const onError = (error: Error) => {
      stopListening();
      reject(error);
    };
    const onExit = (code: number) => {
      stopListening();
      reject(
        new Error(
          `The bi-encoder worker exited with code ${code} before it was ready`,
        ),
      );
    };

    biEncoderWorker.on("message", onMessage);
    biEncoderWorker.on("error", onError);
    biEncoderWorker.on("exit", onExit);
  });

  isReady = true;
  printMessage("Bi-encoder service ready!");
}

export async function stopBiEncoderService() {
  const runningWorker = worker;
  isReady = false;
  worker = null;
  resolvePendingEmpty();
  await runningWorker?.terminate();
}

export async function getBiEncoderStatus() {
  return isReady;
}

/**
 * Returns dense (semantic) scores for passages given a query.
 * Falls back to empty array when the worker is not running.
 */
export async function scorePassages(
  query: string,
  passages: string[],
): Promise<number[]> {
  const runningWorker = worker;
  if (!runningWorker || !isReady || passages.length === 0) {
    return [];
  }

  // Ids are per-process and monotonic because the service can have several
  // scoring passes in flight at once: two concurrent page-content reads each
  // send their own request, and the replies can come back in either order.
  const id = nextRequestId++;

  return new Promise<number[]>((resolve) => {
    pendingScores.set(id, resolve);

    const request: BiEncoderRequest = { type: "score", id, query, passages };
    try {
      runningWorker.postMessage(request);
    } catch (error) {
      // The worker was terminated between the check above and this send.
      pendingScores.delete(id);
      printMessage(
        `Could not reach the bi-encoder worker: ${error instanceof Error ? error.message : String(error)}`,
      );
      resolve([]);
    }
  });
}