@dudousxd/nestjs-agent-rag 0.4.0 → 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 CHANGED
@@ -69,10 +69,45 @@ const store = new PgVectorStore(
69
69
  await store.ensureSchema(); // CREATE EXTENSION vector + table + HNSW cosine index
70
70
  ```
71
71
 
72
+ ## Hybrid search — and deleting from it
73
+
74
+ Hybrid search means chunking once and feeding the SAME chunks to both halves, so their ids line up
75
+ and `HybridRetriever` can fuse the rankings:
76
+
77
+ ```ts
78
+ const chunks = chunkDocuments(docs);
79
+ await ingestChunks(chunks, { embedder, store });
80
+ keyword.add(chunks);
81
+
82
+ const retriever = new HybridRetriever([new EmbeddingRetriever(embedder, store), keyword]);
83
+ ```
84
+
85
+ Because there are two indexes, **deletes have to hit both**. `KeywordRetriever` keeps its own copy of
86
+ each chunk's text, so a document dropped from the vector store alone stays fully retrievable —
87
+ `retrieve` will keep returning the removed passage:
88
+
89
+ ```ts
90
+ await store.remove(documentId);
91
+ keyword.remove(documentId); // ← the other half. Same document id; chunk ids collapse for you.
92
+ ```
93
+
94
+ `KeywordRetriever` also exposes `clear()` and `size` for rebuilding the index wholesale.
95
+
96
+ ## RediSearch store
97
+
98
+ `RedisVectorStore` needs `ensureSchema()` at boot. It creates the index if missing — and if the index
99
+ already exists, it compares it against your configuration rather than assuming they agree. A
100
+ `filterableFields` entry added after the index was created is repaired in place with
101
+ `FT.ALTER … SCHEMA ADD` (chunks written before the repair only become filterable on that key once
102
+ re-ingested). A changed `dimensions` — a model swap — can't be repaired without a full reindex, so it
103
+ throws `RedisVectorSchemaMismatchError` instead of silently leaving the index on the old width.
104
+
72
105
  ## API
73
106
 
74
107
  - `chunkText(text, { chunkSize?, overlap? })` — overlapping, boundary-aware chunks.
75
108
  - `ingestDocuments(docs, { embedder, store, chunkSize?, overlap? })` — chunk → embed → upsert.
76
109
  - `EmbeddingRetriever(embedder, store)` — the core `Retriever` from an embedder + vector store.
77
- - `MemoryVectorStore` / `PgVectorStore` — `VectorStore` adapters.
110
+ - `KeywordRetriever` — BM25 lexical half of hybrid search: `add(chunks)`, `remove(documentId)`,
111
+ `clear()`, `size`.
112
+ - `MemoryVectorStore` / `PgVectorStore` / `RedisVectorStore` — `VectorStore` adapters.
78
113
  - `createRetrievalTool(retriever, { name?, description?, topK? })` — the agentic-retrieval tool.
package/dist/index.cjs CHANGED
@@ -27,6 +27,7 @@ __export(index_exports, {
27
27
  KeywordRetriever: () => KeywordRetriever,
28
28
  MemoryVectorStore: () => MemoryVectorStore,
29
29
  PgVectorStore: () => PgVectorStore,
30
+ RedisVectorSchemaMismatchError: () => RedisVectorSchemaMismatchError,
30
31
  RedisVectorStore: () => RedisVectorStore,
31
32
  RerankingRetriever: () => RerankingRetriever,
32
33
  chunkDocuments: () => chunkDocuments,
@@ -306,6 +307,19 @@ function toVectorLiteral(embedding) {
306
307
  __name(toVectorLiteral, "toVectorLiteral");
307
308
 
308
309
  // src/redis-vector-store.ts
310
+ var RedisVectorSchemaMismatchError = class extends Error {
311
+ static {
312
+ __name(this, "RedisVectorSchemaMismatchError");
313
+ }
314
+ index;
315
+ field;
316
+ expected;
317
+ actual;
318
+ constructor(index, field, expected, actual, remedy) {
319
+ 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;
320
+ this.name = "RedisVectorSchemaMismatchError";
321
+ }
322
+ };
309
323
  var RedisVectorStore = class {
310
324
  static {
311
325
  __name(this, "RedisVectorStore");
@@ -322,16 +336,74 @@ var RedisVectorStore = class {
322
336
  this.dimensions = options.dimensions ?? 1536;
323
337
  this.filterableFields = options.filterableFields ?? [];
324
338
  }
325
- /** Idempotent: create the HNSW/cosine index if `FT.INFO` says it doesn't exist yet. */
339
+ /**
340
+ * Idempotent, and drift-aware: create the HNSW/cosine index if `FT.INFO` says it doesn't exist —
341
+ * and if it DOES exist, compare what's there against this store's configuration instead of
342
+ * assuming they agree. An index that outlives a config change is the normal case (schemas live in
343
+ * Redis, config lives in a redeployed process), and the two failure modes it produces are both
344
+ * invisible at runtime: a `filterableFields` entry added later has no `meta_*` TAG, so every search
345
+ * filtering on it matches *nothing*; a changed `dimensions` leaves the index on the old width, so
346
+ * writes are rejected or KNN ranks garbage. Neither raises anything on its own.
347
+ *
348
+ * The two are repaired differently, because only one of them CAN be repaired:
349
+ *
350
+ * - **A missing filterable TAG is additive** → `FT.ALTER … SCHEMA ADD`. Nothing that already works
351
+ * changes, so doing it automatically is safe. Note the chunks written *before* the alter carry no
352
+ * `meta_*` hash field at all (`upsert` only writes the fields configured at the time), so they
353
+ * only become filterable on that key once they're re-ingested — the schema is repaired here, the
354
+ * backfill is the host's call.
355
+ * - **A dimension (or field-type) change is not repairable** → {@link RedisVectorSchemaMismatchError}.
356
+ * It needs a drop + full reindex, which would destroy the host's corpus; failing loudly at boot is
357
+ * the only honest option.
358
+ *
359
+ * If `FT.INFO` comes back in a shape this parser doesn't recognise, no drift is inferred — an
360
+ * unreadable reply must not be turned into a false alarm that blocks a boot.
361
+ */
326
362
  async ensureSchema() {
363
+ let info;
327
364
  try {
328
- await this.client.sendCommand([
365
+ info = await this.client.sendCommand([
329
366
  "FT.INFO",
330
367
  this.index
331
368
  ]);
332
- return;
333
369
  } catch {
370
+ await this.createIndex();
371
+ return;
372
+ }
373
+ await this.reconcileSchema(info);
374
+ }
375
+ /** Repair what can be repaired in place; throw on what can't. */
376
+ async reconcileSchema(info) {
377
+ const attributes = parseIndexAttributes(info);
378
+ if (attributes.size === 0) {
379
+ return;
334
380
  }
381
+ const vector = attributes.get("embedding");
382
+ if (vector?.dimensions !== void 0 && vector.dimensions !== this.dimensions) {
383
+ 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.`);
384
+ }
385
+ const missing = [];
386
+ for (const field of this.filterableFields) {
387
+ const name = `meta_${field}`;
388
+ const existing = attributes.get(name);
389
+ if (existing === void 0) {
390
+ missing.push(name);
391
+ } else if (existing.type !== "TAG") {
392
+ 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.`);
393
+ }
394
+ }
395
+ for (const name of missing) {
396
+ await this.client.sendCommand([
397
+ "FT.ALTER",
398
+ this.index,
399
+ "SCHEMA",
400
+ "ADD",
401
+ name,
402
+ "TAG"
403
+ ]);
404
+ }
405
+ }
406
+ async createIndex() {
335
407
  const schema = [
336
408
  "text",
337
409
  "TEXT",
@@ -485,6 +557,58 @@ var RedisVectorStore = class {
485
557
  return parseSearchReply(reply, this.prefix);
486
558
  }
487
559
  };
560
+ function parseIndexAttributes(reply) {
561
+ const attributes = /* @__PURE__ */ new Map();
562
+ for (const entry of readInfoField(reply, "attributes")) {
563
+ const fields = readKeyValues(entry);
564
+ const name = fields.attribute ?? fields.identifier;
565
+ if (name === void 0) {
566
+ continue;
567
+ }
568
+ const dim = Number(fields.dim);
569
+ attributes.set(name, {
570
+ type: (fields.type ?? "").toUpperCase(),
571
+ ...fields.dim !== void 0 && Number.isFinite(dim) ? {
572
+ dimensions: dim
573
+ } : {}
574
+ });
575
+ }
576
+ return attributes;
577
+ }
578
+ __name(parseIndexAttributes, "parseIndexAttributes");
579
+ function readInfoField(reply, key) {
580
+ if (Array.isArray(reply)) {
581
+ for (let index = 0; index + 1 < reply.length; index += 2) {
582
+ if (toStr(reply[index]) === key) {
583
+ const value = reply[index + 1];
584
+ return Array.isArray(value) ? value : [];
585
+ }
586
+ }
587
+ return [];
588
+ }
589
+ if (typeof reply === "object" && reply !== null && key in reply) {
590
+ const value = reply[key];
591
+ return Array.isArray(value) ? value : [];
592
+ }
593
+ return [];
594
+ }
595
+ __name(readInfoField, "readInfoField");
596
+ function readKeyValues(entry) {
597
+ const fields = {};
598
+ if (Array.isArray(entry)) {
599
+ for (let index = 0; index + 1 < entry.length; index += 2) {
600
+ fields[toStr(entry[index]).toLowerCase()] = toStr(entry[index + 1]);
601
+ }
602
+ return fields;
603
+ }
604
+ if (typeof entry === "object" && entry !== null) {
605
+ for (const [key, value] of Object.entries(entry)) {
606
+ fields[key.toLowerCase()] = toStr(value);
607
+ }
608
+ }
609
+ return fields;
610
+ }
611
+ __name(readKeyValues, "readKeyValues");
488
612
  function encodeVector(embedding) {
489
613
  return Buffer.from(new Float32Array(embedding).buffer);
490
614
  }
@@ -662,10 +786,14 @@ var KeywordRetriever = class {
662
786
  this.k1 = options.k1 ?? 1.5;
663
787
  this.b = options.b ?? 0.75;
664
788
  }
789
+ /** How many chunks are currently indexed. */
790
+ get size() {
791
+ return this.docs.size;
792
+ }
665
793
  /** Index (or re-index) records. Re-adding an existing id replaces its posting and updates stats. */
666
794
  add(records) {
667
795
  for (const record of records) {
668
- this.remove(record.id);
796
+ this.removeChunk(record.id);
669
797
  const tokens = tokenize(record.text);
670
798
  const frequencies = /* @__PURE__ */ new Map();
671
799
  for (const token of tokens) {
@@ -682,7 +810,30 @@ var KeywordRetriever = class {
682
810
  this.totalLength += tokens.length;
683
811
  }
684
812
  }
685
- remove(id) {
813
+ /**
814
+ * Forget a source document — every chunk whose id is `${documentId}` or `${documentId}#<n>`, the
815
+ * same id scheme (and the same {@link documentIdOf} collapse) the vector stores use for their
816
+ * `remove`. The keyword half of a hybrid index keeps its own copy of the chunk text, so a document
817
+ * dropped from the vector store stays fully retrievable here until this is called. Unknown ids are
818
+ * a no-op.
819
+ */
820
+ remove(documentId) {
821
+ for (const id of [
822
+ ...this.docs.keys()
823
+ ]) {
824
+ if (id === documentId || documentIdOf(id) === documentId) {
825
+ this.removeChunk(id);
826
+ }
827
+ }
828
+ }
829
+ /** Drop every indexed chunk, resetting the corpus statistics with it. */
830
+ clear() {
831
+ this.docs.clear();
832
+ this.documentFrequency.clear();
833
+ this.totalLength = 0;
834
+ }
835
+ /** Drop ONE chunk by its exact id, decrementing the corpus statistics it contributed. */
836
+ removeChunk(id) {
686
837
  const existing = this.docs.get(id);
687
838
  if (existing === void 0) {
688
839
  return;
@@ -932,6 +1083,7 @@ __name(createRetrievalTool, "createRetrievalTool");
932
1083
  KeywordRetriever,
933
1084
  MemoryVectorStore,
934
1085
  PgVectorStore,
1086
+ RedisVectorSchemaMismatchError,
935
1087
  RedisVectorStore,
936
1088
  RerankingRetriever,
937
1089
  chunkDocuments,