@dudousxd/nestjs-agent-rag 0.4.0 → 0.5.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 +122 -3
- package/dist/index.cjs +250 -15
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +177 -4
- package/dist/index.d.ts +177 -4
- package/dist/index.js +246 -14
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -40,6 +40,10 @@ 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");
|
|
43
47
|
function documentIdOf(chunkId) {
|
|
44
48
|
return chunkId.replace(/#\d+$/, "");
|
|
45
49
|
}
|
|
@@ -269,6 +273,19 @@ function toVectorLiteral(embedding) {
|
|
|
269
273
|
__name(toVectorLiteral, "toVectorLiteral");
|
|
270
274
|
|
|
271
275
|
// src/redis-vector-store.ts
|
|
276
|
+
var RedisVectorSchemaMismatchError = class extends Error {
|
|
277
|
+
static {
|
|
278
|
+
__name(this, "RedisVectorSchemaMismatchError");
|
|
279
|
+
}
|
|
280
|
+
index;
|
|
281
|
+
field;
|
|
282
|
+
expected;
|
|
283
|
+
actual;
|
|
284
|
+
constructor(index, field, expected, actual, remedy) {
|
|
285
|
+
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;
|
|
286
|
+
this.name = "RedisVectorSchemaMismatchError";
|
|
287
|
+
}
|
|
288
|
+
};
|
|
272
289
|
var RedisVectorStore = class {
|
|
273
290
|
static {
|
|
274
291
|
__name(this, "RedisVectorStore");
|
|
@@ -278,23 +295,83 @@ var RedisVectorStore = class {
|
|
|
278
295
|
prefix;
|
|
279
296
|
dimensions;
|
|
280
297
|
filterableFields;
|
|
298
|
+
lexicalScorer;
|
|
281
299
|
constructor(client, options = {}) {
|
|
282
300
|
this.client = client;
|
|
283
301
|
this.index = options.index ?? "agent_rag_idx";
|
|
284
302
|
this.prefix = options.prefix ?? "agent_rag:";
|
|
285
303
|
this.dimensions = options.dimensions ?? 1536;
|
|
286
304
|
this.filterableFields = options.filterableFields ?? [];
|
|
305
|
+
this.lexicalScorer = options.lexicalScorer ?? "BM25";
|
|
287
306
|
}
|
|
288
|
-
/**
|
|
307
|
+
/**
|
|
308
|
+
* Idempotent, and drift-aware: create the HNSW/cosine index if `FT.INFO` says it doesn't exist —
|
|
309
|
+
* and if it DOES exist, compare what's there against this store's configuration instead of
|
|
310
|
+
* assuming they agree. An index that outlives a config change is the normal case (schemas live in
|
|
311
|
+
* Redis, config lives in a redeployed process), and the two failure modes it produces are both
|
|
312
|
+
* invisible at runtime: a `filterableFields` entry added later has no `meta_*` TAG, so every search
|
|
313
|
+
* filtering on it matches *nothing*; a changed `dimensions` leaves the index on the old width, so
|
|
314
|
+
* writes are rejected or KNN ranks garbage. Neither raises anything on its own.
|
|
315
|
+
*
|
|
316
|
+
* The two are repaired differently, because only one of them CAN be repaired:
|
|
317
|
+
*
|
|
318
|
+
* - **A missing filterable TAG is additive** → `FT.ALTER … SCHEMA ADD`. Nothing that already works
|
|
319
|
+
* changes, so doing it automatically is safe. Note the chunks written *before* the alter carry no
|
|
320
|
+
* `meta_*` hash field at all (`upsert` only writes the fields configured at the time), so they
|
|
321
|
+
* only become filterable on that key once they're re-ingested — the schema is repaired here, the
|
|
322
|
+
* backfill is the host's call.
|
|
323
|
+
* - **A dimension (or field-type) change is not repairable** → {@link RedisVectorSchemaMismatchError}.
|
|
324
|
+
* It needs a drop + full reindex, which would destroy the host's corpus; failing loudly at boot is
|
|
325
|
+
* the only honest option.
|
|
326
|
+
*
|
|
327
|
+
* If `FT.INFO` comes back in a shape this parser doesn't recognise, no drift is inferred — an
|
|
328
|
+
* unreadable reply must not be turned into a false alarm that blocks a boot.
|
|
329
|
+
*/
|
|
289
330
|
async ensureSchema() {
|
|
331
|
+
let info;
|
|
290
332
|
try {
|
|
291
|
-
await this.client.sendCommand([
|
|
333
|
+
info = await this.client.sendCommand([
|
|
292
334
|
"FT.INFO",
|
|
293
335
|
this.index
|
|
294
336
|
]);
|
|
295
|
-
return;
|
|
296
337
|
} catch {
|
|
338
|
+
await this.createIndex();
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
await this.reconcileSchema(info);
|
|
342
|
+
}
|
|
343
|
+
/** Repair what can be repaired in place; throw on what can't. */
|
|
344
|
+
async reconcileSchema(info) {
|
|
345
|
+
const attributes = parseIndexAttributes(info);
|
|
346
|
+
if (attributes.size === 0) {
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
const vector = attributes.get("embedding");
|
|
350
|
+
if (vector?.dimensions !== void 0 && vector.dimensions !== this.dimensions) {
|
|
351
|
+
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.`);
|
|
352
|
+
}
|
|
353
|
+
const missing = [];
|
|
354
|
+
for (const field of this.filterableFields) {
|
|
355
|
+
const name = `meta_${field}`;
|
|
356
|
+
const existing = attributes.get(name);
|
|
357
|
+
if (existing === void 0) {
|
|
358
|
+
missing.push(name);
|
|
359
|
+
} else if (existing.type !== "TAG") {
|
|
360
|
+
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.`);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
for (const name of missing) {
|
|
364
|
+
await this.client.sendCommand([
|
|
365
|
+
"FT.ALTER",
|
|
366
|
+
this.index,
|
|
367
|
+
"SCHEMA",
|
|
368
|
+
"ADD",
|
|
369
|
+
name,
|
|
370
|
+
"TAG"
|
|
371
|
+
]);
|
|
297
372
|
}
|
|
373
|
+
}
|
|
374
|
+
async createIndex() {
|
|
298
375
|
const schema = [
|
|
299
376
|
"text",
|
|
300
377
|
"TEXT",
|
|
@@ -447,7 +524,100 @@ var RedisVectorStore = class {
|
|
|
447
524
|
]);
|
|
448
525
|
return parseSearchReply(reply, this.prefix);
|
|
449
526
|
}
|
|
527
|
+
/**
|
|
528
|
+
* Lexical (BM25) search over the same index `search` uses — the {@link LexicalVectorStore} half of
|
|
529
|
+
* hybrid retrieval, served by RediSearch's own keyword index rather than an in-process copy of the
|
|
530
|
+
* corpus. `options.filter` is ANDed onto the text clause with the identical TAG semantics as
|
|
531
|
+
* `search` (array = OR, empty array = deny), so a lexical hit can never escape a filter a vector
|
|
532
|
+
* hit would have respected.
|
|
533
|
+
*
|
|
534
|
+
* A query with no searchable terms — empty, whitespace, or pure punctuation — returns `[]`. That is
|
|
535
|
+
* deliberate: RediSearch's `*` would match the entire corpus, which is the opposite of what a
|
|
536
|
+
* relevance query with nothing to be relevant to should do.
|
|
537
|
+
*/
|
|
538
|
+
async searchText(query, options) {
|
|
539
|
+
if (filterMatchesNothing(options.filter)) {
|
|
540
|
+
return [];
|
|
541
|
+
}
|
|
542
|
+
const terms = tokenizeQuery(query);
|
|
543
|
+
if (terms.length === 0) {
|
|
544
|
+
return [];
|
|
545
|
+
}
|
|
546
|
+
const filterQuery = buildFilter(options.filter);
|
|
547
|
+
const textQuery = `@text:(${terms.join("|")})`;
|
|
548
|
+
const reply = await this.client.sendCommand([
|
|
549
|
+
"FT.SEARCH",
|
|
550
|
+
this.index,
|
|
551
|
+
filterQuery === "*" ? textQuery : `(${filterQuery} ${textQuery})`,
|
|
552
|
+
"SCORER",
|
|
553
|
+
this.lexicalScorer,
|
|
554
|
+
"WITHSCORES",
|
|
555
|
+
"RETURN",
|
|
556
|
+
"3",
|
|
557
|
+
"text",
|
|
558
|
+
"source",
|
|
559
|
+
"metadata_json",
|
|
560
|
+
"DIALECT",
|
|
561
|
+
"2",
|
|
562
|
+
"LIMIT",
|
|
563
|
+
"0",
|
|
564
|
+
String(options.topK)
|
|
565
|
+
]);
|
|
566
|
+
return parseSearchReply(reply, this.prefix, "lexical");
|
|
567
|
+
}
|
|
450
568
|
};
|
|
569
|
+
function parseIndexAttributes(reply) {
|
|
570
|
+
const attributes = /* @__PURE__ */ new Map();
|
|
571
|
+
for (const entry of readInfoField(reply, "attributes")) {
|
|
572
|
+
const fields = readKeyValues(entry);
|
|
573
|
+
const name = fields.attribute ?? fields.identifier;
|
|
574
|
+
if (name === void 0) {
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
const dim = Number(fields.dim);
|
|
578
|
+
attributes.set(name, {
|
|
579
|
+
type: (fields.type ?? "").toUpperCase(),
|
|
580
|
+
...fields.dim !== void 0 && Number.isFinite(dim) ? {
|
|
581
|
+
dimensions: dim
|
|
582
|
+
} : {}
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
return attributes;
|
|
586
|
+
}
|
|
587
|
+
__name(parseIndexAttributes, "parseIndexAttributes");
|
|
588
|
+
function readInfoField(reply, key) {
|
|
589
|
+
if (Array.isArray(reply)) {
|
|
590
|
+
for (let index = 0; index + 1 < reply.length; index += 2) {
|
|
591
|
+
if (toStr(reply[index]) === key) {
|
|
592
|
+
const value = reply[index + 1];
|
|
593
|
+
return Array.isArray(value) ? value : [];
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
return [];
|
|
597
|
+
}
|
|
598
|
+
if (typeof reply === "object" && reply !== null && key in reply) {
|
|
599
|
+
const value = reply[key];
|
|
600
|
+
return Array.isArray(value) ? value : [];
|
|
601
|
+
}
|
|
602
|
+
return [];
|
|
603
|
+
}
|
|
604
|
+
__name(readInfoField, "readInfoField");
|
|
605
|
+
function readKeyValues(entry) {
|
|
606
|
+
const fields = {};
|
|
607
|
+
if (Array.isArray(entry)) {
|
|
608
|
+
for (let index = 0; index + 1 < entry.length; index += 2) {
|
|
609
|
+
fields[toStr(entry[index]).toLowerCase()] = toStr(entry[index + 1]);
|
|
610
|
+
}
|
|
611
|
+
return fields;
|
|
612
|
+
}
|
|
613
|
+
if (typeof entry === "object" && entry !== null) {
|
|
614
|
+
for (const [key, value] of Object.entries(entry)) {
|
|
615
|
+
fields[key.toLowerCase()] = toStr(value);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
return fields;
|
|
619
|
+
}
|
|
620
|
+
__name(readKeyValues, "readKeyValues");
|
|
451
621
|
function encodeVector(embedding) {
|
|
452
622
|
return Buffer.from(new Float32Array(embedding).buffer);
|
|
453
623
|
}
|
|
@@ -473,6 +643,10 @@ function filterMatchesNothing(filter) {
|
|
|
473
643
|
return Object.values(filter).some((value) => Array.isArray(value) && value.length === 0);
|
|
474
644
|
}
|
|
475
645
|
__name(filterMatchesNothing, "filterMatchesNothing");
|
|
646
|
+
function tokenizeQuery(query) {
|
|
647
|
+
return query.match(/[\p{L}\p{N}_]+/gu) ?? [];
|
|
648
|
+
}
|
|
649
|
+
__name(tokenizeQuery, "tokenizeQuery");
|
|
476
650
|
function escapeTag(value) {
|
|
477
651
|
return value.replace(/[^a-zA-Z0-9_]/g, (char) => `\\${char}`);
|
|
478
652
|
}
|
|
@@ -509,17 +683,19 @@ function toStr(value) {
|
|
|
509
683
|
return Buffer.isBuffer(value) ? value.toString("utf8") : String(value);
|
|
510
684
|
}
|
|
511
685
|
__name(toStr, "toStr");
|
|
512
|
-
function parseSearchReply(reply, prefix) {
|
|
686
|
+
function parseSearchReply(reply, prefix, scoreSource = "vector") {
|
|
687
|
+
const stride = scoreSource === "lexical" ? 3 : 2;
|
|
513
688
|
if (Array.isArray(reply)) {
|
|
514
689
|
const passages = [];
|
|
515
|
-
for (let index = 1; index + 1 < reply.length; index +=
|
|
516
|
-
const rawFields = reply[index + 1];
|
|
690
|
+
for (let index = 1; index + stride - 1 < reply.length; index += stride) {
|
|
691
|
+
const rawFields = reply[index + stride - 1];
|
|
517
692
|
if (Array.isArray(rawFields)) {
|
|
518
693
|
const attrs = {};
|
|
519
694
|
for (let field = 0; field + 1 < rawFields.length; field += 2) {
|
|
520
695
|
attrs[toStr(rawFields[field])] = toStr(rawFields[field + 1]);
|
|
521
696
|
}
|
|
522
|
-
|
|
697
|
+
const inlineScore = scoreSource === "lexical" ? toStr(reply[index + 1]) : void 0;
|
|
698
|
+
passages.push(toPassage(toStr(reply[index]), attrs, prefix, scoreSource, inlineScore));
|
|
523
699
|
}
|
|
524
700
|
}
|
|
525
701
|
return passages;
|
|
@@ -527,12 +703,19 @@ function parseSearchReply(reply, prefix) {
|
|
|
527
703
|
if (typeof reply === "object" && reply !== null && "results" in reply) {
|
|
528
704
|
const results = reply.results;
|
|
529
705
|
if (Array.isArray(results)) {
|
|
530
|
-
return results.map((result) => toPassage(readId(result), readAttributes(result), prefix));
|
|
706
|
+
return results.map((result) => toPassage(readId(result), readAttributes(result), prefix, scoreSource, scoreSource === "lexical" ? readScore(result) : void 0));
|
|
531
707
|
}
|
|
532
708
|
}
|
|
533
709
|
return [];
|
|
534
710
|
}
|
|
535
711
|
__name(parseSearchReply, "parseSearchReply");
|
|
712
|
+
function readScore(result) {
|
|
713
|
+
if (typeof result === "object" && result !== null && "score" in result) {
|
|
714
|
+
return toStr(result.score);
|
|
715
|
+
}
|
|
716
|
+
return void 0;
|
|
717
|
+
}
|
|
718
|
+
__name(readScore, "readScore");
|
|
536
719
|
function readId(result) {
|
|
537
720
|
if (typeof result === "object" && result !== null && "id" in result) {
|
|
538
721
|
return toStr(result.id);
|
|
@@ -553,13 +736,13 @@ function readAttributes(result) {
|
|
|
553
736
|
return attrs;
|
|
554
737
|
}
|
|
555
738
|
__name(readAttributes, "readAttributes");
|
|
556
|
-
function toPassage(rawId, attrs, prefix) {
|
|
557
|
-
const
|
|
739
|
+
function toPassage(rawId, attrs, prefix, scoreSource = "vector", inlineScore) {
|
|
740
|
+
const score = scoreSource === "lexical" ? Number(inlineScore ?? "0") : 1 - Number(attrs.vector_score ?? "1");
|
|
558
741
|
const source = attrs.source;
|
|
559
742
|
return {
|
|
560
743
|
id: rawId.startsWith(prefix) ? rawId.slice(prefix.length) : rawId,
|
|
561
744
|
text: attrs.text ?? "",
|
|
562
|
-
score:
|
|
745
|
+
score: Number.isFinite(score) ? score : 0,
|
|
563
746
|
...source !== void 0 ? {
|
|
564
747
|
source
|
|
565
748
|
} : {},
|
|
@@ -625,10 +808,14 @@ var KeywordRetriever = class {
|
|
|
625
808
|
this.k1 = options.k1 ?? 1.5;
|
|
626
809
|
this.b = options.b ?? 0.75;
|
|
627
810
|
}
|
|
811
|
+
/** How many chunks are currently indexed. */
|
|
812
|
+
get size() {
|
|
813
|
+
return this.docs.size;
|
|
814
|
+
}
|
|
628
815
|
/** Index (or re-index) records. Re-adding an existing id replaces its posting and updates stats. */
|
|
629
816
|
add(records) {
|
|
630
817
|
for (const record of records) {
|
|
631
|
-
this.
|
|
818
|
+
this.removeChunk(record.id);
|
|
632
819
|
const tokens = tokenize(record.text);
|
|
633
820
|
const frequencies = /* @__PURE__ */ new Map();
|
|
634
821
|
for (const token of tokens) {
|
|
@@ -645,7 +832,30 @@ var KeywordRetriever = class {
|
|
|
645
832
|
this.totalLength += tokens.length;
|
|
646
833
|
}
|
|
647
834
|
}
|
|
648
|
-
|
|
835
|
+
/**
|
|
836
|
+
* Forget a source document — every chunk whose id is `${documentId}` or `${documentId}#<n>`, the
|
|
837
|
+
* same id scheme (and the same {@link documentIdOf} collapse) the vector stores use for their
|
|
838
|
+
* `remove`. The keyword half of a hybrid index keeps its own copy of the chunk text, so a document
|
|
839
|
+
* dropped from the vector store stays fully retrievable here until this is called. Unknown ids are
|
|
840
|
+
* a no-op.
|
|
841
|
+
*/
|
|
842
|
+
remove(documentId) {
|
|
843
|
+
for (const id of [
|
|
844
|
+
...this.docs.keys()
|
|
845
|
+
]) {
|
|
846
|
+
if (id === documentId || documentIdOf(id) === documentId) {
|
|
847
|
+
this.removeChunk(id);
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
/** Drop every indexed chunk, resetting the corpus statistics with it. */
|
|
852
|
+
clear() {
|
|
853
|
+
this.docs.clear();
|
|
854
|
+
this.documentFrequency.clear();
|
|
855
|
+
this.totalLength = 0;
|
|
856
|
+
}
|
|
857
|
+
/** Drop ONE chunk by its exact id, decrementing the corpus statistics it contributed. */
|
|
858
|
+
removeChunk(id) {
|
|
649
859
|
const existing = this.docs.get(id);
|
|
650
860
|
if (existing === void 0) {
|
|
651
861
|
return;
|
|
@@ -706,6 +916,25 @@ function tokenize(text) {
|
|
|
706
916
|
}
|
|
707
917
|
__name(tokenize, "tokenize");
|
|
708
918
|
|
|
919
|
+
// src/lexical-retriever.ts
|
|
920
|
+
var LexicalRetriever = class {
|
|
921
|
+
static {
|
|
922
|
+
__name(this, "LexicalRetriever");
|
|
923
|
+
}
|
|
924
|
+
store;
|
|
925
|
+
constructor(store) {
|
|
926
|
+
this.store = store;
|
|
927
|
+
}
|
|
928
|
+
async retrieve(query, options = {}) {
|
|
929
|
+
return this.store.searchText(query, {
|
|
930
|
+
topK: options.topK ?? 5,
|
|
931
|
+
...options.filter !== void 0 ? {
|
|
932
|
+
filter: options.filter
|
|
933
|
+
} : {}
|
|
934
|
+
});
|
|
935
|
+
}
|
|
936
|
+
};
|
|
937
|
+
|
|
709
938
|
// src/hybrid-retriever.ts
|
|
710
939
|
var HybridRetriever = class {
|
|
711
940
|
static {
|
|
@@ -892,8 +1121,10 @@ export {
|
|
|
892
1121
|
FilteredRetriever,
|
|
893
1122
|
HybridRetriever,
|
|
894
1123
|
KeywordRetriever,
|
|
1124
|
+
LexicalRetriever,
|
|
895
1125
|
MemoryVectorStore,
|
|
896
1126
|
PgVectorStore,
|
|
1127
|
+
RedisVectorSchemaMismatchError,
|
|
897
1128
|
RedisVectorStore,
|
|
898
1129
|
RerankingRetriever,
|
|
899
1130
|
chunkDocuments,
|
|
@@ -901,6 +1132,7 @@ export {
|
|
|
901
1132
|
createRetrievalTool,
|
|
902
1133
|
documentIdOf,
|
|
903
1134
|
ingestChunks,
|
|
904
|
-
ingestDocuments
|
|
1135
|
+
ingestDocuments,
|
|
1136
|
+
isLexicalVectorStore
|
|
905
1137
|
};
|
|
906
1138
|
//# sourceMappingURL=index.js.map
|