@remit/search-service 0.0.1

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.
Files changed (39) hide show
  1. package/package.json +66 -0
  2. package/src/anchor.test.ts +164 -0
  3. package/src/anchor.ts +127 -0
  4. package/src/backends/bedrock.test.ts +148 -0
  5. package/src/backends/bedrock.ts +105 -0
  6. package/src/backends/memory.test.ts +168 -0
  7. package/src/backends/memory.ts +152 -0
  8. package/src/backends/pgvector.integ.test.ts +174 -0
  9. package/src/backends/pgvector.ts +306 -0
  10. package/src/backends/runtime-import.ts +16 -0
  11. package/src/backends/s3-vectors.test.ts +929 -0
  12. package/src/backends/s3-vectors.ts +383 -0
  13. package/src/backends/sqlite-vec.integ.test.ts +144 -0
  14. package/src/backends/sqlite-vec.ts +250 -0
  15. package/src/bedrock.ts +4 -0
  16. package/src/chunking/chunker.test.ts +79 -0
  17. package/src/chunking/chunker.ts +56 -0
  18. package/src/chunking/entities.test.ts +82 -0
  19. package/src/chunking/entities.ts +74 -0
  20. package/src/chunking/entropy.test.ts +98 -0
  21. package/src/chunking/entropy.ts +161 -0
  22. package/src/chunking/keys.ts +22 -0
  23. package/src/chunking/structured.test.ts +120 -0
  24. package/src/chunking/structured.ts +79 -0
  25. package/src/content-hash.test.ts +27 -0
  26. package/src/content-hash.ts +10 -0
  27. package/src/embeddings.test.ts +28 -0
  28. package/src/embeddings.ts +149 -0
  29. package/src/from-env.test.ts +62 -0
  30. package/src/from-env.ts +130 -0
  31. package/src/index.ts +71 -0
  32. package/src/pgvector.ts +4 -0
  33. package/src/s3-vectors.ts +5 -0
  34. package/src/search.test.ts +772 -0
  35. package/src/search.ts +395 -0
  36. package/src/semantic-search.integ.test.ts +130 -0
  37. package/src/sqlite-vec.ts +4 -0
  38. package/src/types.ts +155 -0
  39. package/tsconfig.json +8 -0
