@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 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,
@@ -84,10 +85,17 @@ __name(documentIdOf, "documentIdOf");
84
85
 
85
86
  // src/filter.ts
86
87
  function matchesFilter(metadata, filter) {
88
+ const entries = Object.entries(filter);
87
89
  if (metadata === void 0) {
88
- return Object.keys(filter).length === 0;
90
+ return entries.length === 0;
89
91
  }
90
- return Object.entries(filter).every(([key, value]) => metadata[key] === value);
92
+ return entries.every(([key, expected]) => {
93
+ if (Array.isArray(expected)) {
94
+ const actual = metadata[key];
95
+ return Array.isArray(actual) ? actual.some((value) => expected.includes(value)) : expected.includes(actual);
96
+ }
97
+ return metadata[key] === expected;
98
+ });
91
99
  }
92
100
  __name(matchesFilter, "matchesFilter");
93
101
 
@@ -216,13 +224,11 @@ var PgVectorStore = class {
216
224
  ]);
217
225
  }
218
226
  async listDocuments(filter) {
219
- const hasFilter = filter !== void 0 && Object.keys(filter).length > 0;
227
+ const where = buildWhere(filter, 1);
220
228
  const rows = await this.client.query(`SELECT DISTINCT ON (${DOCUMENT_ID_FROM_CHUNK}) ${DOCUMENT_ID_FROM_CHUNK} AS doc_id, metadata
221
229
  FROM ${this.table}
222
- ${hasFilter ? "WHERE metadata @> $1::jsonb" : ""}
223
- ORDER BY ${DOCUMENT_ID_FROM_CHUNK}`, hasFilter ? [
224
- JSON.stringify(filter)
225
- ] : []);
230
+ ${where.sql}
231
+ ORDER BY ${DOCUMENT_ID_FROM_CHUNK}`, where.params);
226
232
  return rows.map((row) => ({
227
233
  id: row.doc_id,
228
234
  ...row.metadata !== null ? {
@@ -232,18 +238,15 @@ var PgVectorStore = class {
232
238
  }
233
239
  async search(embedding, options) {
234
240
  const vector = toVectorLiteral(embedding);
235
- const hasFilter = options.filter !== void 0 && Object.keys(options.filter).length > 0;
241
+ const where = buildWhere(options.filter, 3);
236
242
  const rows = await this.client.query(`SELECT id, text, source, metadata, 1 - (embedding <=> $1::vector) AS score
237
243
  FROM ${this.table}
238
- ${hasFilter ? "WHERE metadata @> $3::jsonb" : ""}
244
+ ${where.sql}
239
245
  ORDER BY embedding <=> $1::vector
240
- LIMIT $2`, hasFilter ? [
246
+ LIMIT $2`, [
241
247
  vector,
242
248
  options.topK,
243
- JSON.stringify(options.filter)
244
- ] : [
245
- vector,
246
- options.topK
249
+ ...where.params
247
250
  ]);
248
251
  return rows.map((row) => ({
249
252
  id: row.id,
@@ -258,6 +261,45 @@ var PgVectorStore = class {
258
261
  }));
259
262
  }
260
263
  };
264
+ function buildWhere(filter, startIndex) {
265
+ if (filter === void 0 || Object.keys(filter).length === 0) {
266
+ return {
267
+ sql: "",
268
+ params: []
269
+ };
270
+ }
271
+ const clauses = [];
272
+ const params = [];
273
+ const scalar = {};
274
+ let index = startIndex;
275
+ for (const [key, value] of Object.entries(filter)) {
276
+ if (Array.isArray(value)) {
277
+ if (value.length === 0) {
278
+ clauses.push("false");
279
+ continue;
280
+ }
281
+ const keyParam = `$${index}`;
282
+ params.push(key);
283
+ index += 1;
284
+ const arrParam = `$${index}`;
285
+ params.push(value.map(String));
286
+ index += 1;
287
+ clauses.push(`(CASE WHEN jsonb_typeof(metadata->${keyParam}) = 'array' THEN metadata->${keyParam} ELSE jsonb_build_array(metadata->${keyParam}) END) ?| ${arrParam}::text[]`);
288
+ } else {
289
+ scalar[key] = value;
290
+ }
291
+ }
292
+ if (Object.keys(scalar).length > 0) {
293
+ clauses.push(`metadata @> $${index}::jsonb`);
294
+ params.push(JSON.stringify(scalar));
295
+ index += 1;
296
+ }
297
+ return {
298
+ sql: `WHERE ${clauses.join(" AND ")}`,
299
+ params
300
+ };
301
+ }
302
+ __name(buildWhere, "buildWhere");
261
303
  var DOCUMENT_ID_FROM_CHUNK = "regexp_replace(id, '#[0-9]+$', '')";
