@dudousxd/nestjs-agent-rag 0.4.1 → 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.js CHANGED
@@ -40,11 +40,60 @@ function chunkText(text, options = {}) {
40
40
  __name(chunkText, "chunkText");
41
41
 
42
42
  // src/vector-store.ts
43
+ function isLexicalVectorStore(store) {
44
+ return typeof store.searchText === "function";
45
+ }
46
+ __name(isLexicalVectorStore, "isLexicalVectorStore");
47
+ var UnsafeRemovalError = class extends Error {
48
+ static {
49
+ __name(this, "UnsafeRemovalError");
50
+ }
51
+ reason;
52
+ constructor(reason, message) {
53
+ super(message), this.reason = reason;
54
+ this.name = "UnsafeRemovalError";
55
+ }
56
+ };
43
57
  function documentIdOf(chunkId) {
44
58
  return chunkId.replace(/#\d+$/, "");
45
59
  }
46
60
  __name(documentIdOf, "documentIdOf");
47
61
 
62
+ // src/metadata-patch.ts
63
+ function splitMetadataPatch(patch) {
64
+ const set = {};
65
+ const remove = [];
66
+ for (const [key, value] of Object.entries(patch)) {
67
+ if (value === null) {
68
+ remove.push(key);
69
+ } else if (value !== void 0) {
70
+ set[key] = value;
71
+ }
72
+ }
73
+ return {
74
+ set,
75
+ remove
76
+ };
77
+ }
78
+ __name(splitMetadataPatch, "splitMetadataPatch");
79
+ function isEmptyMetadataPatch(patch) {
80
+ const { set, remove } = splitMetadataPatch(patch);
81
+ return Object.keys(set).length === 0 && remove.length === 0;
82
+ }
83
+ __name(isEmptyMetadataPatch, "isEmptyMetadataPatch");
84
+ function applyMetadataPatch(metadata, patch) {
85
+ const { set, remove } = splitMetadataPatch(patch);
86
+ const next = {
87
+ ...metadata,
88
+ ...set
89
+ };
90
+ for (const key of remove) {
91
+ delete next[key];
92
+ }
93
+ return next;
94
+ }
95
+ __name(applyMetadataPatch, "applyMetadataPatch");
96
+
48
97
  // src/filter.ts
49
98
  function matchesFilter(metadata, filter) {
50
99
  const entries = Object.entries(filter);
@@ -60,6 +109,13 @@ function matchesFilter(metadata, filter) {
60
109
  });
61
110
  }
62
111
  __name(matchesFilter, "matchesFilter");
112
+ function filterMatchesNothing(filter) {
113
+ if (filter === void 0) {
114
+ return false;
115
+ }
116
+ return Object.values(filter).some((value) => Array.isArray(value) && value.length === 0);
117
+ }
118
+ __name(filterMatchesNothing, "filterMatchesNothing");
63
119
 
64
120
  // src/memory-vector-store.ts
65
121
  var MemoryVectorStore = class {
@@ -79,6 +135,29 @@ var MemoryVectorStore = class {
79
135
  }
80
136
  }
81
137
  }
