@dudousxd/nestjs-agent-rag 0.5.0 → 0.7.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/README.md +140 -2
- package/dist/index.cjs +548 -19
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +375 -2
- package/dist/index.d.ts +375 -2
- package/dist/index.js +546 -19
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
package/dist/index.js
CHANGED
|
@@ -44,11 +44,56 @@ function isLexicalVectorStore(store) {
|
|
|
44
44
|
return typeof store.searchText === "function";
|
|
45
45
|
}
|
|
46
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
|
+
};
|
|
47
57
|
function documentIdOf(chunkId) {
|
|
48
58
|
return chunkId.replace(/#\d+$/, "");
|
|
49
59
|
}
|
|
50
60
|
__name(documentIdOf, "documentIdOf");
|
|
51
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
|
+
|
|
52
97
|
// src/filter.ts
|
|
53
98
|
function matchesFilter(metadata, filter) {
|
|
54
99
|
const entries = Object.entries(filter);
|
|
@@ -64,6 +109,13 @@ function matchesFilter(metadata, filter) {
|
|
|
64
109
|
});
|
|
65
110
|
}
|
|
66
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");
|
|
67
119
|
|
|
68
120
|
// src/memory-vector-store.ts
|
|
69
121
|
var MemoryVectorStore = class {
|
|
@@ -83,6 +135,29 @@ var MemoryVectorStore = class {
|
|
|
83
135
|
}
|
|
84
136
|
}
|
|
85
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
|
+
}
|
|
86
161
|
async listDocuments(filter) {
|
|
87
162
|
const documents = /* @__PURE__ */ new Map();
|
|
88
163
|
for (const record of this.records.values()) {
|
|
@@ -103,6 +178,66 @@ var MemoryVectorStore = class {
|
|
|
103
178
|
...documents.values()
|
|
104
179
|
];
|
|
105
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
|
+
}
|
|
106
241
|
async search(embedding, options) {
|
|
107
242
|
const scored = [];
|
|
108
243
|
for (const record of this.records.values()) {
|
|
@@ -189,6 +324,33 @@ var PgVectorStore = class {
|
|
|
189
324
|
documentId
|
|
190
325
|
]);
|
|
191
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
|
+
}
|
|
192
354
|
async listDocuments(filter) {
|
|
193
355
|
const where = buildWhere(filter, 1);
|
|
194
356
|
const rows = await this.client.query(`SELECT DISTINCT ON (${DOCUMENT_ID_FROM_CHUNK}) ${DOCUMENT_ID_FROM_CHUNK} AS doc_id, metadata
|
|
@@ -202,6 +364,51 @@ var PgVectorStore = class {
|
|
|
202
364
|
} : {}
|
|
203
365
|
}));
|
|
204
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
|
+
}
|
|
205
412
|
async search(embedding, options) {
|
|
206
413
|
const vector = toVectorLiteral(embedding);
|
|
207
414
|
const where = buildWhere(options.filter, 3);
|
|
@@ -415,18 +622,129 @@ var RedisVectorStore = class {
|
|
|
415
622
|
for (const field of this.filterableFields) {
|
|
416
623
|
const value = record.metadata[field];
|
|
417
624
|
if (value !== void 0) {
|
|
418
|
-
|
|
419
|
-
args.push(`meta_${field}`, tag);
|
|
625
|
+
args.push(`meta_${field}`, encodeTag(value));
|
|
420
626
|
}
|
|
421
627
|
}
|
|
422
628
|
}
|
|
423
629
|
await this.client.sendCommand(args);
|
|
424
630
|
}
|
|
425
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
|
+
*/
|
|
426
652
|
async remove(documentId) {
|
|
427
|
-
const keys =
|
|
428
|
-
|
|
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
|
|
429
744
|
]);
|
|
745
|
+
if (Number(toStr(exists)) > 0) {
|
|
746
|
+
keys.add(bare);
|
|
747
|
+
}
|
|
430
748
|
const pattern = `${this.prefix}${escapeGlob(documentId)}#*`;
|
|
431
749
|
let cursor = "0";
|
|
432
750
|
do {
|
|
@@ -444,12 +762,9 @@ var RedisVectorStore = class {
|
|
|
444
762
|
keys.add(key);
|
|
445
763
|
}
|
|
446
764
|
} while (cursor !== "0");
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
...keys
|
|
451
|
-
]);
|
|
452
|
-
}
|
|
765
|
+
return [
|
|
766
|
+
...keys
|
|
767
|
+
];
|
|
453
768
|
}
|
|
454
769
|
async listDocuments(filter) {
|
|
455
770
|
if (filterMatchesNothing(filter)) {
|
|
@@ -494,6 +809,186 @@ var RedisVectorStore = class {
|
|
|
494
809
|
...documents.values()
|
|
495
810
|
];
|
|
496
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
|
+
}
|
|
497
992
|
async search(embedding, options) {
|
|
498
993
|
if (filterMatchesNothing(options.filter)) {
|
|
499
994
|
return [];
|
|
@@ -566,6 +1061,35 @@ var RedisVectorStore = class {
|
|
|
566
1061
|
return parseSearchReply(reply, this.prefix, "lexical");
|
|
567
1062
|
}
|
|
568
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");
|
|
569
1093
|
function parseIndexAttributes(reply) {
|
|
570
1094
|
const attributes = /* @__PURE__ */ new Map();
|
|
571
1095
|
for (const entry of readInfoField(reply, "attributes")) {
|
|
@@ -636,17 +1160,14 @@ function buildFilter(filter) {
|
|
|
636
1160
|
return `(${clauses.join(" ")})`;
|
|
637
1161
|
}
|
|
638
1162
|
__name(buildFilter, "buildFilter");
|
|
639
|
-
function filterMatchesNothing(filter) {
|
|
640
|
-
if (filter === void 0) {
|
|
641
|
-
return false;
|
|
642
|
-
}
|
|
643
|
-
return Object.values(filter).some((value) => Array.isArray(value) && value.length === 0);
|
|
644
|
-
}
|
|
645
|
-
__name(filterMatchesNothing, "filterMatchesNothing");
|
|
646
1163
|
function tokenizeQuery(query) {
|
|
647
1164
|
return query.match(/[\p{L}\p{N}_]+/gu) ?? [];
|
|
648
1165
|
}
|
|
649
1166
|
__name(tokenizeQuery, "tokenizeQuery");
|
|
1167
|
+
function encodeTag(value) {
|
|
1168
|
+
return Array.isArray(value) ? value.map(String).join(",") : String(value);
|
|
1169
|
+
}
|
|
1170
|
+
__name(encodeTag, "encodeTag");
|
|
650
1171
|
function escapeTag(value) {
|
|
651
1172
|
return value.replace(/[^a-zA-Z0-9_]/g, (char) => `\\${char}`);
|
|
652
1173
|
}
|
|
@@ -774,9 +1295,11 @@ var EmbeddingRetriever = class {
|
|
|
774
1295
|
}
|
|
775
1296
|
embedder;
|
|
776
1297
|
store;
|
|
777
|
-
|
|
1298
|
+
options;
|
|
1299
|
+
constructor(embedder, store, options = {}) {
|
|
778
1300
|
this.embedder = embedder;
|
|
779
1301
|
this.store = store;
|
|
1302
|
+
this.options = options;
|
|
780
1303
|
}
|
|
781
1304
|
async retrieve(query, options = {}) {
|
|
782
1305
|
const [embedding] = await this.embedder.embed([
|
|
@@ -785,12 +1308,14 @@ var EmbeddingRetriever = class {
|
|
|
785
1308
|
if (embedding === void 0) {
|
|
786
1309
|
return [];
|
|
787
1310
|
}
|
|
788
|
-
|
|
1311
|
+
const passages = await this.store.search(embedding, {
|
|
789
1312
|
topK: options.topK ?? 5,
|
|
790
1313
|
...options.filter !== void 0 ? {
|
|
791
1314
|
filter: options.filter
|
|
792
1315
|
} : {}
|
|
793
1316
|
});
|
|
1317
|
+
const { minScore } = this.options;
|
|
1318
|
+
return minScore === void 0 ? passages : passages.filter((passage) => passage.score >= minScore);
|
|
794
1319
|
}
|
|
795
1320
|
};
|
|
796
1321
|
|
|
@@ -1127,6 +1652,8 @@ export {
|
|
|
1127
1652
|
RedisVectorSchemaMismatchError,
|
|
1128
1653
|
RedisVectorStore,
|
|
1129
1654
|
RerankingRetriever,
|
|
1655
|
+
UnsafeRemovalError,
|
|
1656
|
+
applyMetadataPatch,
|
|
1130
1657
|
chunkDocuments,
|
|
1131
1658
|
chunkText,
|
|
1132
1659
|
createRetrievalTool,
|