@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
package/src/search.ts
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
import type { VectorStoreService } from "./backends/memory.js";
|
|
2
|
+
import { createEmailChunker, type EmailChunker } from "./chunking/chunker.js";
|
|
3
|
+
import { extractAttachmentFileTypes } from "./chunking/structured.js";
|
|
4
|
+
import { computeContentHash } from "./content-hash.js";
|
|
5
|
+
import type { EmbeddingService } from "./embeddings.js";
|
|
6
|
+
import type {
|
|
7
|
+
Chunk,
|
|
8
|
+
ChunkMetadata,
|
|
9
|
+
IndexEmailParams,
|
|
10
|
+
SearchParams,
|
|
11
|
+
SearchResult,
|
|
12
|
+
VectorMatch,
|
|
13
|
+
VectorRecord,
|
|
14
|
+
} from "./types.js";
|
|
15
|
+
|
|
16
|
+
/** Outcome of an upsert: how many vectors were written vs skipped as unchanged. */
|
|
17
|
+
export interface UpsertResult {
|
|
18
|
+
upserted: number;
|
|
19
|
+
skipped: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface UpsertOptions {
|
|
23
|
+
/** Re-PUT every record regardless of content hash (deliberate full re-embed / repair). */
|
|
24
|
+
force?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface SearchService {
|
|
28
|
+
index(params: IndexEmailParams): Promise<void>;
|
|
29
|
+
prepareVectors(params: IndexEmailParams): Promise<VectorRecord[]>;
|
|
30
|
+
upsertVectors(
|
|
31
|
+
records: VectorRecord[],
|
|
32
|
+
options?: UpsertOptions,
|
|
33
|
+
): Promise<UpsertResult>;
|
|
34
|
+
/**
|
|
35
|
+
* Chunk, then embed and upsert only the chunks whose content hash changed.
|
|
36
|
+
* The hash is computed from the chunk text before embedding, so an unchanged,
|
|
37
|
+
* already-indexed message costs one `existingContentHashes` lookup and no
|
|
38
|
+
* embedding. `force` re-embeds every chunk (move metadata refresh / repair).
|
|
39
|
+
*
|
|
40
|
+
* `{ upserted: 0, skipped: 0 }` means the message has no indexable content
|
|
41
|
+
* (no chunks); `{ upserted: 0, skipped: n>0 }` means everything was unchanged.
|
|
42
|
+
*/
|
|
43
|
+
indexIncremental(
|
|
44
|
+
params: IndexEmailParams,
|
|
45
|
+
options?: UpsertOptions,
|
|
46
|
+
): Promise<UpsertResult>;
|
|
47
|
+
search(params: SearchParams): Promise<SearchResult[]>;
|
|
48
|
+
delete(messageId: string): Promise<void>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const DEFAULT_TOP_K = 50;
|
|
52
|
+
const DEFAULT_LIMIT = 25;
|
|
53
|
+
|
|
54
|
+
// S3 Vectors limits (docs.aws.amazon.com/AmazonS3/latest/userguide/s3-vectors-limitations.html,
|
|
55
|
+
// verified 2026-07): filterable metadata <= 2 KB/vector, total metadata <= 40 KB/vector.
|
|
56
|
+
// textPreview should be declared non-filterable via the index's
|
|
57
|
+
// metadataConfiguration.nonFilterableMetadataKeys (a CDK-level change) so it only counts
|
|
58
|
+
// against the 40 KB total budget; that is not planned (see PR description) — the byte-bounded
|
|
59
|
+
// preview below is the design, not an interim measure.
|
|
60
|
+
//
|
|
61
|
+
// Until then, textPreview shares the 2048 B filterable budget with every other ChunkMetadata
|
|
62
|
+
// field written alongside it in the same PutVectors call (see toMetadataDocument in
|
|
63
|
+
// backends/s3-vectors.ts). `slice(0, N)` on a JS string counts UTF-16 code units, not bytes —
|
|
64
|
+
// for CJK/Cyrillic/Arabic text (2-3 bytes/char in UTF-8) a 512-char preview alone can reach
|
|
65
|
+
// ~1.5 KB, leaving no room for the rest of the metadata and blowing the cap (PutVectors then
|
|
66
|
+
// rejects the whole vector, dead-lettering the message). The budget below is therefore a fixed
|
|
67
|
+
// byte cap, not a char cap.
|
|
68
|
+
//
|
|
69
|
+
// Worst-case JSON-serialized size of the other filterable fields (key + value + quoting/comma
|
|
70
|
+
// overhead), rounded up per field:
|
|
71
|
+
//
|
|
72
|
+
// messageId, threadId, accountConfigId 3 UUIDs ~170 B
|
|
73
|
+
// contentHash sha256 hex ~85 B
|
|
74
|
+
// mailboxIds up to ~6 labels/UUIDs ~250 B
|
|
75
|
+
// chunkType, category short enum strings ~50 B
|
|
76
|
+
// sentDate, isRead, hasAttachment,
|
|
77
|
+
// hasStars 1 number + 3 booleans ~70 B
|
|
78
|
+
// fileTypes a handful of MIME types ~110 B
|
|
79
|
+
// fromName, subject display strings (unbounded
|
|
80
|
+
// elsewhere in the system) ~450 B
|
|
81
|
+
// ---------
|
|
82
|
+
// total ~1185 B
|
|
83
|
+
//
|
|
84
|
+
// subject/fromName/mailboxIds have no hard length limit upstream, so this is a realistic
|
|
85
|
+
// worst case, not a proof. OTHER_METADATA_MAX_BYTES rounds it up to 1300 B for headroom.
|
|
86
|
+
// SAFETY_MARGIN_BYTES shaves another 48 B off the 748 B remainder, landing on a round
|
|
87
|
+
// 700 B for textPreview — comfortably above what a 512-char ASCII preview needs (512 B,
|
|
88
|
+
// so the byte cap never shortens the common case) and enough for ~175 chars of 4-byte
|
|
89
|
+
// UTF-8 (emoji) or ~233 chars of 3-byte UTF-8 (CJK).
|
|
90
|
+
const TEXT_PREVIEW_MAX_CHARS = 512;
|
|
91
|
+
const S3_VECTORS_FILTERABLE_METADATA_MAX_BYTES = 2048;
|
|
92
|
+
const OTHER_METADATA_MAX_BYTES = 1300;
|
|
93
|
+
const SAFETY_MARGIN_BYTES = 48;
|
|
94
|
+
const TEXT_PREVIEW_MAX_BYTES =
|
|
95
|
+
S3_VECTORS_FILTERABLE_METADATA_MAX_BYTES -
|
|
96
|
+
OTHER_METADATA_MAX_BYTES -
|
|
97
|
+
SAFETY_MARGIN_BYTES;
|
|
98
|
+
|
|
99
|
+
const utf8Decoder = new TextDecoder("utf-8", { fatal: true });
|
|
100
|
+
|
|
101
|
+
// Truncate `text` to at most `maxBytes` UTF-8 bytes without splitting a multi-byte
|
|
102
|
+
// sequence (or a surrogate pair, which encodes as one 4-byte UTF-8 sequence) —
|
|
103
|
+
// naive byte-slicing can cut mid-character and produce invalid UTF-8 / a broken
|
|
104
|
+
// glyph. Backs off at most 3 bytes (the longest UTF-8 sequence is 4 bytes) before
|
|
105
|
+
// landing on a valid boundary.
|
|
106
|
+
export const truncateUtf8Bytes = (text: string, maxBytes: number): string => {
|
|
107
|
+
const bytes = Buffer.from(text, "utf8");
|
|
108
|
+
if (bytes.byteLength <= maxBytes) return text;
|
|
109
|
+
for (let len = maxBytes; len > 0; len--) {
|
|
110
|
+
try {
|
|
111
|
+
return utf8Decoder.decode(bytes.subarray(0, len));
|
|
112
|
+
} catch {
|
|
113
|
+
// Landed mid-sequence; back off one byte and retry.
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return "";
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// Preview stored in vector metadata: bounded by char count (existing preview-length
|
|
120
|
+
// semantics) and, independently, by UTF-8 byte size (the S3 Vectors filterable
|
|
121
|
+
// metadata cap — see TEXT_PREVIEW_MAX_BYTES above). The byte cap only bites for
|
|
122
|
+
// multi-byte text; a 512-char ASCII preview is unaffected.
|
|
123
|
+
export const buildTextPreview = (text: string): string =>
|
|
124
|
+
truncateUtf8Bytes(
|
|
125
|
+
text.slice(0, TEXT_PREVIEW_MAX_CHARS),
|
|
126
|
+
TEXT_PREVIEW_MAX_BYTES,
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
// Blend weights for hybrid re-ranking: literal substring matches on the stored
|
|
130
|
+
// textPreview outweigh raw cosine similarity, so exact terms (invoice numbers,
|
|
131
|
+
// names, codes) surface over a merely-similar semantic neighbor.
|
|
132
|
+
const RERANK_COSINE_WEIGHT = 0.4;
|
|
133
|
+
const RERANK_LITERAL_WEIGHT = 0.6;
|
|
134
|
+
|
|
135
|
+
const QUERY_TOKEN_MIN_LENGTH = 3;
|
|
136
|
+
const QUERY_TOKEN_MAX_COUNT = 8;
|
|
137
|
+
|
|
138
|
+
export const tokenizeQuery = (query: string): string[] =>
|
|
139
|
+
query
|
|
140
|
+
.toLowerCase()
|
|
141
|
+
.split(/\s+/)
|
|
142
|
+
.filter((token) => token.length >= QUERY_TOKEN_MIN_LENGTH)
|
|
143
|
+
.slice(0, QUERY_TOKEN_MAX_COUNT);
|
|
144
|
+
|
|
145
|
+
// Fraction of query tokens found as substrings in the chunk's textPreview.
|
|
146
|
+
// `undefined` means "no preview stored" (pre-rerank vector) — the caller must
|
|
147
|
+
// treat that as score-neutral, not as a literal score of 0. A query with no
|
|
148
|
+
// qualifying tokens (all shorter than QUERY_TOKEN_MIN_LENGTH) has no literal
|
|
149
|
+
// signal to compute either, so it is also treated as neutral.
|
|
150
|
+
export const literalMatchScore = (
|
|
151
|
+
queryTokens: string[],
|
|
152
|
+
textPreview: string | undefined,
|
|
153
|
+
): number | undefined => {
|
|
154
|
+
if (textPreview === undefined) return undefined;
|
|
155
|
+
if (queryTokens.length === 0) return undefined;
|
|
156
|
+
const haystack = textPreview.toLowerCase();
|
|
157
|
+
const hits = queryTokens.filter((token) => haystack.includes(token)).length;
|
|
158
|
+
return hits / queryTokens.length;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
// Blend cosine similarity with a literal-substring score computed from the
|
|
162
|
+
// chunk's textPreview. Applied to the full topK candidate window, before
|
|
163
|
+
// dedupe-by-message, so the literal boost can change which chunk represents a
|
|
164
|
+
// message. Vectors with no stored textPreview (written before this field
|
|
165
|
+
// existed) keep their raw cosine score unscaled — never penalized for missing
|
|
166
|
+
// a preview.
|
|
167
|
+
export const rerank = (
|
|
168
|
+
matches: VectorMatch[],
|
|
169
|
+
query: string,
|
|
170
|
+
): VectorMatch[] => {
|
|
171
|
+
const queryTokens = tokenizeQuery(query);
|
|
172
|
+
return matches.map((match) => {
|
|
173
|
+
const literal = literalMatchScore(queryTokens, match.metadata.textPreview);
|
|
174
|
+
if (literal === undefined) return match;
|
|
175
|
+
return {
|
|
176
|
+
...match,
|
|
177
|
+
score:
|
|
178
|
+
RERANK_COSINE_WEIGHT * match.score + RERANK_LITERAL_WEIGHT * literal,
|
|
179
|
+
};
|
|
180
|
+
});
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const dedupeMatchesByMessage = (matches: VectorMatch[]): VectorMatch[] => {
|
|
184
|
+
const best = new Map<string, VectorMatch>();
|
|
185
|
+
for (const m of matches) {
|
|
186
|
+
const existing = best.get(m.metadata.messageId);
|
|
187
|
+
if (!existing || m.score > existing.score) {
|
|
188
|
+
best.set(m.metadata.messageId, m);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return Array.from(best.values()).sort((a, b) => b.score - a.score);
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
export interface SearchServiceConfig {
|
|
195
|
+
chunker?: EmailChunker;
|
|
196
|
+
embedder: EmbeddingService;
|
|
197
|
+
store: VectorStoreService;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export class DefaultSearchService implements SearchService {
|
|
201
|
+
private chunker: EmailChunker;
|
|
202
|
+
private embedder: EmbeddingService;
|
|
203
|
+
private store: VectorStoreService;
|
|
204
|
+
|
|
205
|
+
constructor(config: SearchServiceConfig) {
|
|
206
|
+
this.chunker = config.chunker ?? createEmailChunker();
|
|
207
|
+
this.embedder = config.embedder;
|
|
208
|
+
this.store = config.store;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
index = async (params: IndexEmailParams): Promise<void> => {
|
|
212
|
+
const records = await this.prepareVectors(params);
|
|
213
|
+
if (records.length === 0) return;
|
|
214
|
+
await this.upsertVectors(records);
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
prepareVectors = async (
|
|
218
|
+
params: IndexEmailParams,
|
|
219
|
+
): Promise<VectorRecord[]> => {
|
|
220
|
+
const { envelope, parsedBody, metadata } = params;
|
|
221
|
+
const chunks = this.chunker.chunk({
|
|
222
|
+
envelope,
|
|
223
|
+
parsedBody,
|
|
224
|
+
messageId: metadata.messageId,
|
|
225
|
+
});
|
|
226
|
+
if (chunks.length === 0) return [];
|
|
227
|
+
|
|
228
|
+
const vectors = await this.embedder.embed(chunks.map((c) => c.text));
|
|
229
|
+
if (vectors.length !== chunks.length) {
|
|
230
|
+
throw new Error(
|
|
231
|
+
`Embedding count mismatch: ${vectors.length} vectors for ${chunks.length} chunks`,
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const fileTypes = extractAttachmentFileTypes(envelope.attachments);
|
|
236
|
+
const { embeddingId } = this.embedder;
|
|
237
|
+
|
|
238
|
+
return chunks.map((chunk, i) => {
|
|
239
|
+
const meta: ChunkMetadata = {
|
|
240
|
+
...metadata,
|
|
241
|
+
chunkType: chunk.chunkType,
|
|
242
|
+
contentHash: computeContentHash(embeddingId, chunk.text),
|
|
243
|
+
textPreview: buildTextPreview(chunk.text),
|
|
244
|
+
...(chunk.chunkType === "attachment" && fileTypes.length > 0
|
|
245
|
+
? { fileTypes }
|
|
246
|
+
: {}),
|
|
247
|
+
};
|
|
248
|
+
return {
|
|
249
|
+
chunkId: chunk.chunkId,
|
|
250
|
+
vector: vectors[i],
|
|
251
|
+
metadata: meta,
|
|
252
|
+
};
|
|
253
|
+
});
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
// Idempotent by contract: PUT a vector only when its content hash differs from
|
|
257
|
+
// what is already stored. An unchanged re-index/backfill reads the existing
|
|
258
|
+
// hashes (cheap GetVectors, addressed by deterministic key — never a scan) and
|
|
259
|
+
// writes nothing. A content change or embedding-model bump changes the hash and
|
|
260
|
+
// re-PUTs; `force` re-PUTs every record regardless.
|
|
261
|
+
upsertVectors = async (
|
|
262
|
+
records: VectorRecord[],
|
|
263
|
+
options?: UpsertOptions,
|
|
264
|
+
): Promise<UpsertResult> => {
|
|
265
|
+
if (records.length === 0) return { upserted: 0, skipped: 0 };
|
|
266
|
+
|
|
267
|
+
if (options?.force) {
|
|
268
|
+
await this.store.upsert(records);
|
|
269
|
+
return { upserted: records.length, skipped: 0 };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const existing = await this.store.existingContentHashes(
|
|
273
|
+
records.map((r) => r.chunkId),
|
|
274
|
+
);
|
|
275
|
+
const changed = records.filter(
|
|
276
|
+
(r) => existing.get(r.chunkId) !== r.metadata.contentHash,
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
if (changed.length > 0) await this.store.upsert(changed);
|
|
280
|
+
return {
|
|
281
|
+
upserted: changed.length,
|
|
282
|
+
skipped: records.length - changed.length,
|
|
283
|
+
};
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
indexIncremental = async (
|
|
287
|
+
params: IndexEmailParams,
|
|
288
|
+
options?: UpsertOptions,
|
|
289
|
+
): Promise<UpsertResult> => {
|
|
290
|
+
const { envelope, parsedBody, metadata } = params;
|
|
291
|
+
const chunks = this.chunker.chunk({
|
|
292
|
+
envelope,
|
|
293
|
+
parsedBody,
|
|
294
|
+
messageId: metadata.messageId,
|
|
295
|
+
});
|
|
296
|
+
if (chunks.length === 0) return { upserted: 0, skipped: 0 };
|
|
297
|
+
|
|
298
|
+
const byId = new Map<string, Chunk>();
|
|
299
|
+
for (const chunk of chunks) byId.set(chunk.chunkId, chunk);
|
|
300
|
+
const unique = [...byId.values()];
|
|
301
|
+
|
|
302
|
+
const { embeddingId } = this.embedder;
|
|
303
|
+
const hashed = unique.map((chunk) => ({
|
|
304
|
+
chunk,
|
|
305
|
+
contentHash: computeContentHash(embeddingId, chunk.text),
|
|
306
|
+
}));
|
|
307
|
+
|
|
308
|
+
// Gate the embed on content hash. This is the whole point of the method:
|
|
309
|
+
// a re-delivered event for an unchanged message reads the stored hashes
|
|
310
|
+
// (cheap, keyed GetVectors) and returns without embedding anything.
|
|
311
|
+
let toEmbed = hashed;
|
|
312
|
+
if (!options?.force) {
|
|
313
|
+
const existing = await this.store.existingContentHashes(
|
|
314
|
+
unique.map((c) => c.chunkId),
|
|
315
|
+
);
|
|
316
|
+
toEmbed = hashed.filter(
|
|
317
|
+
(h) => existing.get(h.chunk.chunkId) !== h.contentHash,
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
const skipped = unique.length - toEmbed.length;
|
|
321
|
+
if (toEmbed.length === 0) return { upserted: 0, skipped };
|
|
322
|
+
|
|
323
|
+
const vectors = await this.embedder.embed(toEmbed.map((h) => h.chunk.text));
|
|
324
|
+
if (vectors.length !== toEmbed.length) {
|
|
325
|
+
throw new Error(
|
|
326
|
+
`Embedding count mismatch: ${vectors.length} vectors for ${toEmbed.length} chunks`,
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const fileTypes = extractAttachmentFileTypes(envelope.attachments);
|
|
331
|
+
const records: VectorRecord[] = toEmbed.map((h, i) => ({
|
|
332
|
+
chunkId: h.chunk.chunkId,
|
|
333
|
+
vector: vectors[i],
|
|
334
|
+
metadata: {
|
|
335
|
+
...metadata,
|
|
336
|
+
chunkType: h.chunk.chunkType,
|
|
337
|
+
contentHash: h.contentHash,
|
|
338
|
+
textPreview: buildTextPreview(h.chunk.text),
|
|
339
|
+
...(h.chunk.chunkType === "attachment" && fileTypes.length > 0
|
|
340
|
+
? { fileTypes }
|
|
341
|
+
: {}),
|
|
342
|
+
},
|
|
343
|
+
}));
|
|
344
|
+
await this.store.upsert(records);
|
|
345
|
+
return { upserted: records.length, skipped };
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
search = async (params: SearchParams): Promise<SearchResult[]> => {
|
|
349
|
+
const limit = params.limit ?? DEFAULT_LIMIT;
|
|
350
|
+
const [queryVector] = await this.embedder.embed([params.query]);
|
|
351
|
+
if (!queryVector) return [];
|
|
352
|
+
|
|
353
|
+
const matches = await this.store.query({
|
|
354
|
+
vector: queryVector,
|
|
355
|
+
topK: Math.max(limit * 4, DEFAULT_TOP_K),
|
|
356
|
+
filter: {
|
|
357
|
+
accountConfigId: params.accountConfigId,
|
|
358
|
+
mailboxId: params.mailboxId,
|
|
359
|
+
sentDateRange: params.sentDateRange,
|
|
360
|
+
hasAttachment: params.hasAttachment,
|
|
361
|
+
hasStars: params.hasStars,
|
|
362
|
+
isRead: params.isRead,
|
|
363
|
+
category: params.category,
|
|
364
|
+
},
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
const reranked = rerank(matches, params.query);
|
|
368
|
+
const deduped = dedupeMatchesByMessage(reranked).slice(0, limit);
|
|
369
|
+
return deduped.map((m) => ({
|
|
370
|
+
messageId: m.metadata.messageId,
|
|
371
|
+
threadId: m.metadata.threadId,
|
|
372
|
+
score: m.score,
|
|
373
|
+
matchedChunkType: m.metadata.chunkType,
|
|
374
|
+
mailboxIds: m.metadata.mailboxIds,
|
|
375
|
+
sentDate: m.metadata.sentDate,
|
|
376
|
+
...(m.metadata.fromName !== undefined
|
|
377
|
+
? { fromName: m.metadata.fromName }
|
|
378
|
+
: {}),
|
|
379
|
+
...(m.metadata.subject !== undefined
|
|
380
|
+
? { subject: m.metadata.subject }
|
|
381
|
+
: {}),
|
|
382
|
+
...(m.metadata.category !== undefined
|
|
383
|
+
? { category: m.metadata.category }
|
|
384
|
+
: {}),
|
|
385
|
+
}));
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
delete = async (messageId: string): Promise<void> => {
|
|
389
|
+
await this.store.delete({ messageId });
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
export const createSearchService = (
|
|
394
|
+
config: SearchServiceConfig,
|
|
395
|
+
): SearchService => new DefaultSearchService(config);
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exercises the real local backends end to end: the persistent sqlite-vec
|
|
3
|
+
* vector store and the Transformers.js (MiniLM) embedder. It proves the search
|
|
4
|
+
* is semantic rather than keyword — the query shares no words with the target
|
|
5
|
+
* message body, so the deterministic bag-of-words embedder would not rank it
|
|
6
|
+
* first.
|
|
7
|
+
*
|
|
8
|
+
* Downloads the MiniLM model on first run, so it is gated behind RUN_INTEG_TESTS
|
|
9
|
+
* and excluded from the default unit-test path.
|
|
10
|
+
*
|
|
11
|
+
* npm run test:integ -w packages/search-service
|
|
12
|
+
*/
|
|
13
|
+
import assert from "node:assert";
|
|
14
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { after, before, describe, test } from "node:test";
|
|
18
|
+
import { createSqliteVectorStore } from "./backends/sqlite-vec.js";
|
|
19
|
+
import { createLocalEmbeddingService } from "./embeddings.js";
|
|
20
|
+
import { createSearchService, type SearchService } from "./search.js";
|
|
21
|
+
import type { IndexEmailParams } from "./types.js";
|
|
22
|
+
|
|
23
|
+
const ACCOUNT_CONFIG_ID = "acc-1";
|
|
24
|
+
|
|
25
|
+
const message = (
|
|
26
|
+
messageId: string,
|
|
27
|
+
subject: string,
|
|
28
|
+
body: string,
|
|
29
|
+
): IndexEmailParams => ({
|
|
30
|
+
envelope: {
|
|
31
|
+
from: { name: "Sender", email: "sender@example.com" },
|
|
32
|
+
to: [{ name: "You", email: "you@example.com" }],
|
|
33
|
+
cc: [],
|
|
34
|
+
bcc: [],
|
|
35
|
+
subject,
|
|
36
|
+
attachments: [],
|
|
37
|
+
},
|
|
38
|
+
parsedBody: { text: body, html: null },
|
|
39
|
+
metadata: {
|
|
40
|
+
messageId,
|
|
41
|
+
threadId: `thread-${messageId}`,
|
|
42
|
+
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
43
|
+
mailboxIds: ["inbox"],
|
|
44
|
+
sentDate: 1_700_000_000,
|
|
45
|
+
isRead: false,
|
|
46
|
+
hasAttachment: false,
|
|
47
|
+
hasStars: false,
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const FLIGHT = message(
|
|
52
|
+
"msg-flight",
|
|
53
|
+
"Your itinerary",
|
|
54
|
+
"Your flight is confirmed. You depart Amsterdam Schiphol bound for New York JFK on Tuesday morning; please arrive at the gate early.",
|
|
55
|
+
);
|
|
56
|
+
const FINANCE = message(
|
|
57
|
+
"msg-finance",
|
|
58
|
+
"Quarterly numbers",
|
|
59
|
+
"The quarterly earnings report is attached. Revenue rose twelve percent and operating margin improved over the previous period.",
|
|
60
|
+
);
|
|
61
|
+
const DENTIST = message(
|
|
62
|
+
"msg-dentist",
|
|
63
|
+
"See you soon",
|
|
64
|
+
"This is a reminder that your dental check-up is scheduled for next Monday at nine in the morning. Call us to reschedule.",
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
describe(
|
|
68
|
+
"semantic search over real local backends",
|
|
69
|
+
{ skip: !process.env.RUN_INTEG_TESTS },
|
|
70
|
+
() => {
|
|
71
|
+
let dir: string;
|
|
72
|
+
let dbPath: string;
|
|
73
|
+
let search: SearchService;
|
|
74
|
+
|
|
75
|
+
before(async () => {
|
|
76
|
+
dir = mkdtempSync(join(tmpdir(), "remit-vec-"));
|
|
77
|
+
dbPath = join(dir, "vectors.sqlite");
|
|
78
|
+
search = createSearchService({
|
|
79
|
+
store: createSqliteVectorStore({ path: dbPath }),
|
|
80
|
+
embedder: createLocalEmbeddingService(),
|
|
81
|
+
});
|
|
82
|
+
await search.index(FLIGHT);
|
|
83
|
+
await search.index(FINANCE);
|
|
84
|
+
await search.index(DENTIST);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
after(() => {
|
|
88
|
+
rmSync(dir, { recursive: true, force: true });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("a paraphrased query returns the semantically closest message", async () => {
|
|
92
|
+
const results = await search.search({
|
|
93
|
+
query: "air travel reservation booking",
|
|
94
|
+
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
95
|
+
limit: 3,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
assert.ok(results.length > 0, "expected at least one hit");
|
|
99
|
+
assert.equal(
|
|
100
|
+
results[0].messageId,
|
|
101
|
+
"msg-flight",
|
|
102
|
+
`expected the flight message to rank first, got ${results
|
|
103
|
+
.map((r) => r.messageId)
|
|
104
|
+
.join(", ")}`,
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("vectors persist to disk across store instances", async () => {
|
|
109
|
+
const reopened = createSearchService({
|
|
110
|
+
store: createSqliteVectorStore({ path: dbPath }),
|
|
111
|
+
embedder: createLocalEmbeddingService(),
|
|
112
|
+
});
|
|
113
|
+
const results = await reopened.search({
|
|
114
|
+
query: "earnings and revenue report",
|
|
115
|
+
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
116
|
+
limit: 3,
|
|
117
|
+
});
|
|
118
|
+
assert.equal(results[0].messageId, "msg-finance");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("the metadata filter scopes results to the account", async () => {
|
|
122
|
+
const results = await search.search({
|
|
123
|
+
query: "air travel reservation booking",
|
|
124
|
+
accountConfigId: "other-account",
|
|
125
|
+
limit: 3,
|
|
126
|
+
});
|
|
127
|
+
assert.equal(results.length, 0);
|
|
128
|
+
});
|
|
129
|
+
},
|
|
130
|
+
);
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import type { MessageCategory } from "@remit/api-openapi-types";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
export const searchIndexMessageSchema = z.object({
|
|
5
|
+
eventName: z.enum(["INSERT", "MODIFY", "REMOVE"]),
|
|
6
|
+
entity: z.literal("Message"),
|
|
7
|
+
eventID: z.string(),
|
|
8
|
+
eventTimestamp: z.number(),
|
|
9
|
+
accountId: z.string().min(1),
|
|
10
|
+
keys: z.object({ pk: z.string(), sk: z.string() }),
|
|
11
|
+
messageId: z.string().min(1),
|
|
12
|
+
/** Re-PUT every vector regardless of content hash (deliberate full re-embed / repair). */
|
|
13
|
+
force: z.boolean().optional(),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
export type SearchIndexMessage = z.infer<typeof searchIndexMessageSchema>;
|
|
17
|
+
|
|
18
|
+
export type ChunkType =
|
|
19
|
+
| "sender"
|
|
20
|
+
| "recipient"
|
|
21
|
+
| "subject"
|
|
22
|
+
| "attachment"
|
|
23
|
+
| "body"
|
|
24
|
+
| "entities";
|
|
25
|
+
|
|
26
|
+
export interface ChunkMetadata {
|
|
27
|
+
messageId: string;
|
|
28
|
+
threadId: string;
|
|
29
|
+
accountConfigId: string;
|
|
30
|
+
mailboxIds: string[];
|
|
31
|
+
chunkType: ChunkType;
|
|
32
|
+
sentDate: number;
|
|
33
|
+
isRead: boolean;
|
|
34
|
+
hasAttachment: boolean;
|
|
35
|
+
hasStars: boolean;
|
|
36
|
+
fileTypes?: string[];
|
|
37
|
+
/** Display name of the sender. Stored at index time; absent for pre-enrichment vectors. */
|
|
38
|
+
fromName?: string | null;
|
|
39
|
+
/** Message subject. Stored at index time; absent for pre-enrichment vectors. */
|
|
40
|
+
subject?: string;
|
|
41
|
+
/** Header-derived category. Stored at index time; absent for pre-enrichment vectors. */
|
|
42
|
+
category?: MessageCategory;
|
|
43
|
+
/**
|
|
44
|
+
* sha256 over the embedding model/version id and the chunk's embeddable text.
|
|
45
|
+
* Lets a re-index skip an unchanged chunk and re-embed only when content or the
|
|
46
|
+
* embedding model changes. Absent on pre-hash vectors (re-PUT once to populate).
|
|
47
|
+
*/
|
|
48
|
+
contentHash?: string;
|
|
49
|
+
/**
|
|
50
|
+
* Prefix of the chunk's embeddable text, stored at index time and used for the
|
|
51
|
+
* literal-match re-rank in search.ts. Bounded independently by char count
|
|
52
|
+
* (`TEXT_PREVIEW_MAX_CHARS`) and UTF-8 byte size (`TEXT_PREVIEW_MAX_BYTES`,
|
|
53
|
+
* search.ts) — the byte bound keeps this field, plus the rest of a vector's
|
|
54
|
+
* filterable metadata, under the S3 Vectors 2 KB/vector filterable cap even for
|
|
55
|
+
* multi-byte text (CJK, Cyrillic, Arabic, emoji). Absent for vectors written
|
|
56
|
+
* before this field existed — those are re-ranked as cosine-only
|
|
57
|
+
* (score-neutral), never penalized. No backfill; a preview appears organically
|
|
58
|
+
* the next time a message re-indexes.
|
|
59
|
+
*/
|
|
60
|
+
textPreview?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface Chunk {
|
|
64
|
+
chunkId: string;
|
|
65
|
+
text: string;
|
|
66
|
+
chunkType: ChunkType;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface VectorRecord {
|
|
70
|
+
chunkId: string;
|
|
71
|
+
vector: number[];
|
|
72
|
+
metadata: ChunkMetadata;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface VectorMatch {
|
|
76
|
+
chunkId: string;
|
|
77
|
+
score: number;
|
|
78
|
+
metadata: ChunkMetadata;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface VectorQueryFilter {
|
|
82
|
+
accountConfigId?: string;
|
|
83
|
+
mailboxId?: string;
|
|
84
|
+
sentDateRange?: { from?: number; to?: number };
|
|
85
|
+
hasAttachment?: boolean;
|
|
86
|
+
hasStars?: boolean;
|
|
87
|
+
isRead?: boolean;
|
|
88
|
+
chunkType?: ChunkType;
|
|
89
|
+
category?: MessageCategory;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface VectorQuery {
|
|
93
|
+
vector: number[];
|
|
94
|
+
topK: number;
|
|
95
|
+
filter?: VectorQueryFilter;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface EnvelopeChunkAddress {
|
|
99
|
+
name: string | null;
|
|
100
|
+
email: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface EnvelopeChunkInput {
|
|
104
|
+
from: EnvelopeChunkAddress;
|
|
105
|
+
to: EnvelopeChunkAddress[];
|
|
106
|
+
cc: EnvelopeChunkAddress[];
|
|
107
|
+
bcc: EnvelopeChunkAddress[];
|
|
108
|
+
subject: string;
|
|
109
|
+
attachments: AttachmentChunkInput[];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface AttachmentChunkInput {
|
|
113
|
+
filename: string | null;
|
|
114
|
+
contentType: string;
|
|
115
|
+
size: number;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface ParsedBodyForChunking {
|
|
119
|
+
text: string | null;
|
|
120
|
+
html: string | null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface IndexEmailParams {
|
|
124
|
+
envelope: EnvelopeChunkInput;
|
|
125
|
+
parsedBody: ParsedBodyForChunking;
|
|
126
|
+
metadata: Omit<ChunkMetadata, "chunkType" | "fileTypes">;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface SearchParams {
|
|
130
|
+
query: string;
|
|
131
|
+
accountConfigId: string;
|
|
132
|
+
mailboxId?: string;
|
|
133
|
+
sentDateRange?: { from?: number; to?: number };
|
|
134
|
+
hasAttachment?: boolean;
|
|
135
|
+
hasStars?: boolean;
|
|
136
|
+
isRead?: boolean;
|
|
137
|
+
category?: MessageCategory;
|
|
138
|
+
limit?: number;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface SearchResult {
|
|
142
|
+
messageId: string;
|
|
143
|
+
threadId: string;
|
|
144
|
+
score: number;
|
|
145
|
+
matchedChunkType: ChunkType;
|
|
146
|
+
mailboxIds: string[];
|
|
147
|
+
/** Sender display name, populated for messages indexed after display-field enrichment. */
|
|
148
|
+
fromName?: string | null;
|
|
149
|
+
/** Message subject, populated for messages indexed after display-field enrichment. */
|
|
150
|
+
subject?: string;
|
|
151
|
+
/** Sent date as Unix epoch seconds, always populated. */
|
|
152
|
+
sentDate: number;
|
|
153
|
+
/** Header-derived category, populated for messages indexed after category enrichment. */
|
|
154
|
+
category?: MessageCategory;
|
|
155
|
+
}
|