@remit/search-service 0.0.11 → 0.0.13

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.11",
3
+ "version": "0.0.13",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -33,7 +33,9 @@
33
33
  "scripts": {
34
34
  "test:typecheck": "tsgo --noEmit",
35
35
  "test:run": "node --env-file=../../localhost-test-unit.env --import tsx --experimental-test-coverage --test-coverage-include='src/**' --test-coverage-exclude='src/**/*.test.ts' --test-coverage-exclude='src/**/*.test.tsx' --test-coverage-lines=82 --test 'src/**/*.test.ts'",
36
- "test:integ": "RUN_INTEG_TESTS=1 node --env-file=../../localhost-test-unit.env --import tsx --test 'src/**/*.integ.test.ts'",
36
+ "test:integ:local": "RUN_INTEG_TESTS=1 node --env-file=../../localhost-test-unit.env --import tsx --test 'src/backends/sqlite-vec.integ.test.ts'",
37
+ "test:integ:pg": "RUN_INTEG_TESTS=1 node --env-file=../../localhost-test-unit.env --import tsx --test 'src/backends/pgvector.integ.test.ts'",
38
+ "test:integ:model": "RUN_INTEG_TESTS=1 node --env-file=../../localhost-test-unit.env --import tsx --test 'src/semantic-search.integ.test.ts'",
37
39
  "test": "npm run test:typecheck && npm run test:run"
38
40
  },
