@dudousxd/nestjs-agent-rag 0.4.1 → 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/README.md +103 -19
- package/dist/index.cjs +93 -10
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +103 -2
- package/dist/index.d.ts +103 -2
- package/dist/index.js +90 -9
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
# @dudousxd/nestjs-agent-rag
|
|
2
2
|
|
|
3
3
|
Retrieval-Augmented Generation for [`@dudousxd/nestjs-agent`](../nestjs). Chunking, ingestion, an
|
|
4
|
-
embedding-backed `Retriever`, and
|
|
5
|
-
it depends only on `-core`, and you bring your own embedding model and (for
|
|
4
|
+
embedding-backed `Retriever`, and three vector stores — in-memory, pgvector and RediSearch.
|
|
5
|
+
Framework-agnostic: it depends only on `-core`, and you bring your own embedding model and (for the
|
|
6
|
+
pgvector / RediSearch stores) database driver.
|
|
6
7
|
|
|
7
8
|
## Install
|
|
8
9
|
|
|
@@ -69,45 +70,128 @@ const store = new PgVectorStore(
|
|
|
69
70
|
await store.ensureSchema(); // CREATE EXTENSION vector + table + HNSW cosine index
|
|
70
71
|
```
|
|
71
72
|
|
|
72
|
-
##
|
|
73
|
+
## RediSearch store
|
|
74
|
+
|
|
75
|
+
`RedisVectorStore` takes anything that can send a raw command — `node-redis`, or `ioredis` wrapped —
|
|
76
|
+
so this package pulls in no Redis driver either. Needs the RediSearch module (Redis Stack / Redis 8+):
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
import { createClient } from 'redis';
|
|
80
|
+
import { RedisVectorStore } from '@dudousxd/nestjs-agent-rag';
|
|
81
|
+
|
|
82
|
+
const client = createClient({ url: process.env.REDIS_URL });
|
|
83
|
+
await client.connect();
|
|
84
|
+
|
|
85
|
+
const store = new RedisVectorStore(
|
|
86
|
+
{ sendCommand: (args) => client.sendCommand(args) },
|
|
87
|
+
{ dimensions: 1536, filterableFields: ['audience'] },
|
|
88
|
+
);
|
|
89
|
+
await store.ensureSchema(); // FT.CREATE: TEXT + TAG + HNSW cosine vector field
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
`filterableFields` declares which metadata keys become filterable `TAG` fields — RediSearch needs them
|
|
93
|
+
up front, so only those keys can be used in a `filter`. This store also does lexical search; see below.
|
|
94
|
+
|
|
95
|
+
`ensureSchema()` belongs at boot. It creates the index if missing — and if the index already exists,
|
|
96
|
+
it compares it against your configuration rather than assuming they agree. A `filterableFields` entry
|
|
97
|
+
added after the index was created is repaired in place with `FT.ALTER … SCHEMA ADD` (chunks written
|
|
98
|
+
before the repair only become filterable on that key once re-ingested). A changed `dimensions` — a
|
|
99
|
+
model swap — can't be repaired without a full reindex, so it throws `RedisVectorSchemaMismatchError`
|
|
100
|
+
instead of silently leaving the index on the old width.
|
|
101
|
+
|
|
102
|
+
## Hybrid search (dense + lexical)
|
|
103
|
+
|
|
104
|
+
`HybridRetriever` fuses several retrievers with Reciprocal Rank Fusion. RRF is **rank**-based, so the
|
|
105
|
+
two halves need no common score scale — cosine similarity and BM25 fuse as they are.
|
|
106
|
+
|
|
107
|
+
There are two ways to get the lexical half, and which one you want is decided by your deployment, not
|
|
108
|
+
your taste.
|
|
109
|
+
|
|
110
|
+
**Store-backed (the default for anything multi-process).** `RedisVectorStore` already declares the
|
|
111
|
+
chunk text as a RediSearch `TEXT` field, so the lexical index is *already there* — `LexicalRetriever`
|
|
112
|
+
just queries it with BM25:
|
|
73
113
|
|
|
74
|
-
|
|
75
|
-
|
|
114
|
+
```ts
|
|
115
|
+
import { EmbeddingRetriever, HybridRetriever, LexicalRetriever } from '@dudousxd/nestjs-agent-rag';
|
|
116
|
+
|
|
117
|
+
const retriever = new HybridRetriever([
|
|
118
|
+
new EmbeddingRetriever(embedder, store),
|
|
119
|
+
new LexicalRetriever(store), // ← same store, no second index
|
|
120
|
+
]);
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Nothing else changes: no chunks to hand the retriever, no state in the process, no deletes to mirror.
|
|
124
|
+
This is the only option that works when **ingestion and search run in different processes** — a
|
|
125
|
+
worker that ingests and an API pod that searches. The API pod never sees the chunks, so an in-process
|
|
126
|
+
index there would be permanently empty (or kept warm by scanning the corpus into JS heap on every
|
|
127
|
+
pod, which is the workaround this exists to delete). It also means a document is lexically findable
|
|
128
|
+
the moment it is upserted, with no refresh window.
|
|
129
|
+
|
|
130
|
+
`searchText` applies `filter` with exactly the same semantics as `search` — array = OR, **empty array
|
|
131
|
+
= deny** — so a lexical hit can never escape a filter a vector hit would have respected. That matters
|
|
132
|
+
when the filter is carrying capability tokens rather than a hint.
|
|
133
|
+
|
|
134
|
+
A query is reduced to letter/digit/`_` terms before it is sent, so RediSearch query syntax in user
|
|
135
|
+
input (`*`, `@field:{…}`, `|`, `-`, `=>[KNN …]`) is inert rather than escaped-but-live. A query with
|
|
136
|
+
no searchable terms left — empty, whitespace, or pure punctuation — returns `[]` rather than matching
|
|
137
|
+
the corpus.
|
|
138
|
+
|
|
139
|
+
**In-process (`KeywordRetriever`).** Still the right answer for `MemoryVectorStore`, for a store with
|
|
140
|
+
no lexical capability, and for single-process deployments where you control both halves. It is a full
|
|
141
|
+
BM25 implementation over chunks you feed it, so chunk once and give the SAME chunks to both halves —
|
|
142
|
+
their ids line up and `HybridRetriever` can fuse the rankings:
|
|
76
143
|
|
|
77
144
|
```ts
|
|
78
145
|
const chunks = chunkDocuments(docs);
|
|
79
146
|
await ingestChunks(chunks, { embedder, store });
|
|
80
|
-
keyword.add(chunks);
|
|
147
|
+
keyword.add(chunks); // its own copy of the corpus, in this process only
|
|
81
148
|
|
|
82
149
|
const retriever = new HybridRetriever([new EmbeddingRetriever(embedder, store), keyword]);
|
|
83
150
|
```
|
|
84
151
|
|
|
85
|
-
Because
|
|
86
|
-
each chunk's text, so a document dropped from the vector
|
|
87
|
-
`retrieve` will keep returning the removed passage:
|
|
152
|
+
Because that is a second index, **deletes have to hit both**, and only this process can see it.
|
|
153
|
+
`KeywordRetriever` keeps its own copy of each chunk's text, so a document dropped from the vector
|
|
154
|
+
store alone stays fully retrievable — `retrieve` will keep returning the removed passage:
|
|
88
155
|
|
|
89
156
|
```ts
|
|
90
157
|
await store.remove(documentId);
|
|
91
158
|
keyword.remove(documentId); // ← the other half. Same document id; chunk ids collapse for you.
|
|
92
159
|
```
|
|
93
160
|
|
|
94
|
-
`KeywordRetriever` also exposes `clear()` and `size` for rebuilding the index wholesale.
|
|
161
|
+
`KeywordRetriever` also exposes `clear()` and `size` for rebuilding the index wholesale. None of this
|
|
162
|
+
bookkeeping exists on the store-backed path above, which is the point of it.
|
|
95
163
|
|
|
96
|
-
|
|
164
|
+
Ask the store which it supports rather than hard-coding:
|
|
165
|
+
|
|
166
|
+
```ts
|
|
167
|
+
import { isLexicalVectorStore } from '@dudousxd/nestjs-agent-rag';
|
|
168
|
+
|
|
169
|
+
const lexical = isLexicalVectorStore(store) ? new LexicalRetriever(store) : keywordRetriever;
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### Which stores implement `LexicalVectorStore`
|
|
173
|
+
|
|
174
|
+
| Store | Lexical | Why |
|
|
175
|
+
| --- | --- | --- |
|
|
176
|
+
| `RedisVectorStore` | ✅ | RediSearch indexes the chunk text as `TEXT`; BM25 is already there |
|
|
177
|
+
| `PgVectorStore` | ❌ | the chunks table has no `tsvector`/GIN index, and adding one is a migration |
|
|
178
|
+
| `MemoryVectorStore` | ❌ | single-process by definition — use `KeywordRetriever` |
|
|
97
179
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
throws `RedisVectorSchemaMismatchError` instead of silently leaving the index on the old width.
|
|
180
|
+
Postgres is a deliberate omission. Lexical search there needs new DDL (a GIN index), and `CREATE
|
|
181
|
+
INDEX` in `ensureSchema` would take a write lock on an already-populated chunks table at boot; it also
|
|
182
|
+
forces a text-search-configuration choice that must match between index and query or Postgres silently
|
|
183
|
+
falls back to a sequential scan. That wants a migration you run and watch, not a side effect of a
|
|
184
|
+
library upgrade. The interface is open, so it can be added later without changing anything else.
|
|
104
185
|
|
|
105
186
|
## API
|
|
106
187
|
|
|
107
188
|
- `chunkText(text, { chunkSize?, overlap? })` — overlapping, boundary-aware chunks.
|
|
108
189
|
- `ingestDocuments(docs, { embedder, store, chunkSize?, overlap? })` — chunk → embed → upsert.
|
|
109
190
|
- `EmbeddingRetriever(embedder, store)` — the core `Retriever` from an embedder + vector store.
|
|
110
|
-
- `
|
|
111
|
-
|
|
191
|
+
- `LexicalRetriever(store)` — the core `Retriever` from a store's own full-text index (BM25).
|
|
192
|
+
- `KeywordRetriever` — in-process BM25 over chunks you feed it; the single-process lexical half:
|
|
193
|
+
`add(chunks)`, `remove(documentId)`, `clear()`, `size`.
|
|
194
|
+
- `HybridRetriever(retrievers, { k?, fetchTopK?, weights? })` — RRF fusion of the two.
|
|
112
195
|
- `MemoryVectorStore` / `PgVectorStore` / `RedisVectorStore` — `VectorStore` adapters.
|
|
196
|
+
- `isLexicalVectorStore(store)` — type guard for the optional `LexicalVectorStore` capability.
|
|
113
197
|
- `createRetrievalTool(retriever, { name?, description?, topK? })` — the agentic-retrieval tool.
|
package/dist/index.cjs
CHANGED
|
@@ -25,6 +25,7 @@ __export(index_exports, {
|
|
|
25
25
|
FilteredRetriever: () => FilteredRetriever,
|
|
26
26
|
HybridRetriever: () => HybridRetriever,
|
|
27
27
|
KeywordRetriever: () => KeywordRetriever,
|
|
28
|
+
LexicalRetriever: () => LexicalRetriever,
|
|
28
29
|
MemoryVectorStore: () => MemoryVectorStore,
|
|
29
30
|
PgVectorStore: () => PgVectorStore,
|
|
30
31
|
RedisVectorSchemaMismatchError: () => RedisVectorSchemaMismatchError,
|
|
@@ -35,7 +36,8 @@ __export(index_exports, {
|
|
|
35
36
|
createRetrievalTool: () => createRetrievalTool,
|
|
36
37
|
documentIdOf: () => documentIdOf,
|
|
37
38
|
ingestChunks: () => ingestChunks,
|
|
38
|
-
ingestDocuments: () => ingestDocuments
|
|
39
|
+
ingestDocuments: () => ingestDocuments,
|
|
40
|
+
isLexicalVectorStore: () => isLexicalVectorStore
|
|
39
41
|
});
|
|
40
42
|
module.exports = __toCommonJS(index_exports);
|
|
41
43
|
|
|
@@ -78,6 +80,10 @@ function chunkText(text, options = {}) {
|
|
|
78
80
|
__name(chunkText, "chunkText");
|
|
79
81
|
|
|
80
82
|
// src/vector-store.ts
|
|
83
|
+
function isLexicalVectorStore(store) {
|
|
84
|
+
return typeof store.searchText === "function";
|
|
85
|
+
}
|
|
86
|
+
__name(isLexicalVectorStore, "isLexicalVectorStore");
|
|
81
87
|
function documentIdOf(chunkId) {
|
|
82
88
|
return chunkId.replace(/#\d+$/, "");
|
|
83
89
|
}
|
|
@@ -329,12 +335,14 @@ var RedisVectorStore = class {
|
|
|
329
335
|
prefix;
|
|
330
336
|
dimensions;
|
|
331
337
|
filterableFields;
|
|
338
|
+
lexicalScorer;
|
|
332
339
|
constructor(client, options = {}) {
|
|
333
340
|
this.client = client;
|
|
334
341
|
this.index = options.index ?? "agent_rag_idx";
|
|
335
342
|
this.prefix = options.prefix ?? "agent_rag:";
|
|
336
343
|
this.dimensions = options.dimensions ?? 1536;
|
|
337
344
|
this.filterableFields = options.filterableFields ?? [];
|
|
345
|
+
this.lexicalScorer = options.lexicalScorer ?? "BM25";
|
|
338
346
|
}
|
|
339
347
|
/**
|
|
340
348
|
* Idempotent, and drift-aware: create the HNSW/cosine index if `FT.INFO` says it doesn't exist —
|
|
@@ -556,6 +564,47 @@ var RedisVectorStore = class {
|
|
|
556
564
|
]);
|
|
557
565
|
return parseSearchReply(reply, this.prefix);
|
|
558
566
|
}
|
|
567
|
+
/**
|
|
568
|
+
* Lexical (BM25) search over the same index `search` uses — the {@link LexicalVectorStore} half of
|
|
569
|
+
* hybrid retrieval, served by RediSearch's own keyword index rather than an in-process copy of the
|
|
570
|
+
* corpus. `options.filter` is ANDed onto the text clause with the identical TAG semantics as
|
|
571
|
+
* `search` (array = OR, empty array = deny), so a lexical hit can never escape a filter a vector
|
|
572
|
+
* hit would have respected.
|
|
573
|
+
*
|
|
574
|
+
* A query with no searchable terms — empty, whitespace, or pure punctuation — returns `[]`. That is
|
|
575
|
+
* deliberate: RediSearch's `*` would match the entire corpus, which is the opposite of what a
|
|
576
|
+
* relevance query with nothing to be relevant to should do.
|
|
577
|
+
*/
|
|
578
|
+
async searchText(query, options) {
|
|
579
|
+
if (filterMatchesNothing(options.filter)) {
|
|
580
|
+
return [];
|
|
581
|
+
}
|
|
582
|
+
const terms = tokenizeQuery(query);
|
|
583
|
+
if (terms.length === 0) {
|
|
584
|
+
return [];
|
|
585
|
+
}
|
|
586
|
+
const filterQuery = buildFilter(options.filter);
|
|
587
|
+
const textQuery = `@text:(${terms.join("|")})`;
|
|
588
|
+
const reply = await this.client.sendCommand([
|
|
589
|
+
"FT.SEARCH",
|
|
590
|
+
this.index,
|
|
591
|
+
filterQuery === "*" ? textQuery : `(${filterQuery} ${textQuery})`,
|
|
592
|
+
"SCORER",
|
|
593
|
+
this.lexicalScorer,
|
|
594
|
+
"WITHSCORES",
|
|
595
|
+
"RETURN",
|
|
596
|
+
"3",
|
|
597
|
+
"text",
|
|
598
|
+
"source",
|
|
599
|
+
"metadata_json",
|
|
600
|
+
"DIALECT",
|
|
601
|
+
"2",
|
|
602
|
+
"LIMIT",
|
|
603
|
+
"0",
|
|
604
|
+
String(options.topK)
|
|
605
|
+
]);
|
|
606
|
+
return parseSearchReply(reply, this.prefix, "lexical");
|
|
607
|
+
}
|
|
559
608
|
};
|
|
560
609
|
function parseIndexAttributes(reply) {
|
|
561
610
|
const attributes = /* @__PURE__ */ new Map();
|
|
@@ -634,6 +683,10 @@ function filterMatchesNothing(filter) {
|
|
|
634
683
|
return Object.values(filter).some((value) => Array.isArray(value) && value.length === 0);
|
|
635
684
|
}
|
|
636
685
|
__name(filterMatchesNothing, "filterMatchesNothing");
|
|
686
|
+
function tokenizeQuery(query) {
|
|
687
|
+
return query.match(/[\p{L}\p{N}_]+/gu) ?? [];
|
|
688
|
+
}
|
|
689
|
+
__name(tokenizeQuery, "tokenizeQuery");
|
|
637
690
|
function escapeTag(value) {
|
|
638
691
|
return value.replace(/[^a-zA-Z0-9_]/g, (char) => `\\${char}`);
|
|
639
692
|
}
|
|
@@ -670,17 +723,19 @@ function toStr(value) {
|
|
|
670
723
|
return Buffer.isBuffer(value) ? value.toString("utf8") : String(value);
|
|
671
724
|
}
|
|
672
725
|
__name(toStr, "toStr");
|
|
673
|
-
function parseSearchReply(reply, prefix) {
|
|
726
|
+
function parseSearchReply(reply, prefix, scoreSource = "vector") {
|
|
727
|
+
const stride = scoreSource === "lexical" ? 3 : 2;
|
|
674
728
|
if (Array.isArray(reply)) {
|
|
675
729
|
const passages = [];
|
|
676
|
-
for (let index = 1; index + 1 < reply.length; index +=
|
|
677
|
-
const rawFields = reply[index + 1];
|
|
730
|
+
for (let index = 1; index + stride - 1 < reply.length; index += stride) {
|
|
731
|
+
const rawFields = reply[index + stride - 1];
|
|
678
732
|
if (Array.isArray(rawFields)) {
|
|
679
733
|
const attrs = {};
|
|
680
734
|
for (let field = 0; field + 1 < rawFields.length; field += 2) {
|
|
681
735
|
attrs[toStr(rawFields[field])] = toStr(rawFields[field + 1]);
|
|
682
736
|
}
|
|
683
|
-
|
|
737
|
+
const inlineScore = scoreSource === "lexical" ? toStr(reply[index + 1]) : void 0;
|
|
738
|
+
passages.push(toPassage(toStr(reply[index]), attrs, prefix, scoreSource, inlineScore));
|
|
684
739
|
}
|
|
685
740
|
}
|
|
686
741
|
return passages;
|
|
@@ -688,12 +743,19 @@ function parseSearchReply(reply, prefix) {
|
|
|
688
743
|
if (typeof reply === "object" && reply !== null && "results" in reply) {
|
|
689
744
|
const results = reply.results;
|
|
690
745
|
if (Array.isArray(results)) {
|
|
691
|
-
return results.map((result) => toPassage(readId(result), readAttributes(result), prefix));
|
|
746
|
+
return results.map((result) => toPassage(readId(result), readAttributes(result), prefix, scoreSource, scoreSource === "lexical" ? readScore(result) : void 0));
|
|
692
747
|
}
|
|
693
748
|
}
|
|
694
749
|
return [];
|
|
695
750
|
}
|
|
696
751
|
__name(parseSearchReply, "parseSearchReply");
|
|
752
|
+
function readScore(result) {
|
|
753
|
+
if (typeof result === "object" && result !== null && "score" in result) {
|
|
754
|
+
return toStr(result.score);
|
|
755
|
+
}
|
|
756
|
+
return void 0;
|
|
757
|
+
}
|
|
758
|
+
__name(readScore, "readScore");
|
|
697
759
|
function readId(result) {
|
|
698
760
|
if (typeof result === "object" && result !== null && "id" in result) {
|
|
699
761
|
return toStr(result.id);
|
|
@@ -714,13 +776,13 @@ function readAttributes(result) {
|
|
|
714
776
|
return attrs;
|
|
715
777
|
}
|
|
716
778
|
__name(readAttributes, "readAttributes");
|
|
717
|
-
function toPassage(rawId, attrs, prefix) {
|
|
718
|
-
const
|
|
779
|
+
function toPassage(rawId, attrs, prefix, scoreSource = "vector", inlineScore) {
|
|
780
|
+
const score = scoreSource === "lexical" ? Number(inlineScore ?? "0") : 1 - Number(attrs.vector_score ?? "1");
|
|
719
781
|
const source = attrs.source;
|
|
720
782
|
return {
|
|
721
783
|
id: rawId.startsWith(prefix) ? rawId.slice(prefix.length) : rawId,
|
|
722
784
|
text: attrs.text ?? "",
|
|
723
|
-
score:
|
|
785
|
+
score: Number.isFinite(score) ? score : 0,
|
|
724
786
|
...source !== void 0 ? {
|
|
725
787
|
source
|
|
726
788
|
} : {},
|
|
@@ -894,6 +956,25 @@ function tokenize(text) {
|
|
|
894
956
|
}
|
|
895
957
|
__name(tokenize, "tokenize");
|
|
896
958
|
|
|
959
|
+
// src/lexical-retriever.ts
|
|
960
|
+
var LexicalRetriever = class {
|
|
961
|
+
static {
|
|
962
|
+
__name(this, "LexicalRetriever");
|
|
963
|
+
}
|
|
964
|
+
store;
|
|
965
|
+
constructor(store) {
|
|
966
|
+
this.store = store;
|
|
967
|
+
}
|
|
968
|
+
async retrieve(query, options = {}) {
|
|
969
|
+
return this.store.searchText(query, {
|
|
970
|
+
topK: options.topK ?? 5,
|
|
971
|
+
...options.filter !== void 0 ? {
|
|
972
|
+
filter: options.filter
|
|
973
|
+
} : {}
|
|
974
|
+
});
|
|
975
|
+
}
|
|
976
|
+
};
|
|
977
|
+
|
|
897
978
|
// src/hybrid-retriever.ts
|
|
898
979
|
var HybridRetriever = class {
|
|
899
980
|
static {
|
|
@@ -1081,6 +1162,7 @@ __name(createRetrievalTool, "createRetrievalTool");
|
|
|
1081
1162
|
FilteredRetriever,
|
|
1082
1163
|
HybridRetriever,
|
|
1083
1164
|
KeywordRetriever,
|
|
1165
|
+
LexicalRetriever,
|
|
1084
1166
|
MemoryVectorStore,
|
|
1085
1167
|
PgVectorStore,
|
|
1086
1168
|
RedisVectorSchemaMismatchError,
|
|
@@ -1091,6 +1173,7 @@ __name(createRetrievalTool, "createRetrievalTool");
|
|
|
1091
1173
|
createRetrievalTool,
|
|
1092
1174
|
documentIdOf,
|
|
1093
1175
|
ingestChunks,
|
|
1094
|
-
ingestDocuments
|
|
1176
|
+
ingestDocuments,
|
|
1177
|
+
isLexicalVectorStore
|
|
1095
1178
|
});
|
|
1096
1179
|
//# sourceMappingURL=index.cjs.map
|