@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,306 @@
1
+ import type { Pool, PoolClient } from "pg";
2
+ import type {
3
+ ChunkMetadata,
4
+ VectorMatch,
5
+ VectorQuery,
6
+ VectorQueryFilter,
7
+ VectorRecord,
8
+ } from "../types.js";
9
+ import type { VectorStoreService } from "./memory.js";
10
+ import { runtimeImport } from "./runtime-import.js";
11
+
12
+ type PgModule = {
13
+ default: { Pool: new (config: { connectionString: string }) => Pool };
14
+ };
15
+
16
+ /**
17
+ * pgvector-backed vector store for the Postgres-parity stack. Vectors live in
18
+ * the same Postgres as their message rows (one row per chunk), so there is no
19
+ * write amplification and scoped queries are plain SQL WHERE clauses combined
20
+ * with the ANN ordering — the equality/range filters run in the same query as
21
+ * the KNN instead of post-filtering a fixed top-k (which silently drops recall).
22
+ *
23
+ * `mailboxId` is a single-value scalar because the indexing pipeline always
24
+ * writes a one-element `mailboxIds`; membership reduces to equality — same as
25
+ * the sqlite-vec backend.
26
+ *
27
+ * The store self-provisions its table and indexes on first use (the `vector`
28
+ * extension must already be enabled on the database — the local pg container
29
+ * does this at startup). This mirrors the sqlite-vec backend, which owns its
30
+ * own CREATE, and keeps the embedding table out of the drizzle schema that the
31
+ * extension-less embedded-postgres test harness pushes.
32
+ */
33
+ const DEFAULT_DIMENSIONS = 384;
34
+ const DEFAULT_TABLE = "message_embedding";
35
+
36
+ const createTableSql = (table: string, dimensions: number): string => `
37
+ CREATE TABLE IF NOT EXISTS ${table} (
38
+ chunk_id TEXT PRIMARY KEY,
39
+ message_id TEXT NOT NULL,
40
+ account_config_id TEXT NOT NULL,
41
+ mailbox_id TEXT NOT NULL,
42
+ chunk_type TEXT NOT NULL,
43
+ sent_date BIGINT NOT NULL,
44
+ is_read BOOLEAN NOT NULL,
45
+ has_attachment BOOLEAN NOT NULL,
46
+ has_stars BOOLEAN NOT NULL,
47
+ embedding VECTOR(${dimensions}) NOT NULL,
48
+ metadata JSONB NOT NULL
49
+ );
50
+ `;
51
+
52
+ const createIndexesSql = (table: string): string[] => [
53
+ `CREATE INDEX IF NOT EXISTS ${table}_message_id_idx ON ${table} (message_id);`,
54
+ `CREATE INDEX IF NOT EXISTS ${table}_account_config_id_idx ON ${table} (account_config_id);`,
55
+ `CREATE INDEX IF NOT EXISTS ${table}_embedding_hnsw_idx ON ${table} USING hnsw (embedding vector_cosine_ops);`,
56
+ ];
57
+
58
+ const toVectorLiteral = (vector: number[]): string => `[${vector.join(",")}]`;
59
+
60
+ // pgvector renders a `vector` column as `[1,2,3]`; parse it back to a number[]
61
+ // for the anchor pooling read path (getByMessage).
62
+ const parseVectorLiteral = (literal: string): number[] => {
63
+ const inner = literal.trim().replace(/^\[/, "").replace(/\]$/, "");
64
+ if (inner.length === 0) return [];
65
+ return inner.split(",").map((n) => Number(n));
66
+ };
67
+
68
+ interface WhereClause {
69
+ sql: string;
70
+ params: unknown[];
71
+ }
72
+
73
+ const buildFilterClause = (
74
+ filter: VectorQueryFilter | undefined,
75
+ nextParamIndex: number,
76
+ ): WhereClause => {
77
+ const conditions: string[] = [];
78
+ const params: unknown[] = [];
79
+ let i = nextParamIndex;
80
+ if (!filter) return { sql: "", params };
81
+
82
+ if (filter.accountConfigId !== undefined) {
83
+ conditions.push(`account_config_id = $${i++}`);
84
+ params.push(filter.accountConfigId);
85
+ }
86
+ if (filter.mailboxId !== undefined) {
87
+ conditions.push(`mailbox_id = $${i++}`);
88
+ params.push(filter.mailboxId);
89
+ }
90
+ if (filter.chunkType !== undefined) {
91
+ conditions.push(`chunk_type = $${i++}`);
92
+ params.push(filter.chunkType);
93
+ }
94
+ if (filter.category !== undefined) {
95
+ conditions.push(`metadata->>'category' = $${i++}`);
96
+ params.push(filter.category);
97
+ }
98
+ if (filter.hasAttachment !== undefined) {
99
+ conditions.push(`has_attachment = $${i++}`);
100
+ params.push(filter.hasAttachment);
101
+ }
102
+ if (filter.hasStars !== undefined) {
103
+ conditions.push(`has_stars = $${i++}`);
104
+ params.push(filter.hasStars);
105
+ }
106
+ if (filter.isRead !== undefined) {
107
+ conditions.push(`is_read = $${i++}`);
108
+ params.push(filter.isRead);
109
+ }
110
+ if (filter.sentDateRange?.from !== undefined) {
111
+ conditions.push(`sent_date >= $${i++}`);
112
+ params.push(Math.trunc(filter.sentDateRange.from));
113
+ }
114
+ if (filter.sentDateRange?.to !== undefined) {
115
+ conditions.push(`sent_date <= $${i++}`);
116
+ params.push(Math.trunc(filter.sentDateRange.to));
117
+ }
118
+
119
+ return {
120
+ sql: conditions.length > 0 ? ` AND ${conditions.join(" AND ")}` : "",
121
+ params,
122
+ };
123
+ };
124
+
125
+ export interface PgVectorStoreConfig {
126
+ connectionString: string;
127
+ dimensions?: number;
128
+ tableName?: string;
129
+ }
130
+
131
+ export const createPgVectorStore = (
132
+ config: PgVectorStoreConfig,
133
+ ): VectorStoreService => {
134
+ const dimensions = config.dimensions ?? DEFAULT_DIMENSIONS;
135
+ const table = config.tableName ?? DEFAULT_TABLE;
136
+ let poolPromise: Promise<Pool> | null = null;
137
+
138
+ const getPool = async (): Promise<Pool> => {
139
+ if (poolPromise) return poolPromise;
140
+ poolPromise = (async () => {
141
+ const pg = await runtimeImport<PgModule>("pg");
142
+ const pool = new pg.default.Pool({
143
+ connectionString: config.connectionString,
144
+ });
145
+ await pool.query(createTableSql(table, dimensions));
146
+ for (const sql of createIndexesSql(table)) {
147
+ await pool.query(sql);
148
+ }
149
+ return pool;
150
+ })();
151
+ return poolPromise;
152
+ };
153
+
154
+ const upsert = async (vectors: VectorRecord[]): Promise<void> => {
155
+ if (vectors.length === 0) return;
156
+ const pool = await getPool();
157
+ const client = await pool.connect();
158
+ try {
159
+ await client.query("BEGIN");
160
+ for (const record of vectors) {
161
+ const m = record.metadata;
162
+ await client.query(
163
+ `
164
+ INSERT INTO ${table} (
165
+ chunk_id, message_id, account_config_id, mailbox_id, chunk_type,
166
+ sent_date, is_read, has_attachment, has_stars, embedding, metadata
167
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::vector, $11::jsonb)
168
+ ON CONFLICT (chunk_id) DO UPDATE SET
169
+ message_id = EXCLUDED.message_id,
170
+ account_config_id = EXCLUDED.account_config_id,
171
+ mailbox_id = EXCLUDED.mailbox_id,
172
+ chunk_type = EXCLUDED.chunk_type,
173
+ sent_date = EXCLUDED.sent_date,
174
+ is_read = EXCLUDED.is_read,
175
+ has_attachment = EXCLUDED.has_attachment,
176
+ has_stars = EXCLUDED.has_stars,
177
+ embedding = EXCLUDED.embedding,
178
+ metadata = EXCLUDED.metadata
179
+ `,
180
+ [
181
+ record.chunkId,
182
+ m.messageId,
183
+ m.accountConfigId,
184
+ m.mailboxIds[0] ?? "",
185
+ m.chunkType,
186
+ Math.trunc(m.sentDate),
187
+ m.isRead,
188
+ m.hasAttachment,
189
+ m.hasStars,
190
+ toVectorLiteral(record.vector),
191
+ JSON.stringify(m),
192
+ ],
193
+ );
194
+ }
195
+ await client.query("COMMIT");
196
+ } catch (error) {
197
+ await client.query("ROLLBACK");
198
+ throw error;
199
+ } finally {
200
+ client.release();
201
+ }
202
+ };
203
+
204
+ const query = async (params: VectorQuery): Promise<VectorMatch[]> => {
205
+ const pool = await getPool();
206
+ const vectorLiteral = toVectorLiteral(params.vector);
207
+ const { sql: filterSql, params: filterParams } = buildFilterClause(
208
+ params.filter,
209
+ 3,
210
+ );
211
+ // Iterative index scan keeps a selective metadata filter from under-filling
212
+ // the top-k: pgvector re-scans the HNSW graph until topK matches pass the
213
+ // WHERE instead of stopping at the first ef_search candidates.
214
+ const client: PoolClient = await pool.connect();
215
+ try {
216
+ await client.query("BEGIN");
217
+ await client.query("SET LOCAL hnsw.iterative_scan = relaxed_order");
218
+ const result = await client.query<{
219
+ chunk_id: string;
220
+ score: number;
221
+ metadata: ChunkMetadata;
222
+ }>(
223
+ `
224
+ SELECT chunk_id, metadata, 1 - (embedding <=> $1::vector) AS score
225
+ FROM ${table}
226
+ WHERE TRUE${filterSql}
227
+ ORDER BY embedding <=> $1::vector
228
+ LIMIT $2
229
+ `,
230
+ [vectorLiteral, params.topK, ...filterParams],
231
+ );
232
+ await client.query("COMMIT");
233
+ return result.rows.map((row) => ({
234
+ chunkId: row.chunk_id,
235
+ score: Number(row.score),
236
+ metadata: row.metadata,
237
+ }));
238
+ } catch (error) {
239
+ await client.query("ROLLBACK");
240
+ throw error;
241
+ } finally {
242
+ client.release();
243
+ }
244
+ };
245
+
246
+ const existingContentHashes = async (
247
+ chunkIds: string[],
248
+ ): Promise<Map<string, string>> => {
249
+ const out = new Map<string, string>();
250
+ if (chunkIds.length === 0) return out;
251
+ const pool = await getPool();
252
+ const result = await pool.query<{
253
+ chunk_id: string;
254
+ content_hash: string | null;
255
+ }>(
256
+ `SELECT chunk_id, metadata->>'contentHash' AS content_hash
257
+ FROM ${table} WHERE chunk_id = ANY($1::text[])`,
258
+ [chunkIds],
259
+ );
260
+ for (const row of result.rows) {
261
+ if (row.content_hash !== null) out.set(row.chunk_id, row.content_hash);
262
+ }
263
+ return out;
264
+ };
265
+
266
+ const getByMessage = async (messageId: string): Promise<VectorRecord[]> => {
267
+ const pool = await getPool();
268
+ const result = await pool.query<{
269
+ chunk_id: string;
270
+ embedding: string;
271
+ metadata: ChunkMetadata;
272
+ }>(
273
+ `SELECT chunk_id, embedding::text AS embedding, metadata
274
+ FROM ${table} WHERE message_id = $1`,
275
+ [messageId],
276
+ );
277
+ return result.rows.map((row) => ({
278
+ chunkId: row.chunk_id,
279
+ vector: parseVectorLiteral(row.embedding),
280
+ metadata: row.metadata,
281
+ }));
282
+ };
283
+
284
+ const del = async (filter: { messageId: string }): Promise<void> => {
285
+ const pool = await getPool();
286
+ await pool.query(`DELETE FROM ${table} WHERE message_id = $1`, [
287
+ filter.messageId,
288
+ ]);
289
+ };
290
+
291
+ const close = async (): Promise<void> => {
292
+ if (!poolPromise) return;
293
+ const pool = await poolPromise;
294
+ poolPromise = null;
295
+ await pool.end();
296
+ };
297
+
298
+ return {
299
+ upsert,
300
+ query,
301
+ existingContentHashes,
302
+ getByMessage,
303
+ delete: del,
304
+ close,
305
+ };
306
+ };
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Import a module by a specifier the bundler cannot see.
3
+ *
4
+ * `remit-search-service` is bundled into the production Lambdas (the API and the
5
+ * search-index worker) with esbuild. The local-only backends below depend on
6
+ * native / heavyweight packages (`better-sqlite3`, `sqlite-vec`,
7
+ * `@huggingface/transformers`) that must never enter a production bundle —
8
+ * esbuild cannot bundle a `.node` binary and would break the deploy.
9
+ *
10
+ * Passing the specifier through a variable defeats esbuild's static analysis, so
11
+ * the import stays a real runtime `import()` that is resolved by Node only when
12
+ * the local backend is actually constructed (i.e. under `npm start` / the
13
+ * integration test, never in production where these env flags are unset).
14
+ */
15
+ export const runtimeImport = async <T>(specifier: string): Promise<T> =>
16
+ import(specifier) as Promise<T>;