@dudousxd/nestjs-agent-rag 0.14.1 → 0.14.3

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/README.md CHANGED
@@ -481,8 +481,10 @@ array is refused so a request can't widen a search. A corpus shared by everyone
481
481
 
482
482
  **Saving doesn't index.** Embedding is slow and paid for, so call `index` from a job or wherever the
483
483
  text changed. It embeds first, so a provider failure keeps the old chunks. On `PgVectorStore` the swap is
484
- one transaction. On Redis, a search can briefly see both versions, but concurrent calls wait for each
485
- other, so they can't leave a mix behind. On stores without `replaceDocument`, it is two statements.
484
+ one transaction. On Redis, it is one script, and concurrent calls wait for each other. A Redis document
485
+ of more than 256 chunks, counting old and new, is written in steps so it doesn't block the server: a
486
+ search can briefly see both versions, but concurrent calls still can't leave a mix behind. On stores
487
+ without `replaceDocument`, it is two statements.
486
488
 
487
489
  **Deleting.** With Drizzle, use `.returning()` and call `remove`. With MikroORM, add
488
490
  `provideRagDeleteSync(ProjectDocument, DocumentsRag)` from `@dudousxd/nestjs-agent-store-mikro-orm` to
package/dist/index.cjs CHANGED
@@ -279,6 +279,7 @@ var MemoryVectorStore = class {
279
279
  __name(this, "MemoryVectorStore");
280
280
  }
281
281
  records = /* @__PURE__ */ new Map();