@@ -0,0 +1,929 @@
1
+ import assert from "node:assert/strict";
2
+ import { afterEach, beforeEach, describe, it } from "node:test";
3
+ import {
4
+ DeleteVectorsCommand,
5
+ GetVectorsCommand,
6
+ ListVectorsCommand,
7
+ PutVectorsCommand,
8
+ QueryVectorsCommand,
9
+ S3VectorsClient,
10
+ } from "@aws-sdk/client-s3vectors";
11
+ import { type AwsClientStub, mockClient } from "aws-sdk-client-mock";
12
+ import { candidateChunkKeys } from "../chunking/keys.js";
13
+ import { buildTextPreview } from "../search.js";
14
+ import type { VectorQuery, VectorRecord } from "../types.js";
15
+ import { S3VectorsBackend } from "./s3-vectors.js";
16
+
17
+ const VECTOR_BUCKET = "test-vector-bucket";
18
+ const INDEX_NAME = "test-index";
19
+ const MESSAGE_ID = "msg-abc";
20
+
21
+ const buildBackend = () =>
22
+ new S3VectorsBackend({
23
+ client: new S3VectorsClient({ region: "us-east-1" }),
24
+ vectorBucketName: VECTOR_BUCKET,
25
+ indexName: INDEX_NAME,
26
+ });
27
+
28
+ describe("S3VectorsBackend.delete (deterministic keys, no index scan)", () => {
29
+ let s3vMock: AwsClientStub<S3VectorsClient>;
30
+
31
+ beforeEach(() => {
32
+ s3vMock = mockClient(S3VectorsClient);
33
+ });
34
+
35
+ afterEach(() => {
36
+ s3vMock.restore();
37
+ });
38
+
39
+ it("deletes the message's deterministic keys without ever listing the index", async () => {
40
+ const deleted: string[][] = [];
41
+ s3vMock.on(DeleteVectorsCommand).callsFake((input) => {
42
+ deleted.push((input.keys ?? []) as string[]);
43
+ return {};
44
+ });
45
+
46
+ await buildBackend().delete({ messageId: MESSAGE_ID });
47
+
48
+ assert.deepEqual(
49
+ deleted.flat().sort(),
50
+ [...candidateChunkKeys(MESSAGE_ID)].sort(),
51
+ "delete must address exactly the message's candidate key set",
52
+ );
53
+ for (const key of deleted.flat()) {
54
+ assert.ok(
55
+ key.startsWith(`${MESSAGE_ID}::`),
56
+ `every deleted key must belong to the message, got ${key}`,
57
+ );
58
+ }
59
+ });
60
+
61
+ it("covers structured, body, and entity chunk keys", async () => {
62
+ const deleted: string[][] = [];
63
+ s3vMock.on(DeleteVectorsCommand).callsFake((input) => {
64
+ deleted.push((input.keys ?? []) as string[]);
65
+ return {};
66
+ });
67
+
68
+ await buildBackend().delete({ messageId: MESSAGE_ID });
69
+
70
+ const all = new Set(deleted.flat());
71
+ for (const suffix of ["sender", "subject", "body-0", "entities"]) {
72
+ assert.ok(
73
+ all.has(`${MESSAGE_ID}::${suffix}`),
74
+ `candidate keys must include ${suffix}`,
75
+ );
76
+ }
77
+ });
78
+
79
+ it("batches deletes under the AWS 500-keys-per-call cap", async () => {
80
+ s3vMock.on(DeleteVectorsCommand).resolves({});
81
+
82
+ await buildBackend().delete({ messageId: MESSAGE_ID });
83
+
84
+ const calls = s3vMock.commandCalls(DeleteVectorsCommand);
85
+ assert.ok(
86
+ calls.length >= 1,
87
+ "should issue at least one DeleteVectors call",
88
+ );
89
+ for (const call of calls) {
90
+ const keys = call.args[0].input.keys ?? [];
91
+ assert.ok(keys.length <= 500, "no call exceeds the AWS 500/call cap");
92
+ }
93
+ });
94
+
95
+ it("never issues a QueryVectors call (regression: no per-message ranking lookup)", async () => {
96
+ s3vMock.on(DeleteVectorsCommand).resolves({});
97
+
98
+ await buildBackend().delete({ messageId: MESSAGE_ID });
99
+
100
+ assert.equal(
101
+ s3vMock.commandCalls(QueryVectorsCommand).length,
102
+ 0,
103
+ "delete must not use QueryVectors",
104
+ );
105
+ });
106
+ });
107
+
108
+ describe("S3VectorsBackend.existingContentHashes (GetVectors by key, no scan)", () => {
109
+ let s3vMock: AwsClientStub<S3VectorsClient>;
110
+
111
+ beforeEach(() => {
112
+ s3vMock = mockClient(S3VectorsClient);
113
+ });
114
+
115
+ afterEach(() => {
116
+ s3vMock.restore();
117
+ });
118
+
119
+ it("reads content hashes from metadata, omitting keys with no stored vector", async () => {
120
+ s3vMock.on(GetVectorsCommand).resolves({
121
+ vectors: [
122
+ {
123
+ key: `${MESSAGE_ID}::body-0`,
124
+ metadata: { contentHash: "hash-a", chunkType: "body" },
125
+ },
126
+ {
127
+ key: `${MESSAGE_ID}::subject`,
128
+ metadata: { contentHash: "hash-b", chunkType: "subject" },
129
+ },
130
+ ],
131
+ });
132
+
133
+ const hashes = await buildBackend().existingContentHashes([
134
+ `${MESSAGE_ID}::body-0`,
135
+ `${MESSAGE_ID}::subject`,
136
+ `${MESSAGE_ID}::missing`,
137
+ ]);
138
+
139
+ assert.equal(hashes.get(`${MESSAGE_ID}::body-0`), "hash-a");
140
+ assert.equal(hashes.get(`${MESSAGE_ID}::subject`), "hash-b");
141
+ assert.equal(
142
+ hashes.has(`${MESSAGE_ID}::missing`),
143
+ false,
144
+ "a key with no stored vector is absent from the map",
145
+ );
146
+ });
147
+
148
+ it("requests metadata only (no vector data) and addresses vectors by key", async () => {
149
+ s3vMock.on(GetVectorsCommand).resolves({ vectors: [] });
150
+
151
+ await buildBackend().existingContentHashes([`${MESSAGE_ID}::body-0`]);
152
+
153
+ const calls = s3vMock.commandCalls(GetVectorsCommand);
154
+ assert.equal(calls.length, 1);
155
+ const input = calls[0].args[0].input;
156
+ assert.equal(input.returnMetadata, true);
157
+ assert.equal(input.returnData, false, "must not pull vector data");
158
+ assert.deepEqual(input.keys, [`${MESSAGE_ID}::body-0`]);
159
+ });
160
+
161
+ it("never issues a ListVectors call (no index-wide scan)", async () => {
162
+ s3vMock.on(GetVectorsCommand).resolves({ vectors: [] });
163
+
164
+ await buildBackend().existingContentHashes([`${MESSAGE_ID}::body-0`]);
165
+
166
+ assert.equal(
167
+ s3vMock.commandCalls(ListVectorsCommand).length,
168
+ 0,
169
+ "the unchanged-skip path must read by key, never list the index",
170
+ );
171
+ });
172
+
173
+ it("batches reads under the AWS 100-keys-per-GetVectors cap", async () => {
174
+ s3vMock.on(GetVectorsCommand).resolves({ vectors: [] });
175
+
176
+ const keys = Array.from(
177
+ { length: 250 },
178
+ (_, i) => `${MESSAGE_ID}::body-${i}`,
179
+ );
180
+ await buildBackend().existingContentHashes(keys);
181
+
182
+ const calls = s3vMock.commandCalls(GetVectorsCommand);
183
+ const sizes = calls.map((c) => (c.args[0].input.keys ?? []).length);
184
+ assert.deepEqual(sizes, [100, 100, 50]);
185
+ for (const size of sizes) {
186
+ assert.ok(size <= 100, "no call exceeds the AWS 100/call cap");
187
+ }
188
+ });
189
+ });
190
+
191
+ describe("S3VectorsBackend.getByMessage (GetVectors by deterministic key, no scan)", () => {
192
+ let s3vMock: AwsClientStub<S3VectorsClient>;
193
+
194
+ beforeEach(() => {
195
+ s3vMock = mockClient(S3VectorsClient);
196
+ });
197
+
198
+ afterEach(() => {
199
+ s3vMock.restore();
200
+ });
201
+
202
+ const meta = (chunkType: string): Record<string, unknown> => ({
203
+ messageId: MESSAGE_ID,
204
+ threadId: "thread-1",
205
+ accountConfigId: "acct-1",
206
+ mailboxIds: ["mb-inbox"],
207
+ chunkType,
208
+ sentDate: 1_700_000_000,
209
+ isRead: false,
210
+ hasAttachment: false,
211
+ hasStars: false,
212
+ });
213
+
214
+ it("returns each stored chunk's vector and metadata, addressed by candidate key", async () => {
215
+ const stored = new Map<
216
+ string,
217
+ { data: { float32: number[] }; metadata: Record<string, unknown> }
218
+ >([
219
+ [
220
+ `${MESSAGE_ID}::subject`,
221
+ { data: { float32: [1, 0, 0] }, metadata: meta("subject") },
222
+ ],
223
+ [
224
+ `${MESSAGE_ID}::body-0`,
225
+ { data: { float32: [0, 1, 0] }, metadata: meta("body") },
226
+ ],
227
+ ]);
228
+ s3vMock.on(GetVectorsCommand).callsFake((input) => {
229
+ const keys = (input.keys ?? []) as string[];
230
+ return {
231
+ vectors: keys
232
+ .filter((k) => stored.has(k))
233
+ .map((k) => ({ key: k, ...stored.get(k) })),
234
+ };
235
+ });
236
+
237
+ const records = await buildBackend().getByMessage(MESSAGE_ID);
238
+
239
+ const byId = new Map(records.map((r) => [r.chunkId, r]));
240
+ assert.equal(records.length, 2);
241
+ assert.deepEqual(byId.get(`${MESSAGE_ID}::subject`)?.vector, [1, 0, 0]);
242
+ assert.deepEqual(byId.get(`${MESSAGE_ID}::body-0`)?.vector, [0, 1, 0]);
243
+ assert.equal(byId.get(`${MESSAGE_ID}::body-0`)?.metadata.chunkType, "body");
244
+ assert.equal(
245
+ byId.get(`${MESSAGE_ID}::subject`)?.metadata.messageId,
246
+ MESSAGE_ID,
247
+ );
248
+ });
249
+
250
+ it("requests vector data and metadata, addressing vectors by candidate key", async () => {
251
+ s3vMock.on(GetVectorsCommand).resolves({ vectors: [] });
252
+
253
+ await buildBackend().getByMessage(MESSAGE_ID);
254
+
255
+ const calls = s3vMock.commandCalls(GetVectorsCommand);
256
+ assert.ok(calls.length >= 1);
257
+ const input = calls[0].args[0].input;
258
+ assert.equal(input.returnData, true, "must pull vector data");
259
+ assert.equal(input.returnMetadata, true);
260
+ for (const key of input.keys ?? []) {
261
+ assert.ok(
262
+ (key as string).startsWith(`${MESSAGE_ID}::`),
263
+ `every requested key must belong to the message, got ${key}`,
264
+ );
265
+ }
266
+ });
267
+
268
+ it("reads exactly the candidate key set, batched under the AWS 100-keys-per-call cap", async () => {
269
+ s3vMock.on(GetVectorsCommand).resolves({ vectors: [] });
270
+
271
+ await buildBackend().getByMessage(MESSAGE_ID);
272
+
273
+ const calls = s3vMock.commandCalls(GetVectorsCommand);
274
+ let total = 0;
275
+ for (const call of calls) {
276
+ const keys = call.args[0].input.keys ?? [];
277
+ assert.ok(keys.length <= 100, "no call exceeds the AWS 100/call cap");
278
+ total += keys.length;
279
+ }
280
+ assert.equal(
281
+ total,
282
+ candidateChunkKeys(MESSAGE_ID).length,
283
+ "reads the message's full candidate key set, no more",
284
+ );
285
+ });
286
+
287
+ it("never issues a ListVectors call (no index-wide scan)", async () => {
288
+ s3vMock.on(GetVectorsCommand).resolves({ vectors: [] });
289
+
290
+ await buildBackend().getByMessage(MESSAGE_ID);
291
+
292
+ assert.equal(
293
+ s3vMock.commandCalls(ListVectorsCommand).length,
294
+ 0,
295
+ "the anchor-pooling read must address keys, never list the index",
296
+ );
297
+ });
298
+
299
+ it("skips a stored vector that carries metadata but no float32 data", async () => {
300
+ s3vMock.on(GetVectorsCommand).callsFake((input) => {
301
+ const keys = (input.keys ?? []) as string[];
302
+ if (keys.includes(`${MESSAGE_ID}::subject`)) {
303
+ return {
304
+ vectors: [
305
+ { key: `${MESSAGE_ID}::subject`, metadata: meta("subject") },
306
+ ],
307
+ };
308
+ }
309
+ return { vectors: [] };
310
+ });
311
+
312
+ const records = await buildBackend().getByMessage(MESSAGE_ID);
313
+
314
+ assert.equal(records.length, 0, "a vector with no data is not returned");
315
+ });
316
+ });
317
+
318
+ describe("S3VectorsBackend.query topK guard", () => {
319
+ let s3vMock: AwsClientStub<S3VectorsClient>;
320
+
321
+ beforeEach(() => {
322
+ s3vMock = mockClient(S3VectorsClient);
323
+ });
324
+
325
+ afterEach(() => {
326
+ s3vMock.restore();
327
+ });
328
+
329
+ it("forwards topK to QueryVectorsCommand (caller is responsible for staying within the AWS 1..100 limit)", async () => {
330
+ s3vMock.on(QueryVectorsCommand).resolves({
331
+ vectors: [],
332
+ distanceMetric: "cosine",
333
+ });
334
+
335
+ await buildBackend().query({ vector: [0.1, 0.2, 0.3], topK: 100 });
336
+
337
+ const calls = s3vMock.commandCalls(QueryVectorsCommand);
338
+ assert.equal(calls.length, 1);
339
+ const topK = calls[0].args[0].input.topK;
340
+ assert.equal(topK, 100, "topK should be passed through unchanged");
341
+ assert.ok(
342
+ topK !== undefined && topK >= 1 && topK <= 100,
343
+ `topK must be within AWS S3 Vectors 1..100 range, got ${topK}`,
344
+ );
345
+ });
346
+ });
347
+
348
+ describe("S3VectorsBackend.query pagination (follows nextToken)", () => {
349
+ let s3vMock: AwsClientStub<S3VectorsClient>;
350
+
351
+ beforeEach(() => {
352
+ s3vMock = mockClient(S3VectorsClient);
353
+ });
354
+
355
+ afterEach(() => {
356
+ s3vMock.restore();
357
+ });
358
+
359
+ const pageMeta = (messageId: string) => ({
360
+ messageId,
361
+ threadId: "thread-1",
362
+ accountConfigId: "acct-1",
363
+ mailboxIds: ["mb-inbox"],
364
+ chunkType: "body",
365
+ sentDate: 1_700_000_000,
366
+ isRead: false,
367
+ hasAttachment: false,
368
+ hasStars: false,
369
+ });
370
+
371
+ // A large topK spans pages: the first QueryVectors returns one page plus a
372
+ // nextToken, and the wrapper must replay that token to gather the rest instead
373
+ // of silently capping at the first page (the anchored back-apply requests
374
+ // topK = 4000).
375
+ it("follows nextToken across pages until it is empty, replaying the token", async () => {
376
+ s3vMock.on(QueryVectorsCommand).callsFake((input) => {
377
+ if (!input.nextToken) {
378
+ return {
379
+ vectors: [
380
+ { key: "m1::body-0", distance: 0.1, metadata: pageMeta("m1") },
381
+ ],
382
+ nextToken: "tok-1",
383
+ distanceMetric: "cosine",
384
+ };
385
+ }
386
+ assert.equal(input.nextToken, "tok-1", "second page replays the token");
387
+ return {
388
+ vectors: [
389
+ { key: "m2::body-0", distance: 0.2, metadata: pageMeta("m2") },
390
+ ],
391
+ distanceMetric: "cosine",
392
+ };
393
+ });
394
+
395
+ const matches = await buildBackend().query({
396
+ vector: [0.1, 0.2, 0.3],
397
+ topK: 4000,
398
+ filter: { accountConfigId: "acct-1" },
399
+ });
400
+
401
+ const calls = s3vMock.commandCalls(QueryVectorsCommand);
402
+ assert.equal(calls.length, 2, "must issue a second call for the next page");
403
+ assert.equal(
404
+ calls[0].args[0].input.nextToken,
405
+ undefined,
406
+ "the first page carries no token",
407
+ );
408
+ assert.equal(
409
+ calls[1].args[0].input.nextToken,
410
+ "tok-1",
411
+ "the second page carries the token from the first response",
412
+ );
413
+ assert.deepEqual(
414
+ matches.map((m) => m.chunkId),
415
+ ["m1::body-0", "m2::body-0"],
416
+ "results from every page are accumulated in order",
417
+ );
418
+ });
419
+
420
+ it("stops after the first page when no nextToken is returned", async () => {
421
+ s3vMock.on(QueryVectorsCommand).resolves({
422
+ vectors: [{ key: "m1::body-0", distance: 0.1, metadata: pageMeta("m1") }],
423
+ distanceMetric: "cosine",
424
+ });
425
+
426
+ const matches = await buildBackend().query({
427
+ vector: [0.1, 0.2, 0.3],
428
+ topK: 4000,
429
+ });
430
+
431
+ assert.equal(s3vMock.commandCalls(QueryVectorsCommand).length, 1);
432
+ assert.equal(matches.length, 1);
433
+ });
434
+
435
+ // Bounded by topK: once topK matches are in hand the wrapper stops requesting
436
+ // pages even if the service still hands back a nextToken.
437
+ it("never requests another page once topK matches are collected", async () => {
438
+ s3vMock.on(QueryVectorsCommand).callsFake(() => ({
439
+ vectors: [
440
+ { key: "m1::body-0", distance: 0.1, metadata: pageMeta("m1") },
441
+ { key: "m2::body-0", distance: 0.2, metadata: pageMeta("m2") },
442
+ ],
443
+ nextToken: "always-more",
444
+ distanceMetric: "cosine",
445
+ }));
446
+
447
+ const matches = await buildBackend().query({
448
+ vector: [0.1, 0.2, 0.3],
449
+ topK: 2,
450
+ });
451
+
452
+ assert.equal(
453
+ s3vMock.commandCalls(QueryVectorsCommand).length,
454
+ 1,
455
+ "topK filled on the first page must not trigger a further page request",
456
+ );
457
+ assert.equal(matches.length, 2);
458
+ });
459
+ });
460
+
461
+ describe("S3VectorsBackend.query filter expression", () => {
462
+ let s3vMock: AwsClientStub<S3VectorsClient>;
463
+
464
+ beforeEach(() => {
465
+ s3vMock = mockClient(S3VectorsClient);
466
+ });
467
+
468
+ afterEach(() => {
469
+ s3vMock.restore();
470
+ });
471
+
472
+ const filterFor = async (filter: VectorQuery["filter"]): Promise<unknown> => {
473
+ s3vMock.resetHistory();
474
+ s3vMock.on(QueryVectorsCommand).resolves({
475
+ vectors: [],
476
+ distanceMetric: "cosine",
477
+ });
478
+ await buildBackend().query({ vector: [0.1, 0.2, 0.3], topK: 10, filter });
479
+ const calls = s3vMock.commandCalls(QueryVectorsCommand);
480
+ assert.equal(calls.length, 1);
481
+ return calls[0].args[0].input.filter;
482
+ };
483
+
484
+ it("emits a bare single-key object for a single condition (no $and)", async () => {
485
+ const emitted = await filterFor({ accountConfigId: "acct-1" });
486
+ assert.deepEqual(emitted, { accountConfigId: "acct-1" });
487
+ });
488
+
489
+ it("wraps two conditions in $and, one single-key object each", async () => {
490
+ const emitted = await filterFor({
491
+ accountConfigId: "acct-1",
492
+ mailboxId: "mb-inbox",
493
+ });
494
+ assert.deepEqual(emitted, {
495
+ $and: [
496
+ { accountConfigId: "acct-1" },
497
+ { mailboxIds: { $in: ["mb-inbox"] } },
498
+ ],
499
+ });
500
+ });
501
+
502
+ it("wraps two scalar conditions in $and", async () => {
503
+ const emitted = await filterFor({
504
+ accountConfigId: "acct-1",
505
+ hasStars: true,
506
+ });
507
+ assert.deepEqual(emitted, {
508
+ $and: [{ accountConfigId: "acct-1" }, { hasStars: true }],
509
+ });
510
+ });
511
+
512
+ it("emits no filter for zero conditions", async () => {
513
+ assert.equal(await filterFor(undefined), undefined);
514
+ assert.equal(await filterFor({}), undefined);
515
+ });
516
+
517
+ it("emits category as a bare discrete condition", async () => {
518
+ const emitted = await filterFor({ category: "newsletter" });
519
+ assert.deepEqual(emitted, { category: "newsletter" });
520
+ });
521
+
522
+ it("combines category with other conditions under $and", async () => {
523
+ const emitted = await filterFor({
524
+ accountConfigId: "acct-1",
525
+ category: "newsletter",
526
+ });
527
+ assert.deepEqual(emitted, {
528
+ $and: [{ accountConfigId: "acct-1" }, { category: "newsletter" }],
529
+ });
530
+ });
531
+
532
+ it("emits the inbox Related shape as $and (regression for the Invalid filter 500)", async () => {
533
+ // The inbox "Related" section always filters by account + mailbox. As a
534
+ // flat 2-key object S3 Vectors rejects it with ValidationException, leaving
535
+ // the section permanently empty. It must be wrapped in $and.
536
+ const emitted = await filterFor({
537
+ accountConfigId: "acct-1",
538
+ mailboxId: "mb-inbox",
539
+ });
540
+ assert.ok(
541
+ emitted !== null &&
542
+ typeof emitted === "object" &&
543
+ "$and" in (emitted as Record<string, unknown>),
544
+ "multi-condition inbox filter must be wrapped in $and",
545
+ );
546
+ assert.deepEqual(emitted, {
547
+ $and: [
548
+ { accountConfigId: "acct-1" },
549
+ { mailboxIds: { $in: ["mb-inbox"] } },
550
+ ],
551
+ });
552
+ });
553
+ });
554
+
555
+ describe("S3VectorsBackend.upsert batching", () => {
556
+ let s3vMock: AwsClientStub<S3VectorsClient>;
557
+
558
+ beforeEach(() => {
559
+ s3vMock = mockClient(S3VectorsClient);
560
+ });
561
+
562
+ afterEach(() => {
563
+ s3vMock.restore();
564
+ });
565
+
566
+ const buildVectors = (count: number): VectorRecord[] =>
567
+ Array.from({ length: count }, (_, i) => ({
568
+ chunkId: `${MESSAGE_ID}::body-${i}`,
569
+ vector: [0.1, 0.2, 0.3],
570
+ metadata: {
571
+ messageId: MESSAGE_ID,
572
+ threadId: "thread-1",
573
+ accountConfigId: "acct-1",
574
+ mailboxIds: ["mb-inbox"],
575
+ chunkType: "body",
576
+ sentDate: 1_700_000_000,
577
+ isRead: false,
578
+ hasAttachment: false,
579
+ hasStars: false,
580
+ },
581
+ }));
582
+
583
+ it("splits a >500-vector group into multiple PutVectors calls under the AWS cap", async () => {
584
+ s3vMock.on(PutVectorsCommand).resolves({});
585
+
586
+ await buildBackend().upsert(buildVectors(250));
587
+
588
+ const putCalls = s3vMock.commandCalls(PutVectorsCommand);
589
+ assert.equal(putCalls.length, 3, "250 vectors should split into 3 calls");
590
+ const sizes = putCalls.map((c) => (c.args[0].input.vectors ?? []).length);
591
+ assert.deepEqual(sizes, [100, 100, 50]);
592
+ for (const size of sizes) {
593
+ assert.ok(size <= 500, "no call exceeds the AWS 500/call cap");
594
+ }
595
+ });
596
+
597
+ // S3 Vectors caps filterable metadata at 2 KB/vector and this index declares no
598
+ // non-filterable keys, so every field in ChunkMetadata counts against that cap.
599
+ // A worst-case chunk (long UUIDs/subject/mailboxIds/fromName plus a multi-byte
600
+ // CJK textPreview) must still fit — regression for the CJK PutVectors dead-letter.
601
+ it("keeps worst-case filterable metadata for a CJK chunk under the 2 KB S3 Vectors cap", async () => {
602
+ s3vMock.on(PutVectorsCommand).resolves({});
603
+
604
+ const cjkChunk = "取引先への請求書を添付いたします。".repeat(40);
605
+ const record: VectorRecord = {
606
+ chunkId: `${MESSAGE_ID}::body-0`,
607
+ vector: [0.1, 0.2, 0.3],
608
+ metadata: {
609
+ messageId: "018f2e1a-4b3d-4c2e-9f1a-0123456789ab",
610
+ threadId: "018f2e1a-4b3d-4c2e-9f1a-0123456789cd",
611
+ accountConfigId: "018f2e1a-4b3d-4c2e-9f1a-0123456789ef",
612
+ mailboxIds: [
613
+ "018f2e1a-0000-4c2e-9f1a-000000000001",
614
+ "018f2e1a-0000-4c2e-9f1a-000000000002",
615
+ "018f2e1a-0000-4c2e-9f1a-000000000003",
616
+ "018f2e1a-0000-4c2e-9f1a-000000000004",
617
+ "018f2e1a-0000-4c2e-9f1a-000000000005",
618
+ "018f2e1a-0000-4c2e-9f1a-000000000006",
619
+ ],
620
+ chunkType: "attachment",
621
+ sentDate: 1_750_000_000,
622
+ isRead: false,
623
+ hasAttachment: true,
624
+ hasStars: true,
625
+ fileTypes: [
626
+ "application/pdf",
627
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
628
+ "image/png",
629
+ "text/csv",
630
+ ],
631
+ fromName:
632
+ "取引先ご担当者様 (Accounts Payable Department, Global Procurement)",
633
+ subject:
634
+ "請求書送付のご案内: Invoice for Q3 Global Procurement Reconciliation and Renewal",
635
+ category: "newsletter",
636
+ contentHash: "a".repeat(64), // sha256 hex digest is always 64 chars
637
+ textPreview: buildTextPreview(cjkChunk),
638
+ },
639
+ };
640
+
641
+ await buildBackend().upsert([record]);
642
+
643
+ const putCalls = s3vMock.commandCalls(PutVectorsCommand);
644
+ const sentMetadata = putCalls[0]?.args[0].input.vectors?.[0]?.metadata;
645
+ assert.ok(sentMetadata, "PutVectors must receive metadata");
646
+
647
+ const bytes = Buffer.byteLength(JSON.stringify(sentMetadata), "utf8");
648
+ assert.ok(
649
+ bytes < 2048,
650
+ `worst-case filterable metadata is ${bytes} bytes, must stay under the 2 KB S3 Vectors cap`,
651
+ );
652
+ });
653
+ });
654
+
655
+ describe("S3VectorsBackend.query metadata backward-compat (no reindex)", () => {
656
+ let s3vMock: AwsClientStub<S3VectorsClient>;
657
+
658
+ beforeEach(() => {
659
+ s3vMock = mockClient(S3VectorsClient);
660
+ });
661
+
662
+ afterEach(() => {
663
+ s3vMock.restore();
664
+ });
665
+
666
+ // Legacy metadata shape: indexed before display-field enrichment, so it
667
+ // omits fromName and subject. The strict toMetadata() parser must accept
668
+ // this without throwing so pre-enrichment vectors keep working with no
669
+ // bulk reindex required.
670
+ const legacyMetadata = {
671
+ messageId: MESSAGE_ID,
672
+ threadId: "thread-1",
673
+ accountConfigId: "acct-1",
674
+ mailboxIds: ["mb-inbox"],
675
+ chunkType: "subject",
676
+ sentDate: 1_700_000_000,
677
+ isRead: false,
678
+ hasAttachment: false,
679
+ hasStars: false,
680
+ };
681
+
682
+ it("parses legacy metadata that omits fromName/subject without throwing", async () => {
683
+ s3vMock.on(QueryVectorsCommand).resolves({
684
+ vectors: [
685
+ {
686
+ key: `${MESSAGE_ID}::subject`,
687
+ distance: 0.1,
688
+ metadata: legacyMetadata,
689
+ },
690
+ ],
691
+ distanceMetric: "cosine",
692
+ });
693
+
694
+ const matches = await buildBackend().query({
695
+ vector: [0.1, 0.2, 0.3],
696
+ topK: 10,
697
+ });
698
+
699
+ assert.equal(matches.length, 1);
700
+ const meta = matches[0].metadata;
701
+ // Display fields simply absent — not blank strings, not an error.
702
+ assert.equal(meta.fromName, undefined);
703
+ assert.equal(meta.subject, undefined);
704
+ // Pre-existing fields still parse.
705
+ assert.equal(meta.messageId, MESSAGE_ID);
706
+ assert.equal(meta.sentDate, 1_700_000_000);
707
+ });
708
+
709
+ it("parses metadata with fromName: null (sender has no display name)", async () => {
710
+ s3vMock.on(QueryVectorsCommand).resolves({
711
+ vectors: [
712
+ {
713
+ key: `${MESSAGE_ID}::subject`,
714
+ distance: 0.1,
715
+ metadata: {
716
+ ...legacyMetadata,
717
+ fromName: null,
718
+ subject: "Q1 invoice review",
719
+ },
720
+ },
721
+ ],
722
+ distanceMetric: "cosine",
723
+ });
724
+
725
+ const matches = await buildBackend().query({
726
+ vector: [0.1, 0.2, 0.3],
727
+ topK: 10,
728
+ });
729
+
730
+ assert.equal(matches.length, 1);
731
+ const meta = matches[0].metadata;
732
+ assert.equal(meta.fromName, null);
733
+ assert.equal(meta.subject, "Q1 invoice review");
734
+ });
735
+
736
+ it("parses enriched metadata with fromName and subject present", async () => {
737
+ s3vMock.on(QueryVectorsCommand).resolves({
738
+ vectors: [
739
+ {
740
+ key: `${MESSAGE_ID}::subject`,
741
+ distance: 0.1,
742
+ metadata: {
743
+ ...legacyMetadata,
744
+ fromName: "Alice",
745
+ subject: "Q1 invoice review",
746
+ },
747
+ },
748
+ ],
749
+ distanceMetric: "cosine",
750
+ });
751
+
752
+ const matches = await buildBackend().query({
753
+ vector: [0.1, 0.2, 0.3],
754
+ topK: 10,
755
+ });
756
+
757
+ assert.equal(matches.length, 1);
758
+ const meta = matches[0].metadata;
759
+ assert.equal(meta.fromName, "Alice");
760
+ assert.equal(meta.subject, "Q1 invoice review");
761
+ });
762
+
763
+ it("parses enriched metadata with a category present", async () => {
764
+ s3vMock.on(QueryVectorsCommand).resolves({
765
+ vectors: [
766
+ {
767
+ key: `${MESSAGE_ID}::subject`,
768
+ distance: 0.1,
769
+ metadata: { ...legacyMetadata, category: "newsletter" },
770
+ },
771
+ ],
772
+ distanceMetric: "cosine",
773
+ });
774
+
775
+ const matches = await buildBackend().query({
776
+ vector: [0.1, 0.2, 0.3],
777
+ topK: 10,
778
+ });
779
+
780
+ assert.equal(matches.length, 1);
781
+ assert.equal(matches[0].metadata.category, "newsletter");
782
+ });
783
+
784
+ it("ignores an unknown category value (absent, not thrown)", async () => {
785
+ s3vMock.on(QueryVectorsCommand).resolves({
786
+ vectors: [
787
+ {
788
+ key: `${MESSAGE_ID}::subject`,
789
+ distance: 0.1,
790
+ metadata: { ...legacyMetadata, category: "not-a-category" },
791
+ },
792
+ ],
793
+ distanceMetric: "cosine",
794
+ });
795
+
796
+ const matches = await buildBackend().query({
797
+ vector: [0.1, 0.2, 0.3],
798
+ topK: 10,
799
+ });
800
+
801
+ assert.equal(matches.length, 1);
802
+ assert.equal(matches[0].metadata.category, undefined);
803
+ });
804
+ });
805
+
806
+ describe("S3VectorsBackend.upsert metadata flattening", () => {
807
+ let s3vMock: AwsClientStub<S3VectorsClient>;
808
+
809
+ beforeEach(() => {
810
+ s3vMock = mockClient(S3VectorsClient);
811
+ });
812
+
813
+ afterEach(() => {
814
+ s3vMock.restore();
815
+ });
816
+
817
+ const baseMetadata: VectorRecord["metadata"] = {
818
+ messageId: MESSAGE_ID,
819
+ threadId: "thread-1",
820
+ accountConfigId: "acct-1",
821
+ mailboxIds: ["mb-inbox"],
822
+ chunkType: "sender",
823
+ sentDate: 1_700_000_000,
824
+ isRead: false,
825
+ hasAttachment: false,
826
+ hasStars: false,
827
+ };
828
+
829
+ const upsertedMetadata = async (
830
+ metadata: VectorRecord["metadata"],
831
+ ): Promise<Record<string, unknown>> => {
832
+ s3vMock.on(PutVectorsCommand).resolves({});
833
+ await buildBackend().upsert([
834
+ { chunkId: `${MESSAGE_ID}::sender`, vector: [0.1, 0.2, 0.3], metadata },
835
+ ]);
836
+ const putCalls = s3vMock.commandCalls(PutVectorsCommand);
837
+ assert.equal(putCalls.length, 1);
838
+ const sent = putCalls[0].args[0].input.vectors?.[0]?.metadata;
839
+ assert.ok(sent && typeof sent === "object" && !Array.isArray(sent));
840
+ return sent as Record<string, unknown>;
841
+ };
842
+
843
+ const assertS3VectorsSafe = (meta: Record<string, unknown>): void => {
844
+ for (const [key, value] of Object.entries(meta)) {
845
+ if (Array.isArray(value)) {
846
+ for (const item of value) {
847
+ assert.ok(
848
+ typeof item === "string" ||
849
+ typeof item === "number" ||
850
+ typeof item === "boolean",
851
+ `array element of ${key} must be a scalar, got ${typeof item}`,
852
+ );
853
+ }
854
+ continue;
855
+ }
856
+ assert.ok(
857
+ typeof value === "string" ||
858
+ typeof value === "number" ||
859
+ typeof value === "boolean",
860
+ `${key} must be a scalar, got ${value === null ? "null" : typeof value}`,
861
+ );
862
+ }
863
+ };
864
+
865
+ it("flattens an object-valued sender to a display string", async () => {
866
+ // Reproduces the dead-letter case: an older producer wrote `sender` as an
867
+ // address object, which S3 Vectors rejects as a non-scalar.
868
+ const metadata = {
869
+ ...baseMetadata,
870
+ sender: { name: "Alice", email: "alice@example.com" },
871
+ } as unknown as VectorRecord["metadata"];
872
+
873
+ const sent = await upsertedMetadata(metadata);
874
+
875
+ assert.equal(sent.sender, "Alice <alice@example.com>");
876
+ assertS3VectorsSafe(sent);
877
+ });
878
+
879
+ it("flattens an array of address objects to an array of strings", async () => {
880
+ const metadata = {
881
+ ...baseMetadata,
882
+ sender: [
883
+ { name: "Alice", mailbox: "alice", host: "example.com" },
884
+ { mailbox: "bob", host: "example.com" },
885
+ ],
886
+ } as unknown as VectorRecord["metadata"];
887
+
888
+ const sent = await upsertedMetadata(metadata);
889
+
890
+ assert.deepEqual(sent.sender, [
891
+ "Alice <alice@example.com>",
892
+ "bob@example.com",
893
+ ]);
894
+ assertS3VectorsSafe(sent);
895
+ });
896
+
897
+ it("keeps clean scalar metadata unchanged and S3-Vectors-safe", async () => {
898
+ const metadata: VectorRecord["metadata"] = {
899
+ ...baseMetadata,
900
+ fileTypes: ["pdf", "png"],
901
+ fromName: null,
902
+ subject: "Q1 invoice review",
903
+ };
904
+
905
+ const sent = await upsertedMetadata(metadata);
906
+
907
+ assert.equal(sent.messageId, MESSAGE_ID);
908
+ assert.deepEqual(sent.mailboxIds, ["mb-inbox"]);
909
+ assert.deepEqual(sent.fileTypes, ["pdf", "png"]);
910
+ assert.equal(sent.subject, "Q1 invoice review");
911
+ assertS3VectorsSafe(sent);
912
+ });
913
+
914
+ it("omits a null-valued key rather than emitting null", async () => {
915
+ // S3 Vectors rejects null metadata values. A sender with no display name
916
+ // (fromName: null) must drop the key entirely, not send fromName: null.
917
+ const metadata: VectorRecord["metadata"] = {
918
+ ...baseMetadata,
919
+ fromName: null,
920
+ subject: "Q1 invoice review",
921
+ };
922
+
923
+ const sent = await upsertedMetadata(metadata);
924
+
925
+ assert.ok(!("fromName" in sent), "fromName key must be omitted when null");
926
+ assert.equal(sent.subject, "Q1 invoice review");
927
+ assertS3VectorsSafe(sent);
928
+ });
929
+ });