@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 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 two vector stores — in-memory and pgvector. Framework-agnostic:
5
- it depends only on `-core`, and you bring your own embedding model and (for pgvector) Postgres driver.
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
- ## Hybrid search — and deleting from it
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
- 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:
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 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:
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
- ## RediSearch store
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
- `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.
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
- - `KeywordRetriever` — BM25 lexical half of hybrid search: `add(chunks)`, `remove(documentId)`,
111
- `clear()`, `size`.
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 += 2) {
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
- passages.push(toPassage(toStr(reply[index]), attrs, prefix));
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 distance = Number(attrs.vector_score ?? "1");
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: 1 - distance,
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