138
+ /**
139
+ * Merge `patch` into every chunk of `documentId`, leaving `text` and `embedding` untouched. The
140
+ * record is *replaced* rather than mutated: `upsert` stores the caller's own object, and patching
141
+ * it in place would edit an object the caller still holds — a surprise no out-of-process store
142
+ * could reproduce, and therefore one this reference adapter must not teach.
143
+ */
144
+ async updateMetadata(documentId, patch) {
145
+ if (isEmptyMetadataPatch(patch)) {
146
+ return 0;
147
+ }
148
+ let updated = 0;
149
+ for (const [id, record] of this.records) {
150
+ if (documentIdOf(id) !== documentId) {
151
+ continue;
152
+ }
153
+ this.records.set(id, {
154
+ ...record,
155
+ metadata: applyMetadataPatch(record.metadata, patch)
156
+ });
157
+ updated += 1;
158
+ }
159
+ return updated;
160
+ }
82
161
  async listDocuments(filter) {
83
162
  const documents = /* @__PURE__ */ new Map();
84
163
  for (const record of this.records.values()) {
@@ -99,6 +178,66 @@ var MemoryVectorStore = class {
99
178
  ...documents.values()
100
179
  ];
101
180
  }
181
+ async listDocumentIds(filter) {
182
+ if (filterMatchesNothing(filter)) {
183
+ return [];
184
+ }
185
+ const documents = /* @__PURE__ */ new Set();
186
+ for (const record of this.records.values()) {
187
+ if (filter !== void 0 && !matchesFilter(record.metadata, filter)) {
188
+ continue;
189
+ }
190
+ documents.add(documentIdOf(record.id));
191
+ }
192
+ return [
193
+ ...documents
194
+ ];
195
+ }
196
+ async removeMany(documentIds) {
197
+ if (documentIds.length === 0) {
198
+ return;
199
+ }
200
+ const wanted = new Set(documentIds);
201
+ for (const id of this.records.keys()) {
202
+ if (wanted.has(documentIdOf(id))) {
203
+ this.records.delete(id);
204
+ }
205
+ }
206
+ }
207
+ /**
208
+ * See {@link VectorStore.removeWhere}. The empty-array short-circuit below is redundant
209
+ * against `matchesFilter` (which already matches nothing for an empty array) and is kept anyway:
210
+ * this is the method where "the filter accidentally means everything" destroys data, so the deny is
211
+ * stated where a reader can see it rather than inferred from another module's truth table.
212
+ */
213
+ async removeWhere(filter) {
214
+ if (Object.keys(filter).length === 0) {
215
+ 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()).");
216
+ }
217
+ if (filterMatchesNothing(filter)) {
218
+ return 0;
219
+ }
220
+ let removed = 0;
221
+ for (const [id, record] of this.records) {
222
+ if (matchesFilter(record.metadata, filter)) {
223
+ this.records.delete(id);
224
+ removed += 1;
225
+ }
226
+ }
227
+ return removed;
228
+ }
229
+ async countChunks(filter) {
230
+ if (filterMatchesNothing(filter)) {
231
+ return 0;
232
+ }
233
+ let count = 0;
234
+ for (const record of this.records.values()) {
235
+ if (filter === void 0 || matchesFilter(record.metadata, filter)) {
236
+ count += 1;
237
+ }
238
+ }
239
+ return count;
240
+ }
102
241
  async search(embedding, options) {
103
242
  const scored = [];
104
243
  for (const record of this.records.values()) {
@@ -185,6 +324,33 @@ var PgVectorStore = class {
185
324
  documentId
186
325
  ]);
187
326
  }
