@dudousxd/nestjs-agent-rag 0.5.0 → 0.6.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.
package/dist/index.cjs CHANGED
@@ -31,6 +31,8 @@ __export(index_exports, {
31
31
  RedisVectorSchemaMismatchError: () => RedisVectorSchemaMismatchError,
32
32
  RedisVectorStore: () => RedisVectorStore,
33
33
  RerankingRetriever: () => RerankingRetriever,
34
+ UnsafeRemovalError: () => UnsafeRemovalError,
35
+ applyMetadataPatch: () => applyMetadataPatch,
34
36
  chunkDocuments: () => chunkDocuments,
35
37
  chunkText: () => chunkText,
36
38
  createRetrievalTool: () => createRetrievalTool,
@@ -84,11 +86,56 @@ function isLexicalVectorStore(store) {
84
86
  return typeof store.searchText === "function";
85
87
  }
86
88
  __name(isLexicalVectorStore, "isLexicalVectorStore");
89
+ var UnsafeRemovalError = class extends Error {
90
+ static {
91
+ __name(this, "UnsafeRemovalError");
92
+ }
93
+ reason;
94
+ constructor(reason, message) {
95
+ super(message), this.reason = reason;
96
+ this.name = "UnsafeRemovalError";
97
+ }
98
+ };
87
99
  function documentIdOf(chunkId) {
88
100
  return chunkId.replace(/#\d+$/, "");
89
101
  }
90
102
  __name(documentIdOf, "documentIdOf");
91
103
 
104
+ // src/metadata-patch.ts
105
+ function splitMetadataPatch(patch) {
106
+ const set = {};
107
+ const remove = [];
108
+ for (const [key, value] of Object.entries(patch)) {
109
+ if (value === null) {
110
+ remove.push(key);
111
+ } else if (value !== void 0) {
112
+ set[key] = value;
113
+ }
114
+ }
115
+ return {
116
+ set,
117
+ remove
118
+ };
119
+ }
120
+ __name(splitMetadataPatch, "splitMetadataPatch");
121
+ function isEmptyMetadataPatch(patch) {
122
+ const { set, remove } = splitMetadataPatch(patch);
123
+ return Object.keys(set).length === 0 && remove.length === 0;
124
+ }
125
+ __name(isEmptyMetadataPatch, "isEmptyMetadataPatch");
126
+ function applyMetadataPatch(metadata, patch) {
127
+ const { set, remove } = splitMetadataPatch(patch);
128
+ const next = {
129
+ ...metadata,
130
+ ...set
131
+ };
132
+ for (const key of remove) {
133
+ delete next[key];
134
+ }
135
+ return next;
136
+ }
137
+ __name(applyMetadataPatch, "applyMetadataPatch");
138
+
92
139
  // src/filter.ts
93
140
  function matchesFilter(metadata, filter) {
94
141
  const entries = Object.entries(filter);
@@ -104,6 +151,13 @@ function matchesFilter(metadata, filter) {
104
151
  });
105
152
  }
106
153
  __name(matchesFilter, "matchesFilter");
154
+ function filterMatchesNothing(filter) {
155
+ if (filter === void 0) {
156
+ return false;
157
+ }
158
+ return Object.values(filter).some((value) => Array.isArray(value) && value.length === 0);
159
+ }
160
+ __name(filterMatchesNothing, "filterMatchesNothing");
107
161
 
108
162
  // src/memory-vector-store.ts
109
163
  var MemoryVectorStore = class {
@@ -123,6 +177,29 @@ var MemoryVectorStore = class {
123
177
  }
124
178
  }
125
179
  }
180
+ /**
181
+ * Merge `patch` into every chunk of `documentId`, leaving `text` and `embedding` untouched. The
182
+ * record is *replaced* rather than mutated: `upsert` stores the caller's own object, and patching
183
+ * it in place would edit an object the caller still holds — a surprise no out-of-process store
184
+ * could reproduce, and therefore one this reference adapter must not teach.
185
+ */
186
+ async updateMetadata(documentId, patch) {
187
+ if (isEmptyMetadataPatch(patch)) {
188
+ return 0;
189
+ }
190
+ let updated = 0;
191
+ for (const [id, record] of this.records) {
192
+ if (documentIdOf(id) !== documentId) {
193
+ continue;
194
+ }
195
+ this.records.set(id, {
196
+ ...record,
197
+ metadata: applyMetadataPatch(record.metadata, patch)
198
+ });
199
+ updated += 1;
200
+ }
201
+ return updated;
202
+ }
126
203
  async listDocuments(filter) {
127
204
  const documents = /* @__PURE__ */ new Map();
128
205
  for (const record of this.records.values()) {
@@ -143,6 +220,66 @@ var MemoryVectorStore = class {
143
220
  ...documents.values()
144
221
  ];
145
222
  }
223
+ async listDocumentIds(filter) {
224
+ if (filterMatchesNothing(filter)) {
225
+ return [];
226
+ }
227
+ const documents = /* @__PURE__ */ new Set();
228
+ for (const record of this.records.values()) {
229
+ if (filter !== void 0 && !matchesFilter(record.metadata, filter)) {
230
+ continue;
231
+ }
232
+ documents.add(documentIdOf(record.id));
233
+ }
234
+ return [
235
+ ...documents
236
+ ];
237
+ }
238
+ async removeMany(documentIds) {
239
+ if (documentIds.length === 0) {
240
+ return;
241
+ }
242
+ const wanted = new Set(documentIds);
243
+ for (const id of this.records.keys()) {
244
+ if (wanted.has(documentIdOf(id))) {
245
+ this.records.delete(id);
246
+ }
247
+ }
248
+ }
249
+ /**
250
+ * See {@link VectorStore.removeWhere}. The empty-array short-circuit below is redundant
251
+ * against `matchesFilter` (which already matches nothing for an empty array) and is kept anyway:
252
+ * this is the method where "the filter accidentally means everything" destroys data, so the deny is
253
+ * stated where a reader can see it rather than inferred from another module's truth table.
254
+ */
255
+ async removeWhere(filter) {
256
+ if (Object.keys(filter).length === 0) {
257
+ throw new UnsafeRemovalError("empty-filter", "removeWhere() refuses an empty filter: it would delete every chunk in the store. Pass a filter that scopes the removal, or delete deliberately with removeMany(await store.listDocumentIds()).");
258
+ }
259
+ if (filterMatchesNothing(filter)) {
260
+ return 0;
261
+ }
262
+ let removed = 0;
263
+ for (const [id, record] of this.records) {
264
+ if (matchesFilter(record.metadata, filter)) {
265
+ this.records.delete(id);
266
+ removed += 1;
267
+ }
268
+ }
269
+ return removed;
270
+ }
271
+ async countChunks(filter) {
272
+ if (filterMatchesNothing(filter)) {
273
+ return 0;
274
+ }
275
+ let count = 0;
276
+ for (const record of this.records.values()) {
277
+ if (filter === void 0 || matchesFilter(record.metadata, filter)) {
278
+ count += 1;
279
+ }
280
+ }
281
+ return count;
282
+ }
146
283
  async search(embedding, options) {
147
284
  const scored = [];
148
285
  for (const record of this.records.values()) {
@@ -229,6 +366,33 @@ var PgVectorStore = class {
229
366
  documentId
230
367
  ]);
231
368
  }
369
+ /**
370
+ * Rewrite a document's metadata without re-embedding it. See {@link VectorStore.updateMetadata}
371
+ * for the semantics; here the whole thing is one statement, because jsonb already *is* the merge:
372
+ * `||` is a shallow key-wise merge (right side wins, arrays replaced wholesale — precisely
373
+ * {@link MetadataPatch}) and `- text[]` drops the keys the patch removed. `COALESCE` covers a chunk
374
+ * ingested with no metadata at all, so a patch can create the object rather than no-op on `NULL`.
375
+ *
376
+ * Unlike RediSearch there is no second representation to keep in step — the `metadata` column is
377
+ * both what `search` filters on and what it returns — so the correctness trap the Redis adapter has
378
+ * to work for simply does not exist here. `RETURNING id` is what supplies the chunk count, since
379
+ * {@link PgClient} is a rows-only surface with no `rowCount`.
380
+ */
381
+ async updateMetadata(documentId, patch) {
382
+ if (isEmptyMetadataPatch(patch)) {
383
+ return 0;
384
+ }
385
+ const { set, remove } = splitMetadataPatch(patch);
386
+ const rows = await this.client.query(`UPDATE ${this.table}
387
+ SET metadata = (COALESCE(metadata, '{}'::jsonb) || $2::jsonb) - $3::text[]
388
+ WHERE ${DOCUMENT_ID_FROM_CHUNK} = $1
389
+ RETURNING id`, [
390
+ documentId,
391
+ JSON.stringify(set),
392
+ remove
393
+ ]);
394
+ return rows.length;
395
+ }
232
396
  async listDocuments(filter) {
233
397
  const where = buildWhere(filter, 1);
234
398
  const rows = await this.client.query(`SELECT DISTINCT ON (${DOCUMENT_ID_FROM_CHUNK}) ${DOCUMENT_ID_FROM_CHUNK} AS doc_id, metadata
@@ -242,6 +406,51 @@ var PgVectorStore = class {
242
406
  } : {}
243
407
  }));
244
408
  }
409
+ /** {@link listDocuments} without the metadata: one `DISTINCT` over the derived document id. */
410
+ async listDocumentIds(filter) {
411
+ if (filterMatchesNothing(filter)) {
412
+ return [];
413
+ }
414
+ const where = buildWhere(filter, 1);
415
+ const rows = await this.client.query(`SELECT DISTINCT ${DOCUMENT_ID_FROM_CHUNK} AS doc_id
416
+ FROM ${this.table}
417
+ ${where.sql}
418
+ ORDER BY doc_id`, where.params);
419
+ return rows.map((row) => row.doc_id);
420
+ }
421
+ /** N documents in one statement — the set-based form of {@link PgVectorStore.remove}. */
422
+ async removeMany(documentIds) {
423
+ if (documentIds.length === 0) {
424
+ return;
425
+ }
426
+ await this.client.query(`DELETE FROM ${this.table} WHERE ${DOCUMENT_ID_FROM_CHUNK} = ANY($1::text[])`, [
427
+ documentIds
428
+ ]);
429
+ }
430
+ /**
431
+ * See {@link VectorStore.removeWhere}: empty filter object throws, empty-array value
432
+ * deletes nothing. The count comes from `RETURNING id` rather than a driver-specific `rowCount`,
433
+ * because {@link PgClient} is deliberately just "run SQL, get rows".
434
+ */
435
+ async removeWhere(filter) {
436
+ if (Object.keys(filter).length === 0) {
437
+ throw new UnsafeRemovalError("empty-filter", "removeWhere() refuses an empty filter: it would delete every chunk in the table. Pass a filter that scopes the removal, or delete deliberately with removeMany(await store.listDocumentIds()).");
438
+ }
439
+ if (filterMatchesNothing(filter)) {
440
+ return 0;
441
+ }
442
+ const where = buildWhere(filter, 1);
443
+ const rows = await this.client.query(`DELETE FROM ${this.table} ${where.sql} RETURNING id`, where.params);
444
+ return rows.length;
445
+ }
446
+ async countChunks(filter) {
447
+ if (filterMatchesNothing(filter)) {
448
+ return 0;
449
+ }
450
+ const where = buildWhere(filter, 1);
451
+ const rows = await this.client.query(`SELECT count(*)::int AS count FROM ${this.table} ${where.sql}`, where.params);
452
+ return Number(rows[0]?.count ?? 0);
453
+ }
245
454
  async search(embedding, options) {
246
455
  const vector = toVectorLiteral(embedding);
247
456
  const where = buildWhere(options.filter, 3);
@@ -455,18 +664,129 @@ var RedisVectorStore = class {
455
664
  for (const field of this.filterableFields) {
456
665
  const value = record.metadata[field];
457
666
  if (value !== void 0) {
458
- const tag = Array.isArray(value) ? value.map(String).join(",") : String(value);
459
- args.push(`meta_${field}`, tag);
667
+ args.push(`meta_${field}`, encodeTag(value));
460
668
  }
461
669
  }
462
670
  }
463
671
  await this.client.sendCommand(args);
464
672
  }
465
673
  }
674
+ /**
675
+ * Single-document deletion, by `SCAN … MATCH`.
676
+ *
677
+ * This deliberately stays a keyspace scan even though the rest of this class no longer needs one.
678
+ * The obvious optimisation is to stamp each chunk with its document id as an implicit filterable
679
+ * TAG at {@link RedisVectorStore.upsert} and turn `remove` into an `FT.SEARCH` — and that is exactly
680
+ * the change that must not ship, because chunks written *before* it carry no such field. `FT.SEARCH`
681
+ * would find zero of them, `remove` would return happily, and a document that has been "deleted"
682
+ * would go on being retrieved. A delete that silently stops deleting is worse than a slow one, and
683
+ * the library cannot tell a fully-migrated corpus from an unmigrated one: both look like "this
684
+ * document has no indexed chunks". Nor is a search-then-fall-back-to-scan hybrid safe — a document
685
+ * re-ingested after the upgrade has *some* stamped chunks, so the search finds hits, the fallback
686
+ * never runs, and the unstamped tail survives. That is the same silent failure with a smaller blast
687
+ * radius, which makes it harder to notice rather than less wrong.
688
+ *
689
+ * The consumer pain this was measured against — deleting a whole collection, one `remove` per
690
+ * document — is answered instead by {@link RedisVectorStore.removeWhere} (one filtered query, no
691
+ * keyspace scan at all) and {@link RedisVectorStore.removeMany} (one scan for N documents rather
692
+ * than N scans). Neither depends on a field that older chunks lack, so neither has a migration.
693
+ */
466
694
  async remove(documentId) {
467
- const keys = /* @__PURE__ */ new Set([
468
- `${this.prefix}${documentId}`
695
+ const keys = await this.chunkKeys(documentId);
696
+ if (keys.length > 0) {
697
+ await this.client.sendCommand([
698
+ "DEL",
699
+ ...keys
700
+ ]);
701
+ }
702
+ }
703
+ /**
704
+ * Rewrite the metadata of every chunk of `documentId` in place, leaving `text` and `embedding`
705
+ * alone. See {@link VectorStore.updateMetadata} for the merge semantics and the return value.
706
+ *
707
+ * The care this needs is entirely about the fact that a chunk stores its metadata **twice**: as the
708
+ * `metadata_json` blob (what {@link RedisVectorStore.search} reads back onto a `Passage`) and as
709
+ * `meta_<field>` TAG fields (what RediSearch actually filters on, and only for the declared
710
+ * `filterableFields`). Move one without the other and the store lies to itself — a chunk that
711
+ * filters as the old value but reports the new one, or the reverse — so this method rewrites both
712
+ * from the same merged object:
713
+ *
714
+ * - a patched key that is **not** filterable lands in `metadata_json` only, and no stray `meta_*`
715
+ * field is invented for it (it would be dead weight the index does not know about);
716
+ * - a patched key that **is** filterable also rewrites its TAG, using the identical encoding
717
+ * `upsert` uses, so an updated chunk is indistinguishable from a re-ingested one;
718
+ * - a filterable key the patch **removes** has its TAG `HDEL`-ed, not left behind as a tag matching
719
+ * a value the document no longer carries.
720
+ *
721
+ * A plain `HSET` is all the index needs: RediSearch re-indexes a hash the moment it is written, so
722
+ * the very next `FT.SEARCH` sees the new TAG and no longer the old one — no reindex command, and
723
+ * no need to rewrite the (untouched, and expensive) vector to nudge it. `HDEL` on an indexed field
724
+ * likewise drops it from the index. Verified against a real Redis Stack in
725
+ * `redis-vector-store.db.spec.ts`, not assumed.
726
+ *
727
+ * Removals are issued **before** the write, so the only window a concurrent reader can observe is
728
+ * "TAG already gone, `metadata_json` not yet updated" — which fails *closed* on a filter carrying
729
+ * an ACL. The reverse order would briefly leave a document tagged with a capability its metadata no
730
+ * longer claims. Read-modify-write across chunks is not transactional; concurrent writers to the
731
+ * same chunk are last-writer-wins, exactly as two concurrent `upsert`s already are.
732
+ */
733
+ async updateMetadata(documentId, patch) {
734
+ if (isEmptyMetadataPatch(patch)) {
735
+ return 0;
736
+ }
737
+ const keys = await this.chunkKeys(documentId);
738
+ let updated = 0;
739
+ for (const key of keys) {
740
+ const raw = await this.client.sendCommand([
741
+ "HGET",
742
+ key,
743
+ "metadata_json"
744
+ ]);
745
+ const current = parseMetadata(raw === null || raw === void 0 ? void 0 : toStr(raw));
746
+ const next = applyMetadataPatch(current.metadata, patch);
747
+ const args = [
748
+ "HSET",
749
+ key,
750
+ "metadata_json",
751
+ JSON.stringify(next)
752
+ ];
753
+ const stale = [];
754
+ for (const field of this.filterableFields) {
755
+ const value = next[field];
756
+ if (value !== void 0) {
757
+ args.push(`meta_${field}`, encodeTag(value));
758
+ } else if (current.metadata?.[field] !== void 0) {
759
+ stale.push(`meta_${field}`);
760
+ }
761
+ }
762
+ if (stale.length > 0) {
763
+ await this.client.sendCommand([
764
+ "HDEL",
765
+ key,
766
+ ...stale
767
+ ]);
768
+ }
769
+ await this.client.sendCommand(args);
770
+ updated += 1;
771
+ }
772
+ return updated;
773
+ }
774
+ /**
775
+ * Every chunk key that currently EXISTS for `documentId` — the bare `${prefix}${documentId}` plus
776
+ * the `${documentId}#<n>` chunks found by `SCAN`. One definition of "chunk belongs to document",
777
+ * shared by `remove` and `updateMetadata`, so the two can never disagree about which keys a
778
+ * document owns. The glob is escaped so `gone` does not reach into `goner#0`.
779
+ */
780
+ async chunkKeys(documentId) {
781
+ const keys = /* @__PURE__ */ new Set();
782
+ const bare = `${this.prefix}${documentId}`;
783
+ const exists = await this.client.sendCommand([
784
+ "EXISTS",
785
+ bare
469
786
  ]);
787
+ if (Number(toStr(exists)) > 0) {
788
+ keys.add(bare);
789
+ }
470
790
  const pattern = `${this.prefix}${escapeGlob(documentId)}#*`;
471
791
  let cursor = "0";
472
792
  do {
@@ -484,12 +804,9 @@ var RedisVectorStore = class {
484
804
  keys.add(key);
485
805
  }
486
806
  } while (cursor !== "0");
487
- if (keys.size > 0) {
488
- await this.client.sendCommand([
489
- "DEL",
490
- ...keys
491
- ]);
492
- }
807
+ return [
808
+ ...keys
809
+ ];
493
810
  }
494
811
  async listDocuments(filter) {
495
812
  if (filterMatchesNothing(filter)) {
@@ -534,6 +851,186 @@ var RedisVectorStore = class {
534
851
  ...documents.values()
535
852
  ];
536
853
  }
854
+ /**
855
+ * The id-only half of {@link RedisVectorStore.listDocuments}, served by `FT.SEARCH … NOCONTENT`:
856
+ * RediSearch returns bare keys, so nothing carries `metadata_json` over the wire and nothing is
857
+ * JSON-parsed. `listDocuments` has to parse one metadata blob per *chunk* — thousands of parses to
858
+ * produce a few hundred ids it then throws the metadata away from — and this does none of it.
859
+ *
860
+ * The collapse to document ids still happens client-side. RediSearch can group server-side
861
+ * (`FT.AGGREGATE … GROUPBY`), but only over an indexed *field*, and a chunk's document id lives in
862
+ * its key, not in a field — deriving it would mean stamping one at write time, which is the
863
+ * migration {@link RedisVectorStore.remove} explains this class refuses. Deduplicating a page of
864
+ * keys in JS is free by comparison.
865
+ */
866
+ async listDocumentIds(filter) {
867
+ if (filterMatchesNothing(filter)) {
868
+ return [];
869
+ }
870
+ const query = buildFilter(filter);
871
+ const documents = /* @__PURE__ */ new Set();
872
+ let offset = 0;
873
+ for (; ; ) {
874
+ const reply = await this.client.sendCommand([
875
+ "FT.SEARCH",
876
+ this.index,
877
+ query,
878
+ "NOCONTENT",
879
+ "LIMIT",
880
+ String(offset),
881
+ String(SCAN_PAGE),
882
+ "DIALECT",
883
+ "2"
884
+ ]);
885
+ const keys = parseSearchKeys(reply);
886
+ for (const key of keys) {
887
+ documents.add(documentIdOf(stripPrefix(key, this.prefix)));
888
+ }
889
+ if (keys.length < SCAN_PAGE) {
890
+ break;
891
+ }
892
+ offset += SCAN_PAGE;
893
+ }
894
+ return [
895
+ ...documents
896
+ ];
897
+ }
898
+ /**
899
+ * Bulk deletion by document id — **one** keyspace scan for N documents, where N calls to
900
+ * {@link RedisVectorStore.remove} would be N of them. The scan is `MATCH ${prefix}*`, so Redis still
901
+ * filters to this store's own keys server-side; membership is then decided in JS against a `Set`,
902
+ * which is what lets a single pass serve any number of ids.
903
+ *
904
+ * It deletes by key, exactly as `remove` does, so it is correct on chunks written by any version of
905
+ * this library — there is no field it needs the corpus to already carry.
906
+ */
907
+ async removeMany(documentIds) {
908
+ if (documentIds.length === 0) {
909
+ return;
910
+ }
911
+ const wanted = new Set(documentIds);
912
+ let pending = documentIds.map((id) => `${this.prefix}${id}`);
913
+ let cursor = "0";
914
+ do {
915
+ const reply = await this.client.sendCommand([
916
+ "SCAN",
917
+ cursor,
918
+ "MATCH",
919
+ `${escapeGlob(this.prefix)}*`,
920
+ "COUNT",
921
+ String(SCAN_PAGE)
922
+ ]);
923
+ const page = parseScanReply(reply);
924
+ cursor = page.cursor;
925
+ for (const key of page.keys) {
926
+ if (wanted.has(documentIdOf(stripPrefix(key, this.prefix)))) {
927
+ pending.push(key);
928
+ }
929
+ }
930
+ if (pending.length >= DELETE_BATCH) {
931
+ await this.client.sendCommand([
932
+ "DEL",
933
+ ...pending
934
+ ]);
935
+ pending = [];
936
+ }
937
+ } while (cursor !== "0");
938
+ if (pending.length > 0) {
939
+ await this.client.sendCommand([
940
+ "DEL",
941
+ ...pending
942
+ ]);
943
+ }
944
+ }
945
+ /**
946
+ * Delete every chunk matching `filter`, via `FT.SEARCH … NOCONTENT` + `DEL` — no keyspace scan at
947
+ * all, so dropping a collection costs one indexed query per page instead of one full pass per
948
+ * document. Resolves the number of chunk keys actually deleted.
949
+ *
950
+ * The two refusals, in this order, before any query is issued:
951
+ *
952
+ * 1. **An empty filter object throws** {@link UnsafeRemovalError}. `buildFilter({})` is `*`, and `*`
953
+ * here would mean "delete the corpus" — far too destructive an outcome for the shape a filter
954
+ * degenerates to when it is built wrong.
955
+ * 2. **An empty-array value deletes nothing** and resolves `0`. This is the package's deny primitive
956
+ * (an actor with no capability tokens), and it is the one that has to be gated *explicitly*: a
957
+ * plausible-looking "normalise the filter first" refactor that drops empty arrays turns a deny
958
+ * into `*` and wipes everything the deny existed to protect.
959
+ *
960
+ * Then one more, which is specific to RediSearch: every filter key must be a declared
961
+ * `filterableFields` entry. `search` lets the engine reject an unknown field, which is fine for a
962
+ * read; for a delete the check is worth making up front and by name.
963
+ *
964
+ * Deletion happens page by page, always re-querying from offset 0 — the rows just deleted are gone
965
+ * from the index, so paging forward would skip their replacements. If a page comes back non-empty
966
+ * but `DEL` reports nothing removed (keys expired between query and delete, or the index is stale),
967
+ * the loop stops rather than spinning on results it can never consume.
968
+ */
969
+ async removeWhere(filter) {
970
+ const keys = Object.keys(filter);
971
+ if (keys.length === 0) {
972
+ throw new UnsafeRemovalError("empty-filter", "removeWhere() refuses an empty filter: it would delete every chunk in the index. Pass a filter that scopes the removal, or delete deliberately with removeMany(await store.listDocumentIds()).");
973
+ }
974
+ const unindexed = keys.filter((key) => !this.filterableFields.includes(key));
975
+ if (unindexed.length > 0) {
976
+ const named = unindexed.map((key) => `"${key}"`).join(", ");
977
+ const declared = this.filterableFields.join(", ") || "none";
978
+ throw new UnsafeRemovalError("unindexed-field", `removeWhere() cannot scope on ${named}: not declared in this store's filterableFields (${declared}), so RediSearch has no TAG to match on.`);
979
+ }
980
+ if (filterMatchesNothing(filter)) {
981
+ return 0;
982
+ }
983
+ const query = buildFilter(filter);
984
+ let removed = 0;
985
+ for (; ; ) {
986
+ const reply = await this.client.sendCommand([
987
+ "FT.SEARCH",
988
+ this.index,
989
+ query,
990
+ "NOCONTENT",
991
+ "LIMIT",
992
+ "0",
993
+ String(DELETE_BATCH),
994
+ "DIALECT",
995
+ "2"
996
+ ]);
997
+ const page = parseSearchKeys(reply);
998
+ if (page.length === 0) {
999
+ break;
1000
+ }
1001
+ const deleted = Number(await this.client.sendCommand([
1002
+ "DEL",
1003
+ ...page
1004
+ ]));
1005
+ if (!Number.isFinite(deleted) || deleted === 0) {
1006
+ break;
1007
+ }
1008
+ removed += deleted;
1009
+ }
1010
+ return removed;
1011
+ }
1012
+ /**
1013
+ * Count matching chunks with `FT.SEARCH … LIMIT 0 0` — RediSearch returns the total and no
1014
+ * documents, so the cost is the query, not the corpus. An empty-array filter value denies and
1015
+ * yields `0`, same as every other filtered path here.
1016
+ */
1017
+ async countChunks(filter) {
1018
+ if (filterMatchesNothing(filter)) {
1019
+ return 0;
1020
+ }
1021
+ const reply = await this.client.sendCommand([
1022
+ "FT.SEARCH",
1023
+ this.index,
1024
+ buildFilter(filter),
1025
+ "NOCONTENT",
1026
+ "LIMIT",
1027
+ "0",
1028
+ "0",
1029
+ "DIALECT",
1030
+ "2"
1031
+ ]);
1032
+ return parseSearchTotal(reply);
1033
+ }
537
1034
  async search(embedding, options) {
538
1035
  if (filterMatchesNothing(options.filter)) {
539
1036
  return [];
@@ -606,6 +1103,35 @@ var RedisVectorStore = class {
606
1103
  return parseSearchReply(reply, this.prefix, "lexical");
607
1104
  }
608
1105
  };
1106
+ var SCAN_PAGE = 1e3;
1107
+ var DELETE_BATCH = 512;
1108
+ function stripPrefix(key, prefix) {
1109
+ return key.startsWith(prefix) ? key.slice(prefix.length) : key;
1110
+ }
1111
+ __name(stripPrefix, "stripPrefix");
1112
+ function parseSearchKeys(reply) {
1113
+ if (Array.isArray(reply)) {
1114
+ return reply.slice(1).map(toStr);
1115
+ }
1116
+ if (typeof reply === "object" && reply !== null && "results" in reply) {
1117
+ const results = reply.results;
1118
+ return Array.isArray(results) ? results.map(readId) : [];
1119
+ }
1120
+ return [];
1121
+ }
1122
+ __name(parseSearchKeys, "parseSearchKeys");
1123
+ function parseSearchTotal(reply) {
1124
+ if (Array.isArray(reply)) {
1125
+ const total = Number(toStr(reply[0]));
1126
+ return Number.isFinite(total) ? total : 0;
1127
+ }
1128
+ if (typeof reply === "object" && reply !== null && "total_results" in reply) {
1129
+ const total = Number(reply.total_results);
1130
+ return Number.isFinite(total) ? total : 0;
1131
+ }
1132
+ return 0;
1133
+ }
1134
+ __name(parseSearchTotal, "parseSearchTotal");
609
1135
  function parseIndexAttributes(reply) {
610
1136
  const attributes = /* @__PURE__ */ new Map();
611
1137
  for (const entry of readInfoField(reply, "attributes")) {
@@ -676,17 +1202,14 @@ function buildFilter(filter) {
676
1202
  return `(${clauses.join(" ")})`;
677
1203
  }
678
1204
  __name(buildFilter, "buildFilter");
679
- function filterMatchesNothing(filter) {
680
- if (filter === void 0) {
681
- return false;
682
- }
683
- return Object.values(filter).some((value) => Array.isArray(value) && value.length === 0);
684
- }
685
- __name(filterMatchesNothing, "filterMatchesNothing");
686
1205
  function tokenizeQuery(query) {
687
1206
  return query.match(/[\p{L}\p{N}_]+/gu) ?? [];
688
1207
  }
689
1208
  __name(tokenizeQuery, "tokenizeQuery");
1209
+ function encodeTag(value) {
1210
+ return Array.isArray(value) ? value.map(String).join(",") : String(value);
1211
+ }
1212
+ __name(encodeTag, "encodeTag");
690
1213
  function escapeTag(value) {
691
1214
  return value.replace(/[^a-zA-Z0-9_]/g, (char) => `\\${char}`);
692
1215
  }
@@ -1168,6 +1691,8 @@ __name(createRetrievalTool, "createRetrievalTool");
1168
1691
  RedisVectorSchemaMismatchError,
1169
1692
  RedisVectorStore,
1170
1693
  RerankingRetriever,
1694
+ UnsafeRemovalError,
1695
+ applyMetadataPatch,
1171
1696
  chunkDocuments,
1172
1697
  chunkText,
1173
1698
  createRetrievalTool,