@remit/search-service 0.0.17 → 0.0.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/search-service",
3
- "version": "0.0.17",
3
+ "version": "0.0.19",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
package/src/embeddings.ts CHANGED
@@ -164,6 +164,15 @@ export class LocalEmbeddingService implements EmbeddingService {
164
164
  try {
165
165
  return await pipeline("feature-extraction", this.modelId, {
166
166
  dtype: this.dtype,
167
+ // onnxruntime's CPU arena is a high-water mark: it sizes itself to the
168
+ // largest batch the session has ever run and never returns that memory
169
+ // to the OS. On a shared 4 GB box that makes one wide batch permanent
170
+ // resident memory, so the search-index worker's throttle (#585) could
171
+ // only ever stop the growth, never walk it back. Off, allocations go
172
+ // through the ordinary allocator and freed tensors are actually
173
+ // released; the cost is per-inference malloc traffic, which is noise
174
+ // next to the model's own work.
175
+ session_options: { enableCpuMemArena: false },
167
176
  });
168
177
  } catch (error) {
169
178
  throw new EmbeddingModelUnavailableError(this.modelId, { cause: error });
@@ -177,10 +186,69 @@ export class LocalEmbeddingService implements EmbeddingService {
177
186
  pooling: "mean",
178
187
  normalize: true,
179
188
  });
180
- return tensor.tolist() as number[][];
189
+ try {
190
+ return tensor.tolist() as number[][];
191
+ } finally {
192
+ // The pooled, normalized output — one vector per text, not the
193
+ // per-token hidden states, which the pipeline drops itself. Small per
194
+ // call, and its buffer is a native allocation outside the V8 heap
195
+ // (#585), so it is released here rather than whenever GC gets to a JS
196
+ // wrapper that looks cheap. `tolist` has already copied what the caller
197
+ // needs. The arena setting above is what bounds the large allocations.
198
+ tensor.dispose();
199
+ }
181
200
  };
182
201
  }
183
202
 
184
203
  export const createLocalEmbeddingService = (
185
204
  config?: LocalEmbeddingConfig,
186
205
  ): EmbeddingService => new LocalEmbeddingService(config);
206
+
207
+ /**
208
+ * Semantic search is off on this instance (`SEARCH_EMBEDDING_PROVIDER=off`).
209
+ *
210
+ * It carries the same `code` a missing model raises, because it is the same
211
+ * capability absence from every caller's point of view: the backend's semantic
212
+ * paths classify it through `noteSemanticCapabilityAbsence` and take the route
213
+ * they already have for a deployment that cannot embed
214
+ * (packages/backend/src/service/semantic-capability.ts). Returning empty
215
+ * results from the embedder instead would report an unavailable pipeline as a
216
+ * search that found nothing.
217
+ */
218
+ export class EmbeddingDisabledError extends Error {
219
+ readonly code = "ERR_EMBEDDING_MODEL_UNAVAILABLE";
220
+ constructor() {
221
+ super(
222
+ "Semantic search is off on this instance (SEARCH_EMBEDDING_PROVIDER=off)",
223
+ );
224
+ this.name = "EmbeddingDisabledError";
225
+ }
226
+ }
227
+
228
+ /**
229
+ * The embedder for `SEARCH_EMBEDDING_PROVIDER=off`: nothing is embedded, and
230
+ * the first caller that asks gets the typed absence above.
231
+ *
232
+ * It still reports a dimension count, because the vector store's column is
233
+ * created from it (`buildVectorStoreFromEnv`) and the stored vectors outlive
234
+ * the setting — turning semantic search off keeps `vec.db` as it is, and
235
+ * turning it back on must find the same 384-wide column rather than a store
236
+ * that disagrees with the embedder.
237
+ */
238
+ export class DisabledEmbeddingService implements EmbeddingService {
239
+ readonly dimensions: number;
240
+ readonly embeddingId: string;
241
+
242
+ constructor(dimensions: number = DEFAULT_LOCAL_DIMENSIONS) {
243
+ this.dimensions = dimensions;
244
+ this.embeddingId = `off@${this.dimensions}`;
245
+ }
246
+
247
+ embed = async (): Promise<number[][]> => {
248
+ throw new EmbeddingDisabledError();
249
+ };
250
+ }
251
+
252
+ export const createDisabledEmbeddingService = (
253
+ dimensions?: number,
254
+ ): EmbeddingService => new DisabledEmbeddingService(dimensions);
@@ -1,6 +1,10 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { afterEach, describe, it } from "node:test";
3
- import { buildEmbeddingServiceFromEnv } from "./from-env.js";
3
+ import { EmbeddingDisabledError } from "./embeddings.js";
4
+ import {
5
+ buildEmbeddingServiceFromEnv,
6
+ readEmbeddingProviderFromEnv,
7
+ } from "./from-env.js";
4
8
 
