@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/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 Object.keys(filter).length === 0;
52
+ return entries.length === 0;
52
53
  }
53
- return Object.entries(filter).every(([key, value]) => metadata[key] === value);
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 hasFilter = filter !== void 0 && Object.keys(filter).length > 0;
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
- ${hasFilter ? "WHERE metadata @> $1::jsonb" : ""}
186
- ORDER BY ${DOCUMENT_ID_FROM_CHUNK}`, hasFilter ? [
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 hasFilter = options.filter !== void 0 && Object.keys(options.filter).length > 0;
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
- ${hasFilter ? "WHERE metadata @> $3::jsonb" : ""}
206
+ ${where.sql}
202
207
  ORDER BY embedding <=> $1::vector
203
- LIMIT $2`, hasFilter ? [
208
+ LIMIT $2`, [
204
209
  vector,
205
210
  options.topK,
206
- JSON.stringify(options.filter)
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
- /** Idempotent: create the HNSW/cosine index if `FT.INFO` says it doesn't exist yet. */
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
- args.push(`meta_${field}`, String(value));
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]) => `@meta_${key}:{${escapeTag(String(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.remove(record.id);
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
- remove(id) {
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,