@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 +1 -1
- package/src/embeddings.ts +69 -1
- package/src/from-env.test.ts +72 -1
- package/src/from-env.ts +54 -2
- package/src/index.ts +3 -0
package/package.json
CHANGED
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
|
-
|
|
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);
|
package/src/from-env.test.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { afterEach, describe, it } from "node:test";
|
|
3
|
-
import {
|
|
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
|
-
* -
|
|
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 =
|
|
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,
|