@denizsvnc/ai-rag-engine 0.0.0-stage → 2.2.0

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 (71) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/LICENSE +21 -0
  3. package/README.md +115 -2
  4. package/dist/adapters/cache/index.d.ts +1 -0
  5. package/dist/adapters/cache/redis-cache.adapter.d.ts +49 -0
  6. package/dist/adapters/faq/local-faq.adapter.d.ts +29 -0
  7. package/dist/adapters/faq/url-faq.adapter.d.ts +14 -0
  8. package/dist/adapters/llm/custom.adapter.d.ts +42 -0
  9. package/dist/adapters/llm/fallback.adapter.d.ts +10 -0
  10. package/dist/adapters/llm/gemini.adapter.d.ts +9 -0
  11. package/dist/adapters/llm/ollama.adapter.d.ts +30 -0
  12. package/dist/adapters/llm/openai.adapter.d.ts +10 -0
  13. package/dist/adapters/llm/openrouter.adapter.d.ts +6 -0
  14. package/dist/adapters/llm/tiered.adapter.d.ts +22 -0
  15. package/dist/adapters/rules/static-rules.adapter.d.ts +6 -0
  16. package/dist/adapters/vector/memory-vector.adapter.d.ts +33 -0
  17. package/dist/adapters/vector/postgres-vector.adapter.d.ts +51 -0
  18. package/dist/api/auth.d.ts +47 -0
  19. package/dist/api/middleware.d.ts +3 -0
  20. package/dist/api/rate-limit.d.ts +8 -0
  21. package/dist/api/routes.d.ts +9 -0
  22. package/dist/api/server.d.ts +1 -0
  23. package/dist/api/server.js +4433 -0
  24. package/dist/config/default.d.ts +22 -0
  25. package/dist/config/ingestion-runtime.d.ts +11 -0
  26. package/dist/core/cache-manager.d.ts +29 -0
  27. package/dist/core/engine.d.ts +75 -0
  28. package/dist/core/ingestion/ingestion-worker.d.ts +19 -0
  29. package/dist/core/ingestion/postgres-ingestion-store.d.ts +56 -0
  30. package/dist/core/ingestion/queued-ingestion.service.d.ts +35 -0
  31. package/dist/core/metrics/prometheus-exporter.d.ts +12 -0
  32. package/dist/core/metrics/rag-evaluator.d.ts +63 -0
  33. package/dist/core/metrics/token-tracker.d.ts +69 -0
  34. package/dist/core/prompt-builder.d.ts +20 -0
  35. package/dist/core/queue/bullmq-queue.d.ts +39 -0
  36. package/dist/core/queue/in-memory-queue.d.ts +24 -0
  37. package/dist/core/queue/job-queue.interface.d.ts +22 -0
  38. package/dist/core/queue/queue-factory.d.ts +12 -0
  39. package/dist/core/security/confirmation.d.ts +64 -0
  40. package/dist/core/security/injection-detector.d.ts +25 -0
  41. package/dist/core/security/nonce-store.d.ts +21 -0
  42. package/dist/core/security/pii-guard.d.ts +38 -0
  43. package/dist/core/security/sanitizer.d.ts +23 -0
  44. package/dist/core/security/secrets.d.ts +2 -0
  45. package/dist/core/security/security-guard.d.ts +18 -0
  46. package/dist/core/security/security.types.d.ts +38 -0
  47. package/dist/core/sml-engine.d.ts +65 -0
  48. package/dist/core/tool-registry.d.ts +17 -0
  49. package/dist/core/vector/bm25.d.ts +44 -0
  50. package/dist/core/vector/hybrid-search.d.ts +15 -0
  51. package/dist/index.d.ts +51 -0
  52. package/dist/index.js +4509 -0
  53. package/dist/interfaces/cache.interface.d.ts +8 -0
  54. package/dist/interfaces/engine-config.interface.d.ts +73 -0
  55. package/dist/interfaces/faq.interface.d.ts +17 -0
  56. package/dist/interfaces/ingestion.interface.d.ts +14 -0
  57. package/dist/interfaces/llm.interface.d.ts +22 -0
  58. package/dist/interfaces/query-context.interface.d.ts +33 -0
  59. package/dist/interfaces/rules.interface.d.ts +3 -0
  60. package/dist/interfaces/tool.interface.d.ts +50 -0
  61. package/dist/interfaces/vector.interface.d.ts +15 -0
  62. package/dist/scripts/migrate-vector.d.ts +1 -0
  63. package/dist/scripts/migrate-vector.js +627 -0
  64. package/dist/services/document-ingestion.service.d.ts +116 -0
  65. package/dist/utils/http.d.ts +2 -0
  66. package/dist/utils/math.d.ts +3 -0
  67. package/dist/worker.d.ts +1 -0
  68. package/dist/worker.js +1787 -0
  69. package/docs/RELEASING.md +55 -0
  70. package/docs/USAGE.md +148 -0
  71. package/package.json +95 -4
