@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 +36 -1
- package/dist/index.cjs +157 -5
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +75 -3
- package/dist/index.d.ts +75 -3
- package/dist/index.js +156 -5
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
-
- `
|
|
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
|
-
/**
|
|
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.
|
|
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
|
-
|
|
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,
|