@remit/search-service 0.0.18 → 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.18",
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
@@ -203,3 +203,52 @@ export class LocalEmbeddingService implements EmbeddingService {
203
203
  export const createLocalEmbeddingService = (
204
204
  config?: LocalEmbeddingConfig,
205
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,