@remit/search-service 0.0.8 → 0.0.10
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/backends/sqlite-vec.integ.test.ts +55 -0
- package/src/backends/sqlite-vec.ts +19 -2
- package/src/embeddings.test.ts +41 -1
- package/src/embeddings.ts +44 -7
- package/src/index.ts +1 -0
package/package.json
CHANGED
|
@@ -142,3 +142,58 @@ describe("sqlite-vec store (integration)", { skip: !RUN }, () => {
|
|
|
142
142
|
assert.equal(hashes.size, 0);
|
|
143
143
|
});
|
|
144
144
|
});
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The SQLITE_VEC_EXTENSION_PATH override branch — how the Alpine/musl backend
|
|
148
|
+
* image loads its own vec0.so instead of the npm package's glibc prebuilt. This
|
|
149
|
+
* suite runs on the glibc dev host, so it points the override at the npm
|
|
150
|
+
* package's own resolved loadable path: it proves the loader short-circuits
|
|
151
|
+
* getLoadablePath() and loads the extension via db.loadExtension(path), then
|
|
152
|
+
* drives the same vec0 read path (create table, upsert, cosine KNN) through it.
|
|
153
|
+
* The musl build of that .so is asserted to dlopen at image-build time in the
|
|
154
|
+
* Dockerfile's sqlite-vec-musl stage.
|
|
155
|
+
*/
|
|
156
|
+
describe("sqlite-vec store — SQLITE_VEC_EXTENSION_PATH override (integration)", {
|
|
157
|
+
skip: !RUN,
|
|
158
|
+
}, () => {
|
|
159
|
+
let store: VectorStoreService;
|
|
160
|
+
let previous: string | undefined;
|
|
161
|
+
|
|
162
|
+
before(async () => {
|
|
163
|
+
previous = process.env.SQLITE_VEC_EXTENSION_PATH;
|
|
164
|
+
const { getLoadablePath } = await import("sqlite-vec");
|
|
165
|
+
process.env.SQLITE_VEC_EXTENSION_PATH = getLoadablePath();
|
|
166
|
+
store = createSqliteVectorStore({
|
|
167
|
+
path: ":memory:",
|
|
168
|
+
dimensions: DIMENSIONS,
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
after(async () => {
|
|
173
|
+
await store.close?.();
|
|
174
|
+
if (previous === undefined) {
|
|
175
|
+
delete process.env.SQLITE_VEC_EXTENSION_PATH;
|
|
176
|
+
} else {
|
|
177
|
+
process.env.SQLITE_VEC_EXTENSION_PATH = previous;
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("loads the extension from the override path and ranks by cosine", async () => {
|
|
182
|
+
await store.upsert([
|
|
183
|
+
record("o-x", [1, 0, 0, 0], { messageId: "m-ox", contentHash: "hx" }),
|
|
184
|
+
record("o-z", [0.9, 0.1, 0, 0], {
|
|
185
|
+
messageId: "m-oz",
|
|
186
|
+
contentHash: "hz",
|
|
187
|
+
}),
|
|
188
|
+
record("o-y", [0, 1, 0, 0], { messageId: "m-oy", contentHash: "hy" }),
|
|
189
|
+
]);
|
|
190
|
+
|
|
191
|
+
const matches = await store.query({ vector: [1, 0, 0, 0], topK: 3 });
|
|
192
|
+
|
|
193
|
+
assert.deepEqual(
|
|
194
|
+
matches.map((m) => m.chunkId),
|
|
195
|
+
["o-x", "o-z", "o-y"],
|
|
196
|
+
);
|
|
197
|
+
assert.ok(matches[0].score > 0.99);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
@@ -21,6 +21,24 @@ type SqliteVecModule = {
|
|
|
21
21
|
load: (db: Database) => void;
|
|
22
22
|
};
|
|
23
23
|
|
|
24
|
+
// `SQLITE_VEC_EXTENSION_PATH` overrides the npm `sqlite-vec` package's
|
|
25
|
+
// getLoadablePath() with a pre-built loadable extension. That package resolves a
|
|
26
|
+
// glibc-only prebuilt (`sqlite-vec-<platform>/vec0.so`) which cannot dlopen on
|
|
27
|
+
// the Alpine/musl backend image, so that image bakes a musl-compiled `vec0.so`
|
|
28
|
+
// and points this at it. better-sqlite3 derives the entry point from the
|
|
29
|
+
// filename, and the `vec0` basename resolves to `sqlite3_vec_init` (SQLite drops
|
|
30
|
+
// the digit). Unset keeps the npm resolution — the glibc search-index-worker
|
|
31
|
+
// image sets nothing and loads the package unchanged.
|
|
32
|
+
const loadSqliteVec = async (db: Database): Promise<void> => {
|
|
33
|
+
const overridePath = process.env.SQLITE_VEC_EXTENSION_PATH;
|
|
34
|
+
if (overridePath) {
|
|
35
|
+
db.loadExtension(overridePath);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const sqliteVec = await runtimeImport<SqliteVecModule>("sqlite-vec");
|
|
39
|
+
sqliteVec.load(db);
|
|
40
|
+
};
|
|
41
|
+
|
|
24
42
|
/**
|
|
25
43
|
* vec0 stores each chunk's vector alongside the scalar fields the query path
|
|
26
44
|
* filters on, so equality / range filters are pushed into the KNN instead of
|
|
@@ -127,13 +145,12 @@ export const createSqliteVectorStore = (
|
|
|
127
145
|
dbPromise = (async () => {
|
|
128
146
|
const { default: Database } =
|
|
129
147
|
await runtimeImport<BetterSqlite3Module>("better-sqlite3");
|
|
130
|
-
const sqliteVec = await runtimeImport<SqliteVecModule>("sqlite-vec");
|
|
131
148
|
if (config.path !== ":memory:") {
|
|
132
149
|
mkdirSync(dirname(config.path), { recursive: true });
|
|
133
150
|
}
|
|
134
151
|
const db = new Database(config.path);
|
|
135
152
|
db.pragma("journal_mode = WAL");
|
|
136
|
-
|
|
153
|
+
await loadSqliteVec(db);
|
|
137
154
|
db.exec(CREATE_TABLE(dimensions));
|
|
138
155
|
return db;
|
|
139
156
|
})();
|
package/src/embeddings.test.ts
CHANGED
|
@@ -1,6 +1,24 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { describe, it } from "node:test";
|
|
3
|
-
import {
|
|
3
|
+
import type { pipeline as PipelineFn } from "@huggingface/transformers";
|
|
4
|
+
import {
|
|
5
|
+
EmbeddingModelUnavailableError,
|
|
6
|
+
LocalEmbeddingService,
|
|
7
|
+
} from "./embeddings.js";
|
|
8
|
+
|
|
9
|
+
// Exercises the model-load path without the network: the pipeline factory
|
|
10
|
+
// rejects the way a failed `from_pretrained` (HuggingFace fetch failed) does.
|
|
11
|
+
class FailingLoadService extends LocalEmbeddingService {
|
|
12
|
+
importCount = 0;
|
|
13
|
+
protected importTransformers(): Promise<{ pipeline: typeof PipelineFn }> {
|
|
14
|
+
this.importCount++;
|
|
15
|
+
const pipeline = (() =>
|
|
16
|
+
Promise.reject(
|
|
17
|
+
new TypeError("fetch failed"),
|
|
18
|
+
)) as unknown as typeof PipelineFn;
|
|
19
|
+
return Promise.resolve({ pipeline });
|
|
20
|
+
}
|
|
21
|
+
}
|
|
4
22
|
|
|
5
23
|
describe("LocalEmbeddingService", () => {
|
|
6
24
|
it("keeps the historical embeddingId when dtype is unset", () => {
|
|
@@ -25,4 +43,26 @@ describe("LocalEmbeddingService", () => {
|
|
|
25
43
|
"local:Xenova/paraphrase-multilingual-MiniLM-L12-v2:q8@384",
|
|
26
44
|
);
|
|
27
45
|
});
|
|
46
|
+
|
|
47
|
+
it("raises a typed EmbeddingModelUnavailableError when the model cannot be loaded", async () => {
|
|
48
|
+
const service = new FailingLoadService({ dimensions: 8 });
|
|
49
|
+
await assert.rejects(
|
|
50
|
+
() => service.embed(["hello"]),
|
|
51
|
+
(error: unknown) => {
|
|
52
|
+
assert.ok(error instanceof EmbeddingModelUnavailableError);
|
|
53
|
+
assert.equal(
|
|
54
|
+
(error as { code?: string }).code,
|
|
55
|
+
"ERR_EMBEDDING_MODEL_UNAVAILABLE",
|
|
56
|
+
);
|
|
57
|
+
return true;
|
|
58
|
+
},
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("clears the memoized pipeline on a load failure so a later call retries", async () => {
|
|
63
|
+
const service = new FailingLoadService({ dimensions: 8 });
|
|
64
|
+
await assert.rejects(() => service.embed(["hello"]));
|
|
65
|
+
await assert.rejects(() => service.embed(["hello"]));
|
|
66
|
+
assert.equal(service.importCount, 2);
|
|
67
|
+
});
|
|
28
68
|
});
|
package/src/embeddings.ts
CHANGED
|
@@ -91,6 +91,24 @@ export interface LocalEmbeddingConfig {
|
|
|
91
91
|
const DEFAULT_LOCAL_MODEL_ID = "Xenova/all-MiniLM-L6-v2";
|
|
92
92
|
const DEFAULT_LOCAL_DIMENSIONS = 384;
|
|
93
93
|
|
|
94
|
+
/**
|
|
95
|
+
* The local embedder could not load its model. Raised when Transformers.js fails
|
|
96
|
+
* to resolve the model files — the common cause is `from_pretrained` lazily
|
|
97
|
+
* fetching the weights from HuggingFace and the network fetch failing
|
|
98
|
+
* (`TypeError: fetch failed`), which otherwise surfaces as an opaque 500.
|
|
99
|
+
*
|
|
100
|
+
* The `code` is the contract consumers match on (mirroring Node's own error
|
|
101
|
+
* codes) to degrade the semantic path to empty results instead of crashing,
|
|
102
|
+
* without importing this class or string-matching undici internals.
|
|
103
|
+
*/
|
|
104
|
+
export class EmbeddingModelUnavailableError extends Error {
|
|
105
|
+
readonly code = "ERR_EMBEDDING_MODEL_UNAVAILABLE";
|
|
106
|
+
constructor(modelId: string, options?: { cause?: unknown }) {
|
|
107
|
+
super(`Embedding model "${modelId}" could not be loaded`, options);
|
|
108
|
+
this.name = "EmbeddingModelUnavailableError";
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
94
112
|
type TransformersModule = { pipeline: typeof PipelineFn };
|
|
95
113
|
|
|
96
114
|
/**
|
|
@@ -122,15 +140,34 @@ export class LocalEmbeddingService implements EmbeddingService {
|
|
|
122
140
|
|
|
123
141
|
private getPipeline = async (): Promise<FeatureExtractionPipeline> => {
|
|
124
142
|
if (this.pipelinePromise) return this.pipelinePromise;
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
143
|
+
const promise = this.loadPipeline();
|
|
144
|
+
this.pipelinePromise = promise;
|
|
145
|
+
try {
|
|
146
|
+
return await promise;
|
|
147
|
+
} catch (error) {
|
|
148
|
+
// A failed load (module absent, or the model could not be fetched) must
|
|
149
|
+
// not poison the instance: clear the memo so a later call retries rather
|
|
150
|
+
// than returning the same rejected promise for the process lifetime.
|
|
151
|
+
if (this.pipelinePromise === promise) this.pipelinePromise = null;
|
|
152
|
+
throw error;
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
// Import boundary kept overridable so a load failure can be exercised without
|
|
157
|
+
// the network; the default resolves Transformers.js lazily via runtimeImport.
|
|
158
|
+
protected importTransformers(): Promise<TransformersModule> {
|
|
159
|
+
return runtimeImport<TransformersModule>("@huggingface/transformers");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private loadPipeline = async (): Promise<FeatureExtractionPipeline> => {
|
|
163
|
+
const { pipeline } = await this.importTransformers();
|
|
164
|
+
try {
|
|
165
|
+
return await pipeline("feature-extraction", this.modelId, {
|
|
130
166
|
dtype: this.dtype,
|
|
131
167
|
});
|
|
132
|
-
}
|
|
133
|
-
|
|
168
|
+
} catch (error) {
|
|
169
|
+
throw new EmbeddingModelUnavailableError(this.modelId, { cause: error });
|
|
170
|
+
}
|
|
134
171
|
};
|
|
135
172
|
|
|
136
173
|
embed = async (texts: string[]): Promise<number[][]> => {
|