327
+ /**
328
+ * Rewrite a document's metadata without re-embedding it. See {@link VectorStore.updateMetadata}
329
+ * for the semantics; here the whole thing is one statement, because jsonb already *is* the merge:
330
+ * `||` is a shallow key-wise merge (right side wins, arrays replaced wholesale — precisely
331
+ * {@link MetadataPatch}) and `- text[]` drops the keys the patch removed. `COALESCE` covers a chunk
332
+ * ingested with no metadata at all, so a patch can create the object rather than no-op on `NULL`.
333
+ *
334
+ * Unlike RediSearch there is no second representation to keep in step — the `metadata` column is
335
+ * both what `search` filters on and what it returns — so the correctness trap the Redis adapter has
336
+ * to work for simply does not exist here. `RETURNING id` is what supplies the chunk count, since
337
+ * {@link PgClient} is a rows-only surface with no `rowCount`.
338
+ */
339
+ async updateMetadata(documentId, patch) {
340
+ if (isEmptyMetadataPatch(patch)) {
341
+ return 0;
342
+ }
343
+ const { set, remove } = splitMetadataPatch(patch);
344
+ const rows = await this.client.query(`UPDATE ${this.table}
345
+ SET metadata = (COALESCE(metadata, '{}'::jsonb) || $2::jsonb) - $3::text[]
346
+ WHERE ${DOCUMENT_ID_FROM_CHUNK} = $1
347
+ RETURNING id`, [
348
+ documentId,
349
+ JSON.stringify(set),
350
+ remove
351
+ ]);
352
+ return rows.length;
353
+ }
188
354
  async listDocuments(filter) {
189
355
  const where = buildWhere(filter, 1);
190
356
  const rows = await this.client.query(`SELECT DISTINCT ON (${DOCUMENT_ID_FROM_CHUNK}) ${DOCUMENT_ID_FROM_CHUNK} AS doc_id, metadata
@@ -198,6 +364,51 @@ var PgVectorStore = class {
198
364
  } : {}
199
365
  }));
200
366
  }
367
+ /** {@link listDocuments} without the metadata: one `DISTINCT` over the derived document id. */
368
+ async listDocumentIds(filter) {
369
+ if (filterMatchesNothing(filter)) {
370
+ return [];
371
+ }
372
+ const where = buildWhere(filter, 1);
373
+ const rows = await this.client.query(`SELECT DISTINCT ${DOCUMENT_ID_FROM_CHUNK} AS doc_id
374
+ FROM ${this.table}
375
+ ${where.sql}
376
+ ORDER BY doc_id`, where.params);
377
+ return rows.map((row) => row.doc_id);
378
+ }
379
+ /** N documents in one statement — the set-based form of {@link PgVectorStore.remove}. */
380
+ async removeMany(documentIds) {
381
+ if (documentIds.length === 0) {
382
+ return;
383
+ }
384
+ await this.client.query(`DELETE FROM ${this.table} WHERE ${DOCUMENT_ID_FROM_CHUNK} = ANY($1::text[])`, [
385
+ documentIds
386
+ ]);
387
+ }
388
+ /**
389
+ * See {@link VectorStore.removeWhere}: empty filter object throws, empty-array value
390
+ * deletes nothing. The count comes from `RETURNING id` rather than a driver-specific `rowCount`,
391
+ * because {@link PgClient} is deliberately just "run SQL, get rows".
392
+ */
393
+ async removeWhere(filter) {
394
+ if (Object.keys(filter).length === 0) {
395
+ 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()).");
396
+ }
397
+ if (filterMatchesNothing(filter)) {
398
+ return 0;
399
+ }
400
+ const where = buildWhere(filter, 1);
401
+ const rows = await this.client.query(`DELETE FROM ${this.table} ${where.sql} RETURNING id`, where.params);
402
+ return rows.length;
403
+ }
404
+ async countChunks(filter) {
405
+ if (filterMatchesNothing(filter)) {
406
+ return 0;
407
+ }
408
+ const where = buildWhere(filter, 1);
409
+ const rows = await this.client.query(`SELECT count(*)::int AS count FROM ${this.table} ${where.sql}`, where.params);
410
+ return Number(rows[0]?.count ?? 0);
411
+ }
201
412
  async search(embedding, options) {
202
413
  const vector = toVectorLiteral(embedding);
203
414
  const where = buildWhere(options.filter, 3);
@@ -291,12 +502,14 @@ var RedisVectorStore = class {
291
502
  prefix;
292
503
  dimensions;
293
504
  filterableFields;
505
+ lexicalScorer;
294
506
  constructor(client, options = {}) {
295
507
  this.client = client;
296
508
  this.index = options.index ?? "agent_rag_idx";
297
509
  this.prefix = options.prefix ?? "agent_rag:";
298
510
  this.dimensions = options.dimensions ?? 1536;
299
511
  this.filterableFields = options.filterableFields ?? [];
512
+ this.lexicalScorer = options.lexicalScorer ?? "BM25";
300
513
  }
301
514
  /**
302
515
  * Idempotent, and drift-aware: create the HNSW/cosine index if `FT.INFO` says it doesn't exist —
@@ -409,18 +622,129 @@ var RedisVectorStore = class {
409
622
  for (const field of this.filterableFields) {
410
623
  const value = record.metadata[field];
411
624
  if (value !== void 0) {
412
- const tag = Array.isArray(value) ? value.map(String).join(",") : String(value);
413
- args.push(`meta_${field}`, tag);
625
+ args.push(`meta_${field}`, encodeTag(value));
414
626
  }
415
627
  }
416
628
  }
417
629
  await this.client.sendCommand(args);
418
630
  }
419
631
  }
632
+ /**
633
+ * Single-document deletion, by `SCAN … MATCH`.
634
+ *
635
+ * This deliberately stays a keyspace scan even though the rest of this class no longer needs one.
636
+ * The obvious optimisation is to stamp each chunk with its document id as an implicit filterable
637
+ * TAG at {@link RedisVectorStore.upsert} and turn `remove` into an `FT.SEARCH` — and that is exactly
638
+ * the change that must not ship, because chunks written *before* it carry no such field. `FT.SEARCH`
639
+ * would find zero of them, `remove` would return happily, and a document that has been "deleted"
640
+ * would go on being retrieved. A delete that silently stops deleting is worse than a slow one, and
641
+ * the library cannot tell a fully-migrated corpus from an unmigrated one: both look like "this
642
+ * document has no indexed chunks". Nor is a search-then-fall-back-to-scan hybrid safe — a document
643
+ * re-ingested after the upgrade has *some* stamped chunks, so the search finds hits, the fallback
644
+ * never runs, and the unstamped tail survives. That is the same silent failure with a smaller blast
645
+ * radius, which makes it harder to notice rather than less wrong.
646
+ *
647
+ * The consumer pain this was measured against — deleting a whole collection, one `remove` per
648
+ * document — is answered instead by {@link RedisVectorStore.removeWhere} (one filtered query, no
649
+ * keyspace scan at all) and {@link RedisVectorStore.removeMany} (one scan for N documents rather
650
+ * than N scans). Neither depends on a field that older chunks lack, so neither has a migration.
651
+ */
420
652
  async remove(documentId) {
421
- const keys = /* @__PURE__ */ new Set([
422
- `${this.prefix}${documentId}`
653
+ const keys = await this.chunkKeys(documentId);
654
+ if (keys.length > 0) {
655
+ await this.client.sendCommand([
656
+ "DEL",
657
+ ...keys
658
+ ]);
659
+ }
660
+ }
661
+ /**
662
+ * Rewrite the metadata of every chunk of `documentId` in place, leaving `text` and `embedding`
663
+ * alone. See {@link VectorStore.updateMetadata} for the merge semantics and the return value.
664
+ *
665
+ * The care this needs is entirely about the fact that a chunk stores its metadata **twice**: as the
666
+ * `metadata_json` blob (what {@link RedisVectorStore.search} reads back onto a `Passage`) and as
667
+ * `meta_<field>` TAG fields (what RediSearch actually filters on, and only for the declared
668
+ * `filterableFields`). Move one without the other and the store lies to itself — a chunk that
669
+ * filters as the old value but reports the new one, or the reverse — so this method rewrites both
670
+ * from the same merged object:
671
+ *
672
+ * - a patched key that is **not** filterable lands in `metadata_json` only, and no stray `meta_*`
673
+ * field is invented for it (it would be dead weight the index does not know about);
674
+ * - a patched key that **is** filterable also rewrites its TAG, using the identical encoding
675
+ * `upsert` uses, so an updated chunk is indistinguishable from a re-ingested one;
676
+ * - a filterable key the patch **removes** has its TAG `HDEL`-ed, not left behind as a tag matching
677
+ * a value the document no longer carries.
678
+ *
679
+ * A plain `HSET` is all the index needs: RediSearch re-indexes a hash the moment it is written, so
680
+ * the very next `FT.SEARCH` sees the new TAG and no longer the old one — no reindex command, and
681
+ * no need to rewrite the (untouched, and expensive) vector to nudge it. `HDEL` on an indexed field
682
+ * likewise drops it from the index. Verified against a real Redis Stack in
683
+ * `redis-vector-store.db.spec.ts`, not assumed.
684
+ *
685
+ * Removals are issued **before** the write, so the only window a concurrent reader can observe is
686
+ * "TAG already gone, `metadata_json` not yet updated" — which fails *closed* on a filter carrying
687
+ * an ACL. The reverse order would briefly leave a document tagged with a capability its metadata no
688
+ * longer claims. Read-modify-write across chunks is not transactional; concurrent writers to the
689
+ * same chunk are last-writer-wins, exactly as two concurrent `upsert`s already are.
690
+ */
691
+ async updateMetadata(documentId, patch) {
692
+ if (isEmptyMetadataPatch(patch)) {
693
+ return 0;
694
+ }
695
+ const keys = await this.chunkKeys(documentId);
696
+ let updated = 0;
697
+ for (const key of keys) {
698
+ const raw = await this.client.sendCommand([
699
+ "HGET",
700
+ key,
701
+ "metadata_json"
702
+ ]);
703
+ const current = parseMetadata(raw === null || raw === void 0 ? void 0 : toStr(raw));
704
+ const next = applyMetadataPatch(current.metadata, patch);
705
+ const args = [
706
+ "HSET",
707
+ key,
708
+ "metadata_json",
709
+ JSON.stringify(next)
710
+ ];
711
+ const stale = [];
712
+ for (const field of this.filterableFields) {
713
+ const value = next[field];
714
+ if (value !== void 0) {
715
+ args.push(`meta_${field}`, encodeTag(value));
716
+ } else if (current.metadata?.[field] !== void 0) {
717
+ stale.push(`meta_${field}`);
718
+ }
719
+ }
720
+ if (stale.length > 0) {
721
+ await this.client.sendCommand([
722
+ "HDEL",
723
+ key,
724
+ ...stale
725
+ ]);
726
+ }
727
+ await this.client.sendCommand(args);
728
+ updated += 1;
729
+ }
730
+ return updated;
731
+ }
732
+ /**
733
+ * Every chunk key that currently EXISTS for `documentId` — the bare `${prefix}${documentId}` plus
734
+ * the `${documentId}#<n>` chunks found by `SCAN`. One definition of "chunk belongs to document",
735
+ * shared by `remove` and `updateMetadata`, so the two can never disagree about which keys a
736
+ * document owns. The glob is escaped so `gone` does not reach into `goner#0`.
737
+ */
738
+ async chunkKeys(documentId) {
739
+ const keys = /* @__PURE__ */ new Set();
740
+ const bare = `${this.prefix}${documentId}`;
741
+ const exists = await this.client.sendCommand([
742
+ "EXISTS",
743
+ bare
423
744
  ]);
745
+ if (Number(toStr(exists)) > 0) {
746
+ keys.add(bare);
747
+ }
424
748
  const pattern = `${this.prefix}${escapeGlob(documentId)}#*`;
425
749
  let cursor = "0";
426
750
  do {
@@ -438,12 +762,9 @@ var RedisVectorStore = class {
438
762
  keys.add(key);
439
763
  }
440
764
  } while (cursor !== "0");
441
- if (keys.size > 0) {
442
- await this.client.sendCommand([
443
- "DEL",
444
- ...keys
445
- ]);
446
- }
765
+ return [
766
+ ...keys
767
+ ];
447
768
  }
448
769
  async listDocuments(filter) {
449
770
  if (filterMatchesNothing(filter)) {
@@ -488,6 +809,186 @@ var RedisVectorStore = class {
488
809
  ...documents.values()
489
810
  ];
490
811
  }
812
+ /**
813
+ * The id-only half of {@link RedisVectorStore.listDocuments}, served by `FT.SEARCH … NOCONTENT`:
814
+ * RediSearch returns bare keys, so nothing carries `metadata_json` over the wire and nothing is
815
+ * JSON-parsed. `listDocuments` has to parse one metadata blob per *chunk* — thousands of parses to
816
+ * produce a few hundred ids it then throws the metadata away from — and this does none of it.
817
+ *
818
+ * The collapse to document ids still happens client-side. RediSearch can group server-side
819
+ * (`FT.AGGREGATE … GROUPBY`), but only over an indexed *field*, and a chunk's document id lives in
820
+ * its key, not in a field — deriving it would mean stamping one at write time, which is the
821
+ * migration {@link RedisVectorStore.remove} explains this class refuses. Deduplicating a page of
822
+ * keys in JS is free by comparison.
823
+ */
824
+ async listDocumentIds(filter) {
825
+ if (filterMatchesNothing(filter)) {
826
+ return [];
827
+ }
828
+ const query = buildFilter(filter);
829
+ const documents = /* @__PURE__ */ new Set();
830
+ let offset = 0;
831
+ for (; ; ) {
832
+ const reply = await this.client.sendCommand([
833
+ "FT.SEARCH",
834
+ this.index,
835
+ query,
836
+ "NOCONTENT",
837
+ "LIMIT",
838
+ String(offset),
839
+ String(SCAN_PAGE),
840
+ "DIALECT",
841
+ "2"
842
+ ]);
843
+ const keys = parseSearchKeys(reply);
844
+ for (const key of keys) {
845
+ documents.add(documentIdOf(stripPrefix(key, this.prefix)));
846
+ }
847
+ if (keys.length < SCAN_PAGE) {
848
+ break;
849
+ }
850
+ offset += SCAN_PAGE;
851
+ }
852
+ return [
853
+ ...documents
854
+ ];
855
+ }
856
+ /**
857
+ * Bulk deletion by document id — **one** keyspace scan for N documents, where N calls to
858
+ * {@link RedisVectorStore.remove} would be N of them. The scan is `MATCH ${prefix}*`, so Redis still
859
+ * filters to this store's own keys server-side; membership is then decided in JS against a `Set`,
860
+ * which is what lets a single pass serve any number of ids.
861
+ *
862
+ * It deletes by key, exactly as `remove` does, so it is correct on chunks written by any version of
863
+ * this library — there is no field it needs the corpus to already carry.
864
+ */
865
+ async removeMany(documentIds) {
866
+ if (documentIds.length === 0) {
867
+ return;
868
+ }
869
+ const wanted = new Set(documentIds);
870
+ let pending = documentIds.map((id) => `${this.prefix}${id}`);
871
+ let cursor = "0";
872
+ do {
873
+ const reply = await this.client.sendCommand([
874
+ "SCAN",
875
+ cursor,
876
+ "MATCH",
877
+ `${escapeGlob(this.prefix)}*`,
878
+ "COUNT",
879
+ String(SCAN_PAGE)
880
+ ]);
881
+ const page = parseScanReply(reply);
882
+ cursor = page.cursor;
883
+ for (const key of page.keys) {
884
+ if (wanted.has(documentIdOf(stripPrefix(key, this.prefix)))) {
885
+ pending.push(key);
886
+ }
887
+ }
888
+ if (pending.length >= DELETE_BATCH) {
889
+ await this.client.sendCommand([
890
+ "DEL",
891
+ ...pending
892
+ ]);
893
+ pending = [];
894
+ }
895
+ } while (cursor !== "0");
896
+ if (pending.length > 0) {
897
+ await this.client.sendCommand([
898
+ "DEL",
899
+ ...pending
900
+ ]);
901
+ }
902
+ }
903
+ /**
904
+ * Delete every chunk matching `filter`, via `FT.SEARCH … NOCONTENT` + `DEL` — no keyspace scan at
905
+ * all, so dropping a collection costs one indexed query per page instead of one full pass per
906
+ * document. Resolves the number of chunk keys actually deleted.
907
+ *
908
+ * The two refusals, in this order, before any query is issued:
909
+ *
910
+ * 1. **An empty filter object throws** {@link UnsafeRemovalError}. `buildFilter({})` is `*`, and `*`
911
+ * here would mean "delete the corpus" — far too destructive an outcome for the shape a filter
912
+ * degenerates to when it is built wrong.
913
+ * 2. **An empty-array value deletes nothing** and resolves `0`. This is the package's deny primitive
914
+ * (an actor with no capability tokens), and it is the one that has to be gated *explicitly*: a
915
+ * plausible-looking "normalise the filter first" refactor that drops empty arrays turns a deny
916
+ * into `*` and wipes everything the deny existed to protect.
917
+ *
918
+ * Then one more, which is specific to RediSearch: every filter key must be a declared
919
+ * `filterableFields` entry. `search` lets the engine reject an unknown field, which is fine for a
920
+ * read; for a delete the check is worth making up front and by name.
921
+ *
922
+ * Deletion happens page by page, always re-querying from offset 0 — the rows just deleted are gone
923
+ * from the index, so paging forward would skip their replacements. If a page comes back non-empty
924
+ * but `DEL` reports nothing removed (keys expired between query and delete, or the index is stale),
925
+ * the loop stops rather than spinning on results it can never consume.
926
+ */
927
+ async removeWhere(filter) {
928
+ const keys = Object.keys(filter);
929
+ if (keys.length === 0) {
930
+ 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()).");
931
+ }
932
+ const unindexed = keys.filter((key) => !this.filterableFields.includes(key));
933
+ if (unindexed.length > 0) {
934
+ const named = unindexed.map((key) => `"${key}"`).join(", ");
935
+ const declared = this.filterableFields.join(", ") || "none";
936
+ 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.`);
937
+ }
938
+ if (filterMatchesNothing(filter)) {
939
+ return 0;
940
+ }
941
+ const query = buildFilter(filter);
942
+ let removed = 0;
943
+ for (; ; ) {
944
+ const reply = await this.client.sendCommand([
945
+ "FT.SEARCH",
946
+ this.index,
947
+ query,
948
+ "NOCONTENT",
949
+ "LIMIT",
950
+ "0",
951
+ String(DELETE_BATCH),
952
+ "DIALECT",
953
+ "2"
954
+ ]);
955
+ const page = parseSearchKeys(reply);
956
+ if (page.length === 0) {
957
+ break;
958
+ }
959
+ const deleted = Number(await this.client.sendCommand([
960
+ "DEL",
961
+ ...page
962
+ ]));
963
+ if (!Number.isFinite(deleted) || deleted === 0) {
964
+ break;
965
+ }
966
+ removed += deleted;
967
+ }
968
+ return removed;
969
+ }
970
+ /**
971
+ * Count matching chunks with `FT.SEARCH … LIMIT 0 0` — RediSearch returns the total and no
972
+ * documents, so the cost is the query, not the corpus. An empty-array filter value denies and
973
+ * yields `0`, same as every other filtered path here.
974
+ */
975
+ async countChunks(filter) {
976
+ if (filterMatchesNothing(filter)) {
977
+ return 0;
978
+ }
979
+ const reply = await this.client.sendCommand([
980
+ "FT.SEARCH",
981
+ this.index,
982
+ buildFilter(filter),
983
+ "NOCONTENT",
984
+ "LIMIT",
985
+ "0",
986
+ "0",
987
+ "DIALECT",
988
+ "2"
989
+ ]);
990
+ return parseSearchTotal(reply);
991
+ }
491
992
  async search(embedding, options) {
492
993
  if (filterMatchesNothing(options.filter)) {
493
994
  return [];
@@ -518,7 +1019,77 @@ var RedisVectorStore = class {
518
1019
  ]);
519
1020
  return parseSearchReply(reply, this.prefix);
520
1021
  }
1022
+ /**
1023
+ * Lexical (BM25) search over the same index `search` uses — the {@link LexicalVectorStore} half of
1024
+ * hybrid retrieval, served by RediSearch's own keyword index rather than an in-process copy of the
1025
+ * corpus. `options.filter` is ANDed onto the text clause with the identical TAG semantics as
1026
+ * `search` (array = OR, empty array = deny), so a lexical hit can never escape a filter a vector
1027
+ * hit would have respected.
1028
+ *
1029
+ * A query with no searchable terms — empty, whitespace, or pure punctuation — returns `[]`. That is
1030
+ * deliberate: RediSearch's `*` would match the entire corpus, which is the opposite of what a
1031
+ * relevance query with nothing to be relevant to should do.
1032
+ */
1033
+ async searchText(query, options) {
1034
+ if (filterMatchesNothing(options.filter)) {
1035
+ return [];
1036
+ }
1037
+ const terms = tokenizeQuery(query);
1038
+ if (terms.length === 0) {
1039
+ return [];
1040
+ }
1041
+ const filterQuery = buildFilter(options.filter);
1042
+ const textQuery = `@text:(${terms.join("|")})`;
1043
+ const reply = await this.client.sendCommand([
1044
+ "FT.SEARCH",
1045
+ this.index,
1046
+ filterQuery === "*" ? textQuery : `(${filterQuery} ${textQuery})`,
1047
+ "SCORER",
1048
+ this.lexicalScorer,
1049
+ "WITHSCORES",
1050
+ "RETURN",
1051
+ "3",
1052
+ "text",
1053
+ "source",
1054
+ "metadata_json",
1055
+ "DIALECT",
1056
+ "2",
1057
+ "LIMIT",
1058
+ "0",
1059
+ String(options.topK)
1060
+ ]);
1061
+ return parseSearchReply(reply, this.prefix, "lexical");
1062
+ }
521
1063
  };
1064
+ var SCAN_PAGE = 1e3;
1065
+ var DELETE_BATCH = 512;
1066
+ function stripPrefix(key, prefix) {
1067
+ return key.startsWith(prefix) ? key.slice(prefix.length) : key;
1068
+ }
1069
+ __name(stripPrefix, "stripPrefix");
1070
+ function parseSearchKeys(reply) {
1071
+ if (Array.isArray(reply)) {
1072
+ return reply.slice(1).map(toStr);
1073
+ }
1074
+ if (typeof reply === "object" && reply !== null && "results" in reply) {
1075
+ const results = reply.results;
1076
+ return Array.isArray(results) ? results.map(readId) : [];
1077
+ }
1078
+ return [];
1079
+ }
1080
+ __name(parseSearchKeys, "parseSearchKeys");
1081
+ function parseSearchTotal(reply) {
1082
+ if (Array.isArray(reply)) {
1083
+ const total = Number(toStr(reply[0]));
1084
+ return Number.isFinite(total) ? total : 0;
1085
+ }
1086
+ if (typeof reply === "object" && reply !== null && "total_results" in reply) {
1087
+ const total = Number(reply.total_results);
1088
+ return Number.isFinite(total) ? total : 0;
1089
+ }
1090
+ return 0;
1091
+ }
1092
+ __name(parseSearchTotal, "parseSearchTotal");
522
1093
  function parseIndexAttributes(reply) {
523
1094
  const attributes = /* @__PURE__ */ new Map();
524
1095
  for (const entry of readInfoField(reply, "attributes")) {
@@ -589,13 +1160,14 @@ function buildFilter(filter) {
589
1160
  return `(${clauses.join(" ")})`;
590
1161
  }
591
1162
  __name(buildFilter, "buildFilter");
592
- function filterMatchesNothing(filter) {
593
- if (filter === void 0) {
594
- return false;
595
- }
596
- return Object.values(filter).some((value) => Array.isArray(value) && value.length === 0);
1163
+ function tokenizeQuery(query) {
1164
+ return query.match(/[\p{L}\p{N}_]+/gu) ?? [];
597
1165
  }
598
- __name(filterMatchesNothing, "filterMatchesNothing");
1166
+ __name(tokenizeQuery, "tokenizeQuery");
1167
+ function encodeTag(value) {
1168
+ return Array.isArray(value) ? value.map(String).join(",") : String(value);
1169
+ }
1170
+ __name(encodeTag, "encodeTag");
599
1171
  function escapeTag(value) {
600
1172
  return value.replace(/[^a-zA-Z0-9_]/g, (char) => `\\${char}`);
601
1173
  }
@@ -632,17 +1204,19 @@ function toStr(value) {
632
1204
  return Buffer.isBuffer(value) ? value.toString("utf8") : String(value);
633
1205
  }
634
1206
  __name(toStr, "toStr");
635
- function parseSearchReply(reply, prefix) {
1207
+ function parseSearchReply(reply, prefix, scoreSource = "vector") {
1208
+ const stride = scoreSource === "lexical" ? 3 : 2;
636
1209
  if (Array.isArray(reply)) {
637
1210
  const passages = [];
638
- for (let index = 1; index + 1 < reply.length; index += 2) {
639
- const rawFields = reply[index + 1];
1211
+ for (let index = 1; index + stride - 1 < reply.length; index += stride) {
1212
+ const rawFields = reply[index + stride - 1];
640
1213
  if (Array.isArray(rawFields)) {
641
1214
  const attrs = {};
642
1215
  for (let field = 0; field + 1 < rawFields.length; field += 2) {
643
1216
  attrs[toStr(rawFields[field])] = toStr(rawFields[field + 1]);
644
1217
  }
645
- passages.push(toPassage(toStr(reply[index]), attrs, prefix));
1218
+ const inlineScore = scoreSource === "lexical" ? toStr(reply[index + 1]) : void 0;
1219
+ passages.push(toPassage(toStr(reply[index]), attrs, prefix, scoreSource, inlineScore));
646
1220
  }
647
1221
  }
648
1222
  return passages;
@@ -650,12 +1224,19 @@ function parseSearchReply(reply, prefix) {
650
1224
  if (typeof reply === "object" && reply !== null && "results" in reply) {
651
1225
  const results = reply.results;
652
1226
  if (Array.isArray(results)) {
653
- return results.map((result) => toPassage(readId(result), readAttributes(result), prefix));
1227
+ return results.map((result) => toPassage(readId(result), readAttributes(result), prefix, scoreSource, scoreSource === "lexical" ? readScore(result) : void 0));
654
1228
  }
655
1229
  }
656
1230
  return [];
657
1231
  }
658
1232
  __name(parseSearchReply, "parseSearchReply");
1233
+ function readScore(result) {
1234
+ if (typeof result === "object" && result !== null && "score" in result) {
1235
+ return toStr(result.score);
1236
+ }
1237
+ return void 0;
1238
+ }
1239
+ __name(readScore, "readScore");
659
1240
  function readId(result) {
660
1241
  if (typeof result === "object" && result !== null && "id" in result) {
661
1242
  return toStr(result.id);
@@ -676,13 +1257,13 @@ function readAttributes(result) {
676
1257
  return attrs;
677
1258
  }
678
1259
  __name(readAttributes, "readAttributes");
679
- function toPassage(rawId, attrs, prefix) {
680
- const distance = Number(attrs.vector_score ?? "1");
1260
+ function toPassage(rawId, attrs, prefix, scoreSource = "vector", inlineScore) {
1261
+ const score = scoreSource === "lexical" ? Number(inlineScore ?? "0") : 1 - Number(attrs.vector_score ?? "1");
681
1262
  const source = attrs.source;
682
1263
  return {
683
1264
  id: rawId.startsWith(prefix) ? rawId.slice(prefix.length) : rawId,
684
1265
  text: attrs.text ?? "",
685
- score: 1 - distance,
1266
+ score: Number.isFinite(score) ? score : 0,
686
1267
  ...source !== void 0 ? {
687
1268
  source
688
1269
  } : {},
@@ -856,6 +1437,25 @@ function tokenize(text) {
856
1437
  }
857
1438
  __name(tokenize, "tokenize");
858
1439
 
1440
+ // src/lexical-retriever.ts
1441
+ var LexicalRetriever = class {
1442
+ static {
1443
+ __name(this, "LexicalRetriever");
1444
+ }
1445
+ store;
1446
+ constructor(store) {
1447
+ this.store = store;
1448
+ }
1449
+ async retrieve(query, options = {}) {
1450
+ return this.store.searchText(query, {
1451
+ topK: options.topK ?? 5,
1452
+ ...options.filter !== void 0 ? {
1453
+ filter: options.filter
1454
+ } : {}
1455
+ });
1456
+ }
1457
+ };
1458
+
859
1459
  // src/hybrid-retriever.ts
860
1460
  var HybridRetriever = class {
861
1461
  static {
@@ -1042,16 +1642,20 @@ export {
1042
1642
  FilteredRetriever,
1043
1643
  HybridRetriever,
1044
1644
  KeywordRetriever,
1645
+ LexicalRetriever,
1045
1646
  MemoryVectorStore,
1046
1647
  PgVectorStore,
1047
1648
  RedisVectorSchemaMismatchError,
1048
1649
  RedisVectorStore,
1049
1650
  RerankingRetriever,
1651
+ UnsafeRemovalError,
1652
+ applyMetadataPatch,
1050
1653
  chunkDocuments,
1051
1654
  chunkText,
1052
1655
  createRetrievalTool,
1053
1656
  documentIdOf,
1054
1657
  ingestChunks,
1055
- ingestDocuments
1658
+ ingestDocuments,
1659
+ isLexicalVectorStore
1056
1660
  };
1057
1661
  //# sourceMappingURL=index.js.map