@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/README.md +208 -20
- package/dist/index.cjs +634 -26
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +422 -2
- package/dist/index.d.ts +422 -2
- package/dist/index.js +629 -25
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
-
|
|
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 =
|
|
422
|
-
|
|
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
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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
|
|
593
|
-
|
|
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(
|
|
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 +=
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|