@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/dist/index.js CHANGED
@@ -191,6 +191,7 @@ var MemoryVectorStore = class {
191
191
  __name(this, "MemoryVectorStore");
192
192
  }
193
193
  records = /* @__PURE__ */ new Map();
194
+ replacing = /* @__PURE__ */ new Map();
194
195
  /**
195
196
  * Telemetry self-description. No `collection`: there is no namespace to name — the corpus IS this
196
197
  * instance, and two `MemoryVectorStore`s in one process are two corpora with nothing to tell them
@@ -206,7 +207,30 @@ var MemoryVectorStore = class {
206
207
  this.records.set(record.id, record);
207
208
  }
208
209
  }
210
+ /** A replace and a removal of one document take turns, in the order they were called. */
211
+ async replaceDocument(documentId, records) {
212
+ await this.inTurn(documentId, async () => {
213
+ this.deleteChunks(documentId);
214
+ await this.upsert(records);
215
+ });
216
+ }
209
217
  async remove(documentId) {
218
+ await this.inTurn(documentId, async () => this.deleteChunks(documentId));
219
+ }
220
+ async inTurn(documentId, work) {
221
+ const previous = this.replacing.get(documentId) ?? Promise.resolve();
222
+ const run = previous.catch(() => {
223
+ }).then(work);
224
+ this.replacing.set(documentId, run);
225
+ try {
226
+ await run;
227
+ } finally {
228
+ if (this.replacing.get(documentId) === run) {
229
+ this.replacing.delete(documentId);
230
+ }
231
+ }
232
+ }
233
+ deleteChunks(documentId) {
210
234
  for (const id of this.records.keys()) {
211
235
  if (documentIdOf(id) === documentId) {
212
236
  this.records.delete(id);
@@ -303,12 +327,9 @@ var MemoryVectorStore = class {
303
327
  if (documentIds.length === 0) {
304
328
  return;
305
329
  }
306
- const wanted = new Set(documentIds);
307
- for (const id of this.records.keys()) {
308
- if (wanted.has(documentIdOf(id))) {
309
- this.records.delete(id);
310
- }
311
- }
330
+ await Promise.all([
331
+ ...new Set(documentIds)
332
+ ].map((documentId) => this.remove(documentId)));
312
333
  }
313
334
  /**
314
335
  * See {@link VectorStore.removeWhere}. The empty-array short-circuit below is redundant
@@ -327,6 +348,7 @@ var MemoryVectorStore = class {
327
348
  if (filterMatchesNothing(filter)) {
328
349
  return 0;
329
350
  }
351
+ await Promise.allSettled(this.replacing.values());
330
352
  let removed = 0;
331
353
  for (const [id, record] of this.records) {
332
354
  if (matchesFilter(record.metadata, filter)) {
@@ -1121,6 +1143,19 @@ var REPLACE_LOCK_TTL_MS = 3e4;
1121
1143
  var REPLACE_LOCK_WAIT_MS = 12e4;
1122
1144
  var RENEW_LOCK_SCRIPT = "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('PEXPIRE', KEYS[1], ARGV[2]) end return 0";
1123
1145
  var RELEASE_LOCK_SCRIPT = "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) end return 0";
1146
+ var SWAP_CHUNKS_SCRIPT = `
1147
+ if redis.call('GET', KEYS[1]) ~= ARGV[1] then return 0 end
1148
+ local stale = tonumber(ARGV[2])
1149
+ for i = 2, stale + 1 do redis.call('DEL', KEYS[i]) end
1150
+ local arg = 3
1151
+ for i = stale + 2, #KEYS do
1152
+ local fields = tonumber(ARGV[arg])
1153
+ redis.call('DEL', KEYS[i])
1154
+ redis.call('HSET', KEYS[i], unpack(ARGV, arg + 1, arg + fields))
1155
+ arg = arg + fields + 1
1156
+ end
1157
+ return 1`;
1158
+ var SWAP_MAX_CHUNKS = 256;
1124
1159
  var RedisVectorSchemaMismatchError = class extends Error {
1125
1160
  static {
1126
1161
  __name(this, "RedisVectorSchemaMismatchError");
@@ -1258,42 +1293,56 @@ var RedisVectorStore = class {
1258
1293
  }
1259
1294
  async upsert(records) {
1260
1295
  for (const record of records) {
1261
- const args = [
1296
+ await this.client.sendCommand([
1262
1297
  "HSET",
1263
1298
  `${this.prefix}${record.id}`,
1264
- "text",
1265
- record.text,
1266
- "embedding",
1267
- encodeVector(record.embedding)
1268
- ];
1269
- if (record.source !== void 0) {
1270
- args.push("source", record.source);
1271
- }
1272
- if (record.metadata !== void 0) {
1273
- args.push("metadata_json", JSON.stringify(record.metadata));
1274
- for (const field of this.filterableFields) {
1275
- const value = record.metadata[field];
1276
- if (value !== void 0) {
1277
- args.push(`meta_${field}`, encodeTag(value));
1278
- }
1299
+ ...this.chunkFields(record)
1300
+ ]);
1301
+ }
1302
+ }
1303
+ chunkFields(record) {
1304
+ const fields = [
1305
+ "text",
1306
+ record.text,
1307
+ "embedding",
1308
+ encodeVector(record.embedding)
1309
+ ];
1310
+ if (record.source !== void 0) {
1311
+ fields.push("source", record.source);
1312
+ }
1313
+ if (record.metadata !== void 0) {
1314
+ fields.push("metadata_json", JSON.stringify(record.metadata));
1315
+ for (const field of this.filterableFields) {
1316
+ const value = record.metadata[field];
1317
+ if (value !== void 0) {
1318
+ fields.push(`meta_${field}`, encodeTag(value));
1279
1319
  }
1280
1320
  }
1281
- await this.client.sendCommand(args);
1282
1321
  }
1322
+ return fields;
1283
1323
  }
1284
1324
  /**
1285
- * Replaces a document's chunks, one writer at a time per document. Redis can't swap them in one step
1286
- * without scanning the keyspace inside a script, so a search may briefly see old and new chunks
1287
- * together, but concurrent calls can't leave them mixed. The new chunks go in before the old ones
1288
- * come out, so a crash leaves the document searchable and the next call repairs it.
1325
+ * Replaces a document's chunks, one writer at a time per document. The swap is one script, so a search
1326
+ * sees the old version or the new one, and a crash leaves the old one. The keys to drop are found
1327
+ * first, under the lock, because a script can't scan the keyspace.
1328
+ *
1329
+ * A document past {@link SWAP_MAX_CHUNKS} would block Redis for too long that way. Its new chunks go in
1330
+ * first and the old ones come out after, so a search may briefly see both, and a crash leaves the
1331
+ * document searchable for the next call to repair.
1289
1332
  */
1290
1333
  async replaceDocument(documentId, records) {
1291
- await this.withDocumentLock(documentId, async (assertHeld) => {
1292
- await this.upsert(records);
1293
- assertHeld();
1334
+ await this.withDocumentLocks([
1335
+ documentId
1336
+ ], async (assertHeld, token) => {
1294
1337
  const current = new Set(records.map((record) => `${this.prefix}${record.id}`));
1295
1338
  const stale = (await this.chunkKeys(documentId)).filter((key) => !current.has(key));
1296
1339
  assertHeld();
1340
+ if (records.length + stale.length <= SWAP_MAX_CHUNKS) {
1341
+ await this.swapChunks(documentId, token, stale, records);
1342
+ return;
1343
+ }
1344
+ await this.upsert(records);
1345
+ assertHeld();
1297
1346
  if (stale.length > 0) {
1298
1347
  await this.client.sendCommand([
1299
1348
  "DEL",
@@ -1302,69 +1351,110 @@ var RedisVectorStore = class {
1302
1351
  }
1303
1352
  });
1304
1353
  }
1354
+ async swapChunks(documentId, token, stale, records) {
1355
+ const keys = [
1356
+ this.lockKey(documentId),
1357
+ ...stale,
1358
+ ...records.map((record) => `${this.prefix}${record.id}`)
1359
+ ];
1360
+ const args = [
1361
+ token,
1362
+ String(stale.length)
1363
+ ];
1364
+ for (const record of records) {
1365
+ const fields = this.chunkFields(record);
1366
+ args.push(String(fields.length), ...fields);
1367
+ }
1368
+ const swapped = await this.client.sendCommand([
1369
+ "EVAL",
1370
+ SWAP_CHUNKS_SCRIPT,
1371
+ String(keys.length),
1372
+ ...keys,
1373
+ ...args
1374
+ ]);
1375
+ if (Number(swapped) === 0) {
1376
+ throw lostLockError(documentId);
1377
+ }
1378
+ }
1379
+ lockKey(documentId) {
1380
+ return `${this.index}:replace-lock:${documentId}`;
1381
+ }
1305
1382
  /**
1306
- * A lease, so a crashed writer can't block the document forever. It is renewed while the work runs,
1307
- * which keeps a large document from outliving it.
1383
+ * A lease on each document, so a crashed writer can't block one forever. They are renewed while the
1384
+ * work runs, which keeps a large document from outliving them.
1385
+ *
1386
+ * Several documents are locked in sorted order, without duplicates. Every other caller either holds
1387
+ * one lock or takes them in that same order, so no two calls can wait on each other.
1308
1388
  */
1309
- async withDocumentLock(documentId, work) {
1310
- const key = `${this.index}:replace-lock:${documentId}`;
1389
+ async withDocumentLocks(documentIds, work) {
1311
1390
  const token = randomUUID();
1312
- const deadline = Date.now() + REPLACE_LOCK_WAIT_MS;
1313
- let delay = 10;
1314
- while (await this.client.sendCommand([
1315
- "SET",
1316
- key,
1317
- token,
1318
- "NX",
1319
- "PX",
1320
- String(REPLACE_LOCK_TTL_MS)
1321
- ]) === null) {
1322
- if (Date.now() >= deadline) {
1323
- throw new Error(`RedisVectorStore: timed out after ${REPLACE_LOCK_WAIT_MS}ms waiting to replace document "${documentId}"`);
1324
- }
1325
- await new Promise((resolve) => setTimeout(resolve, delay));
1326
- delay = Math.min(delay * 2, 250);
1327
- }
1328
- let lost = false;
1391
+ const held = [];
1392
+ let lostDocument;
1329
1393
  const assertHeld = /* @__PURE__ */ __name(() => {
1330
- if (lost) {
1331
- throw new Error(`RedisVectorStore: lost the lock while replacing document "${documentId}"; call again to repair it`);
1394
+ if (lostDocument !== void 0) {
1395
+ throw lostLockError(lostDocument);
1332
1396
  }
1333
1397
  }, "assertHeld");
1334
1398
  const renewal = setInterval(() => {
1335
- void Promise.resolve(this.client.sendCommand([
1336
- "EVAL",
1337
- RENEW_LOCK_SCRIPT,
1338
- "1",
1339
- key,
1340
- token,
1341
- String(REPLACE_LOCK_TTL_MS)
1342
- ])).then(
1343
- (renewed) => {
1344
- if (Number(renewed) === 0) {
1345
- lost = true;
1399
+ for (const key of held) {
1400
+ void Promise.resolve(this.client.sendCommand([
1401
+ "EVAL",
1402
+ RENEW_LOCK_SCRIPT,
1403
+ "1",
1404
+ key,
1405
+ token,
1406
+ String(REPLACE_LOCK_TTL_MS)
1407
+ ])).then(
1408
+ (renewed) => {
1409
+ if (Number(renewed) === 0) {
1410
+ lostDocument = key.slice(this.lockKey("").length);
1411
+ }
1412
+ },
1413
+ // A failed renewal isn't a lost lock yet: the lease may still have time left.
1414
+ () => {
1346
1415
  }
1347
- },
1348
- // A failed renewal isn't a lost lock yet: the lease may still have time left.
1349
- () => {
1350
- }
1351
- );
1416
+ );
1417
+ }
1352
1418
  }, REPLACE_LOCK_TTL_MS / 3);
1353
1419
  renewal.unref?.();
1354
1420
  try {
1355
- const result = await work(assertHeld);
1421
+ for (const documentId of [
1422
+ ...new Set(documentIds)
1423
+ ].sort()) {
1424
+ await this.acquireLock(documentId, token);
1425
+ held.push(this.lockKey(documentId));
1426
+ }
1427
+ const result = await work(assertHeld, token);
1356
1428
  assertHeld();
1357
1429
  return result;
1358
1430
  } finally {
1359
1431
  clearInterval(renewal);
1360
- await Promise.resolve(this.client.sendCommand([
1432
+ await Promise.all(held.map((key) => Promise.resolve(this.client.sendCommand([
1361
1433
  "EVAL",
1362
1434
  RELEASE_LOCK_SCRIPT,
1363
1435
  "1",
1364
1436
  key,
1365
1437
  token
1366
1438
  ])).catch(() => {
1367
- });
1439
+ })));
1440
+ }
1441
+ }
1442
+ async acquireLock(documentId, token) {
1443
+ const deadline = Date.now() + REPLACE_LOCK_WAIT_MS;
1444
+ let delay = 10;
1445
+ while (await this.client.sendCommand([
1446
+ "SET",
1447
+ this.lockKey(documentId),
1448
+ token,
1449
+ "NX",
1450
+ "PX",
1451
+ String(REPLACE_LOCK_TTL_MS)
1452
+ ]) === null) {
1453
+ if (Date.now() >= deadline) {
1454
+ throw new Error(`RedisVectorStore: timed out after ${REPLACE_LOCK_WAIT_MS}ms waiting to change document "${documentId}"`);
1455
+ }
1456
+ await new Promise((resolve) => setTimeout(resolve, delay));
1457
+ delay = Math.min(delay * 2, 250);
1368
1458
  }
1369
1459
  }
1370
1460
  /**
@@ -1388,13 +1478,17 @@ var RedisVectorStore = class {
1388
1478
  * than N scans). Neither depends on a field that older chunks lack, so neither has a migration.
1389
1479
  */
1390
1480
  async remove(documentId) {
1391
- const keys = await this.chunkKeys(documentId);
1392
- if (keys.length > 0) {
1393
- await this.client.sendCommand([
1394
- "DEL",
1395
- ...keys
1396
- ]);
1397
- }
1481
+ await this.withDocumentLocks([
1482
+ documentId
1483
+ ], async () => {
1484
+ const keys = await this.chunkKeys(documentId);
1485
+ if (keys.length > 0) {
1486
+ await this.client.sendCommand([
1487
+ "DEL",
1488
+ ...keys
1489
+ ]);
1490
+ }
1491
+ });
1398
1492
  }
1399
1493
  /**
1400
1494
  * Rewrite the metadata of every chunk of `documentId` in place, leaving `text` and `embedding`
@@ -1651,44 +1745,51 @@ var RedisVectorStore = class {
1651
1745
  *
1652
1746
  * It deletes by key, exactly as `remove` does, so it is correct on chunks written by any version of
1653
1747
  * this library — there is no field it needs the corpus to already carry.
1748
+ *
1749
+ * It holds every document's lock for the whole scan, so it waits for a `replaceDocument` in progress
1750
+ * and one can't start partway through. That adds two commands per document to the one scan.
1654
1751
  */
1655
1752
  async removeMany(documentIds) {
1656
1753
  if (documentIds.length === 0) {
1657
1754
  return;
1658
1755
  }
1659
- const wanted = new Set(documentIds);
1660
- let pending = documentIds.map((id) => `${this.prefix}${id}`);
1661
- let cursor = "0";
1662
- do {
1663
- const reply = await this.client.sendCommand([
1664
- "SCAN",
1665
- cursor,
1666
- "MATCH",
1667
- `${escapeGlob(this.prefix)}*`,
1668
- "COUNT",
1669
- String(SCAN_PAGE)
1670
- ]);
1671
- const page = parseScanReply(reply);
1672
- cursor = page.cursor;
1673
- for (const key of page.keys) {
1674
- if (wanted.has(documentIdOf(stripPrefix(key, this.prefix)))) {
1675
- pending.push(key);
1756
+ await this.withDocumentLocks(documentIds, async (assertHeld) => {
1757
+ const wanted = new Set(documentIds);
1758
+ let pending = documentIds.map((id) => `${this.prefix}${id}`);
1759
+ let cursor = "0";
1760
+ do {
1761
+ const reply = await this.client.sendCommand([
1762
+ "SCAN",
1763
+ cursor,
1764
+ "MATCH",
1765
+ `${escapeGlob(this.prefix)}*`,
1766
+ "COUNT",
1767
+ String(SCAN_PAGE)
1768
+ ]);
1769
+ const page = parseScanReply(reply);
1770
+ cursor = page.cursor;
1771
+ for (const key of page.keys) {
1772
+ if (wanted.has(documentIdOf(stripPrefix(key, this.prefix)))) {
1773
+ pending.push(key);
1774
+ }
1676
1775
  }
1677
- }
1678
- if (pending.length >= DELETE_BATCH) {
1776
+ if (pending.length >= DELETE_BATCH) {
1777
+ assertHeld();
1778
+ await this.client.sendCommand([
1779
+ "DEL",
1780
+ ...pending
1781
+ ]);
1782
+ pending = [];
1783
+ }
1784
+ } while (cursor !== "0");
1785
+ if (pending.length > 0) {
1786
+ assertHeld();
1679
1787
  await this.client.sendCommand([
1680
1788
  "DEL",
1681
1789
  ...pending
1682
1790
  ]);
1683
- pending = [];
1684
1791
  }
1685
- } while (cursor !== "0");
1686
- if (pending.length > 0) {
1687
- await this.client.sendCommand([
1688
- "DEL",
1689
- ...pending
1690
- ]);
1691
- }
1792
+ });
1692
1793
  }
1693
1794
  /**
1694
1795
  * Delete every chunk matching `filter`, via `FT.SEARCH … NOCONTENT` + `DEL` — no keyspace scan at
@@ -1713,6 +1814,10 @@ var RedisVectorStore = class {
1713
1814
  * from the index, so paging forward would skip their replacements. If a page comes back non-empty
1714
1815
  * but `DEL` reports nothing removed (keys expired between query and delete, or the index is stale),
1715
1816
  * the loop stops rather than spinning on results it can never consume.
1817
+ *
1818
+ * The documents aren't known until a page comes back, so each page locks the documents it touches, one
1819
+ * page at a time, and asks again with `INKEYS` once it holds them. A `replaceDocument` that finished in
1820
+ * between may have dropped those chunks, or rewritten them to no longer match.
1716
1821
  */
1717
1822
  async removeWhere(filter) {
1718
1823
  const keys = Object.keys(filter);
@@ -1734,6 +1839,7 @@ var RedisVectorStore = class {
1734
1839
  }
1735
1840
  const query = buildFilter(filter);
1736
1841
  let removed = 0;
1842
+ let emptyPageBefore = false;
1737
1843
  for (; ; ) {
1738
1844
  const reply = await this.client.sendCommand([
1739
1845
  "FT.SEARCH",
@@ -1750,14 +1856,46 @@ var RedisVectorStore = class {
1750
1856
  if (page.length === 0) {
1751
1857
  break;
1752
1858
  }
1753
- const deleted = Number(await this.client.sendCommand([
1754
- "DEL",
1755
- ...page
1756
- ]));
1757
- if (!Number.isFinite(deleted) || deleted === 0) {
1859
+ const documents = page.map((key) => documentIdOf(stripPrefix(key, this.prefix)));
1860
+ const outcome = await this.withDocumentLocks(documents, async (assertHeld) => {
1861
+ const confirmed = parseSearchKeys(await this.client.sendCommand([
1862
+ "FT.SEARCH",
1863
+ this.index,
1864
+ query,
1865
+ "NOCONTENT",
1866
+ "INKEYS",
1867
+ String(page.length),
1868
+ ...page,
1869
+ "LIMIT",
1870
+ "0",
1871
+ String(DELETE_BATCH),
1872
+ "DIALECT",
1873
+ "2"
1874
+ ]));
1875
+ assertHeld();
1876
+ return confirmed.length === 0 ? {
1877
+ confirmed: 0,
1878
+ deleted: 0
1879
+ } : {
1880
+ confirmed: confirmed.length,
1881
+ deleted: Number(await this.client.sendCommand([
1882
+ "DEL",
1883
+ ...confirmed
1884
+ ]))
1885
+ };
1886
+ });
1887
+ if (outcome.confirmed === 0) {
1888
+ if (emptyPageBefore) {
1889
+ break;
1890
+ }
1891
+ emptyPageBefore = true;
1892
+ continue;
1893
+ }
1894
+ emptyPageBefore = false;
1895
+ if (!Number.isFinite(outcome.deleted) || outcome.deleted === 0) {
1758
1896
  break;
1759
1897
  }
1760
- removed += deleted;
1898
+ removed += outcome.deleted;
1761
1899
  }
1762
1900
  return removed;
1763
1901
  }
@@ -1855,6 +1993,10 @@ var RedisVectorStore = class {
1855
1993
  return parseSearchReply(reply, this.prefix, "lexical");
1856
1994
  }
1857
1995
  };
1996
+ function lostLockError(documentId) {
1997
+ return new Error(`RedisVectorStore: lost the lock while changing document "${documentId}"; call again to repair it`);
1998
+ }
1999
+ __name(lostLockError, "lostLockError");
1858
2000
  var SCAN_PAGE = 1e3;
1859
2001
  var DELETE_BATCH = 512;
1860
2002
  function stripPrefix(key, prefix) {
@@ -2921,9 +3063,10 @@ var RagIndex = class {
2921
3063
  }
2922
3064
  /**
2923
3065
  * Replaces the row's chunks and returns how many were written. Embeddings come first: if the
2924
- * provider fails, the previous chunks are still there. The swap is one transaction on Postgres. Redis
2925
- * adds the new chunks before dropping the old, so a search can briefly see both. On a store without
2926
- * `replaceDocument`, a crash mid-swap loses the document until the next `index`.
3066
+ * provider fails, the previous chunks are still there. The swap is one transaction on Postgres and one
3067
+ * script on Redis, where a document too large for that adds the new chunks before dropping the old, so
3068
+ * a search can briefly see both. On a store without `replaceDocument`, a crash mid-swap loses the
3069
+ * document until the next `index`.
2927
3070
  */
2928
3071
  async index(row) {
2929
3072
  const { definition, deps } = this;