@@ -0,0 +1,627 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/scripts/migrate-vector.ts
4
+ import "dotenv/config";
5
+ import { Pool as Pool2 } from "pg";
6
+
7
+ // src/adapters/vector/postgres-vector.adapter.ts
8
+ import { Pool } from "pg";
9
+
10
+ // src/core/sml-engine.ts
11
+ import { LRUCache } from "lru-cache";
12
+
13
+ // src/utils/math.ts
14
+ function dotProduct(vecA, vecB) {
15
+ if (vecA.length !== vecB.length) {
16
+ throw new Error("Vector dimensions do not match");
17
+ }
18
+ let sum = 0;
19
+ for (let i = 0; i < vecA.length; i++) {
20
+ sum += (vecA[i] ?? 0) * (vecB[i] ?? 0);
21
+ }
22
+ return sum;
23
+ }
24
+
25
+ // src/core/sml-engine.ts
26
+ var CircuitBreakerOpenError = class extends Error {
27
+ constructor(message) {
28
+ super(message);
29
+ this.name = "CircuitBreakerOpenError";
30
+ }
31
+ };
32
+ var SMLEngine = class {
33
+ modelName;
34
+ cacheDir;
35
+ localFilesOnly;
36
+ dtype;
37
+ extractorPromise = null;
38
+ embeddingCache;
39
+ // Circuit Breaker State Machine
40
+ circuitState = "CLOSED";
41
+ failureCount = 0;
42
+ lastFailureTime = 0;
43
+ failureThreshold;
44
+ cooldownMs;
45
+ constructor(config) {
46
+ this.cacheDir = config?.cacheDir ?? process.env.RAG_MODEL_CACHE_DIR ?? "./data/models";
47
+ this.localFilesOnly = config?.localFilesOnly ?? false;
48
+ this.modelName = config?.modelName ?? "Xenova/all-MiniLM-L6-v2";
49
+ this.dtype = config?.dtype ?? (config?.quantized === false ? "fp32" : "q8");
50
+ this.failureThreshold = config?.circuitBreakerThreshold ?? 3;
51
+ this.cooldownMs = config?.circuitBreakerCooldownMs ?? 15e3;
52
+ this.embeddingCache = new LRUCache({
53
+ max: config?.cacheSize ?? 1e3
54
+ });
55
+ }
56
+ /**
57
+ * Resets the circuit breaker state (useful for testing and recovery).
58
+ */
59
+ resetCircuit() {
60
+ this.circuitState = "CLOSED";
61
+ this.failureCount = 0;
62
+ this.lastFailureTime = 0;
63
+ this.extractorPromise = null;
64
+ }
65
+ getCircuitState() {
66
+ if (this.circuitState === "OPEN") {
67
+ if (Date.now() - this.lastFailureTime >= this.cooldownMs) {
68
+ this.circuitState = "HALF_OPEN";
69
+ }
70
+ }
71
+ return this.circuitState;
72
+ }
73
+ getFailureCount() {
74
+ return this.failureCount;
75
+ }
76
+ /**
77
+ * Thread-safe pipeline initializer guarded by a Circuit Breaker.
78
+ */
79
+ async init() {
80
+ if (this.circuitState === "OPEN") {
81
+ const timeSinceLastFailure = Date.now() - this.lastFailureTime;
82
+ if (timeSinceLastFailure < this.cooldownMs) {
83
+ const remainingSec = Math.ceil((this.cooldownMs - timeSinceLastFailure) / 1e3);
84
+ throw new CircuitBreakerOpenError(
85
+ `[SML CircuitBreaker] Model '${this.modelName}' is temporarily tripped OPEN after ${this.failureCount} consecutive failures. Cooldown active for another ${remainingSec}s.`
86
+ );
87
+ }
88
+ this.circuitState = "HALF_OPEN";
89
+ console.log(`[SML CircuitBreaker] Cooldown expired. Transitioned to HALF_OPEN for probe.`);
90
+ }
91
+ if (!this.extractorPromise) {
92
+ console.log(`[SML] Loading ${this.modelName} (dtype: ${this.dtype})...`);
93
+ this.extractorPromise = import("@huggingface/transformers").then(async ({ pipeline, env }) => {
94
+ const previousCacheDir = env.cacheDir;
95
+ const previousAllowRemote = env.allowRemoteModels;
96
+ try {
97
+ if (this.cacheDir) {
98
+ env.cacheDir = this.cacheDir;
99
+ }
100
+ if (this.localFilesOnly) {
101
+ env.allowRemoteModels = false;
102
+ }
103
+ return await pipeline("feature-extraction", this.modelName, {
104
+ dtype: this.dtype
105
+ });
106
+ } finally {
107
+ env.cacheDir = previousCacheDir;
108
+ env.allowRemoteModels = previousAllowRemote;
109
+ }
110
+ }).then((pipelineInstance) => {
111
+ this.circuitState = "CLOSED";
112
+ this.failureCount = 0;
113
+ const extract = async (text, options) => {
114
+ const output = await pipelineInstance(text, options);
115
+ const values = output.data;
116
+ const data = output.data instanceof Float32Array ? output.data : Float32Array.from({ length: values.length }, (_, index) => Number(values[index]));
117
+ return { data, dims: output.dims };
118
+ };
119
+ return extract;
120
+ }).catch((err) => {
121
+ this.failureCount++;
122
+ this.lastFailureTime = Date.now();
123
+ this.extractorPromise = null;
124
+ if (this.failureCount >= this.failureThreshold) {
125
+ this.circuitState = "OPEN";
126
+ console.error(
127
+ `[SML CircuitBreaker] \u26A0\uFE0F Tripped to OPEN state! Model failed ${this.failureCount} times. Pausing requests for ${this.cooldownMs / 1e3}s.`
128
+ );
129
+ } else {
130
+ console.error(`[SML] Model loading error (attempt ${this.failureCount}/${this.failureThreshold}):`, err);
131
+ }
132
+ throw err;
133
+ });
134
+ }
135
+ return this.extractorPromise;
136
+ }
137
+ /**
138
+ * Generates a normalized embedding vector for a single text input (with LRU caching).
139
+ */
140
+ async getEmbedding(text) {
141
+ const cleanedText = text.trim();
142
+ if (!cleanedText) {
143
+ throw new Error("Text input cannot be empty.");
144
+ }
145
+ const cached = this.embeddingCache.get(cleanedText);
146
+ if (cached) return cached;
147
+ const extractor = await this.init();
148
+ const output = await extractor(cleanedText, {
149
+ pooling: "mean",
150
+ normalize: true
151
+ });
152
+ const embedding = output.data;
153
+ this.embeddingCache.set(cleanedText, embedding);
154
+ return embedding;
155
+ }
156
+ /**
157
+ * Generates embedding vectors for multiple texts in batch mode.
158
+ */
159
+ async getBatchEmbeddings(texts) {
160
+ if (texts.length === 0) return [];
161
+ const extractor = await this.init();
162
+ const output = await extractor(texts, {
163
+ pooling: "mean",
164
+ normalize: true
165
+ });
166
+ const rawData = output.data;
167
+ const dimensions = texts.length > 0 ? Math.floor(rawData.length / texts.length) : 384;
168
+ const results = [];
169
+ for (let i = 0; i < texts.length; i++) {
170
+ const start = i * dimensions;
171
+ results.push(rawData.subarray(start, start + dimensions));
172
+ }
173
+ return results;
174
+ }
175
+ /**
176
+ * Compares the query against target embeddings and returns the highest scoring match above the threshold.
177
+ */
178
+ async findBestMatch(query, targets, threshold = 0.65) {
179
+ if (targets.length === 0) return null;
180
+ const queryVec = await this.getEmbedding(query);
181
+ let bestTarget = null;
182
+ let highestScore = -1;
183
+ for (let i = 0; i < targets.length; i++) {
184
+ const target = targets[i];
185
+ const score = dotProduct(
186
+ queryVec,
187
+ target.embedding
188
+ );
189
+ if (score > highestScore) {
190
+ highestScore = score;
191
+ bestTarget = target;
192
+ }
193
+ }
194
+ if (bestTarget && highestScore >= threshold) {
195
+ return { target: bestTarget, score: highestScore };
196
+ }
197
+ return null;
198
+ }
199
+ };
200
+
201
+ // src/core/vector/hybrid-search.ts
202
+ function reciprocalRankFusion(denseResults, sparseResults, topK = 3, rrfK = 60) {
203
+ const scoredDocs = /* @__PURE__ */ new Map();
204
+ denseResults.forEach((doc, index) => {
205
+ const rank = index + 1;
206
+ const key = JSON.stringify([doc.metadata?.tenantId ?? null, doc.id]);
207
+ const existing = scoredDocs.get(key) || { doc, rrfScore: 0 };
208
+ existing.denseRank = rank;
209
+ existing.rrfScore += 1 / (rrfK + rank);
210
+ scoredDocs.set(key, existing);
211
+ });
212
+ sparseResults.forEach((doc, index) => {
213
+ const rank = index + 1;
214
+ const key = JSON.stringify([doc.metadata?.tenantId ?? null, doc.id]);
215
+ const existing = scoredDocs.get(key) || { doc, rrfScore: 0 };
216
+ existing.sparseRank = rank;
217
+ existing.rrfScore += 1 / (rrfK + rank);
218
+ scoredDocs.set(key, existing);
219
+ });
220
+ const fusedList = Array.from(scoredDocs.values());
221
+ fusedList.sort((a, b) => b.rrfScore - a.rrfScore);
222
+ return fusedList.slice(0, topK).map(({ doc, denseRank, sparseRank, rrfScore }) => ({
223
+ ...doc,
224
+ denseRank,
225
+ sparseRank,
226
+ rrfScore,
227
+ score: rrfScore
228
+ }));
229
+ }
230
+
231
+ // src/adapters/vector/postgres-vector.adapter.ts
232
+ var PostgresVectorAdapter = class {
233
+ constructor(options) {
234
+ this.options = options;
235
+ const name = options.tableName ?? "rag_documents";
236
+ if (!/^[a-z][a-z0-9_]{0,39}$/.test(name)) throw new Error("Invalid vector table name.");
237
+ this.table = `"${name}"`;
238
+ this.configTable = `"${name}_config"`;
239
+ this.manifests = `"${name}_manifests"`;
240
+ this.epochs = `"${name}_epochs"`;
241
+ this.dimensions = options.dimensions ?? 384;
242
+ if (!Number.isInteger(this.dimensions) || this.dimensions < 1 || this.dimensions > 2e3) {
243
+ throw new Error("Vector dimensions must be an integer between 1 and 2000.");
244
+ }
245
+ this.modelName = options.modelName ?? "Xenova/all-MiniLM-L6-v2";
246
+ if (!options.pool && !options.connectionString) throw new Error("PostgreSQL connectionString or pool is required.");
247
+ this.pool = options.pool ?? new Pool({
248
+ connectionString: options.connectionString,
249
+ max: 5,
250
+ connectionTimeoutMillis: 5e3,
251
+ statement_timeout: 1e4
252
+ });
253
+ if (!options.pool) {
254
+ this.pool.on("error", (error) => console.error("[PostgresVectorAdapter] Idle connection failed:", error.message));
255
+ }
256
+ this.embeddings = options.embeddingProvider ?? new SMLEngine({ modelName: this.modelName });
257
+ }
258
+ options;
259
+ pool;
260
+ table;
261
+ configTable;
262
+ manifests;
263
+ epochs;
264
+ modelName;
265
+ dimensions;
266
+ embeddings;
267
+ initPromise;
268
+ async init() {
269
+ if (!this.initPromise) {
270
+ this.initPromise = this.initialize().catch((error) => {
271
+ this.initPromise = void 0;
272
+ throw error;
273
+ });
274
+ }
275
+ return this.initPromise;
276
+ }
277
+ async initialize() {
278
+ await this.transaction(async (client) => {
279
+ if (this.options.autoMigrate !== false) {
280
+ await client.query("SELECT pg_advisory_xact_lock(hashtext($1))", ["rag-vector-schema"]);
281
+ await client.query("CREATE EXTENSION IF NOT EXISTS vector");
282
+ await client.query(`CREATE TABLE IF NOT EXISTS ${this.configTable} (
283
+ singleton boolean PRIMARY KEY CHECK (singleton), model_name text NOT NULL, dimensions integer NOT NULL
284
+ )`);
285
+ await client.query(`INSERT INTO ${this.configTable} VALUES (true, $1, $2) ON CONFLICT DO NOTHING`, [this.modelName, this.dimensions]);
286
+ await client.query(`CREATE TABLE IF NOT EXISTS ${this.table} (
287
+ tenant_id text NOT NULL, id text NOT NULL, content text NOT NULL, metadata jsonb NOT NULL,
288
+ embedding vector(${this.dimensions}) NOT NULL, version integer NOT NULL DEFAULT 1,
289
+ updated_at timestamptz NOT NULL DEFAULT now(),
290
+ search_text tsvector GENERATED ALWAYS AS (to_tsvector('simple', content)) STORED,
291
+ PRIMARY KEY (tenant_id, id)
292
+ )`);
293
+ await client.query(`CREATE TABLE IF NOT EXISTS ${this.manifests} (
294
+ tenant_id text NOT NULL, document_id text NOT NULL, version integer NOT NULL,
295
+ PRIMARY KEY (tenant_id, document_id)
296
+ )`);
297
+ await client.query(`CREATE TABLE IF NOT EXISTS ${this.epochs} (
298
+ tenant_id text PRIMARY KEY, revision bigint NOT NULL
299
+ )`);
300
+ const name = this.options.tableName ?? "rag_documents";
301
+ await client.query(`CREATE INDEX IF NOT EXISTS "${name}_metadata_idx" ON ${this.table} USING gin (metadata)`);
302
+ await client.query(`CREATE INDEX IF NOT EXISTS "${name}_text_idx" ON ${this.table} USING gin (search_text)`);
303
+ if (this.options.createHnswIndex) {
304
+ await client.query(`CREATE INDEX IF NOT EXISTS "${name}_vector_idx" ON ${this.table} USING hnsw (embedding vector_cosine_ops)`);
305
+ }
306
+ }
307
+ const result = await client.query(`SELECT model_name, dimensions FROM ${this.configTable} WHERE singleton = true`);
308
+ const config = result.rows[0];
309
+ if (!config || config.model_name !== this.modelName || config.dimensions !== this.dimensions) {
310
+ throw new Error("Embedding model/dimensions mismatch. Reindex into a new table before changing models.");
311
+ }
312
+ });
313
+ }
314
+ /** Upserts are atomic per batch and IDs are unique within a tenant. */
315
+ async addDocuments(documents) {
316
+ const rows = await this.prepare(documents);
317
+ if (!rows.length) return;
318
+ await this.init();
319
+ await this.transaction(async (client) => {
320
+ for (const tenant of await this.write(client, rows)) await this.bumpRevision(client, tenant);
321
+ });
322
+ }
323
+ /** Atomically replaces all chunks; retries and older document revisions cannot overwrite newer work. */
324
+ async replaceDocument(documentId, tenantId, version, documents) {
325
+ this.tenant({ tenantId });
326
+ if (!documentId || !Number.isSafeInteger(version) || version < 1 || !documents.length) throw new Error("Invalid document replacement.");
327
+ if (documents.some((doc) => doc.metadata?.tenantId !== tenantId || doc.metadata?.documentId !== documentId)) {
328
+ throw new Error("Replacement tenant/document mismatch.");
329
+ }
330
+ const rows = await this.prepare(documents);
331
+ await this.init();
332
+ return this.transaction(async (client) => {
333
+ await client.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [JSON.stringify([this.table, tenantId, documentId])]);
334
+ const previous = await client.query(`SELECT version FROM ${this.manifests} WHERE tenant_id=$1 AND document_id=$2`, [tenantId, documentId]);
335
+ if (previous.rows[0] && previous.rows[0].version >= version) return previous.rows[0].version === version ? "unchanged" : "superseded";
336
+ await client.query(`DELETE FROM ${this.table} WHERE tenant_id=$1 AND metadata @> $2::jsonb`, [tenantId, JSON.stringify({ documentId })]);
337
+ await this.write(client, rows);
338
+ await client.query(`INSERT INTO ${this.manifests} VALUES ($1,$2,$3)
339
+ ON CONFLICT (tenant_id,document_id) DO UPDATE SET version=EXCLUDED.version`, [tenantId, documentId, version]);
340
+ await this.bumpRevision(client, tenantId);
341
+ return "applied";
342
+ });
343
+ }
344
+ async getRevision(tenantId) {
345
+ this.tenant({ tenantId });
346
+ await this.init();
347
+ const result = await this.pool.query(`SELECT revision::text FROM ${this.epochs} WHERE tenant_id=$1`, [tenantId]);
348
+ return result.rows[0]?.revision ?? "0";
349
+ }
350
+ async prepare(documents) {
351
+ const rows = [];
352
+ for (const doc of documents) {
353
+ if (typeof doc.id !== "string" || !doc.id.trim() || typeof doc.content !== "string" || !doc.content.trim()) {
354
+ throw new Error("Document id and content must be non-empty strings.");
355
+ }
356
+ rows.push({ doc, tenant: this.tenant(doc.metadata), metadata: JSON.stringify(doc.metadata ?? {}), vector: this.vector(await this.embeddings.getEmbedding(doc.content)) });
357
+ }
358
+ return rows;
359
+ }
360
+ async write(client, rows) {
361
+ const changed = /* @__PURE__ */ new Set();
362
+ for (const { doc, tenant, metadata, vector } of rows) {
363
+ const result = await client.query(`INSERT INTO ${this.table} AS current (tenant_id,id,content,metadata,embedding)
364
+ VALUES ($1,$2,$3,$4::jsonb,$5::vector)
365
+ ON CONFLICT (tenant_id,id) DO UPDATE SET content=EXCLUDED.content,metadata=EXCLUDED.metadata,embedding=EXCLUDED.embedding,
366
+ version=current.version+1,updated_at=now()
367
+ WHERE current.content IS DISTINCT FROM EXCLUDED.content OR current.metadata IS DISTINCT FROM EXCLUDED.metadata
368
+ OR current.embedding IS DISTINCT FROM EXCLUDED.embedding`, [tenant, doc.id, doc.content, metadata, vector]);
369
+ if (result.rowCount) changed.add(tenant);
370
+ }
371
+ return changed;
372
+ }
373
+ async bumpRevision(client, tenant) {
374
+ await client.query(`INSERT INTO ${this.epochs} VALUES ($1,1) ON CONFLICT (tenant_id) DO UPDATE SET revision=${this.epochs}.revision+1`, [tenant]);
375
+ }
376
+ async search(query, topK = 3, filter) {
377
+ const tenant = this.tenant(filter);
378
+ this.validateTopK(topK);
379
+ const vector = this.vector(await this.embeddings.getEmbedding(query));
380
+ await this.init();
381
+ const result = await this.pool.query(
382
+ `SELECT id, content, metadata, version,
383
+ 1 - (embedding <=> $3::vector) AS score FROM ${this.table}
384
+ WHERE tenant_id = $1 AND metadata @> $2::jsonb
385
+ ORDER BY embedding <=> $3::vector, id LIMIT $4`,
386
+ [tenant, JSON.stringify(filter), vector, topK]
387
+ );
388
+ return result.rows.map((row) => ({ ...row, score: Number(row.score) }));
389
+ }
390
+ /** PostgreSQL lexical search + dense search, fused with RRF. This is not BM25. */
391
+ async searchHybrid(query, topK = 3, filter) {
392
+ const tenant = this.tenant(filter);
393
+ this.validateTopK(topK);
394
+ const candidates = Math.min(1e3, Math.max(20, topK * 3));
395
+ const dense = await this.search(query, candidates, filter);
396
+ const sparse = await this.pool.query(`SELECT id, content, metadata, version,
397
+ ts_rank_cd(search_text, websearch_to_tsquery('simple', $3)) AS score FROM ${this.table}
398
+ WHERE tenant_id = $1 AND metadata @> $2::jsonb
399
+ AND search_text @@ websearch_to_tsquery('simple', $3)
400
+ ORDER BY score DESC, id LIMIT $4`, [tenant, JSON.stringify(filter), query, candidates]);
401
+ return reciprocalRankFusion(dense, sparse.rows, topK);
402
+ }
403
+ async deleteDocument(id, filter) {
404
+ const tenant = this.tenant(filter);
405
+ await this.init();
406
+ return this.transaction(async (client) => {
407
+ const result = await client.query(`DELETE FROM ${this.table} WHERE tenant_id=$1 AND id=$2 AND metadata @> $3::jsonb`, [tenant, id, JSON.stringify(filter)]);
408
+ if (result.rowCount) await this.bumpRevision(client, tenant);
409
+ return (result.rowCount ?? 0) > 0;
410
+ });
411
+ }
412
+ async clear(filter) {
413
+ const tenant = this.tenant(filter);
414
+ await this.init();
415
+ await this.transaction(async (client) => {
416
+ const result = await client.query(`DELETE FROM ${this.table} WHERE tenant_id=$1 AND metadata @> $2::jsonb`, [tenant, JSON.stringify(filter)]);
417
+ if (result.rowCount) await this.bumpRevision(client, tenant);
418
+ });
419
+ }
420
+ async close() {
421
+ if (!this.options.pool) await this.pool.end();
422
+ }
423
+ tenant(filter) {
424
+ const tenant = filter?.tenantId;
425
+ if (typeof tenant !== "string" || !tenant.trim()) throw new Error("An explicit tenantId is required for PostgreSQL vector operations.");
426
+ return tenant;
427
+ }
428
+ vector(embedding) {
429
+ const values = Array.from(embedding);
430
+ if (values.length !== this.dimensions || !values.every(Number.isFinite) || !values.some((value) => value !== 0)) {
431
+ throw new Error(`Embedding must contain ${this.dimensions} finite values and have non-zero magnitude.`);
432
+ }
433
+ return JSON.stringify(values);
434
+ }
435
+ validateTopK(topK) {
436
+ if (!Number.isInteger(topK) || topK < 1 || topK > 1e3) throw new Error("topK must be an integer between 1 and 1000.");
437
+ }
438
+ async transaction(work) {
439
+ const client = await this.pool.connect();
440
+ try {
441
+ await client.query("BEGIN");
442
+ const result = await work(client);
443
+ await client.query("COMMIT");
444
+ return result;
445
+ } catch (error) {
446
+ await client.query("ROLLBACK").catch(() => {
447
+ });
448
+ throw error;
449
+ } finally {
450
+ client.release();
451
+ }
452
+ }
453
+ };
454
+
455
+ // src/core/ingestion/postgres-ingestion-store.ts
456
+ import { createHash } from "node:crypto";
457
+ var IngestionQuotaError = class extends Error {
458
+ };
459
+ var IngestionConflictError = class extends Error {
460
+ };
461
+ var PostgresIngestionStore = class {
462
+ constructor(pool, tableName = "rag_ingestion") {
463
+ this.pool = pool;
464
+ if (!/^[a-z][a-z0-9_]{0,39}$/.test(tableName)) throw new Error("Invalid ingestion table name.");
465
+ this.tableName = tableName;
466
+ this.table = `"${tableName}"`;
467
+ this.heads = `"${tableName}_heads"`;
468
+ }
469
+ pool;
470
+ table;
471
+ heads;
472
+ tableName;
473
+ async migrate() {
474
+ await this.transaction(async (client) => {
475
+ await client.query("SELECT pg_advisory_xact_lock(hashtext($1))", ["rag-ingestion-schema"]);
476
+ await client.query(`CREATE TABLE IF NOT EXISTS ${this.heads} (
477
+ tenant_id text NOT NULL, document_id text NOT NULL, version integer NOT NULL,
478
+ PRIMARY KEY (tenant_id, document_id)
479
+ )`);
480
+ await client.query(`CREATE TABLE IF NOT EXISTS ${this.table} (
481
+ id text PRIMARY KEY, tenant_id text NOT NULL, user_id text NOT NULL, document_id text NOT NULL,
482
+ filename text NOT NULL, version integer NOT NULL, digest text NOT NULL, settings jsonb NOT NULL,
483
+ content bytea, idempotency_key text, status text NOT NULL CHECK (status IN ('pending','queued','processing','completed','failed')),
484
+ result jsonb, error text, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now()
485
+ )`);
486
+ await client.query(`CREATE UNIQUE INDEX IF NOT EXISTS "${this.tableName}_idempotency_idx" ON ${this.table} (tenant_id, idempotency_key) WHERE idempotency_key IS NOT NULL`);
487
+ await client.query(`CREATE INDEX IF NOT EXISTS "${this.tableName}_pending_idx" ON ${this.table} (status, updated_at)`);
488
+ await client.query(`CREATE INDEX IF NOT EXISTS "${this.tableName}_tenant_idx" ON ${this.table} (tenant_id)`);
489
+ });
490
+ }
491
+ async ready() {
492
+ await this.pool.query(`SELECT id FROM ${this.table} LIMIT 0`);
493
+ }
494
+ async put(input, limits) {
495
+ const digest = createHash("sha256").update(input.content).digest("hex");
496
+ return this.transaction(async (client) => {
497
+ await client.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [`ingestion:${this.tableName}:${input.tenantId}`]);
498
+ const previous = input.idempotencyKey ? await client.query(`SELECT * FROM ${this.table} WHERE tenant_id = $1 AND idempotency_key = $2`, [input.tenantId, input.idempotencyKey]) : await client.query(`SELECT j.* FROM ${this.table} j JOIN ${this.heads} h
499
+ ON j.tenant_id = h.tenant_id AND j.document_id = h.document_id AND j.version = h.version
500
+ WHERE j.tenant_id = $1 AND j.document_id = $2`, [input.tenantId, input.documentId]);
501
+ const old = previous.rows[0];
502
+ if (old && old.digest === digest && old.document_id === input.documentId && old.filename === input.filename && old.settings.chunkSize === input.settings.chunkSize && old.settings.chunkOverlap === input.settings.chunkOverlap) return this.row(old);
503
+ if (old && input.idempotencyKey) throw new IngestionConflictError("Idempotency key already used for different content.");
504
+ const quota = await client.query(`SELECT count(*) FILTER (WHERE status IN ('pending','queued','processing')) AS pending,
505
+ coalesce(sum(octet_length(content)), 0) AS bytes FROM ${this.table} WHERE tenant_id = $1`, [input.tenantId]);
506
+ if (Number(quota.rows[0].pending) >= limits.maxPending || Number(quota.rows[0].bytes) + input.content.length > limits.maxStoredBytes) {
507
+ throw new IngestionQuotaError("Tenant ingestion quota exceeded.");
508
+ }
509
+ const head = await client.query(`INSERT INTO ${this.heads} AS h VALUES ($1, $2, 1)
510
+ ON CONFLICT (tenant_id, document_id) DO UPDATE SET version = h.version + 1 RETURNING version`, [input.tenantId, input.documentId]);
511
+ const id = createHash("sha256").update(JSON.stringify([input.tenantId, input.documentId, head.rows[0].version, digest])).digest("hex");
512
+ const saved = await client.query(
513
+ `INSERT INTO ${this.table}
514
+ (id,tenant_id,user_id,document_id,filename,version,digest,settings,content,idempotency_key,status)
515
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8::jsonb,$9,$10,'pending') RETURNING *`,
516
+ [id, input.tenantId, input.userId, input.documentId, input.filename, head.rows[0].version, digest, JSON.stringify(input.settings), input.content, input.idempotencyKey ?? null]
517
+ );
518
+ return this.row(saved.rows[0]);
519
+ });
520
+ }
521
+ async get(id, tenantId) {
522
+ if (!tenantId) return null;
523
+ const result = await this.pool.query(`SELECT * FROM ${this.table} WHERE id = $1 AND tenant_id = $2`, [id, tenantId]);
524
+ return result.rows[0] ? this.row(result.rows[0]) : null;
525
+ }
526
+ async dispatchable(limit = 100) {
527
+ const result = await this.pool.query(`SELECT id, tenant_id FROM ${this.table} WHERE status IN ('pending','queued','processing')
528
+ ORDER BY (status = 'pending') DESC, updated_at LIMIT $1`, [limit]);
529
+ return result.rows.map((row) => ({ id: row.id, tenantId: row.tenant_id }));
530
+ }
531
+ async mark(id, status, error) {
532
+ const condition = status === "queued" ? "AND status = 'pending'" : "AND status NOT IN ('completed','failed')";
533
+ await this.pool.query(`UPDATE ${this.table} SET status = $2, error = $3, updated_at = now() WHERE id = $1 ${condition}`, [id, status, error ?? null]);
534
+ }
535
+ async complete(id, result) {
536
+ await this.pool.query(`UPDATE ${this.table} SET status = 'completed', result = $2::jsonb, error = NULL,
537
+ content = NULL, updated_at = now() WHERE id = $1`, [id, JSON.stringify(result)]);
538
+ }
539
+ async resetFailed(id, tenantId) {
540
+ const result = await this.pool.query(`UPDATE ${this.table} SET status = 'pending', error = NULL, updated_at = now()
541
+ WHERE id = $1 AND tenant_id = $2 AND status = 'failed' AND content IS NOT NULL`, [id, tenantId]);
542
+ if (!result.rowCount) throw new Error("Failed job missing or its retry payload has expired.");
543
+ }
544
+ async expireFailedBlobs(retentionDays = 7) {
545
+ await this.pool.query(`UPDATE ${this.table} SET content = NULL WHERE status = 'failed' AND content IS NOT NULL
546
+ AND updated_at < now() - ($1 * interval '1 day')`, [retentionDays]);
547
+ }
548
+ row(row) {
549
+ return {
550
+ id: row.id,
551
+ tenantId: row.tenant_id,
552
+ userId: row.user_id,
553
+ documentId: row.document_id,
554
+ filename: row.filename,
555
+ version: row.version,
556
+ digest: row.digest,
557
+ settings: row.settings,
558
+ content: row.content,
559
+ status: row.status,
560
+ result: row.result,
561
+ error: row.error,
562
+ createdAt: new Date(row.created_at).getTime(),
563
+ updatedAt: new Date(row.updated_at).getTime()
564
+ };
565
+ }
566
+ async transaction(work) {
567
+ const client = await this.pool.connect();
568
+ try {
569
+ await client.query("BEGIN");
570
+ const result = await work(client);
571
+ await client.query("COMMIT");
572
+ return result;
573
+ } catch (error) {
574
+ await client.query("ROLLBACK").catch(() => {
575
+ });
576
+ throw error;
577
+ } finally {
578
+ client.release();
579
+ }
580
+ }
581
+ };
582
+
583
+ // src/scripts/migrate-vector.ts
584
+ var role = process.env.RUNTIME_DB_USER;
585
+ var password = process.env.RUNTIME_DB_PASSWORD;
586
+ if (role && (!/^[a-z][a-z0-9_]{0,39}$/.test(role) || !password || password.length < 32)) throw new Error("Runtime role requires a safe name and a 32+ character password.");
587
+ var table = process.env.VECTOR_TABLE_NAME || "rag_documents";
588
+ var ingestion = process.env.INGESTION_TABLE_NAME || "rag_ingestion";
589
+ var store = new PostgresVectorAdapter({
590
+ connectionString: process.env.DATABASE_URL,
591
+ tableName: table,
592
+ modelName: process.env.EMBEDDING_MODEL || "Xenova/all-MiniLM-L6-v2",
593
+ dimensions: Number(process.env.EMBEDDING_DIMENSIONS || 384),
594
+ autoMigrate: true
595
+ });
596
+ try {
597
+ await store.init();
598
+ const pool = new Pool2({ connectionString: process.env.DATABASE_URL });
599
+ try {
600
+ await new PostgresIngestionStore(pool, ingestion).migrate();
601
+ if (role) {
602
+ const client = await pool.connect();
603
+ try {
604
+ await client.query("BEGIN");
605
+ if (!(await client.query("SELECT 1 FROM pg_roles WHERE rolname=$1", [role])).rowCount) {
606
+ const sql = await client.query("SELECT format('CREATE ROLE %I LOGIN PASSWORD %L', $1::text, $2::text) AS statement", [role, password]);
607
+ await client.query(sql.rows[0].statement);
608
+ }
609
+ await client.query(`GRANT USAGE ON SCHEMA public TO "${role}"`);
610
+ await client.query(`GRANT SELECT ON "${table}_config" TO "${role}"`);
611
+ await client.query(`GRANT SELECT,INSERT,UPDATE,DELETE ON "${table}","${table}_epochs","${table}_manifests","${ingestion}","${ingestion}_heads" TO "${role}"`);
612
+ await client.query("COMMIT");
613
+ } catch (error) {
614
+ await client.query("ROLLBACK").catch(() => {
615
+ });
616
+ throw error;
617
+ } finally {
618
+ client.release();
619
+ }
620
+ }
621
+ } finally {
622
+ await pool.end();
623
+ }
624
+ console.log("Vector and ingestion schemas initialized. Runtime privileges configured when requested.");
625
+ } finally {
626
+ await store.close();
627
+ }