@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.
- package/package.json +66 -0
- package/src/anchor.test.ts +164 -0
- package/src/anchor.ts +127 -0
- package/src/backends/bedrock.test.ts +148 -0
- package/src/backends/bedrock.ts +105 -0
- package/src/backends/memory.test.ts +168 -0
- package/src/backends/memory.ts +152 -0
- package/src/backends/pgvector.integ.test.ts +174 -0
- package/src/backends/pgvector.ts +306 -0
- package/src/backends/runtime-import.ts +16 -0
- package/src/backends/s3-vectors.test.ts +929 -0
- package/src/backends/s3-vectors.ts +383 -0
- package/src/backends/sqlite-vec.integ.test.ts +144 -0
- package/src/backends/sqlite-vec.ts +250 -0
- package/src/bedrock.ts +4 -0
- package/src/chunking/chunker.test.ts +79 -0
- package/src/chunking/chunker.ts +56 -0
- package/src/chunking/entities.test.ts +82 -0
- package/src/chunking/entities.ts +74 -0
- package/src/chunking/entropy.test.ts +98 -0
- package/src/chunking/entropy.ts +161 -0
- package/src/chunking/keys.ts +22 -0
- package/src/chunking/structured.test.ts +120 -0
- package/src/chunking/structured.ts +79 -0
- package/src/content-hash.test.ts +27 -0
- package/src/content-hash.ts +10 -0
- package/src/embeddings.test.ts +28 -0
- package/src/embeddings.ts +149 -0
- package/src/from-env.test.ts +62 -0
- package/src/from-env.ts +130 -0
- package/src/index.ts +71 -0
- package/src/pgvector.ts +4 -0
- package/src/s3-vectors.ts +5 -0
- package/src/search.test.ts +772 -0
- package/src/search.ts +395 -0
- package/src/semantic-search.integ.test.ts +130 -0
- package/src/sqlite-vec.ts +4 -0
- package/src/types.ts +155 -0
- package/tsconfig.json +8 -0
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DeleteVectorsCommand,
|
|
3
|
+
GetVectorsCommand,
|
|
4
|
+
PutVectorsCommand,
|
|
5
|
+
QueryVectorsCommand,
|
|
6
|
+
S3VectorsClient,
|
|
7
|
+
} from "@aws-sdk/client-s3vectors";
|
|
8
|
+
import { MessageCategory } from "@remit/domain-enums";
|
|
9
|
+
import type { DocumentType } from "@smithy/types";
|
|
10
|
+
import { candidateChunkKeys } from "../chunking/keys.js";
|
|
11
|
+
import type {
|
|
12
|
+
ChunkMetadata,
|
|
13
|
+
VectorMatch,
|
|
14
|
+
VectorQuery,
|
|
15
|
+
VectorQueryFilter,
|
|
16
|
+
VectorRecord,
|
|
17
|
+
} from "../types.js";
|
|
18
|
+
import type { VectorStoreService } from "./memory.js";
|
|
19
|
+
|
|
20
|
+
// S3 Vectors rejects any metadata value that is not a string, number,
|
|
21
|
+
// boolean, or array of those. Address-shaped objects (sender/recipients) and
|
|
22
|
+
// any other nested object must be flattened to a scalar before upsert or the
|
|
23
|
+
// PutVectors call dead-letters with a ValidationException.
|
|
24
|
+
type AddressLike = {
|
|
25
|
+
name?: string | null;
|
|
26
|
+
email?: string;
|
|
27
|
+
address?: string;
|
|
28
|
+
mailbox?: string;
|
|
29
|
+
host?: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const isAddressLike = (value: Record<string, unknown>): value is AddressLike =>
|
|
33
|
+
typeof value.email === "string" ||
|
|
34
|
+
typeof value.address === "string" ||
|
|
35
|
+
(typeof value.mailbox === "string" && typeof value.host === "string");
|
|
36
|
+
|
|
37
|
+
const formatAddress = (addr: AddressLike): string => {
|
|
38
|
+
const email =
|
|
39
|
+
addr.email ??
|
|
40
|
+
addr.address ??
|
|
41
|
+
(addr.mailbox && addr.host ? `${addr.mailbox}@${addr.host}` : "");
|
|
42
|
+
const name = typeof addr.name === "string" ? addr.name.trim() : "";
|
|
43
|
+
if (name.length > 0) return `${name} <${email}>`;
|
|
44
|
+
return email;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const isScalar = (value: unknown): value is string | number | boolean =>
|
|
48
|
+
typeof value === "string" ||
|
|
49
|
+
typeof value === "number" ||
|
|
50
|
+
typeof value === "boolean";
|
|
51
|
+
|
|
52
|
+
// Flatten a single metadata value to an S3-Vectors-safe scalar (or array of
|
|
53
|
+
// scalars). Objects become display strings; arrays of objects become arrays of
|
|
54
|
+
// strings. The result is guaranteed to satisfy the S3 Vectors constraint.
|
|
55
|
+
const flattenMetadataValue = (value: unknown): DocumentType => {
|
|
56
|
+
if (value === null || value === undefined) return null;
|
|
57
|
+
if (isScalar(value)) return value;
|
|
58
|
+
if (Array.isArray(value)) {
|
|
59
|
+
return value.map((item) => {
|
|
60
|
+
if (item === null || item === undefined) return "";
|
|
61
|
+
if (isScalar(item)) return item;
|
|
62
|
+
if (typeof item === "object" && isAddressLike(item as AddressLike)) {
|
|
63
|
+
return formatAddress(item as AddressLike);
|
|
64
|
+
}
|
|
65
|
+
return JSON.stringify(item);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
if (typeof value === "object") {
|
|
69
|
+
const obj = value as Record<string, unknown>;
|
|
70
|
+
if (isAddressLike(obj)) return formatAddress(obj);
|
|
71
|
+
return JSON.stringify(obj);
|
|
72
|
+
}
|
|
73
|
+
throw new Error(
|
|
74
|
+
`Cannot convert metadata value of type ${typeof value} to a scalar`,
|
|
75
|
+
);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// PutVectors always sends one flat metadata document per vector — S3 Vectors has no
|
|
79
|
+
// per-put "filterable vs non-filterable" split. Which keys count against the 2 KB
|
|
80
|
+
// filterable budget (vs the 40 KB total budget) is decided entirely by the index's
|
|
81
|
+
// CreateIndex-time `metadataConfiguration.nonFilterableMetadataKeys` config
|
|
82
|
+
// (CfnIndex in infra/constructs/s3vectors/remit-vector-index/index.ts), not by this
|
|
83
|
+
// put path. Declaring `textPreview` non-filterable there would need a CDK change that
|
|
84
|
+
// forces index replacement and a full re-index — not planned. `textPreview` is written
|
|
85
|
+
// as ordinary (filterable) metadata by design, bounded to a fixed byte budget
|
|
86
|
+
// (TEXT_PREVIEW_MAX_BYTES in search.ts) so it — plus every other key here — always
|
|
87
|
+
// fits the 2 KB filterable cap.
|
|
88
|
+
const toMetadataDocument = (metadata: ChunkMetadata): DocumentType => {
|
|
89
|
+
const out: { [k: string]: DocumentType } = {};
|
|
90
|
+
for (const [k, v] of Object.entries(metadata)) {
|
|
91
|
+
if (v === undefined) continue;
|
|
92
|
+
const flattened = flattenMetadataValue(v);
|
|
93
|
+
// S3 Vectors rejects null metadata values. Omit the key entirely (e.g.
|
|
94
|
+
// fromName for a sender with no display name) rather than emit null.
|
|
95
|
+
if (flattened === null) continue;
|
|
96
|
+
out[k] = flattened;
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const PUT_BATCH_SIZE = 100;
|
|
102
|
+
const DELETE_BATCH_SIZE = 100;
|
|
103
|
+
// AWS caps GetVectors at 100 keys per call (s3-vectors-limitations).
|
|
104
|
+
const GET_BATCH_SIZE = 100;
|
|
105
|
+
|
|
106
|
+
const MESSAGE_CATEGORIES = new Set<string>(Object.values(MessageCategory));
|
|
107
|
+
|
|
108
|
+
const isMessageCategory = (
|
|
109
|
+
value: unknown,
|
|
110
|
+
): value is ChunkMetadata["category"] =>
|
|
111
|
+
typeof value === "string" && MESSAGE_CATEGORIES.has(value);
|
|
112
|
+
|
|
113
|
+
const readContentHash = (metadata: unknown): string | undefined => {
|
|
114
|
+
if (typeof metadata !== "object" || metadata === null) return undefined;
|
|
115
|
+
const hash = (metadata as Record<string, unknown>).contentHash;
|
|
116
|
+
return typeof hash === "string" ? hash : undefined;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
export interface S3VectorsBackendConfig {
|
|
120
|
+
client?: S3VectorsClient;
|
|
121
|
+
region?: string;
|
|
122
|
+
vectorBucketName: string;
|
|
123
|
+
indexName: string;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const isStringArray = (value: unknown): value is string[] =>
|
|
127
|
+
Array.isArray(value) && value.every((v) => typeof v === "string");
|
|
128
|
+
|
|
129
|
+
const isNumberArray = (value: unknown): value is number[] =>
|
|
130
|
+
Array.isArray(value) && value.every((v) => typeof v === "number");
|
|
131
|
+
|
|
132
|
+
const toMetadata = (raw: unknown): ChunkMetadata => {
|
|
133
|
+
if (typeof raw !== "object" || raw === null) {
|
|
134
|
+
throw new Error("S3 Vectors metadata is not an object");
|
|
135
|
+
}
|
|
136
|
+
const obj = raw as Record<string, unknown>;
|
|
137
|
+
if (
|
|
138
|
+
typeof obj.messageId !== "string" ||
|
|
139
|
+
typeof obj.threadId !== "string" ||
|
|
140
|
+
typeof obj.accountConfigId !== "string" ||
|
|
141
|
+
typeof obj.chunkType !== "string" ||
|
|
142
|
+
typeof obj.sentDate !== "number" ||
|
|
143
|
+
typeof obj.isRead !== "boolean" ||
|
|
144
|
+
typeof obj.hasAttachment !== "boolean" ||
|
|
145
|
+
typeof obj.hasStars !== "boolean" ||
|
|
146
|
+
!isStringArray(obj.mailboxIds)
|
|
147
|
+
) {
|
|
148
|
+
throw new Error(`S3 Vectors metadata is malformed: ${JSON.stringify(obj)}`);
|
|
149
|
+
}
|
|
150
|
+
const fileTypes =
|
|
151
|
+
obj.fileTypes !== undefined && isStringArray(obj.fileTypes)
|
|
152
|
+
? obj.fileTypes
|
|
153
|
+
: undefined;
|
|
154
|
+
// fromName and subject are optional display fields added after initial
|
|
155
|
+
// deployment. Pre-enrichment vectors will not have them; treat as absent.
|
|
156
|
+
const fromName =
|
|
157
|
+
obj.fromName === null
|
|
158
|
+
? null
|
|
159
|
+
: typeof obj.fromName === "string"
|
|
160
|
+
? obj.fromName
|
|
161
|
+
: undefined;
|
|
162
|
+
const subject = typeof obj.subject === "string" ? obj.subject : undefined;
|
|
163
|
+
const category = isMessageCategory(obj.category) ? obj.category : undefined;
|
|
164
|
+
// textPreview is absent on vectors written before hybrid re-ranking shipped;
|
|
165
|
+
// treat as absent (score-neutral re-rank), same as the other display fields.
|
|
166
|
+
const textPreview =
|
|
167
|
+
typeof obj.textPreview === "string" ? obj.textPreview : undefined;
|
|
168
|
+
return {
|
|
169
|
+
messageId: obj.messageId,
|
|
170
|
+
threadId: obj.threadId,
|
|
171
|
+
accountConfigId: obj.accountConfigId,
|
|
172
|
+
mailboxIds: obj.mailboxIds,
|
|
173
|
+
chunkType: obj.chunkType as ChunkMetadata["chunkType"],
|
|
174
|
+
sentDate: obj.sentDate,
|
|
175
|
+
isRead: obj.isRead,
|
|
176
|
+
hasAttachment: obj.hasAttachment,
|
|
177
|
+
hasStars: obj.hasStars,
|
|
178
|
+
fileTypes,
|
|
179
|
+
...(fromName !== undefined ? { fromName } : {}),
|
|
180
|
+
...(subject !== undefined ? { subject } : {}),
|
|
181
|
+
...(category !== undefined ? { category } : {}),
|
|
182
|
+
...(textPreview !== undefined ? { textPreview } : {}),
|
|
183
|
+
};
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
// S3 Vectors does NOT accept implicit AND across multiple metadata keys — a flat
|
|
187
|
+
// multi-key object (e.g. { accountConfigId, mailboxIds }) is rejected with
|
|
188
|
+
// `ValidationException: Invalid filter`. Multiple conditions must be combined
|
|
189
|
+
// explicitly with $and, each as its own single-key object. A single condition is
|
|
190
|
+
// accepted bare, so we don't needlessly wrap it.
|
|
191
|
+
const buildFilterExpression = (
|
|
192
|
+
filter: VectorQueryFilter | undefined,
|
|
193
|
+
): DocumentType | undefined => {
|
|
194
|
+
if (!filter) return undefined;
|
|
195
|
+
const conditions: { [k: string]: DocumentType }[] = [];
|
|
196
|
+
if (filter.accountConfigId !== undefined) {
|
|
197
|
+
conditions.push({ accountConfigId: filter.accountConfigId });
|
|
198
|
+
}
|
|
199
|
+
if (filter.mailboxId !== undefined) {
|
|
200
|
+
conditions.push({
|
|
201
|
+
mailboxIds: { $in: [filter.mailboxId] as DocumentType[] },
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
if (filter.chunkType !== undefined) {
|
|
205
|
+
conditions.push({ chunkType: filter.chunkType });
|
|
206
|
+
}
|
|
207
|
+
if (filter.category !== undefined) {
|
|
208
|
+
conditions.push({ category: filter.category });
|
|
209
|
+
}
|
|
210
|
+
if (filter.hasAttachment !== undefined) {
|
|
211
|
+
conditions.push({ hasAttachment: filter.hasAttachment });
|
|
212
|
+
}
|
|
213
|
+
if (filter.hasStars !== undefined) {
|
|
214
|
+
conditions.push({ hasStars: filter.hasStars });
|
|
215
|
+
}
|
|
216
|
+
if (filter.isRead !== undefined) {
|
|
217
|
+
conditions.push({ isRead: filter.isRead });
|
|
218
|
+
}
|
|
219
|
+
if (filter.sentDateRange) {
|
|
220
|
+
const range: { [k: string]: DocumentType } = {};
|
|
221
|
+
if (filter.sentDateRange.from !== undefined) {
|
|
222
|
+
range.$gte = filter.sentDateRange.from;
|
|
223
|
+
}
|
|
224
|
+
if (filter.sentDateRange.to !== undefined) {
|
|
225
|
+
range.$lte = filter.sentDateRange.to;
|
|
226
|
+
}
|
|
227
|
+
if (Object.keys(range).length > 0) conditions.push({ sentDate: range });
|
|
228
|
+
}
|
|
229
|
+
if (conditions.length === 0) return undefined;
|
|
230
|
+
if (conditions.length === 1) return conditions[0];
|
|
231
|
+
return { $and: conditions as DocumentType[] };
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const chunkArray = <T>(arr: T[], size: number): T[][] => {
|
|
235
|
+
const out: T[][] = [];
|
|
236
|
+
for (let i = 0; i < arr.length; i += size) {
|
|
237
|
+
out.push(arr.slice(i, i + size));
|
|
238
|
+
}
|
|
239
|
+
return out;
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
export class S3VectorsBackend implements VectorStoreService {
|
|
243
|
+
private client: S3VectorsClient;
|
|
244
|
+
private vectorBucketName: string;
|
|
245
|
+
private indexName: string;
|
|
246
|
+
|
|
247
|
+
constructor(config: S3VectorsBackendConfig) {
|
|
248
|
+
this.client =
|
|
249
|
+
config.client ?? new S3VectorsClient({ region: config.region });
|
|
250
|
+
this.vectorBucketName = config.vectorBucketName;
|
|
251
|
+
this.indexName = config.indexName;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
upsert = async (vectors: VectorRecord[]): Promise<void> => {
|
|
255
|
+
for (const batch of chunkArray(vectors, PUT_BATCH_SIZE)) {
|
|
256
|
+
const cmd = new PutVectorsCommand({
|
|
257
|
+
vectorBucketName: this.vectorBucketName,
|
|
258
|
+
indexName: this.indexName,
|
|
259
|
+
vectors: batch.map((v) => ({
|
|
260
|
+
key: v.chunkId,
|
|
261
|
+
data: { float32: v.vector },
|
|
262
|
+
metadata: toMetadataDocument(v.metadata),
|
|
263
|
+
})),
|
|
264
|
+
});
|
|
265
|
+
await this.client.send(cmd);
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
// S3 Vectors returns a large topK across pages: one QueryVectors call yields a
|
|
270
|
+
// single page plus a `nextToken` when more results remain. Follow the token,
|
|
271
|
+
// re-issuing with the same queryVector/topK/filter, until the requested topK is
|
|
272
|
+
// filled or no token remains — a single-page query stops after one call. The
|
|
273
|
+
// loop is bounded by topK (never issues a request once topK matches are in
|
|
274
|
+
// hand), so a 4000-topK back-apply reads every page instead of silently capping
|
|
275
|
+
// at the first.
|
|
276
|
+
query = async (params: VectorQuery): Promise<VectorMatch[]> => {
|
|
277
|
+
const filter = buildFilterExpression(params.filter);
|
|
278
|
+
const out: VectorMatch[] = [];
|
|
279
|
+
let nextToken: string | undefined;
|
|
280
|
+
do {
|
|
281
|
+
const cmd = new QueryVectorsCommand({
|
|
282
|
+
vectorBucketName: this.vectorBucketName,
|
|
283
|
+
indexName: this.indexName,
|
|
284
|
+
topK: params.topK,
|
|
285
|
+
queryVector: { float32: params.vector },
|
|
286
|
+
filter,
|
|
287
|
+
returnMetadata: true,
|
|
288
|
+
returnDistance: true,
|
|
289
|
+
nextToken,
|
|
290
|
+
});
|
|
291
|
+
const response = await this.client.send(cmd);
|
|
292
|
+
for (const v of response.vectors ?? []) {
|
|
293
|
+
if (typeof v.key !== "string") continue;
|
|
294
|
+
const distance = v.distance ?? 0;
|
|
295
|
+
const score = 1 - distance;
|
|
296
|
+
out.push({
|
|
297
|
+
chunkId: v.key,
|
|
298
|
+
score,
|
|
299
|
+
metadata: toMetadata(v.metadata),
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
nextToken = response.nextToken || undefined;
|
|
303
|
+
} while (nextToken && out.length < params.topK);
|
|
304
|
+
return out.slice(0, params.topK);
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
// Read the stored content hash for each chunk key via GetVectors — addressed
|
|
308
|
+
// by key, returnMetadata only (no vector data), never an index-wide scan. A
|
|
309
|
+
// read, not a write: the idempotency check costs cheap GETs, never PUTs.
|
|
310
|
+
// Keys with no stored vector are simply absent from the response.
|
|
311
|
+
existingContentHashes = async (
|
|
312
|
+
chunkIds: string[],
|
|
313
|
+
): Promise<Map<string, string>> => {
|
|
314
|
+
const out = new Map<string, string>();
|
|
315
|
+
for (const batch of chunkArray(chunkIds, GET_BATCH_SIZE)) {
|
|
316
|
+
const cmd = new GetVectorsCommand({
|
|
317
|
+
vectorBucketName: this.vectorBucketName,
|
|
318
|
+
indexName: this.indexName,
|
|
319
|
+
keys: batch,
|
|
320
|
+
returnData: false,
|
|
321
|
+
returnMetadata: true,
|
|
322
|
+
});
|
|
323
|
+
const response = await this.client.send(cmd);
|
|
324
|
+
for (const v of response.vectors ?? []) {
|
|
325
|
+
if (typeof v.key !== "string") continue;
|
|
326
|
+
const hash = readContentHash(v.metadata);
|
|
327
|
+
if (hash !== undefined) out.set(v.key, hash);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return out;
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
// Read a message's chunk vectors (data + metadata) via GetVectors, addressed
|
|
334
|
+
// by the message's deterministic candidate keys — never a scan. GetVectors
|
|
335
|
+
// omits keys with no stored vector, so the result holds only chunks the
|
|
336
|
+
// message actually indexed. Backs the filter-anchor pooling (RFC 034
|
|
337
|
+
// Decision 2.1).
|
|
338
|
+
getByMessage = async (messageId: string): Promise<VectorRecord[]> => {
|
|
339
|
+
const out: VectorRecord[] = [];
|
|
340
|
+
const keys = candidateChunkKeys(messageId);
|
|
341
|
+
for (const batch of chunkArray(keys, GET_BATCH_SIZE)) {
|
|
342
|
+
const cmd = new GetVectorsCommand({
|
|
343
|
+
vectorBucketName: this.vectorBucketName,
|
|
344
|
+
indexName: this.indexName,
|
|
345
|
+
keys: batch,
|
|
346
|
+
returnData: true,
|
|
347
|
+
returnMetadata: true,
|
|
348
|
+
});
|
|
349
|
+
const response = await this.client.send(cmd);
|
|
350
|
+
for (const v of response.vectors ?? []) {
|
|
351
|
+
if (typeof v.key !== "string") continue;
|
|
352
|
+
const vector = v.data?.float32;
|
|
353
|
+
if (!isNumberArray(vector)) continue;
|
|
354
|
+
out.push({
|
|
355
|
+
chunkId: v.key,
|
|
356
|
+
vector,
|
|
357
|
+
metadata: toMetadata(v.metadata),
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return out;
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
// A message's chunks live under deterministic keys, so we delete them by
|
|
365
|
+
// address — never by listing the index. DeleteVectors ignores keys that
|
|
366
|
+
// aren't present, so addressing the full candidate set is safe and the cost
|
|
367
|
+
// is a fixed handful of requests per message regardless of index size.
|
|
368
|
+
delete = async (filter: { messageId: string }): Promise<void> => {
|
|
369
|
+
const keys = candidateChunkKeys(filter.messageId);
|
|
370
|
+
for (const batch of chunkArray(keys, DELETE_BATCH_SIZE)) {
|
|
371
|
+
const cmd = new DeleteVectorsCommand({
|
|
372
|
+
vectorBucketName: this.vectorBucketName,
|
|
373
|
+
indexName: this.indexName,
|
|
374
|
+
keys: batch,
|
|
375
|
+
});
|
|
376
|
+
await this.client.send(cmd);
|
|
377
|
+
}
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export const createS3VectorsBackend = (
|
|
382
|
+
config: S3VectorsBackendConfig,
|
|
383
|
+
): S3VectorsBackend => new S3VectorsBackend(config);
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exercises the sqlite-vec store against a real in-memory vec0 table (the local
|
|
3
|
+
* embedded-vector stack). Proves upsert, cosine ranking, content-hash lookup,
|
|
4
|
+
* the getByMessage anchor-pooling read path, and delete round-trip through the
|
|
5
|
+
* native extension.
|
|
6
|
+
*
|
|
7
|
+
* Gated behind RUN_INTEG_TESTS because it loads the native better-sqlite3 and
|
|
8
|
+
* sqlite-vec binaries, matching the pgvector integration suite.
|
|
9
|
+
*
|
|
10
|
+
* npm run test:integ -w packages/search-service
|
|
11
|
+
*/
|
|
12
|
+
import assert from "node:assert";
|
|
13
|
+
import { after, before, describe, test } from "node:test";
|
|
14
|
+
import type { ChunkMetadata, VectorRecord } from "../types.js";
|
|
15
|
+
import type { VectorStoreService } from "./memory.js";
|
|
16
|
+
import { createSqliteVectorStore } from "./sqlite-vec.js";
|
|
17
|
+
|
|
18
|
+
const RUN = process.env.RUN_INTEG_TESTS === "1";
|
|
19
|
+
const DIMENSIONS = 4;
|
|
20
|
+
|
|
21
|
+
const meta = (
|
|
22
|
+
over: Partial<ChunkMetadata> & { messageId: string },
|
|
23
|
+
): ChunkMetadata => ({
|
|
24
|
+
threadId: "t-1",
|
|
25
|
+
accountConfigId: "acc-1",
|
|
26
|
+
mailboxIds: ["mb-1"],
|
|
27
|
+
chunkType: "body",
|
|
28
|
+
sentDate: 1000,
|
|
29
|
+
isRead: false,
|
|
30
|
+
hasAttachment: false,
|
|
31
|
+
hasStars: false,
|
|
32
|
+
...over,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const record = (
|
|
36
|
+
chunkId: string,
|
|
37
|
+
vector: number[],
|
|
38
|
+
over: Partial<ChunkMetadata> & { messageId: string },
|
|
39
|
+
): VectorRecord => ({ chunkId, vector, metadata: meta(over) });
|
|
40
|
+
|
|
41
|
+
describe("sqlite-vec store (integration)", { skip: !RUN }, () => {
|
|
42
|
+
let store: VectorStoreService;
|
|
43
|
+
|
|
44
|
+
before(() => {
|
|
45
|
+
store = createSqliteVectorStore({
|
|
46
|
+
path: ":memory:",
|
|
47
|
+
dimensions: DIMENSIONS,
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
after(async () => {
|
|
52
|
+
await store.close?.();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("upsert then query ranks by cosine similarity", async () => {
|
|
56
|
+
await store.upsert([
|
|
57
|
+
record("c-x", [1, 0, 0, 0], { messageId: "m-x", contentHash: "hx" }),
|
|
58
|
+
record("c-y", [0, 1, 0, 0], { messageId: "m-y", contentHash: "hy" }),
|
|
59
|
+
record("c-z", [0.9, 0.1, 0, 0], { messageId: "m-z", contentHash: "hz" }),
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
const matches = await store.query({ vector: [1, 0, 0, 0], topK: 3 });
|
|
63
|
+
|
|
64
|
+
assert.equal(matches[0].chunkId, "c-x");
|
|
65
|
+
assert.equal(matches[1].chunkId, "c-z");
|
|
66
|
+
assert.equal(matches[2].chunkId, "c-y");
|
|
67
|
+
assert.ok(matches[0].score > matches[1].score);
|
|
68
|
+
assert.ok(matches[0].score > 0.99);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("existingContentHashes returns stored hashes only for known keys", async () => {
|
|
72
|
+
const hashes = await store.existingContentHashes(["c-x", "c-y", "missing"]);
|
|
73
|
+
assert.equal(hashes.get("c-x"), "hx");
|
|
74
|
+
assert.equal(hashes.get("c-y"), "hy");
|
|
75
|
+
assert.equal(hashes.has("missing"), false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("getByMessage returns every chunk of a message with its vector and metadata", async () => {
|
|
79
|
+
await store.upsert([
|
|
80
|
+
record("g-sub", [1, 0, 0, 0], {
|
|
81
|
+
messageId: "m-get",
|
|
82
|
+
chunkType: "subject",
|
|
83
|
+
contentHash: "hg1",
|
|
84
|
+
}),
|
|
85
|
+
record("g-body", [0, 1, 0, 0], {
|
|
86
|
+
messageId: "m-get",
|
|
87
|
+
chunkType: "body",
|
|
88
|
+
contentHash: "hg2",
|
|
89
|
+
}),
|
|
90
|
+
record("g-other", [0, 0, 1, 0], { messageId: "m-other" }),
|
|
91
|
+
]);
|
|
92
|
+
|
|
93
|
+
const records = await store.getByMessage("m-get");
|
|
94
|
+
|
|
95
|
+
const byId = new Map(records.map((r) => [r.chunkId, r]));
|
|
96
|
+
assert.equal(records.length, 2, "only the message's own chunks");
|
|
97
|
+
assert.deepEqual(byId.get("g-sub")?.vector, [1, 0, 0, 0]);
|
|
98
|
+
assert.deepEqual(byId.get("g-body")?.vector, [0, 1, 0, 0]);
|
|
99
|
+
assert.equal(byId.get("g-sub")?.metadata.chunkType, "subject");
|
|
100
|
+
assert.equal(byId.get("g-body")?.metadata.messageId, "m-get");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("getByMessage returns an empty array for an unknown message", async () => {
|
|
104
|
+
assert.deepEqual(await store.getByMessage("m-absent"), []);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("indexes a categoryless chunk and its category stays absent under a category filter", async () => {
|
|
108
|
+
await store.upsert([
|
|
109
|
+
record("cat-none", [1, 0, 0, 0], { messageId: "m-cat-none" }),
|
|
110
|
+
record("cat-news", [1, 0, 0, 0], {
|
|
111
|
+
messageId: "m-cat-news",
|
|
112
|
+
category: "newsletter",
|
|
113
|
+
}),
|
|
114
|
+
]);
|
|
115
|
+
|
|
116
|
+
const scoped = await store.query({
|
|
117
|
+
vector: [1, 0, 0, 0],
|
|
118
|
+
topK: 10,
|
|
119
|
+
filter: { category: "newsletter" },
|
|
120
|
+
});
|
|
121
|
+
assert.deepEqual(
|
|
122
|
+
scoped.map((m) => m.chunkId),
|
|
123
|
+
["cat-news"],
|
|
124
|
+
"a category filter excludes the categoryless chunk",
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
const [record0] = await store.getByMessage("m-cat-none");
|
|
128
|
+
assert.equal(
|
|
129
|
+
record0.metadata.category,
|
|
130
|
+
undefined,
|
|
131
|
+
"the categoryless chunk reads back with no category, not an empty string",
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("delete removes every chunk of a message", async () => {
|
|
136
|
+
await store.upsert([
|
|
137
|
+
record("d-1", [1, 0, 0, 0], { messageId: "m-del" }),
|
|
138
|
+
record("d-2", [0, 1, 0, 0], { messageId: "m-del" }),
|
|
139
|
+
]);
|
|
140
|
+
await store.delete({ messageId: "m-del" });
|
|
141
|
+
const hashes = await store.existingContentHashes(["d-1", "d-2"]);
|
|
142
|
+
assert.equal(hashes.size, 0);
|
|
143
|
+
});
|
|
144
|
+
});
|