39
41
  "dependencies": {
@@ -3,15 +3,18 @@ import { describe, it } from "node:test";
3
3
  import {
4
4
  buildAnchorSourceText,
5
5
  buildMessageAnchor,
6
+ deriveAnchorEmbeddingId,
6
7
  poolChunkVectors,
8
+ UNKNOWN_CHUNK_EMBEDDING_ID,
7
9
  } from "./anchor.js";
8
10
  import { createMemoryVectorStore } from "./backends/memory.js";
9
- import { createDeterministicEmbeddingService } from "./embeddings.js";
10
11
  import type { ChunkMetadata, VectorRecord } from "./types.js";
11
12
 
12
13
  const l2Norm = (vector: number[]): number =>
13
14
  Math.sqrt(vector.reduce((sum, value) => sum + value * value, 0));
14
15
 
16
+ const CURRENT_MODEL = "deterministic@8";
17
+
15
18
  const baseMetadata = (
16
19
  overrides: Partial<ChunkMetadata> = {},
17
20
  ): ChunkMetadata => ({
@@ -24,6 +27,7 @@ const baseMetadata = (
24
27
  isRead: false,
25
28
  hasAttachment: false,
26
29
  hasStars: false,
30
+ embeddingId: CURRENT_MODEL,
27
31
  ...overrides,
28
32
  });
29
33
 
@@ -93,8 +97,6 @@ describe("buildAnchorSourceText", () => {
93
97
  });
94
98
 
95
99
  describe("buildMessageAnchor", () => {
96
- const embedder = createDeterministicEmbeddingService({ dimensions: 8 });
97
-
98
100
  const putRecord = async (
99
101
  store: ReturnType<typeof createMemoryVectorStore>,
100
102
  record: VectorRecord,
@@ -120,12 +122,12 @@ describe("buildMessageAnchor", () => {
120
122
  });
121
123
 
122
124
  const anchor = await buildMessageAnchor(
123
- { store, embedder },
125
+ { store },
124
126
  { accountConfigId: "acct-1", anchorMessageId: "msg-1" },
125
127
  );
126
128
 
127
129
  assert.ok(anchor);
128
- assert.equal(anchor.anchorEmbeddingId, embedder.embeddingId);
130
+ assert.equal(anchor.anchorEmbeddingId, CURRENT_MODEL);
129
131
  assert.equal(anchor.anchorEmbedding.length, 8);
130
132
  assert.ok(Math.abs(l2Norm(anchor.anchorEmbedding) - 1) < 1e-9);
131
133
  assert.equal(
@@ -137,7 +139,7 @@ describe("buildMessageAnchor", () => {
137
139
  it("returns null when the message has no indexed chunks", async () => {
138
140
  const store = createMemoryVectorStore();
139
141
  const anchor = await buildMessageAnchor(
140
- { store, embedder },
142
+ { store },
141
143
  { accountConfigId: "acct-1", anchorMessageId: "absent" },
142
144
  );
143
145
  assert.equal(anchor, null);
@@ -156,9 +158,104 @@ describe("buildMessageAnchor", () => {
156
158
  });
157
159
 
158
160
  const anchor = await buildMessageAnchor(
159
- { store, embedder },
161
+ { store },
160
162
  { accountConfigId: "acct-1", anchorMessageId: "msg-1" },
161
163
  );
162
164
  assert.equal(anchor, null);
163
165
  });
166
+
167
+ it("stamps the model the pooled chunks were actually embedded under, not a caller-supplied current model (#349)", async () => {
168
+ const store = createMemoryVectorStore();
169
+ await putRecord(store, {
170
+ chunkId: "msg-1::subject",
171
+ vector: [1, 0, 0, 0, 0, 0, 0, 0],
172
+ metadata: baseMetadata({
173
+ chunkType: "subject",
174
+ embeddingId: "amazon.titan-embed-text-v1@1024",
175
+ textPreview: "booking confirmed",
176
+ }),
177
+ });
178
+
179
+ const anchor = await buildMessageAnchor(
180
+ { store },
181
+ { accountConfigId: "acct-1", anchorMessageId: "msg-1" },
182
+ );
183
+
184
+ assert.ok(anchor);
185
+ assert.equal(anchor.anchorEmbeddingId, "amazon.titan-embed-text-v1@1024");
186
+ assert.notEqual(anchor.anchorEmbeddingId, CURRENT_MODEL);
187
+ });
188
+
189
+ it("throws when a message's chunks disagree on which model produced them", async () => {
190
+ const store = createMemoryVectorStore();
191
+ await putRecord(store, {
192
+ chunkId: "msg-1::subject",
193
+ vector: [1, 0, 0, 0, 0, 0, 0, 0],
194
+ metadata: baseMetadata({
195
+ chunkType: "subject",
196
+ embeddingId: "model-a@8",
197
+ textPreview: "booking confirmed",
198
+ }),
199
+ });
200
+ await putRecord(store, {
201
+ chunkId: "msg-1::body-0",
202
+ vector: [0, 1, 0, 0, 0, 0, 0, 0],
203
+ metadata: baseMetadata({
204
+ chunkType: "body",
205
+ embeddingId: "model-b@8",
206
+ textPreview: "your trip is booked",
207
+ }),
208
+ });
209
+
210
+ await assert.rejects(
211
+ buildMessageAnchor(
212
+ { store },
213
+ { accountConfigId: "acct-1", anchorMessageId: "msg-1" },
214
+ ),
215
+ /mismatch/,
216
+ );
217
+ });
218
+
219
+ it("treats chunks with no recorded embeddingId as an unknown model, never the current one", async () => {
220
+ const store = createMemoryVectorStore();
221
+ await putRecord(store, {
222
+ chunkId: "msg-1::subject",
223
+ vector: [1, 0, 0, 0, 0, 0, 0, 0],
224
+ metadata: baseMetadata({
225
+ chunkType: "subject",
226
+ embeddingId: undefined,
227
+ textPreview: "booking confirmed",
228
+ }),
229
+ });
230
+
231
+ const anchor = await buildMessageAnchor(
232
+ { store },
233
+ { accountConfigId: "acct-1", anchorMessageId: "msg-1" },
234
+ );
235
+
236
+ assert.ok(anchor);
237
+ assert.equal(anchor.anchorEmbeddingId, UNKNOWN_CHUNK_EMBEDDING_ID);
238
+ });
239
+ });
240
+
241
+ describe("deriveAnchorEmbeddingId", () => {
242
+ const record = (embeddingId: string | undefined): VectorRecord => ({
243
+ chunkId: "c",
244
+ vector: [1],
245
+ metadata: baseMetadata({ embeddingId }),
246
+ });
247
+
248
+ it("returns the shared id when every record agrees", () => {
249
+ assert.equal(
250
+ deriveAnchorEmbeddingId([record("a@1"), record("a@1")]),
251
+ "a@1",
252
+ );
253
+ });
254
+
255
+ it("throws on disagreement between a known id and an unknown one", () => {
256
+ assert.throws(
257
+ () => deriveAnchorEmbeddingId([record("a@1"), record(undefined)]),
258
+ /mismatch/,
259
+ );
260
+ });
164
261
  });
package/src/anchor.ts CHANGED
@@ -1,5 +1,4 @@
1
1
  import type { VectorStoreService } from "./backends/memory.js";
2
- import type { EmbeddingService } from "./embeddings.js";
3
2
  import { buildTextPreview } from "./search.js";
4
3
  import type { ChunkType, VectorRecord } from "./types.js";
5
4
 
@@ -46,6 +45,40 @@ export const poolChunkVectors = (vectors: number[][]): number[] => {
46
45
  return sum.map((value) => value / norm);
47
46
  };
48
47
 
48
+ /**
49
+ * Stamped when none of a message's pooled chunks carry an `embeddingId` (RFC
50
+ * 039 / #349) — vectors written before that metadata field existed. Distinct
51
+ * from every real `EmbeddingService.embeddingId` value, so a drift check that
52
+ * compares against the current model ("does this anchor match what's
53
+ * configured now?") never mistakes an unknown-provenance vector for current;
54
+ * it is always treated as needing a fresh anchor.
55
+ */
56
+ export const UNKNOWN_CHUNK_EMBEDDING_ID = "unknown";
57
+
58
+ /**
59
+ * Derive the single `embeddingId` the pooled chunk records were actually
60
+ * embedded under (#349) — never the caller's currently-configured embedder,
61
+ * which may have changed since these chunks were last (re-)indexed. All
62
+ * chunks for one message are written by the same index-time pass, so they
63
+ * must agree; a chunk with no recorded `embeddingId` (pre-dating that field)
64
+ * counts as `UNKNOWN_CHUNK_EMBEDDING_ID`. Disagreement — including a mix of a
65
+ * known id and an unknown one — throws rather than silently picking one,
66
+ * consistent with `poolChunkVectors`'s "let it crash" stance on a dimension
67
+ * mismatch.
68
+ */
69
+ export const deriveAnchorEmbeddingId = (records: VectorRecord[]): string => {
70
+ const ids = new Set(
71
+ records.map((r) => r.metadata.embeddingId ?? UNKNOWN_CHUNK_EMBEDDING_ID),
72
+ );
73
+ if (ids.size > 1) {
74
+ throw new Error(
75
+ `Chunk embeddingId mismatch pooling anchor from ${records.length} records: ${[...ids].join(", ")}`,
76
+ );
77
+ }
78
+ const [id] = ids;
79
+ return id;
80
+ };
81
+
49
82
  // The chunk types whose text carries the semantic meaning of "messages like
50
83
  // this" — subject and body. Structured chunks (sender, recipient, attachment,
51
84
  // entities) are excluded from the re-embeddable source text; they add no signal
@@ -80,7 +113,6 @@ export const buildAnchorSourceText = (
80
113
 
81
114
  export interface AnchorBuildDeps {
82
115
  store: Pick<VectorStoreService, "getByMessage">;
83
- embedder: Pick<EmbeddingService, "embeddingId">;
84
116
  }
85
117
 
86
118
  export interface AnchorBuildParams {
@@ -97,8 +129,13 @@ export interface AnchorBuildParams {
97
129
  * `FilterAnchor` row (and leave `Filter.hasAnchor` false) rather than persist an
98
130
  * empty anchor.
99
131
  *
100
- * `anchorEmbeddingId` is the current embedder's identifier — the model the
101
- * chunk vectors were embedded under, which the indexing pipeline keeps current.
132
+ * `anchorEmbeddingId` is derived from the pooled records' own
133
+ * `metadata.embeddingId` (#349) the model that actually produced those
134
+ * vectors, which is not necessarily the currently-configured embedder. A
135
+ * message indexed under an older model keeps its old-space vectors until
136
+ * something re-indexes it; stamping the current embedder's id there would
137
+ * mislabel a stale vector as current and permanently defeat any drift check
138
+ * that trusts the stamp.
102
139
  */
103
140
  export const buildMessageAnchor = async (
104
141
  deps: AnchorBuildDeps,
@@ -116,7 +153,7 @@ export const buildMessageAnchor = async (
116
153
 
117
154
  return {
118
155
  anchorEmbedding: poolChunkVectors(records.map((r) => r.vector)),
119
- anchorEmbeddingId: deps.embedder.embeddingId,
156
+ anchorEmbeddingId: deriveAnchorEmbeddingId(records),
120
157
  anchorSourceText: buildAnchorSourceText(
121
158
  records.map((r) => ({
122
159
  chunkType: r.metadata.chunkType,
@@ -8,7 +8,7 @@
8
8
  * Gated behind RUN_INTEG_TESTS. Point PG_CONNECTION_URL at a database whose
9
9
  * `vector` extension is enabled (default: local remit_test).
10
10
  *
11
- * npm run test:integ -w packages/search-service
11
+ * npm run test:integ:pg -w packages/search-service
12
12
  */
13
13
  import assert from "node:assert";
14
14
  import { randomUUID } from "node:crypto";
@@ -247,6 +247,58 @@ describe("S3VectorsBackend.getByMessage (GetVectors by deterministic key, no sca
247
247
  );
248
248
  });
249
249
 
250
+ it("round-trips a chunk's embeddingId (#349) so a pooling anchor can recover which model produced it", async () => {
251
+ const subjectMetadata = {
252
+ messageId: MESSAGE_ID,
253
+ threadId: "thread-1",
254
+ accountConfigId: "acct-1",
255
+ mailboxIds: ["mb-inbox"],
256
+ chunkType: "subject",
257
+ sentDate: 1_700_000_000,
258
+ isRead: false,
259
+ hasAttachment: false,
260
+ hasStars: false,
261
+ embeddingId: "amazon.titan-embed-text-v1@1024",
262
+ };
263
+ const bodyMetadata = {
264
+ messageId: MESSAGE_ID,
265
+ threadId: "thread-1",
266
+ accountConfigId: "acct-1",
267
+ mailboxIds: ["mb-inbox"],
268
+ chunkType: "body",
269
+ sentDate: 1_700_000_000,
270
+ isRead: false,
271
+ hasAttachment: false,
272
+ hasStars: false,
273
+ };
274
+ s3vMock.on(GetVectorsCommand).resolves({
275
+ vectors: [
276
+ {
277
+ key: `${MESSAGE_ID}::subject`,
278
+ data: { float32: [1, 0, 0] },
279
+ metadata: subjectMetadata,
280
+ },
281
+ {
282
+ key: `${MESSAGE_ID}::body-0`,
283
+ data: { float32: [0, 1, 0] },
284
+ metadata: bodyMetadata,
285
+ },
286
+ ],
287
+ });
288
+
289
+ const records = await buildBackend().getByMessage(MESSAGE_ID);
290
+ const byId = new Map(records.map((r) => [r.chunkId, r]));
291
+
292
+ assert.equal(
293
+ byId.get(`${MESSAGE_ID}::subject`)?.metadata.embeddingId,
294
+ "amazon.titan-embed-text-v1@1024",
295
+ );
296
+ assert.equal(
297
+ byId.get(`${MESSAGE_ID}::body-0`)?.metadata.embeddingId,
298
+ undefined,
299
+ );
300
+ });
301
+
250
302
  it("requests vector data and metadata, addressing vectors by candidate key", async () => {
251
303
  s3vMock.on(GetVectorsCommand).resolves({ vectors: [] });
252
304
 
@@ -165,6 +165,12 @@ const toMetadata = (raw: unknown): ChunkMetadata => {
165
165
  // treat as absent (score-neutral re-rank), same as the other display fields.
166
166
  const textPreview =
167
167
  typeof obj.textPreview === "string" ? obj.textPreview : undefined;
168
+ // embeddingId is absent on vectors written before this provenance field
169
+ // existed (#349); treat as absent so a pooling consumer (buildMessageAnchor)
170
+ // can tell "unknown model" apart from a real id, rather than losing the real
171
+ // id this backend actually stored.
172
+ const embeddingId =
173
+ typeof obj.embeddingId === "string" ? obj.embeddingId : undefined;
168
174
  return {
169
175
  messageId: obj.messageId,
170
176
  threadId: obj.threadId,
@@ -180,6 +186,7 @@ const toMetadata = (raw: unknown): ChunkMetadata => {
180
186
  ...(subject !== undefined ? { subject } : {}),
181
187
  ...(category !== undefined ? { category } : {}),
182
188
  ...(textPreview !== undefined ? { textPreview } : {}),
189
+ ...(embeddingId !== undefined ? { embeddingId } : {}),
183
190
  };
184
191
  };
185
192
 
@@ -7,7 +7,7 @@
7
7
  * Gated behind RUN_INTEG_TESTS because it loads the native better-sqlite3 and
8
8
  * sqlite-vec binaries, matching the pgvector integration suite.
9
9
  *
10
- * npm run test:integ -w packages/search-service
10
+ * npm run test:integ:local -w packages/search-service
11
11
  */
12
12
  import assert from "node:assert";
13
13
  import { after, before, describe, test } from "node:test";
@@ -459,6 +459,37 @@ describe("DefaultSearchService idempotent indexing", () => {
459
459
  });
460
460
  });
461
461
 
462
+ describe("chunk embeddingId provenance (#349)", () => {
463
+ it("prepareVectors stamps every chunk with the embedder that produced its vector", async () => {
464
+ const embedder = createDeterministicEmbeddingService({ dimensions: 128 });
465
+ const service = new DefaultSearchService({
466
+ embedder,
467
+ store: new MemoryVectorStore(),
468
+ });
469
+
470
+ const records = await service.prepareVectors(aliceParams(invoiceBody));
471
+
472
+ assert.ok(records.length > 0);
473
+ for (const record of records) {
474
+ assert.equal(record.metadata.embeddingId, embedder.embeddingId);
475
+ }
476
+ });
477
+
478
+ it("indexIncremental stamps every re-embedded chunk with the embedder that produced its vector", async () => {
479
+ const embedder = createDeterministicEmbeddingService({ dimensions: 128 });
480
+ const store = new MemoryVectorStore();
481
+ const service = new DefaultSearchService({ embedder, store });
482
+
483
+ await service.indexIncremental(aliceParams(invoiceBody));
484
+ const stored = await store.getByMessage("msg-alice");
485
+
486
+ assert.ok(stored.length > 0);
487
+ for (const record of stored) {
488
+ assert.equal(record.metadata.embeddingId, embedder.embeddingId);
489
+ }
490
+ });
491
+ });
492
+
462
493
  class CountingEmbedder implements EmbeddingService {
463
494
  readonly embeddingId: string;
464
495
  readonly dimensions: number;
package/src/search.ts CHANGED
@@ -240,6 +240,7 @@ export class DefaultSearchService implements SearchService {
240
240
  ...metadata,
241
241
  chunkType: chunk.chunkType,
242
242
  contentHash: computeContentHash(embeddingId, chunk.text),
243
+ embeddingId,
243
244
  textPreview: buildTextPreview(chunk.text),
244
245
  ...(chunk.chunkType === "attachment" && fileTypes.length > 0
245
246
  ? { fileTypes }
@@ -335,6 +336,7 @@ export class DefaultSearchService implements SearchService {
335
336
  ...metadata,
336
337
  chunkType: h.chunk.chunkType,
337
338
  contentHash: h.contentHash,
339
+ embeddingId,
338
340
  textPreview: buildTextPreview(h.chunk.text),
339
341
  ...(h.chunk.chunkType === "attachment" && fileTypes.length > 0
340
342
  ? { fileTypes }
@@ -8,7 +8,7 @@
8
8
  * Downloads the MiniLM model on first run, so it is gated behind RUN_INTEG_TESTS
9
9
  * and excluded from the default unit-test path.
10
10
  *
11
- * npm run test:integ -w packages/search-service
11
+ * npm run test:integ:model -w packages/search-service
12
12
  */
13
13
  import assert from "node:assert";
14
14
  import { mkdtempSync, rmSync } from "node:fs";
package/src/types.ts CHANGED
@@ -48,6 +48,16 @@ export interface ChunkMetadata {
48
48
  * embedding model changes. Absent on pre-hash vectors (re-PUT once to populate).
49
49
  */
50
50
  contentHash?: string;
51
+ /**
52
+ * `<modelId>@<dimensions>` id of the embedder that produced this chunk's
53
+ * vector, stored at index time (see `EmbeddingService.embeddingId`). Lets a
54
+ * consumer that pools chunk vectors — `buildMessageAnchor` — recover which
55
+ * model actually produced them instead of assuming the currently configured
56
+ * embedder. Absent on vectors written before this field existed; re-embedded
57
+ * organically on next touch like `contentHash`/`textPreview` above, no
58
+ * backfill.
59
+ */
60
+ embeddingId?: string;
51
61
  /**
52
62
  * Prefix of the chunk's embeddable text, stored at index time and used for the
53
63
  * literal-match re-rank in search.ts. Bounded independently by char count