@dudousxd/nestjs-agent-rag 0.4.1 → 0.6.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,232 @@ 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
+ ## Changing metadata without re-embedding
73
103
 
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:
104
+ Some metadata is derived from the document's content and only changes when the content does (its
105
+ size, its mime type, its ingestion fingerprint). Some isn't: who may see it, which bases or teams it
106
+ is relevant to, what it was last classified as. That second kind changes on its own schedule, and
107
+ without a way to say so you are left choosing between re-embedding a whole document to change a
108
+ string — paying the model bill, and the ingestion budget, for a reclassification — or refusing to
109
+ stamp the dimension onto chunks at all and resolving it at query time instead, which turns a filter
110
+ the index could have applied into a join you have to.
111
+
112
+ `updateMetadata` is that way to say so. It rewrites the metadata of **every chunk** of a document and
113
+ touches neither the text nor the embeddings:
114
+
115
+ ```ts
116
+ // this document now covers bases B and C, and no longer A. No embedding is recomputed.
117
+ const chunks = await store.updateMetadata(documentId, { bases: ['B', 'C'] });
118
+ ```
119
+
120
+ The patch is applied as a shallow [JSON Merge Patch](https://www.rfc-editor.org/rfc/rfc7386):
121
+
122
+ | in the patch | effect |
123
+ | --- | --- |
124
+ | key absent | the stored value is kept — this is a **partial** update, not a replacement |
125
+ | `key: value` | the stored value is replaced |
126
+ | `key: [a, b]` | replaced **wholesale** — an array is a set-valued dimension, not something to append to |
127
+ | `key: null` | the key is **removed**. This is the only way to remove one |
128
+ | `key: undefined` | **ignored**, as if the patch had not mentioned the key |
129
+
130
+ `undefined` is ignored rather than treated as a removal on purpose. It does not survive
131
+ `JSON.stringify`, and a patch routinely crosses a JSON boundary (an HTTP body, a workflow step
132
+ payload) on its way in, so honouring it would make `{ audience: undefined }` mean *delete* on one
133
+ side of that boundary and *no-op* on the other. It is also what `{ ...doc, audience: doc.audience }`
134
+ produces by accident — and an accidental deletion of an access-control dimension is the worst thing
135
+ this API could do. The cost is that a literal `null` cannot be stored as a value, which is no loss:
136
+ "absent" and "null" filter identically anyway.
137
+
138
+ The return value is the number of chunks written. **An unknown document id updates nothing and
139
+ returns `0` rather than throwing** — like `remove`, this is a reconciliation-shaped call, made by a
140
+ loop diffing your source of truth against the index, and such a loop races with ingestion and
141
+ deletion by construction; an exception would make the ordinary case an error you had to catch and
142
+ swallow. (A patch that writes nothing also returns `0`: the count is chunks *written*, not chunks
143
+ *matched*.)
144
+
145
+ Both representations move together. `RedisVectorStore` stores metadata twice — as `meta_<field>`
146
+ TAGs, which is what RediSearch filters on, and as a `metadata_json` blob, which is what comes back on
147
+ a `Passage` — and `updateMetadata` rewrites both from the same merged object, dropping the TAG of a
148
+ removed key rather than leaving it matching a value the document no longer carries. A patched chunk
149
+ is indistinguishable from a re-ingested one: filtering on the new value finds it, filtering on the
150
+ old value does not, and the metadata read back off a retrieved passage agrees with both. (A plain
151
+ `HSET` is all RediSearch needs to re-index — proven against a real Redis Stack, not assumed.)
152
+
153
+ ## Hybrid search (dense + lexical)
154
+
155
+ `HybridRetriever` fuses several retrievers with Reciprocal Rank Fusion. RRF is **rank**-based, so the
156
+ two halves need no common score scale — cosine similarity and BM25 fuse as they are.
157
+
158
+ There are two ways to get the lexical half, and which one you want is decided by your deployment, not
159
+ your taste.
160
+
161
+ **Store-backed (the default for anything multi-process).** `RedisVectorStore` already declares the
162
+ chunk text as a RediSearch `TEXT` field, so the lexical index is *already there* — `LexicalRetriever`
163
+ just queries it with BM25:
164
+
165
+ ```ts
166
+ import { EmbeddingRetriever, HybridRetriever, LexicalRetriever } from '@dudousxd/nestjs-agent-rag';
167
+
168
+ const retriever = new HybridRetriever([
169
+ new EmbeddingRetriever(embedder, store),
170
+ new LexicalRetriever(store), // ← same store, no second index
171
+ ]);
172
+ ```
173
+
174
+ Nothing else changes: no chunks to hand the retriever, no state in the process, no deletes to mirror.
175
+ This is the only option that works when **ingestion and search run in different processes** — a
176
+ worker that ingests and an API pod that searches. The API pod never sees the chunks, so an in-process
177
+ index there would be permanently empty (or kept warm by scanning the corpus into JS heap on every
178
+ pod, which is the workaround this exists to delete). It also means a document is lexically findable
179
+ the moment it is upserted, with no refresh window.
180
+
181
+ `searchText` applies `filter` with exactly the same semantics as `search` — array = OR, **empty array
182
+ = deny** — so a lexical hit can never escape a filter a vector hit would have respected. That matters
183
+ when the filter is carrying capability tokens rather than a hint.
184
+
185
+ A query is reduced to letter/digit/`_` terms before it is sent, so RediSearch query syntax in user
186
+ input (`*`, `@field:{…}`, `|`, `-`, `=>[KNN …]`) is inert rather than escaped-but-live. A query with
187
+ no searchable terms left — empty, whitespace, or pure punctuation — returns `[]` rather than matching
188
+ the corpus.
189
+
190
+ **In-process (`KeywordRetriever`).** Still the right answer for `MemoryVectorStore`, for a store with
191
+ no lexical capability, and for single-process deployments where you control both halves. It is a full
192
+ BM25 implementation over chunks you feed it, so chunk once and give the SAME chunks to both halves —
193
+ their ids line up and `HybridRetriever` can fuse the rankings:
76
194
 
77
195
  ```ts
78
196
  const chunks = chunkDocuments(docs);
79
197
  await ingestChunks(chunks, { embedder, store });
80
- keyword.add(chunks);
198
+ keyword.add(chunks); // its own copy of the corpus, in this process only
81
199
 
82
200
  const retriever = new HybridRetriever([new EmbeddingRetriever(embedder, store), keyword]);
83
201
  ```
84
202
 
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:
203
+ Because that is a second index, **deletes have to hit both**, and only this process can see it.
204
+ `KeywordRetriever` keeps its own copy of each chunk's text, so a document dropped from the vector
205
+ store alone stays fully retrievable — `retrieve` will keep returning the removed passage:
88
206
 
89
207
  ```ts
90
208
  await store.remove(documentId);
91
209
  keyword.remove(documentId); // ← the other half. Same document id; chunk ids collapse for you.
92
210
  ```
93
211
 
94
- `KeywordRetriever` also exposes `clear()` and `size` for rebuilding the index wholesale.
212
+ `KeywordRetriever` also exposes `clear()` and `size` for rebuilding the index wholesale. None of this
213
+ bookkeeping exists on the store-backed path above, which is the point of it.
95
214
 
96
- ## RediSearch store
215
+ Ask the store which it supports rather than hard-coding:
97
216
 
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.
217
+ ```ts
218
+ import { isLexicalVectorStore } from '@dudousxd/nestjs-agent-rag';
219
+
220
+ const lexical = isLexicalVectorStore(store) ? new LexicalRetriever(store) : keywordRetriever;
221
+ ```
222
+
223
+ ### Which stores implement `LexicalVectorStore`
224
+
225
+ | Store | Lexical | Why |
226
+ | --- | --- | --- |
227
+ | `RedisVectorStore` | ✅ | RediSearch indexes the chunk text as `TEXT`; BM25 is already there |
228
+ | `PgVectorStore` | ❌ | the chunks table has no `tsvector`/GIN index, and adding one is a migration |
229
+ | `MemoryVectorStore` | ❌ | single-process by definition — use `KeywordRetriever` |
230
+
231
+ Postgres is a deliberate omission. Lexical search there needs new DDL (a GIN index), and `CREATE
232
+ INDEX` in `ensureSchema` would take a write lock on an already-populated chunks table at boot; it also
233
+ forces a text-search-configuration choice that must match between index and query or Postgres silently
234
+ falls back to a sequential scan. That wants a migration you run and watch, not a side effect of a
235
+ library upgrade. The interface is open, so it can be added later without changing anything else.
104
236
 
105
237
  ## API
106
238
 
107
239
  - `chunkText(text, { chunkSize?, overlap? })` — overlapping, boundary-aware chunks.
108
240
  - `ingestDocuments(docs, { embedder, store, chunkSize?, overlap? })` — chunk → embed → upsert.
109
241
  - `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`.
112
- - `MemoryVectorStore` / `PgVectorStore` / `RedisVectorStore` — `VectorStore` adapters.
242
+ - `LexicalRetriever(store)` — the core `Retriever` from a store's own full-text index (BM25).
243
+ - `KeywordRetriever` — in-process BM25 over chunks you feed it; the single-process lexical half:
244
+ `add(chunks)`, `remove(documentId)`, `clear()`, `size`.
245
+ - `HybridRetriever(retrievers, { k?, fetchTopK?, weights? })` — RRF fusion of the two.
246
+ - `MemoryVectorStore` / `PgVectorStore` / `RedisVectorStore` — `VectorStore` adapters:
247
+ `upsert(records)`, `search(embedding, opts)`, `remove(documentId)`,
248
+ `updateMetadata(documentId, patch)`, `listDocuments(filter?)`.
249
+ - `applyMetadataPatch(metadata, patch)` — the `MetadataPatch` merge semantics, exported so your own
250
+ `VectorStore` can implement `updateMetadata` with exactly the same ones.
251
+ - `isLexicalVectorStore(store)` — type guard for the optional `LexicalVectorStore` capability.
113
252
  - `createRetrievalTool(retriever, { name?, description?, topK? })` — the agentic-retrieval tool.
253
+
254
+ ## Collection maintenance
255
+
256
+ `remove` and `listDocuments` can only enumerate documents *with* their metadata and delete them *one
257
+ at a time*, which makes routine maintenance quadratic-ish: dropping a 2 000-document collection that
258
+ way is one `listDocuments()` that parses a metadata blob per chunk, then 2 000 `remove()` calls — and
259
+ on Redis each of those is a `SCAN` over the whole keyspace, queues and app cache included.
260
+
261
+ So `VectorStore` **requires** four more methods, each doing the same work as one filtered query. They
262
+ are not an optional capability and there is nothing to narrow to — every store has them:
263
+
264
+ ```ts
265
+ await store.countChunks({ collection: 'handbook' }); // FT.SEARCH LIMIT 0 0 / count(*)
266
+ await store.listDocumentIds({ collection: 'handbook' }); // ids only — no metadata round-trip
267
+ await store.removeWhere({ collection: 'handbook' }); // one query, returns chunks removed
268
+ await store.removeMany(['doc-a', 'doc-b']); // N documents, one pass
269
+ ```
270
+
271
+ `removeWhere` is the destructive one, so it has two rules worth knowing before you call it:
272
+
273
+ | filter | result |
274
+ |---|---|
275
+ | `{ collection: 'handbook' }` | deletes that scope, returns the chunk count |
276
+ | `{ audience: [] }` (or any empty array) | **deletes nothing**, returns `0` — the empty array is this package's deny primitive, and a deny must never be read as "no filter" |
277
+ | `{}` | throws `UnsafeRemovalError` — an empty filter is far more often a filter built wrong than a deliberate request to wipe the store |
278
+ | `{ notFilterable: 'x' }` (Redis) | throws `UnsafeRemovalError` — the key has no `meta_*` TAG to match on |
279
+
280
+ Deliberate mass deletion is still available; it just has to be spelled out:
281
+ `await store.removeMany(await store.listDocumentIds())`.
282
+
283
+ `VectorStore.remove` deliberately stays a `SCAN` on the Redis adapter. Turning it into an `FT.SEARCH`
284
+ would mean stamping the document id as an indexed field at write time, and chunks written before that
285
+ change carry no such field — the search would find none of them and `remove` would silently stop
286
+ deleting. The methods above need no new field, so they have no migration.
287
+
288
+ ### Why these are required and `searchText` is optional
289
+
290
+ A method is **required on `VectorStore`** when every backend can implement it from what it already
291
+ stores. Enumeration and bulk deletion are CRUD over records the store already holds — the same
292
+ footing as `remove` and `listDocuments` — so making them optional would only cost every consumer a
293
+ permanent `if (supported)` and an unreachable "and if it isn't?" branch.
294
+
295
+ A method goes in an **optional capability interface** when some backend genuinely *cannot* provide it
296
+ without infrastructure the consumer has to adopt. `LexicalVectorStore.searchText` is the only one
297
+ today, and it earns it: RediSearch has BM25 natively, Postgres needs a `tsvector` column, a GIN index
298
+ and a migration you run and watch, and `MemoryVectorStore` would have to implement BM25 itself.
299
+
300
+ The test is whether a backend can do it at all — not whether adding it would inconvenience an
301
+ implementer who has already shipped.