282
+ replacing = /* @__PURE__ */ new Map();
282
283
  /**
283
284
  * Telemetry self-description. No `collection`: there is no namespace to name — the corpus IS this
284
285
  * instance, and two `MemoryVectorStore`s in one process are two corpora with nothing to tell them
@@ -294,7 +295,30 @@ var MemoryVectorStore = class {
294
295
  this.records.set(record.id, record);
295
296
  }
296
297
  }
298
+ /** A replace and a removal of one document take turns, in the order they were called. */
299
+ async replaceDocument(documentId, records) {
300
+ await this.inTurn(documentId, async () => {
301
+ this.deleteChunks(documentId);
302
+ await this.upsert(records);
303
+ });
304
+ }
297
305
  async remove(documentId) {
306
+ await this.inTurn(documentId, async () => this.deleteChunks(documentId));
307
+ }
308
+ async inTurn(documentId, work) {
309
+ const previous = this.replacing.get(documentId) ?? Promise.resolve();
310
+ const run = previous.catch(() => {
311
+ }).then(work);
312
+ this.replacing.set(documentId, run);
313
+ try {
314
+ await run;
315
+ } finally {
316
+ if (this.replacing.get(documentId) === run) {
317
+ this.replacing.delete(documentId);
318
+ }
319
+ }
320
+ }
321
+ deleteChunks(documentId) {
298
322
  for (const id of this.records.keys()) {
299
323
  if (documentIdOf(id) === documentId) {
300
324
  this.records.delete(id);
@@ -391,12 +415,9 @@ var MemoryVectorStore = class {
391
415
  if (documentIds.length === 0) {
392
416
  return;
393
417
  }
394
- const wanted = new Set(documentIds);
395
- for (const id of this.records.keys()) {
396
- if (wanted.has(documentIdOf(id))) {
397
- this.records.delete(id);
398
- }
399
- }
418
+ await Promise.all([
419
+ ...new Set(documentIds)
420
+ ].map((documentId) => this.remove(documentId)));
400
421
  }
401
422
  /**
402
423
  * See {@link VectorStore.removeWhere}. The empty-array short-circuit below is redundant
@@ -415,6 +436,7 @@ var MemoryVectorStore = class {
415
436
  if (filterMatchesNothing(filter)) {
416
437
  return 0;
417
438
  }
439
+ await Promise.allSettled(this.replacing.values());
418
440
  let removed = 0;
419
441
  for (const [id, record] of this.records) {
420
442
  if (matchesFilter(record.metadata, filter)) {
@@ -1209,6 +1231,19 @@ var REPLACE_LOCK_TTL_MS = 3e4;
1209
1231
  var REPLACE_LOCK_WAIT_MS = 12e4;
1210
1232
  var RENEW_LOCK_SCRIPT = "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('PEXPIRE', KEYS[1], ARGV[2]) end return 0";
1211
1233
  var RELEASE_LOCK_SCRIPT = "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) end return 0";
1234
+ var SWAP_CHUNKS_SCRIPT = `
1235
+ if redis.call('GET', KEYS[1]) ~= ARGV[1] then return 0 end
1236
+ local stale = tonumber(ARGV[2])
1237
+ for i = 2, stale + 1 do redis.call('DEL', KEYS[i]) end
1238
+ local arg = 3
1239
+ for i = stale + 2, #KEYS do
1240
+ local fields = tonumber(ARGV[arg])
1241
+ redis.call('DEL', KEYS[i])
1242
+ redis.call('HSET', KEYS[i], unpack(ARGV, arg + 1, arg + fields))
1243
+ arg = arg + fields + 1
1244
+ end
1245
+ return 1`;
1246
+ var SWAP_MAX_CHUNKS = 256;
1212
1247
  var RedisVectorSchemaMismatchError = class extends Error {
1213
1248
  static {
1214
1249
  __name(this, "RedisVectorSchemaMismatchError");
@@ -1346,42 +1381,56 @@ var RedisVectorStore = class {
1346
1381
  }
1347
1382
  async upsert(records) {
1348
1383
  for (const record of records) {
1349
- const args = [
1384
+ await this.client.sendCommand([
1350
1385
  "HSET",
1351
1386
  `${this.prefix}${record.id}`,
1352
- "text",
1353
- record.text,
1354
- "embedding",
1355
- encodeVector(record.embedding)
1356
- ];
1357
- if (record.source !== void 0) {
1358
- args.push("source", record.source);
1359
- }
1360
- if (record.metadata !== void 0) {
1361
- args.push("metadata_json", JSON.stringify(record.metadata));
1362
- for (const field of this.filterableFields) {
1363
- const value = record.metadata[field];
1364
- if (value !== void 0) {
1365
- args.push(`meta_${field}`, encodeTag(value));
1366
- }
1387
+ ...this.chunkFields(record)
1388
+ ]);
1389
+ }
1390
+ }
1391
+ chunkFields(record) {
1392
+ const fields = [
1393
+ "text",
1394
+ record.text,
1395
+ "embedding",
1396
+ encodeVector(record.embedding)
1397
+ ];
1398
+ if (record.source !== void 0) {
1399
+ fields.push("source", record.source);
1400
+ }
1401
+ if (record.metadata !== void 0) {
1402
+ fields.push("metadata_json", JSON.stringify(record.metadata));
1403
+ for (const field of this.filterableFields) {
1404
+ const value = record.metadata[field];
1405
+ if (value !== void 0) {
1406
+ fields.push(`meta_${field}`, encodeTag(value));
1367
1407
  }
1368
1408
  }
1369
- await this.client.sendCommand(args);
1370
1409
  }
1410
+ return fields;
1371
1411
  }
1372
1412
  /**
1373
- * Replaces a document's chunks, one writer at a time per document. Redis can't swap them in one step
1374
- * without scanning the keyspace inside a script, so a search may briefly see old and new chunks
1375
- * together, but concurrent calls can't leave them mixed. The new chunks go in before the old ones
1376
- * come out, so a crash leaves the document searchable and the next call repairs it.
1413
+ * Replaces a document's chunks, one writer at a time per document. The swap is one script, so a search
1414
+ * sees the old version or the new one, and a crash leaves the old one. The keys to drop are found
1415
+ * first, under the lock, because a script can't scan the keyspace.
1416
+ *
1417
+ * A document past {@link SWAP_MAX_CHUNKS} would block Redis for too long that way. Its new chunks go in
1418
+ * first and the old ones come out after, so a search may briefly see both, and a crash leaves the
1419
+ * document searchable for the next call to repair.
1377
1420
  */
1378
1421
  async replaceDocument(documentId, records) {
1379
- await this.withDocumentLock(documentId, async (assertHeld) => {
1380
- await this.upsert(records);
1381
- assertHeld();
1422
+ await this.withDocumentLocks([
1423
+ documentId
1424
+ ], async (assertHeld, token) => {
1382
1425
  const current = new Set(records.map((record) => `${this.prefix}${record.id}`));
1383
1426
  const stale = (await this.chunkKeys(documentId)).filter((key) => !current.has(key));
1384
1427
  assertHeld();
1428
+ if (records.length + stale.length <= SWAP_MAX_CHUNKS) {
1429
+ await this.swapChunks(documentId, token, stale, records);
1430
+ return;
1431
+ }
1432
+ await this.upsert(records);
1433
+ assertHeld();
1385
1434
  if (stale.length > 0) {
1386
1435
  await this.client.sendCommand([
1387
1436
  "DEL",
@@ -1390,69 +1439,110 @@ var RedisVectorStore = class {
1390
1439
  }
1391
1440
  });
1392
1441
  }
1442
+ async swapChunks(documentId, token, stale, records) {
1443
+ const keys = [
1444
+ this.lockKey(documentId),
1445
+ ...stale,
1446
+ ...records.map((record) => `${this.prefix}${record.id}`)
1447
+ ];
1448
+ const args = [
1449
+ token,
1450
+ String(stale.length)
1451
+ ];
1452
+ for (const record of records) {
1453
+ const fields = this.chunkFields(record);
1454
+ args.push(String(fields.length), ...fields);
1455
+ }
1456
+ const swapped = await this.client.sendCommand([
1457
+ "EVAL",
1458
+ SWAP_CHUNKS_SCRIPT,
1459
+ String(keys.length),
1460
+ ...keys,
1461
+ ...args
1462
+ ]);
1463
+ if (Number(swapped) === 0) {
1464
+ throw lostLockError(documentId);
1465
+ }
1466
+ }
1467
+ lockKey(documentId) {
1468
+ return `${this.index}:replace-lock:${documentId}`;
1469
+ }
1393
1470
  /**
1394
- * A lease, so a crashed writer can't block the document forever. It is renewed while the work runs,
1395
- * which keeps a large document from outliving it.
1471
+ * A lease on each document, so a crashed writer can't block one forever. They are renewed while the
1472
+ * work runs, which keeps a large document from outliving them.
1473
+ *
1474
+ * Several documents are locked in sorted order, without duplicates. Every other caller either holds
1475
+ * one lock or takes them in that same order, so no two calls can wait on each other.
1396
1476
  */
1397
- async withDocumentLock(documentId, work) {
1398
- const key = `${this.index}:replace-lock:${documentId}`;
1477
+ async withDocumentLocks(documentIds, work) {
1399
1478
  const token = (0, import_node_crypto.randomUUID)();
1400
- const deadline = Date.now() + REPLACE_LOCK_WAIT_MS;
1401
- let delay = 10;
1402
- while (await this.client.sendCommand([
1403
- "SET",
1404
- key,
1405
- token,
1406
- "NX",
1407
- "PX",
1408
- String(REPLACE_LOCK_TTL_MS)
1409
- ]) === null) {
1410
- if (Date.now() >= deadline) {
1411
- throw new Error(`RedisVectorStore: timed out after ${REPLACE_LOCK_WAIT_MS}ms waiting to replace document "${documentId}"`);
1412
- }
1413
- await new Promise((resolve) => setTimeout(resolve, delay));
1414
- delay = Math.min(delay * 2, 250);
1415
- }
1416
- let lost = false;
1479
+ const held = [];
1480
+ let lostDocument;
1417
1481
  const assertHeld = /* @__PURE__ */ __name(() => {
1418
- if (lost) {
1419
- throw new Error(`RedisVectorStore: lost the lock while replacing document "${documentId}"; call again to repair it`);
1482
+ if (lostDocument !== void 0) {
1483
+ throw lostLockError(lostDocument);
1420
1484
  }
1421
1485
  }, "assertHeld");
1422
1486
  const renewal = setInterval(() => {
1423
- void Promise.resolve(this.client.sendCommand([
1424
- "EVAL",
1425
- RENEW_LOCK_SCRIPT,
1426
- "1",
1427
- key,
1428
- token,
1429
- String(REPLACE_LOCK_TTL_MS)
1430
- ])).then(
1431
- (renewed) => {
1432
- if (Number(renewed) === 0) {
1433
- lost = true;
1487
+ for (const key of held) {
1488
+ void Promise.resolve(this.client.sendCommand([
1489
+ "EVAL",
1490
+ RENEW_LOCK_SCRIPT,
1491
+ "1",
1492
+ key,
1493
+ token,
1494
+ String(REPLACE_LOCK_TTL_MS)
1495
+ ])).then(
1496
+ (renewed) => {
1497
+ if (Number(renewed) === 0) {
1498
+ lostDocument = key.slice(this.lockKey("").length);
1499
+ }
1500
+ },
1501
+ // A failed renewal isn't a lost lock yet: the lease may still have time left.
1502
+ () => {
1434
1503
  }
1435
- },
1436
- // A failed renewal isn't a lost lock yet: the lease may still have time left.
1437
- () => {
1438
- }
1439
- );
1504
+ );
1505
+ }
1440
1506
  }, REPLACE_LOCK_TTL_MS / 3);
1441
1507
  renewal.unref?.();
1442
1508
  try {
1443
- const result = await work(assertHeld);
1509
+ for (const documentId of [
1510
+ ...new Set(documentIds)
1511
+ ].sort()) {
1512
+ await this.acquireLock(documentId, token);
1513
+ held.push(this.lockKey(documentId));
1514
+ }
1515
+ const result = await work(assertHeld, token);
1444
1516
  assertHeld();
1445
1517
  return result;
1446
1518
  } finally {
1447
1519
  clearInterval(renewal);
1448
- await Promise.resolve(this.client.sendCommand([
1520
+ await Promise.all(held.map((key) => Promise.resolve(this.client.sendCommand([
1449
1521
  "EVAL",
1450
1522
  RELEASE_LOCK_SCRIPT,
1451
1523
  "1",
1452
1524
  key,
1453
1525
  token
1454
1526
  ])).catch(() => {
1455
- });
1527
+ })));
1528
+ }
1529
+ }
1530
+ async acquireLock(documentId, token) {
1531
+ const deadline = Date.now() + REPLACE_LOCK_WAIT_MS;
1532
+ let delay = 10;
1533
+ while (await this.client.sendCommand([
1534
+ "SET",
1535
+ this.lockKey(documentId),
1536
+ token,
1537
+ "NX",
1538
+ "PX",
1539
+ String(REPLACE_LOCK_TTL_MS)
1540
+ ]) === null) {
1541
+ if (Date.now() >= deadline) {
1542
+ throw new Error(`RedisVectorStore: timed out after ${REPLACE_LOCK_WAIT_MS}ms waiting to change document "${documentId}"`);
1543
+ }
1544
+ await new Promise((resolve) => setTimeout(resolve, delay));
1545
+ delay = Math.min(delay * 2, 250);
1456
1546
  }
1457
1547
  }
1458
1548
  /**
@@ -1476,13 +1566,17 @@ var RedisVectorStore = class {
1476
1566
  * than N scans). Neither depends on a field that older chunks lack, so neither has a migration.
1477
1567
  */
1478
1568
  async remove(documentId) {
1479
- const keys = await this.chunkKeys(documentId);
1480
- if (keys.length > 0) {
1481
- await this.client.sendCommand([
1482
- "DEL",
1483
- ...keys
1484
- ]);
1485
- }
1569
+ await this.withDocumentLocks([
1570
+ documentId
1571
+ ], async () => {
1572
+ const keys = await this.chunkKeys(documentId);
1573
+ if (keys.length > 0) {
1574
+ await this.client.sendCommand([
1575
+ "DEL",
1576
+ ...keys
1577
+ ]);
1578
+ }
1579
+ });
1486
1580
  }
1487
1581
  /**
1488
1582
  * Rewrite the metadata of every chunk of `documentId` in place, leaving `text` and `embedding`
@@ -1739,44 +1833,51 @@ var RedisVectorStore = class {
1739
1833
  *
1740
1834
  * It deletes by key, exactly as `remove` does, so it is correct on chunks written by any version of
1741
1835
  * this library — there is no field it needs the corpus to already carry.
1836
+ *
1837
+ * It holds every document's lock for the whole scan, so it waits for a `replaceDocument` in progress
1838
+ * and one can't start partway through. That adds two commands per document to the one scan.
1742
1839
  */
1743
1840
  async removeMany(documentIds) {
1744
1841
  if (documentIds.length === 0) {
1745
1842
  return;
1746
1843
  }
1747
- const wanted = new Set(documentIds);
1748
- let pending = documentIds.map((id) => `${this.prefix}${id}`);
1749
- let cursor = "0";
1750
- do {
1751
- const reply = await this.client.sendCommand([
1752
- "SCAN",
1753
- cursor,
1754
- "MATCH",
1755
- `${escapeGlob(this.prefix)}*`,
1756
- "COUNT",
1757
- String(SCAN_PAGE)
1758
- ]);
1759
- const page = parseScanReply(reply);
1760
- cursor = page.cursor;
1761
- for (const key of page.keys) {
1762
- if (wanted.has(documentIdOf(stripPrefix(key, this.prefix)))) {
1763
- pending.push(key);
1844
+ await this.withDocumentLocks(documentIds, async (assertHeld) => {
1845
+ const wanted = new Set(documentIds);
1846
+ let pending = documentIds.map((id) => `${this.prefix}${id}`);
1847
+ let cursor = "0";
1848
+ do {
1849
+ const reply = await this.client.sendCommand([
1850
+ "SCAN",
1851
+ cursor,
1852
+ "MATCH",
1853
+ `${escapeGlob(this.prefix)}*`,
1854
+ "COUNT",
1855
+ String(SCAN_PAGE)
1856
+ ]);
1857
+ const page = parseScanReply(reply);
1858
+ cursor = page.cursor;
1859
+ for (const key of page.keys) {
1860
+ if (wanted.has(documentIdOf(stripPrefix(key, this.prefix)))) {
1861
+ pending.push(key);
1862
+ }
1764
1863
  }
1765
- }
1766
- if (pending.length >= DELETE_BATCH) {
1864
+ if (pending.length >= DELETE_BATCH) {
1865
+ assertHeld();
1866
+ await this.client.sendCommand([
1867
+ "DEL",
1868
+ ...pending
1869
+ ]);
1870
+ pending = [];
1871
+ }
1872
+ } while (cursor !== "0");
1873
+ if (pending.length > 0) {
1874
+ assertHeld();
1767
1875
  await this.client.sendCommand([
1768
1876
  "DEL",
1769
1877
  ...pending
1770
1878
  ]);
1771
- pending = [];
1772
1879
  }
1773
- } while (cursor !== "0");
1774
- if (pending.length > 0) {
1775
- await this.client.sendCommand([
1776
- "DEL",
1777
- ...pending
1778
- ]);
1779
- }
1880
+ });
1780
1881
  }
1781
1882
  /**
1782
1883
  * Delete every chunk matching `filter`, via `FT.SEARCH … NOCONTENT` + `DEL` — no keyspace scan at
@@ -1801,6 +1902,10 @@ var RedisVectorStore = class {
1801
1902
  * from the index, so paging forward would skip their replacements. If a page comes back non-empty
1802
1903
  * but `DEL` reports nothing removed (keys expired between query and delete, or the index is stale),
1803
1904
  * the loop stops rather than spinning on results it can never consume.
1905
+ *
1906
+ * The documents aren't known until a page comes back, so each page locks the documents it touches, one
1907
+ * page at a time, and asks again with `INKEYS` once it holds them. A `replaceDocument` that finished in
1908
+ * between may have dropped those chunks, or rewritten them to no longer match.
1804
1909
  */
1805
1910
  async removeWhere(filter) {
1806
1911
  const keys = Object.keys(filter);
@@ -1822,6 +1927,7 @@ var RedisVectorStore = class {
1822
1927
  }
1823
1928
  const query = buildFilter(filter);
1824
1929
  let removed = 0;
1930
+ let emptyPageBefore = false;
1825
1931
  for (; ; ) {
1826
1932
  const reply = await this.client.sendCommand([
1827
1933
  "FT.SEARCH",
@@ -1838,14 +1944,46 @@ var RedisVectorStore = class {
1838
1944
  if (page.length === 0) {
1839
1945
  break;
1840
1946
  }
1841
- const deleted = Number(await this.client.sendCommand([
1842
- "DEL",
1843
- ...page
1844
- ]));
1845
- if (!Number.isFinite(deleted) || deleted === 0) {
1947
+ const documents = page.map((key) => documentIdOf(stripPrefix(key, this.prefix)));
1948
+ const outcome = await this.withDocumentLocks(documents, async (assertHeld) => {
1949
+ const confirmed = parseSearchKeys(await this.client.sendCommand([
1950
+ "FT.SEARCH",
1951
+ this.index,
1952
+ query,
1953
+ "NOCONTENT",
1954
+ "INKEYS",
1955
+ String(page.length),
1956
+ ...page,
1957
+ "LIMIT",
1958
+ "0",
1959
+ String(DELETE_BATCH),
1960
+ "DIALECT",
1961
+ "2"
1962
+ ]));
1963
+ assertHeld();
1964
+ return confirmed.length === 0 ? {
1965
+ confirmed: 0,
1966
+ deleted: 0
1967
+ } : {
1968
+ confirmed: confirmed.length,
1969
+ deleted: Number(await this.client.sendCommand([
1970
+ "DEL",
1971
+ ...confirmed
1972
+ ]))
1973
+ };
1974
+ });
1975
+ if (outcome.confirmed === 0) {
1976
+ if (emptyPageBefore) {
1977
+ break;
1978
+ }
1979
+ emptyPageBefore = true;
1980
+ continue;
1981
+ }
1982
+ emptyPageBefore = false;
1983
+ if (!Number.isFinite(outcome.deleted) || outcome.deleted === 0) {
1846
1984
  break;
1847
1985
  }
1848
- removed += deleted;
1986
+ removed += outcome.deleted;
1849
1987
  }
1850
1988
  return removed;
1851
1989
  }
@@ -1943,6 +2081,10 @@ var RedisVectorStore = class {
1943
2081
  return parseSearchReply(reply, this.prefix, "lexical");
1944
2082
  }
1945
2083
  };
2084
+ function lostLockError(documentId) {
2085
+ return new Error(`RedisVectorStore: lost the lock while changing document "${documentId}"; call again to repair it`);
2086
+ }
2087
+ __name(lostLockError, "lostLockError");
1946
2088
  var SCAN_PAGE = 1e3;
1947
2089
  var DELETE_BATCH = 512;
1948
2090
  function stripPrefix(key, prefix) {
@@ -3009,9 +3151,10 @@ var RagIndex = class {
3009
3151
  }
3010
3152
  /**
3011
3153
  * Replaces the row's chunks and returns how many were written. Embeddings come first: if the
3012
- * provider fails, the previous chunks are still there. The swap is one transaction on Postgres. Redis
3013
- * adds the new chunks before dropping the old, so a search can briefly see both. On a store without
3014
- * `replaceDocument`, a crash mid-swap loses the document until the next `index`.
3154
+ * provider fails, the previous chunks are still there. The swap is one transaction on Postgres and one
3155
+ * script on Redis, where a document too large for that adds the new chunks before dropping the old, so
3156
+ * a search can briefly see both. On a store without `replaceDocument`, a crash mid-swap loses the
3157
+ * document until the next `index`.
3015
3158
  */
3016
3159
  async index(row) {
3017
3160
  const { definition, deps } = this;