@dudousxd/nestjs-agent-rag 0.3.10 → 0.4.1
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 +36 -1
- package/dist/index.cjs +234 -21
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +75 -3
- package/dist/index.d.ts +75 -3
- package/dist/index.js +233 -21
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -47,10 +47,17 @@ __name(documentIdOf, "documentIdOf");
|
|
|
47
47
|
|
|
48
48
|
// src/filter.ts
|
|
49
49
|
function matchesFilter(metadata, filter) {
|
|
50
|
+
const entries = Object.entries(filter);
|
|
50
51
|
if (metadata === void 0) {
|
|
51
|
-
return
|
|
52
|
+
return entries.length === 0;
|
|
52
53
|
}
|
|
53
|
-
return
|
|
54
|
+
return entries.every(([key, expected]) => {
|
|
55
|
+
if (Array.isArray(expected)) {
|
|
56
|
+
const actual = metadata[key];
|
|
57
|
+
return Array.isArray(actual) ? actual.some((value) => expected.includes(value)) : expected.includes(actual);
|
|
58
|
+
}
|
|
59
|
+
return metadata[key] === expected;
|
|
60
|
+
});
|
|
54
61
|
}
|
|
55
62
|
__name(matchesFilter, "matchesFilter");
|
|
56
63
|
|
|
@@ -179,13 +186,11 @@ var PgVectorStore = class {
|
|
|
179
186
|
]);
|
|
180
187
|
}
|
|
181
188
|
async listDocuments(filter) {
|
|
182
|
-
const
|
|
189
|
+
const where = buildWhere(filter, 1);
|
|
183
190
|
const rows = await this.client.query(`SELECT DISTINCT ON (${DOCUMENT_ID_FROM_CHUNK}) ${DOCUMENT_ID_FROM_CHUNK} AS doc_id, metadata
|
|
184
191
|
FROM ${this.table}
|
|
185
|
-
${
|
|
186
|
-
ORDER BY ${DOCUMENT_ID_FROM_CHUNK}`,
|
|
187
|
-
JSON.stringify(filter)
|
|
188
|
-
] : []);
|
|
192
|
+
${where.sql}
|
|
193
|
+
ORDER BY ${DOCUMENT_ID_FROM_CHUNK}`, where.params);
|
|
189
194
|
return rows.map((row) => ({
|
|
190
195
|
id: row.doc_id,
|
|
191
196
|
...row.metadata !== null ? {
|
|
@@ -195,18 +200,15 @@ var PgVectorStore = class {
|
|
|
195
200
|
}
|
|
196
201
|
async search(embedding, options) {
|
|
197
202
|
const vector = toVectorLiteral(embedding);
|
|
198
|
-
const
|
|
203
|
+
const where = buildWhere(options.filter, 3);
|
|
199
204
|
const rows = await this.client.query(`SELECT id, text, source, metadata, 1 - (embedding <=> $1::vector) AS score
|
|
200
205
|
FROM ${this.table}
|
|
201
|
-
${
|
|
206
|
+
${where.sql}
|
|
202
207
|
ORDER BY embedding <=> $1::vector
|
|
203
|
-
LIMIT $2`,
|
|
208
|
+
LIMIT $2`, [
|
|
204
209
|
vector,
|
|
205
210
|
options.topK,
|
|
206
|
-
|
|
207
|
-
] : [
|
|
208
|
-
vector,
|
|
209
|
-
options.topK
|
|
211
|
+
...where.params
|
|
210
212
|
]);
|
|
211
213
|
return rows.map((row) => ({
|
|
212
214
|
id: row.id,
|
|
@@ -221,6 +223,45 @@ var PgVectorStore = class {
|
|
|
221
223
|
}));
|
|
222
224
|
}
|
|
223
225
|
};
|
|
226
|
+
function buildWhere(filter, startIndex) {
|
|
227
|
+
if (filter === void 0 || Object.keys(filter).length === 0) {
|
|
228
|
+
return {
|
|
229
|
+
sql: "",
|
|
230
|
+
params: []
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
const clauses = [];
|
|
234
|
+
const params = [];
|
|
235
|
+
const scalar = {};
|
|
236
|
+
let index = startIndex;
|
|
237
|
+
for (const [key, value] of Object.entries(filter)) {
|
|
238
|
+
if (Array.isArray(value)) {
|
|
239
|
+
if (value.length === 0) {
|
|
240
|
+
clauses.push("false");
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
const keyParam = `$${index}`;
|
|
244
|
+
params.push(key);
|
|
245
|
+
index += 1;
|
|
246
|
+
const arrParam = `$${index}`;
|
|
247
|
+
params.push(value.map(String));
|
|
248
|
+
index += 1;
|
|
249
|
+
clauses.push(`(CASE WHEN jsonb_typeof(metadata->${keyParam}) = 'array' THEN metadata->${keyParam} ELSE jsonb_build_array(metadata->${keyParam}) END) ?| ${arrParam}::text[]`);
|
|
250
|
+
} else {
|
|
251
|
+
scalar[key] = value;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
if (Object.keys(scalar).length > 0) {
|
|
255
|
+
clauses.push(`metadata @> $${index}::jsonb`);
|
|
256
|
+
params.push(JSON.stringify(scalar));
|
|
257
|
+
index += 1;
|
|
258
|
+
}
|
|
259
|
+
return {
|
|
260
|
+
sql: `WHERE ${clauses.join(" AND ")}`,
|
|
261
|
+
params
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
__name(buildWhere, "buildWhere");
|
|
224
265
|
var DOCUMENT_ID_FROM_CHUNK = "regexp_replace(id, '#[0-9]+$', '')";
|
|
225
266
|
function toVectorLiteral(embedding) {
|
|
226
267
|
return `[${embedding.join(",")}]`;
|
|
@@ -228,6 +269,19 @@ function toVectorLiteral(embedding) {
|
|
|
228
269
|
__name(toVectorLiteral, "toVectorLiteral");
|
|
229
270
|
|
|
230
271
|
// src/redis-vector-store.ts
|
|
272
|
+
var RedisVectorSchemaMismatchError = class extends Error {
|
|
273
|
+
static {
|
|
274
|
+
__name(this, "RedisVectorSchemaMismatchError");
|
|
275
|
+
}
|
|
276
|
+
index;
|
|
277
|
+
field;
|
|
278
|
+
expected;
|
|
279
|
+
actual;
|
|
280
|
+
constructor(index, field, expected, actual, remedy) {
|
|
281
|
+
super(`RediSearch index "${index}" disagrees with this RedisVectorStore's configuration: field "${field}" is ${actual}, expected ${expected}. ${remedy}`), this.index = index, this.field = field, this.expected = expected, this.actual = actual;
|
|
282
|
+
this.name = "RedisVectorSchemaMismatchError";
|
|
283
|
+
}
|
|
284
|
+
};
|
|
231
285
|
var RedisVectorStore = class {
|
|
232
286
|
static {
|
|
233
287
|
__name(this, "RedisVectorStore");
|
|
@@ -244,16 +298,74 @@ var RedisVectorStore = class {
|
|
|
244
298
|
this.dimensions = options.dimensions ?? 1536;
|
|
245
299
|
this.filterableFields = options.filterableFields ?? [];
|
|
246
300
|
}
|
|
247
|
-
/**
|
|
301
|
+
/**
|
|
302
|
+
* Idempotent, and drift-aware: create the HNSW/cosine index if `FT.INFO` says it doesn't exist —
|
|
303
|
+
* and if it DOES exist, compare what's there against this store's configuration instead of
|
|
304
|
+
* assuming they agree. An index that outlives a config change is the normal case (schemas live in
|
|
305
|
+
* Redis, config lives in a redeployed process), and the two failure modes it produces are both
|
|
306
|
+
* invisible at runtime: a `filterableFields` entry added later has no `meta_*` TAG, so every search
|
|
307
|
+
* filtering on it matches *nothing*; a changed `dimensions` leaves the index on the old width, so
|
|
308
|
+
* writes are rejected or KNN ranks garbage. Neither raises anything on its own.
|
|
309
|
+
*
|
|
310
|
+
* The two are repaired differently, because only one of them CAN be repaired:
|
|
311
|
+
*
|
|
312
|
+
* - **A missing filterable TAG is additive** → `FT.ALTER … SCHEMA ADD`. Nothing that already works
|
|
313
|
+
* changes, so doing it automatically is safe. Note the chunks written *before* the alter carry no
|
|
314
|
+
* `meta_*` hash field at all (`upsert` only writes the fields configured at the time), so they
|
|
315
|
+
* only become filterable on that key once they're re-ingested — the schema is repaired here, the
|
|
316
|
+
* backfill is the host's call.
|
|
317
|
+
* - **A dimension (or field-type) change is not repairable** → {@link RedisVectorSchemaMismatchError}.
|
|
318
|
+
* It needs a drop + full reindex, which would destroy the host's corpus; failing loudly at boot is
|
|
319
|
+
* the only honest option.
|
|
320
|
+
*
|
|
321
|
+
* If `FT.INFO` comes back in a shape this parser doesn't recognise, no drift is inferred — an
|
|
322
|
+
* unreadable reply must not be turned into a false alarm that blocks a boot.
|
|
323
|
+
*/
|
|
248
324
|
async ensureSchema() {
|
|
325
|
+
let info;
|
|
249
326
|
try {
|
|
250
|
-
await this.client.sendCommand([
|
|
327
|
+
info = await this.client.sendCommand([
|
|
251
328
|
"FT.INFO",
|
|
252
329
|
this.index
|
|
253
330
|
]);
|
|
254
|
-
return;
|
|
255
331
|
} catch {
|
|
332
|
+
await this.createIndex();
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
await this.reconcileSchema(info);
|
|
336
|
+
}
|
|
337
|
+
/** Repair what can be repaired in place; throw on what can't. */
|
|
338
|
+
async reconcileSchema(info) {
|
|
339
|
+
const attributes = parseIndexAttributes(info);
|
|
340
|
+
if (attributes.size === 0) {
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
const vector = attributes.get("embedding");
|
|
344
|
+
if (vector?.dimensions !== void 0 && vector.dimensions !== this.dimensions) {
|
|
345
|
+
throw new RedisVectorSchemaMismatchError(this.index, "embedding", `DIM ${this.dimensions}`, `DIM ${vector.dimensions}`, `A vector field's width is fixed at FT.CREATE and every stored embedding has the old one, so this cannot be altered in place: drop the index (FT.DROPINDEX ${this.index} DD) and re-ingest with the new embedding model.`);
|
|
346
|
+
}
|
|
347
|
+
const missing = [];
|
|
348
|
+
for (const field of this.filterableFields) {
|
|
349
|
+
const name = `meta_${field}`;
|
|
350
|
+
const existing = attributes.get(name);
|
|
351
|
+
if (existing === void 0) {
|
|
352
|
+
missing.push(name);
|
|
353
|
+
} else if (existing.type !== "TAG") {
|
|
354
|
+
throw new RedisVectorSchemaMismatchError(this.index, name, "TAG", existing.type, `A field's type cannot be changed in place: drop the index (FT.DROPINDEX ${this.index} DD) and re-ingest, or rename the metadata key.`);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
for (const name of missing) {
|
|
358
|
+
await this.client.sendCommand([
|
|
359
|
+
"FT.ALTER",
|
|
360
|
+
this.index,
|
|
361
|
+
"SCHEMA",
|
|
362
|
+
"ADD",
|
|
363
|
+
name,
|
|
364
|
+
"TAG"
|
|
365
|
+
]);
|
|
256
366
|
}
|
|
367
|
+
}
|
|
368
|
+
async createIndex() {
|
|
257
369
|
const schema = [
|
|
258
370
|
"text",
|
|
259
371
|
"TEXT",
|
|
@@ -297,7 +409,8 @@ var RedisVectorStore = class {
|
|
|
297
409
|
for (const field of this.filterableFields) {
|
|
298
410
|
const value = record.metadata[field];
|
|
299
411
|
if (value !== void 0) {
|
|
300
|
-
|
|
412
|
+
const tag = Array.isArray(value) ? value.map(String).join(",") : String(value);
|
|
413
|
+
args.push(`meta_${field}`, tag);
|
|
301
414
|
}
|
|
302
415
|
}
|
|
303
416
|
}
|
|
@@ -333,6 +446,9 @@ var RedisVectorStore = class {
|
|
|
333
446
|
}
|
|
334
447
|
}
|
|
335
448
|
async listDocuments(filter) {
|
|
449
|
+
if (filterMatchesNothing(filter)) {
|
|
450
|
+
return [];
|
|
451
|
+
}
|
|
336
452
|
const query = buildFilter(filter);
|
|
337
453
|
const documents = /* @__PURE__ */ new Map();
|
|
338
454
|
const batchSize = 1e3;
|
|
@@ -373,6 +489,9 @@ var RedisVectorStore = class {
|
|
|
373
489
|
];
|
|
374
490
|
}
|
|
375
491
|
async search(embedding, options) {
|
|
492
|
+
if (filterMatchesNothing(options.filter)) {
|
|
493
|
+
return [];
|
|
494
|
+
}
|
|
376
495
|
const query = `${buildFilter(options.filter)}=>[KNN ${options.topK} @embedding $BLOB AS vector_score]`;
|
|
377
496
|
const reply = await this.client.sendCommand([
|
|
378
497
|
"FT.SEARCH",
|
|
@@ -400,6 +519,58 @@ var RedisVectorStore = class {
|
|
|
400
519
|
return parseSearchReply(reply, this.prefix);
|
|
401
520
|
}
|
|
402
521
|
};
|
|
522
|
+
function parseIndexAttributes(reply) {
|
|
523
|
+
const attributes = /* @__PURE__ */ new Map();
|
|
524
|
+
for (const entry of readInfoField(reply, "attributes")) {
|
|
525
|
+
const fields = readKeyValues(entry);
|
|
526
|
+
const name = fields.attribute ?? fields.identifier;
|
|
527
|
+
if (name === void 0) {
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
const dim = Number(fields.dim);
|
|
531
|
+
attributes.set(name, {
|
|
532
|
+
type: (fields.type ?? "").toUpperCase(),
|
|
533
|
+
...fields.dim !== void 0 && Number.isFinite(dim) ? {
|
|
534
|
+
dimensions: dim
|
|
535
|
+
} : {}
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
return attributes;
|
|
539
|
+
}
|
|
540
|
+
__name(parseIndexAttributes, "parseIndexAttributes");
|
|
541
|
+
function readInfoField(reply, key) {
|
|
542
|
+
if (Array.isArray(reply)) {
|
|
543
|
+
for (let index = 0; index + 1 < reply.length; index += 2) {
|
|
544
|
+
if (toStr(reply[index]) === key) {
|
|
545
|
+
const value = reply[index + 1];
|
|
546
|
+
return Array.isArray(value) ? value : [];
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
return [];
|
|
550
|
+
}
|
|
551
|
+
if (typeof reply === "object" && reply !== null && key in reply) {
|
|
552
|
+
const value = reply[key];
|
|
553
|
+
return Array.isArray(value) ? value : [];
|
|
554
|
+
}
|
|
555
|
+
return [];
|
|
556
|
+
}
|
|
557
|
+
__name(readInfoField, "readInfoField");
|
|
558
|
+
function readKeyValues(entry) {
|
|
559
|
+
const fields = {};
|
|
560
|
+
if (Array.isArray(entry)) {
|
|
561
|
+
for (let index = 0; index + 1 < entry.length; index += 2) {
|
|
562
|
+
fields[toStr(entry[index]).toLowerCase()] = toStr(entry[index + 1]);
|
|
563
|
+
}
|
|
564
|
+
return fields;
|
|
565
|
+
}
|
|
566
|
+
if (typeof entry === "object" && entry !== null) {
|
|
567
|
+
for (const [key, value] of Object.entries(entry)) {
|
|
568
|
+
fields[key.toLowerCase()] = toStr(value);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
return fields;
|
|
572
|
+
}
|
|
573
|
+
__name(readKeyValues, "readKeyValues");
|
|
403
574
|
function encodeVector(embedding) {
|
|
404
575
|
return Buffer.from(new Float32Array(embedding).buffer);
|
|
405
576
|
}
|
|
@@ -408,10 +579,23 @@ function buildFilter(filter) {
|
|
|
408
579
|
if (filter === void 0 || Object.keys(filter).length === 0) {
|
|
409
580
|
return "*";
|
|
410
581
|
}
|
|
411
|
-
const clauses = Object.entries(filter).map(([key, value]) =>
|
|
582
|
+
const clauses = Object.entries(filter).map(([key, value]) => {
|
|
583
|
+
const values = Array.isArray(value) ? value : [
|
|
584
|
+
value
|
|
585
|
+
];
|
|
586
|
+
const alternation = values.map((entry) => escapeTag(String(entry))).join("|");
|
|
587
|
+
return `@meta_${key}:{${alternation}}`;
|
|
588
|
+
});
|
|
412
589
|
return `(${clauses.join(" ")})`;
|
|
413
590
|
}
|
|
414
591
|
__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);
|
|
597
|
+
}
|
|
598
|
+
__name(filterMatchesNothing, "filterMatchesNothing");
|
|
415
599
|
function escapeTag(value) {
|
|
416
600
|
return value.replace(/[^a-zA-Z0-9_]/g, (char) => `\\${char}`);
|
|
417
601
|
}
|
|
@@ -564,10 +748,14 @@ var KeywordRetriever = class {
|
|
|
564
748
|
this.k1 = options.k1 ?? 1.5;
|
|
565
749
|
this.b = options.b ?? 0.75;
|
|
566
750
|
}
|
|
751
|
+
/** How many chunks are currently indexed. */
|
|
752
|
+
get size() {
|
|
753
|
+
return this.docs.size;
|
|
754
|
+
}
|
|
567
755
|
/** Index (or re-index) records. Re-adding an existing id replaces its posting and updates stats. */
|
|
568
756
|
add(records) {
|
|
569
757
|
for (const record of records) {
|
|
570
|
-
this.
|
|
758
|
+
this.removeChunk(record.id);
|
|
571
759
|
const tokens = tokenize(record.text);
|
|
572
760
|
const frequencies = /* @__PURE__ */ new Map();
|
|
573
761
|
for (const token of tokens) {
|
|
@@ -584,7 +772,30 @@ var KeywordRetriever = class {
|
|
|
584
772
|
this.totalLength += tokens.length;
|
|
585
773
|
}
|
|
586
774
|
}
|
|
587
|
-
|
|
775
|
+
/**
|
|
776
|
+
* Forget a source document — every chunk whose id is `${documentId}` or `${documentId}#<n>`, the
|
|
777
|
+
* same id scheme (and the same {@link documentIdOf} collapse) the vector stores use for their
|
|
778
|
+
* `remove`. The keyword half of a hybrid index keeps its own copy of the chunk text, so a document
|
|
779
|
+
* dropped from the vector store stays fully retrievable here until this is called. Unknown ids are
|
|
780
|
+
* a no-op.
|
|
781
|
+
*/
|
|
782
|
+
remove(documentId) {
|
|
783
|
+
for (const id of [
|
|
784
|
+
...this.docs.keys()
|
|
785
|
+
]) {
|
|
786
|
+
if (id === documentId || documentIdOf(id) === documentId) {
|
|
787
|
+
this.removeChunk(id);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
/** Drop every indexed chunk, resetting the corpus statistics with it. */
|
|
792
|
+
clear() {
|
|
793
|
+
this.docs.clear();
|
|
794
|
+
this.documentFrequency.clear();
|
|
795
|
+
this.totalLength = 0;
|
|
796
|
+
}
|
|
797
|
+
/** Drop ONE chunk by its exact id, decrementing the corpus statistics it contributed. */
|
|
798
|
+
removeChunk(id) {
|
|
588
799
|
const existing = this.docs.get(id);
|
|
589
800
|
if (existing === void 0) {
|
|
590
801
|
return;
|
|
@@ -833,6 +1044,7 @@ export {
|
|
|
833
1044
|
KeywordRetriever,
|
|
834
1045
|
MemoryVectorStore,
|
|
835
1046
|
PgVectorStore,
|
|
1047
|
+
RedisVectorSchemaMismatchError,
|
|
836
1048
|
RedisVectorStore,
|
|
837
1049
|
RerankingRetriever,
|
|
838
1050
|
chunkDocuments,
|