262
304
  function toVectorLiteral(embedding) {
263
305
  return `[${embedding.join(",")}]`;
@@ -265,6 +307,19 @@ function toVectorLiteral(embedding) {
265
307
  __name(toVectorLiteral, "toVectorLiteral");
266
308
 
267
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
+ };
268
323
  var RedisVectorStore = class {
269
324
  static {
270
325
  __name(this, "RedisVectorStore");
@@ -281,16 +336,74 @@ var RedisVectorStore = class {
281
336
  this.dimensions = options.dimensions ?? 1536;
282
337
  this.filterableFields = options.filterableFields ?? [];
283
338
  }
284
- /** 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
+ */
285
362
  async ensureSchema() {
363
+ let info;
286
364
  try {
287
- await this.client.sendCommand([
365
+ info = await this.client.sendCommand([
288
366
  "FT.INFO",
289
367
  this.index
290
368
  ]);
291
- return;
292
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;
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
+ ]);
293
404
  }
405
+ }
406
+ async createIndex() {
294
407
  const schema = [
295
408
  "text",
296
409
  "TEXT",
@@ -334,7 +447,8 @@ var RedisVectorStore = class {
334
447
  for (const field of this.filterableFields) {
335
448
  const value = record.metadata[field];
336
449
  if (value !== void 0) {
337
- args.push(`meta_${field}`, String(value));
450
+ const tag = Array.isArray(value) ? value.map(String).join(",") : String(value);
451
+ args.push(`meta_${field}`, tag);
338
452
  }
339
453
  }
340
454
  }
@@ -370,6 +484,9 @@ var RedisVectorStore = class {
370
484
  }
371
485
  }
372
486
  async listDocuments(filter) {
487
+ if (filterMatchesNothing(filter)) {
488
+ return [];
489
+ }
373
490
  const query = buildFilter(filter);
374
491
  const documents = /* @__PURE__ */ new Map();
375
492
  const batchSize = 1e3;
@@ -410,6 +527,9 @@ var RedisVectorStore = class {
410
527
  ];
411
528
  }
412
529
  async search(embedding, options) {
530
+ if (filterMatchesNothing(options.filter)) {
531
+ return [];
532
+ }
413
533
  const query = `${buildFilter(options.filter)}=>[KNN ${options.topK} @embedding $BLOB AS vector_score]`;
414
534
  const reply = await this.client.sendCommand([
415
535
  "FT.SEARCH",
@@ -437,6 +557,58 @@ var RedisVectorStore = class {
437
557
  return parseSearchReply(reply, this.prefix);
438
558
  }
439
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");
440
612
  function encodeVector(embedding) {
441
613
  return Buffer.from(new Float32Array(embedding).buffer);
442
614
  }
@@ -445,10 +617,23 @@ function buildFilter(filter) {
445
617
  if (filter === void 0 || Object.keys(filter).length === 0) {
446
618
  return "*";
447
619
  }
448
- const clauses = Object.entries(filter).map(([key, value]) => `@meta_${key}:{${escapeTag(String(value))}}`);
620
+ const clauses = Object.entries(filter).map(([key, value]) => {
621
+ const values = Array.isArray(value) ? value : [
622
+ value
623
+ ];
624
+ const alternation = values.map((entry) => escapeTag(String(entry))).join("|");
625
+ return `@meta_${key}:{${alternation}}`;
626
+ });
449
627
  return `(${clauses.join(" ")})`;
450
628
  }
451
629
  __name(buildFilter, "buildFilter");
630
+ function filterMatchesNothing(filter) {
631
+ if (filter === void 0) {
632
+ return false;
633
+ }
634
+ return Object.values(filter).some((value) => Array.isArray(value) && value.length === 0);
635
+ }
636
+ __name(filterMatchesNothing, "filterMatchesNothing");
452
637
  function escapeTag(value) {
453
638
  return value.replace(/[^a-zA-Z0-9_]/g, (char) => `\\${char}`);
454
639
  }
@@ -601,10 +786,14 @@ var KeywordRetriever = class {
601
786
  this.k1 = options.k1 ?? 1.5;
602
787
  this.b = options.b ?? 0.75;
603
788
  }
789
+ /** How many chunks are currently indexed. */
790
+ get size() {
791
+ return this.docs.size;
792
+ }
604
793
  /** Index (or re-index) records. Re-adding an existing id replaces its posting and updates stats. */
605
794
  add(records) {
606
795
  for (const record of records) {
607
- this.remove(record.id);
796
+ this.removeChunk(record.id);
608
797
  const tokens = tokenize(record.text);
609
798
  const frequencies = /* @__PURE__ */ new Map();
610
799
  for (const token of tokens) {
@@ -621,7 +810,30 @@ var KeywordRetriever = class {
621
810
  this.totalLength += tokens.length;
622
811
  }
623
812
  }
624
- 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) {
625
837
  const existing = this.docs.get(id);
626
838
  if (existing === void 0) {
627
839
  return;
@@ -871,6 +1083,7 @@ __name(createRetrievalTool, "createRetrievalTool");
871
1083
  KeywordRetriever,
872
1084
  MemoryVectorStore,
873
1085
  PgVectorStore,
1086
+ RedisVectorSchemaMismatchError,
874
1087
  RedisVectorStore,
875
1088
  RerankingRetriever,
876
1089
  chunkDocuments,