@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/dist/index.js CHANGED
@@ -207,14 +207,20 @@ var MemoryVectorStore = class {
207
207
  this.records.set(record.id, record);
208
208
  }
209
209
  }
210
- /** Replaces take turns per document. A `remove` during one isn't queued, and may be undone by it. */
210
+ /** A replace and a removal of one document take turns, in the order they were called. */
211
211
  async replaceDocument(documentId, records) {
212
- const previous = this.replacing.get(documentId) ?? Promise.resolve();
213
- const run = previous.catch(() => {
214
- }).then(async () => {
215
- await this.remove(documentId);
212
+ await this.inTurn(documentId, async () => {
213
+ this.deleteChunks(documentId);
216
214
  await this.upsert(records);
217
215
  });
216
+ }
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);
218
224
  this.replacing.set(documentId, run);
219
225
  try {
220
226
  await run;
@@ -224,7 +230,7 @@ var MemoryVectorStore = class {
224
230
  }
225
231
  }
226
232
  }
227
- async remove(documentId) {
233
+ deleteChunks(documentId) {
228
234
  for (const id of this.records.keys()) {
229
235
  if (documentIdOf(id) === documentId) {
230
236
  this.records.delete(id);
@@ -321,12 +327,9 @@ var MemoryVectorStore = class {
321
327
  if (documentIds.length === 0) {
322
328
  return;
323
329
  }
324
- const wanted = new Set(documentIds);
325
- for (const id of this.records.keys()) {
326
- if (wanted.has(documentIdOf(id))) {
327
- this.records.delete(id);
328
- }
329
- }
330
+ await Promise.all([
331
+ ...new Set(documentIds)
332
+ ].map((documentId) => this.remove(documentId)));
330
333
  }
331
334
  /**
332
335
  * See {@link VectorStore.removeWhere}. The empty-array short-circuit below is redundant
@@ -345,6 +348,7 @@ var MemoryVectorStore = class {
345
348
  if (filterMatchesNothing(filter)) {
346
349
  return 0;
347
350
  }
351
+ await Promise.allSettled(this.replacing.values());
348
352
  let removed = 0;
349
353
  for (const [id, record] of this.records) {
350
354
  if (matchesFilter(record.metadata, filter)) {
@@ -1139,6 +1143,19 @@ var REPLACE_LOCK_TTL_MS = 3e4;
1139
1143
  var REPLACE_LOCK_WAIT_MS = 12e4;
1140
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";
1141
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;
1142
1159
  var RedisVectorSchemaMismatchError = class extends Error {
1143
1160
  static {
1144
1161
  __name(this, "RedisVectorSchemaMismatchError");
@@ -1276,42 +1293,56 @@ var RedisVectorStore = class {
1276
1293
  }
1277
1294
  async upsert(records) {
1278
1295
  for (const record of records) {
1279
- const args = [
1296
+ await this.client.sendCommand([
1280
1297
  "HSET",
1281
1298
  `${this.prefix}${record.id}`,
1282
- "text",
1283
- record.text,
1284
- "embedding",
1285
- encodeVector(record.embedding)
1286
- ];
1287
- if (record.source !== void 0) {
1288
- args.push("source", record.source);
1289
- }
1290
- if (record.metadata !== void 0) {
1291
- args.push("metadata_json", JSON.stringify(record.metadata));
1292
- for (const field of this.filterableFields) {
1293
- const value = record.metadata[field];
1294
- if (value !== void 0) {
1295
- args.push(`meta_${field}`, encodeTag(value));
1296
- }
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));
1297
1319
  }
1298
1320
  }
1299
- await this.client.sendCommand(args);
1300
1321
  }
1322
+ return fields;
1301
1323
  }
1302
1324
  /**
1303
- * Replaces a document's chunks, one writer at a time per document. Redis can't swap them in one step
1304
- * without scanning the keyspace inside a script, so a search may briefly see old and new chunks
1305
- * together, but concurrent calls can't leave them mixed. The new chunks go in before the old ones
1306
- * 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.
1307
1332
  */
1308
1333
  async replaceDocument(documentId, records) {
1309
- await this.withDocumentLock(documentId, async (assertHeld) => {
1310
- await this.upsert(records);
1311
- assertHeld();
1334
+ await this.withDocumentLocks([
1335
+ documentId
1336
+ ], async (assertHeld, token) => {
1312
1337
  const current = new Set(records.map((record) => `${this.prefix}${record.id}`));
1313
1338
  const stale = (await this.chunkKeys(documentId)).filter((key) => !current.has(key));
1314
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();
1315
1346
  if (stale.length > 0) {
1316
1347
  await this.client.sendCommand([
1317
1348
  "DEL",
@@ -1320,69 +1351,110 @@ var RedisVectorStore = class {
1320
1351
  }
1321
1352
  });
1322
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
+ }
1323
1382
  /**
1324
- * A lease, so a crashed writer can't block the document forever. It is renewed while the work runs,
1325
- * 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.
1326
1388
  */
1327
- async withDocumentLock(documentId, work) {
1328
- const key = `${this.index}:replace-lock:${documentId}`;
1389
+ async withDocumentLocks(documentIds, work) {
1329
1390
  const token = randomUUID();
1330
- const deadline = Date.now() + REPLACE_LOCK_WAIT_MS;
1331
- let delay = 10;
1332
- while (await this.client.sendCommand([
1333
- "SET",
1334
- key,
1335
- token,
1336
- "NX",
1337
- "PX",
1338
- String(REPLACE_LOCK_TTL_MS)
1339
- ]) === null) {
1340
- if (Date.now() >= deadline) {
1341
- throw new Error(`RedisVectorStore: timed out after ${REPLACE_LOCK_WAIT_MS}ms waiting to change document "${documentId}"`);
1342
- }
1343
- await new Promise((resolve) => setTimeout(resolve, delay));
1344
- delay = Math.min(delay * 2, 250);
1345
- }
1346
- let lost = false;
1391
+ const held = [];
1392
+ let lostDocument;
1347
1393
  const assertHeld = /* @__PURE__ */ __name(() => {
1348
- if (lost) {
1349
- throw new Error(`RedisVectorStore: lost the lock while changing document "${documentId}"; call again to repair it`);
1394
+ if (lostDocument !== void 0) {
1395
+ throw lostLockError(lostDocument);
1350
1396
  }
1351
1397
  }, "assertHeld");
1352
1398
  const renewal = setInterval(() => {
1353
- void Promise.resolve(this.client.sendCommand([
1354
- "EVAL",
1355
- RENEW_LOCK_SCRIPT,
1356
- "1",
1357
- key,
1358
- token,
1359
- String(REPLACE_LOCK_TTL_MS)
1360
- ])).then(
1361
- (renewed) => {
1362
- if (Number(renewed) === 0) {
1363
- 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
+ () => {
1364
1415
  }
1365
- },
1366
- // A failed renewal isn't a lost lock yet: the lease may still have time left.
1367
- () => {
1368
- }
1369
- );
1416
+ );
1417
+ }
1370
1418
  }, REPLACE_LOCK_TTL_MS / 3);
1371
1419
  renewal.unref?.();
1372
1420
  try {
1373
- 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);
1374
1428
  assertHeld();
1375
1429
  return result;
1376
1430
  } finally {
1377
1431
  clearInterval(renewal);
1378
- await Promise.resolve(this.client.sendCommand([
1432
+ await Promise.all(held.map((key) => Promise.resolve(this.client.sendCommand([
1379
1433
  "EVAL",
1380
1434
  RELEASE_LOCK_SCRIPT,
1381
1435
  "1",
1382
1436
  key,
1383
1437
  token
1384
1438
  ])).catch(() => {
1385
- });
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);
1386
1458
  }
1387
1459
  }
1388
1460
  /**
@@ -1406,7 +1478,9 @@ var RedisVectorStore = class {
1406
1478
  * than N scans). Neither depends on a field that older chunks lack, so neither has a migration.
1407
1479
  */
1408
1480
  async remove(documentId) {
1409
- await this.withDocumentLock(documentId, async () => {
1481
+ await this.withDocumentLocks([
1482
+ documentId
1483
+ ], async () => {
1410
1484
  const keys = await this.chunkKeys(documentId);
1411
1485
  if (keys.length > 0) {
1412
1486
  await this.client.sendCommand([
@@ -1671,44 +1745,51 @@ var RedisVectorStore = class {
1671
1745
  *
1672
1746
  * It deletes by key, exactly as `remove` does, so it is correct on chunks written by any version of
1673
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.
1674
1751
  */
1675
1752
  async removeMany(documentIds) {
1676
1753
  if (documentIds.length === 0) {
1677
1754
  return;
1678
1755
  }
1679
- const wanted = new Set(documentIds);
1680
- let pending = documentIds.map((id) => `${this.prefix}${id}`);
1681
- let cursor = "0";
1682
- do {
1683
- const reply = await this.client.sendCommand([
1684
- "SCAN",
1685
- cursor,
1686
- "MATCH",
1687
- `${escapeGlob(this.prefix)}*`,
1688
- "COUNT",
1689
- String(SCAN_PAGE)
1690
- ]);
1691
- const page = parseScanReply(reply);
1692
- cursor = page.cursor;
1693
- for (const key of page.keys) {
1694
- if (wanted.has(documentIdOf(stripPrefix(key, this.prefix)))) {
1695
- 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
+ }
1696
1775
  }
1697
- }
1698
- 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();
1699
1787
  await this.client.sendCommand([
1700
1788
  "DEL",
1701
1789
  ...pending
1702
1790
  ]);
1703
- pending = [];
1704
1791
  }
1705
- } while (cursor !== "0");
1706
- if (pending.length > 0) {
1707
- await this.client.sendCommand([
1708
- "DEL",
1709
- ...pending
1710
- ]);
1711
- }
1792
+ });
1712
1793
  }
1713
1794
  /**
1714
1795
  * Delete every chunk matching `filter`, via `FT.SEARCH … NOCONTENT` + `DEL` — no keyspace scan at
@@ -1733,6 +1814,10 @@ var RedisVectorStore = class {
1733
1814
  * from the index, so paging forward would skip their replacements. If a page comes back non-empty
1734
1815
  * but `DEL` reports nothing removed (keys expired between query and delete, or the index is stale),
1735
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.
1736
1821
  */
1737
1822
  async removeWhere(filter) {
1738
1823
  const keys = Object.keys(filter);
@@ -1754,6 +1839,7 @@ var RedisVectorStore = class {
1754
1839
  }
1755
1840
  const query = buildFilter(filter);
1756
1841
  let removed = 0;
1842
+ let emptyPageBefore = false;
1757
1843
  for (; ; ) {
1758
1844
  const reply = await this.client.sendCommand([
1759
1845
  "FT.SEARCH",
@@ -1770,14 +1856,46 @@ var RedisVectorStore = class {
1770
1856
  if (page.length === 0) {
1771
1857
  break;
1772
1858
  }
1773
- const deleted = Number(await this.client.sendCommand([
1774
- "DEL",
1775
- ...page
1776
- ]));
1777
- 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) {
1778
1896
  break;
1779
1897
  }
1780
- removed += deleted;
1898
+ removed += outcome.deleted;
1781
1899
  }
1782
1900
  return removed;
1783
1901
  }
@@ -1875,6 +1993,10 @@ var RedisVectorStore = class {
1875
1993
  return parseSearchReply(reply, this.prefix, "lexical");
1876
1994
  }
1877
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");
1878
2000
  var SCAN_PAGE = 1e3;
1879
2001
  var DELETE_BATCH = 512;
1880
2002
  function stripPrefix(key, prefix) {
@@ -2941,9 +3063,10 @@ var RagIndex = class {
2941
3063
  }
2942
3064
  /**
2943
3065
  * Replaces the row's chunks and returns how many were written. Embeddings come first: if the
2944
- * provider fails, the previous chunks are still there. The swap is one transaction on Postgres. Redis
2945
- * adds the new chunks before dropping the old, so a search can briefly see both. On a store without
2946
- * `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`.
2947
3070
  */
2948
3071
  async index(row) {
2949
3072
  const { definition, deps } = this;