@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/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
- /** Idempotent: create the HNSW/cosine index if `FT.INFO` says it doesn't exist yet. */
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 += 2) {
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
- passages.push(toPassage(toStr(reply[index]), attrs, prefix));
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 distance = Number(attrs.vector_score ?? "1");
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: 1 - distance,
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.remove(record.id);
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
- remove(id) {
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