5
9
  const ENV_KEYS = [
6
10
  "SEARCH_EMBEDDING_PROVIDER",
@@ -60,3 +64,70 @@ describe("buildEmbeddingServiceFromEnv dtype handling", () => {
60
64
  assert.equal(service.embeddingId, "deterministic@64");
61
65
  });
62
66
  });
67
+
68
+ describe("SEARCH_EMBEDDING_PROVIDER=off", () => {
69
+ it("builds an embedder that embeds nothing", () => {
70
+ process.env.SEARCH_EMBEDDING_PROVIDER = "off";
71
+ delete process.env.SEARCH_EMBEDDING_DIMENSIONS;
72
+
73
+ const service = buildEmbeddingServiceFromEnv();
74
+
75
+ assert.equal(service.embeddingId, "off@384");
76
+ });
77
+
78
+ it("keeps the store's column width, so turning it back on finds the vectors it left", () => {
79
+ process.env.SEARCH_EMBEDDING_PROVIDER = "off";
80
+
81
+ assert.equal(buildEmbeddingServiceFromEnv().dimensions, 384);
82
+ });
83
+
84
+ it("raises the capability absence the backend already degrades on, not empty results", async () => {
85
+ process.env.SEARCH_EMBEDDING_PROVIDER = "off";
86
+ const service = buildEmbeddingServiceFromEnv();
87
+
88
+ await assert.rejects(
89
+ () => service.embed(["anything"]),
90
+ (error: unknown) => {
91
+ assert.ok(error instanceof EmbeddingDisabledError);
92
+ assert.equal(
93
+ (error as { code: string }).code,
94
+ "ERR_EMBEDDING_MODEL_UNAVAILABLE",
95
+ );
96
+ return true;
97
+ },
98
+ );
99
+ });
100
+ });
101
+
102
+ describe("readEmbeddingProviderFromEnv", () => {
103
+ it("accepts every provider this deployment understands", () => {
104
+ for (const provider of ["off", "local", "bedrock", "deterministic"]) {
105
+ process.env.SEARCH_EMBEDDING_PROVIDER = provider;
106
+ assert.equal(readEmbeddingProviderFromEnv(), provider);
107
+ }
108
+ });
109
+
110
+ it("falls back to the deterministic embedder when nothing is set", () => {
111
+ delete process.env.SEARCH_EMBEDDING_PROVIDER;
112
+ assert.equal(readEmbeddingProviderFromEnv(), "deterministic");
113
+
114
+ process.env.SEARCH_EMBEDDING_PROVIDER = "";
115
+ assert.equal(readEmbeddingProviderFromEnv(), "deterministic");
116
+ });
117
+
118
+ it("rejects a value nothing selects, rather than indexing with the test embedder", () => {
119
+ for (const garbage of ["Off", "none", "disabled", "loca", "true"]) {
120
+ process.env.SEARCH_EMBEDDING_PROVIDER = garbage;
121
+ assert.throws(
122
+ () => readEmbeddingProviderFromEnv(),
123
+ /SEARCH_EMBEDDING_PROVIDER must be one of/,
124
+ garbage,
125
+ );
126
+ assert.throws(
127
+ () => buildEmbeddingServiceFromEnv(),
128
+ /SEARCH_EMBEDDING_PROVIDER must be one of/,
129
+ garbage,
130
+ );
131
+ }
132
+ });
133
+ });
package/src/from-env.ts CHANGED
@@ -6,10 +6,52 @@ import { createS3VectorsBackend } from "./backends/s3-vectors.js";
6
6
  import { createSqliteVectorStore } from "./backends/sqlite-vec.js";
7
7
  import {
8
8
  createDeterministicEmbeddingService,
9
+ createDisabledEmbeddingService,
9
10
  createLocalEmbeddingService,
10
11
  type EmbeddingService,
11
12
  } from "./embeddings.js";
12
13
 
