@dudousxd/nestjs-agent-rag 0.14.2 → 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
@@ -295,14 +295,20 @@ var MemoryVectorStore = class {
295
295
  this.records.set(record.id, record);
296
296
  }
297
297
  }
298
- /** Replaces take turns per document. A `remove` during one isn't queued, and may be undone by it. */
298
+ /** A replace and a removal of one document take turns, in the order they were called. */
299
299
  async replaceDocument(documentId, records) {
300
- const previous = this.replacing.get(documentId) ?? Promise.resolve();
301
- const run = previous.catch(() => {
302
- }).then(async () => {
303
- await this.remove(documentId);
300
+ await this.inTurn(documentId, async () => {
301
+ this.deleteChunks(documentId);
304
302
  await this.upsert(records);
305
303
  });
304
+ }
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);
306
312
  this.replacing.set(documentId, run);
307
313
  try {
308
314
  await run;
@@ -312,7 +318,7 @@ var MemoryVectorStore = class {
312
318
  }
313
319
  }
314
320
  }
315
- async remove(documentId) {
321
+ deleteChunks(documentId) {
316
322
  for (const id of this.records.keys()) {
317
323
  if (documentIdOf(id) === documentId) {
318
324
  this.records.delete(id);
@@ -409,12 +415,9 @@ var MemoryVectorStore = class {
409
415
  if (documentIds.length === 0) {
410
416
  return;
411
417
  }
412
- const wanted = new Set(documentIds);
413
- for (const id of this.records.keys()) {
414
- if (wanted.has(documentIdOf(id))) {
415
- this.records.delete(id);
416
- }
417
- }
418
+ await Promise.all([
419
+ ...new Set(documentIds)
420
+ ].map((documentId) => this.remove(documentId)));
418
421
  }
419
422
  /**
420
423
  * See {@link VectorStore.removeWhere}. The empty-array short-circuit below is redundant
@@ -433,6 +436,7 @@ var MemoryVectorStore = class {
433
436
  if (filterMatchesNothing(filter)) {
434
437
  return 0;
435
438
  }
439
+ await Promise.allSettled(this.replacing.values());
436
440
  let removed = 0;
437
441
  for (const [id, record] of this.records) {
438
442
  if (matchesFilter(record.metadata, filter)) {
@@ -1227,6 +1231,19 @@ var REPLACE_LOCK_TTL_MS = 3e4;
1227
1231
  var REPLACE_LOCK_WAIT_MS = 12e4;
1228
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";
1229
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;
1230
1247
  var RedisVectorSchemaMismatchError = class extends Error {
1231
1248
  static {
1232
1249
  __name(this, "RedisVectorSchemaMismatchError");
@@ -1364,42 +1381,56 @@ var RedisVectorStore = class {
1364
1381
  }
1365
1382
  async upsert(records) {
1366
1383
  for (const record of records) {
1367
- const args = [
1384
+ await this.client.sendCommand([
1368
1385
  "HSET",
1369
1386
  `${this.prefix}${record.id}`,
1370
- "text",
1371
- record.text,
1372
- "embedding",
1373
- encodeVector(record.embedding)
1374
- ];
1375
- if (record.source !== void 0) {
1376
- args.push("source", record.source);
1377
- }
1378
- if (record.metadata !== void 0) {
1379
- args.push("metadata_json", JSON.stringify(record.metadata));
1380
- for (const field of this.filterableFields) {
1381
- const value = record.metadata[field];
1382
- if (value !== void 0) {
1383
- args.push(`meta_${field}`, encodeTag(value));
1384
- }
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));
1385
1407
  }
1386
1408
  }
1387
- await this.client.sendCommand(args);
1388
1409
  }
1410
+ return fields;
1389
1411
  }
1390
1412
  /**
1391
- * Replaces a document's chunks, one writer at a time per document. Redis can't swap them in one step
1392
- * without scanning the keyspace inside a script, so a search may briefly see old and new chunks
1393
- * together, but concurrent calls can't leave them mixed. The new chunks go in before the old ones
1394
- * 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.
1395
1420
  */
1396
1421
  async replaceDocument(documentId, records) {
1397
- await this.withDocumentLock(documentId, async (assertHeld) => {
1398
- await this.upsert(records);
1399
- assertHeld();
1422
+ await this.withDocumentLocks([
1423
+ documentId
1424
+ ], async (assertHeld, token) => {
1400
1425
  const current = new Set(records.map((record) => `${this.prefix}${record.id}`));
1401
1426
  const stale = (await this.chunkKeys(documentId)).filter((key) => !current.has(key));
1402
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();
1403
1434
  if (stale.length > 0) {
1404
1435
  await this.client.sendCommand([
1405
1436
  "DEL",
@@ -1408,69 +1439,110 @@ var RedisVectorStore = class {
1408
1439
  }
1409
1440
  });
1410
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
+ }
1411
1470
  /**
1412
- * A lease, so a crashed writer can't block the document forever. It is renewed while the work runs,
1413
- * 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.
1414
1476
  */
1415
- async withDocumentLock(documentId, work) {
1416
- const key = `${this.index}:replace-lock:${documentId}`;
1477
+ async withDocumentLocks(documentIds, work) {
1417
1478
  const token = (0, import_node_crypto.randomUUID)();
1418
- const deadline = Date.now() + REPLACE_LOCK_WAIT_MS;
1419
- let delay = 10;
1420
- while (await this.client.sendCommand([
1421
- "SET",
1422
- key,
1423
- token,
1424
- "NX",
1425
- "PX",
1426
- String(REPLACE_LOCK_TTL_MS)
1427
- ]) === null) {
1428
- if (Date.now() >= deadline) {
1429
- throw new Error(`RedisVectorStore: timed out after ${REPLACE_LOCK_WAIT_MS}ms waiting to change document "${documentId}"`);
1430
- }
1431
- await new Promise((resolve) => setTimeout(resolve, delay));
1432
- delay = Math.min(delay * 2, 250);
1433
- }
1434
- let lost = false;
1479
+ const held = [];
1480
+ let lostDocument;
1435
1481
  const assertHeld = /* @__PURE__ */ __name(() => {
1436
- if (lost) {
1437
- throw new Error(`RedisVectorStore: lost the lock while changing document "${documentId}"; call again to repair it`);
1482
+ if (lostDocument !== void 0) {
1483
+ throw lostLockError(lostDocument);
1438
1484
  }
1439
1485
  }, "assertHeld");
1440
1486
  const renewal = setInterval(() => {
1441
- void Promise.resolve(this.client.sendCommand([
1442
- "EVAL",
1443
- RENEW_LOCK_SCRIPT,
1444
- "1",
1445
- key,
1446
- token,
1447
- String(REPLACE_LOCK_TTL_MS)
1448
- ])).then(
1449
- (renewed) => {
1450
- if (Number(renewed) === 0) {
1451
- 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
+ () => {
1452
1503
  }
1453
- },
1454
- // A failed renewal isn't a lost lock yet: the lease may still have time left.
1455
- () => {
1456
- }
1457
- );
1504
+ );
1505
+ }
1458
1506
  }, REPLACE_LOCK_TTL_MS / 3);
1459
1507
  renewal.unref?.();
1460
1508
  try {
1461
- 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);
1462
1516
  assertHeld();
1463
1517
  return result;
1464
1518
  } finally {
1465
1519
  clearInterval(renewal);
1466
- await Promise.resolve(this.client.sendCommand([
1520
+ await Promise.all(held.map((key) => Promise.resolve(this.client.sendCommand([
1467
1521
  "EVAL",
1468
1522
  RELEASE_LOCK_SCRIPT,
1469
1523
  "1",
1470
1524
  key,
1471
1525
  token
1472
1526
  ])).catch(() => {
1473
- });
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);
1474
1546
  }
1475
1547
  }
1476
1548
  /**
@@ -1494,7 +1566,9 @@ var RedisVectorStore = class {
1494
1566
  * than N scans). Neither depends on a field that older chunks lack, so neither has a migration.
1495
1567
  */
1496
1568
  async remove(documentId) {
1497
- await this.withDocumentLock(documentId, async () => {
1569
+ await this.withDocumentLocks([
1570
+ documentId
1571
+ ], async () => {
1498
1572
  const keys = await this.chunkKeys(documentId);
1499
1573
  if (keys.length > 0) {
1500
1574
  await this.client.sendCommand([
@@ -1759,44 +1833,51 @@ var RedisVectorStore = class {
1759
1833
  *
1760
1834
  * It deletes by key, exactly as `remove` does, so it is correct on chunks written by any version of
1761
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.
1762
1839
  */
1763
1840
  async removeMany(documentIds) {
1764
1841
  if (documentIds.length === 0) {
1765
1842
  return;
1766
1843
  }
1767
- const wanted = new Set(documentIds);
1768
- let pending = documentIds.map((id) => `${this.prefix}${id}`);
1769
- let cursor = "0";
1770
- do {
1771
- const reply = await this.client.sendCommand([
1772
- "SCAN",
1773
- cursor,
1774
- "MATCH",
1775
- `${escapeGlob(this.prefix)}*`,
1776
- "COUNT",
1777
- String(SCAN_PAGE)
1778
- ]);
1779
- const page = parseScanReply(reply);
1780
- cursor = page.cursor;
1781
- for (const key of page.keys) {
1782
- if (wanted.has(documentIdOf(stripPrefix(key, this.prefix)))) {
1783
- 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
+ }
1784
1863
  }
1785
- }
1786
- 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();
1787
1875
  await this.client.sendCommand([
1788
1876
  "DEL",
1789
1877
  ...pending
1790
1878
  ]);
1791
- pending = [];
1792
1879
  }
1793
- } while (cursor !== "0");
1794
- if (pending.length > 0) {
1795
- await this.client.sendCommand([
1796
- "DEL",
1797
- ...pending
1798
- ]);
1799
- }
1880
+ });
1800
1881
  }
1801
1882
  /**
1802
1883
  * Delete every chunk matching `filter`, via `FT.SEARCH … NOCONTENT` + `DEL` — no keyspace scan at
@@ -1821,6 +1902,10 @@ var RedisVectorStore = class {
1821
1902
  * from the index, so paging forward would skip their replacements. If a page comes back non-empty
1822
1903
  * but `DEL` reports nothing removed (keys expired between query and delete, or the index is stale),
1823
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.
1824
1909
  */
1825
1910
  async removeWhere(filter) {
1826
1911
  const keys = Object.keys(filter);
@@ -1842,6 +1927,7 @@ var RedisVectorStore = class {
1842
1927
  }
1843
1928
  const query = buildFilter(filter);
1844
1929
  let removed = 0;
1930
+ let emptyPageBefore = false;
1845
1931
  for (; ; ) {
1846
1932
  const reply = await this.client.sendCommand([
1847
1933
  "FT.SEARCH",
@@ -1858,14 +1944,46 @@ var RedisVectorStore = class {
1858
1944
  if (page.length === 0) {
1859
1945
  break;
1860
1946
  }
1861
- const deleted = Number(await this.client.sendCommand([
1862
- "DEL",
1863
- ...page
1864
- ]));
1865
- 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) {
1866
1984
  break;
1867
1985
  }
1868
- removed += deleted;
1986
+ removed += outcome.deleted;
1869
1987
  }
1870
1988
  return removed;
1871
1989
  }
@@ -1963,6 +2081,10 @@ var RedisVectorStore = class {
1963
2081
  return parseSearchReply(reply, this.prefix, "lexical");
1964
2082
  }
1965
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");
1966
2088
  var SCAN_PAGE = 1e3;
1967
2089
  var DELETE_BATCH = 512;
1968
2090
  function stripPrefix(key, prefix) {
@@ -3029,9 +3151,10 @@ var RagIndex = class {
3029
3151
  }
3030
3152
  /**
3031
3153
  * Replaces the row's chunks and returns how many were written. Embeddings come first: if the
3032
- * provider fails, the previous chunks are still there. The swap is one transaction on Postgres. Redis
3033
- * adds the new chunks before dropping the old, so a search can briefly see both. On a store without
3034
- * `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`.
3035
3158
  */
3036
3159
  async index(row) {
3037
3160
  const { definition, deps } = this;