14
+ /**
15
+ * The embedding providers this deployment understands.
16
+ *
17
+ * `off` is a first-class value, not an absent one: the self-host stack ships
18
+ * with semantic search off (deploy/vps/remit.env.template), and an operator
19
+ * turns it on with `remit semantic on`. `deterministic` is the unit-test and
20
+ * e2e embedder, named here so an environment can ask for it rather than
21
+ * getting it by falling through.
22
+ *
23
+ * Anything else fails the process at startup. A typo used to select the
24
+ * deterministic embedder, which writes real-looking vectors nothing can match
25
+ * a query against — a silently useless index on a box that reported success.
26
+ */
27
+ const PROVIDERS = ["off", "local", "bedrock", "deterministic"] as const;
28
+
29
+ export type EmbeddingProvider = (typeof PROVIDERS)[number];
30
+
31
+ /** The provider when `SEARCH_EMBEDDING_PROVIDER` is unset: unit tests and the shims. */
32
+ const DEFAULT_PROVIDER: EmbeddingProvider = "deterministic";
33
+
34
+ export const EMBEDDING_PROVIDER_OFF: EmbeddingProvider = "off";
35
+
36
+ const isProvider = (value: string): value is EmbeddingProvider =>
37
+ (PROVIDERS as readonly string[]).includes(value);
38
+
39
+ /**
40
+ * `SEARCH_EMBEDDING_PROVIDER`, validated. Shared with the search-index worker,
41
+ * which gates its own memory governor and its startup on the same value, so the
42
+ * vocabulary and the rejection live in one place.
43
+ */
44
+ export const readEmbeddingProviderFromEnv = (): EmbeddingProvider => {
45
+ const raw = process.env.SEARCH_EMBEDDING_PROVIDER;
46
+ if (raw === undefined || raw === "") return DEFAULT_PROVIDER;
47
+ if (!isProvider(raw)) {
48
+ throw new Error(
49
+ `SEARCH_EMBEDDING_PROVIDER must be one of ${PROVIDERS.join(", ")}, got: ${raw}`,
50
+ );
51
+ }
52
+ return raw;
53
+ };
54
+
13
55
  const parseDimensions = (): number | undefined => {
14
56
  const raw = process.env.SEARCH_EMBEDDING_DIMENSIONS;
15
57
  if (!raw) return undefined;
@@ -86,12 +128,19 @@ export const buildVectorStoreFromEnv = (
86
128
  /**
87
129
  * Select an embedder from the environment, mirroring `buildVectorStoreFromEnv`:
88
130
  *
131
+ * - `SEARCH_EMBEDDING_PROVIDER=off` → nothing embeds. The self-host default: the
132
+ * search-index worker sits behind the `semantic` compose profile and is not
133
+ * running, and the backend's semantic paths take their existing unavailable
134
+ * route (packages/backend/src/service/semantic-capability.ts) rather than
135
+ * reporting an unavailable pipeline as a search that found nothing. FTS5 text
136
+ * search is unaffected.
89
137
  * - `SEARCH_EMBEDDING_PROVIDER=local` → Transformers.js model (local dev). The
90
138
  * model is `SEARCH_EMBEDDING_MODEL_ID` (default MiniLM); the self-host stack
91
139
  * points it at a multilingual MiniLM so the ~50% non-English mail corpus
92
140
  * embeds well. Both models are 384-dim, so the vector column is stable.
93
141
  * - `SEARCH_EMBEDDING_PROVIDER=bedrock` → Bedrock Titan (prod).
94
- * - otherwise → deterministic bag-of-words embedder (unit tests / default).
142
+ * - `SEARCH_EMBEDDING_PROVIDER=deterministic`, or unset deterministic
143
+ * bag-of-words embedder (unit tests / default). Any other value fails here.
95
144
  *
96
145
  * `SEARCH_EMBEDDING_DIMENSIONS`, when set, pins the dimension count for the local
97
146
  * and deterministic embedders so the store's vector column and the embedder
@@ -102,8 +151,11 @@ export const buildVectorStoreFromEnv = (
102
151
  * `fp32`. The search-index-worker container sets `q8` and bakes the matching file.
103
152
  */
104
153
  export const buildEmbeddingServiceFromEnv = (): EmbeddingService => {
105
- const provider = process.env.SEARCH_EMBEDDING_PROVIDER;
154
+ const provider = readEmbeddingProviderFromEnv();
106
155
  const dimensions = parseDimensions();
156
+ if (provider === "off") {
157
+ return createDisabledEmbeddingService(dimensions);
158
+ }
107
159
  if (provider === "local") {
108
160
  return createLocalEmbeddingService({
109
161
  modelId: process.env.SEARCH_EMBEDDING_MODEL_ID,
package/src/index.ts CHANGED
@@ -33,9 +33,12 @@ export {
33
33
  export { computeContentHash } from "./content-hash.js";
34
34
  export {
35
35
  createDeterministicEmbeddingService,
36
+ createDisabledEmbeddingService,
36
37
  createLocalEmbeddingService,
37
38
  type DeterministicEmbeddingConfig,
38
39
  DeterministicEmbeddingService,
40
+ DisabledEmbeddingService,
41
+ EmbeddingDisabledError,
39
42
  EmbeddingModelUnavailableError,
40
43
  type EmbeddingService,
41
44
  type LocalEmbeddingConfig,