@dudousxd/nestjs-agent-rag 0.5.0 → 0.7.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 +140 -2
- package/dist/index.cjs +548 -19
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +375 -2
- package/dist/index.d.ts +375 -2
- package/dist/index.js +546 -19
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -99,6 +99,57 @@ before the repair only become filterable on that key once re-ingested). A change
|
|
|
99
99
|
model swap — can't be repaired without a full reindex, so it throws `RedisVectorSchemaMismatchError`
|
|
100
100
|
instead of silently leaving the index on the old width.
|
|
101
101
|
|
|
102
|
+
## Changing metadata without re-embedding
|
|
103
|
+
|
|
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
|
+
|
|
102
153
|
## Hybrid search (dense + lexical)
|
|
103
154
|
|
|
104
155
|
`HybridRetriever` fuses several retrievers with Reciprocal Rank Fusion. RRF is **rank**-based, so the
|
|
@@ -120,6 +171,38 @@ const retriever = new HybridRetriever([
|
|
|
120
171
|
]);
|
|
121
172
|
```
|
|
122
173
|
|
|
174
|
+
### The fused score is not a similarity — do not threshold it
|
|
175
|
+
|
|
176
|
+
Rank-based fusion is what makes RRF robust across incompatible scales, and the price is that its
|
|
177
|
+
output is a function of **rank alone**. It never sees a similarity. Two things follow:
|
|
178
|
+
|
|
179
|
+
- The score on a fused passage is **not comparable** to the score on one from `EmbeddingRetriever`,
|
|
180
|
+
even though it arrives in the same field, of the same type, through the same `Retriever` interface.
|
|
181
|
+
There is no discriminator — a consumer holding a `Passage[]` cannot tell which kind of number it
|
|
182
|
+
has. If you show scores to a user or a model, know which retriever produced them.
|
|
183
|
+
- Its range is narrow, fixed and quality-blind: every possible value lies in
|
|
184
|
+
`[minWeight / (k + fetchTopK), Σ weights / (k + 1)]`, which with the defaults is
|
|
185
|
+
`[0.0125, 0.0328]`. A perfect match and the least-bad member of a set of terrible matches both land
|
|
186
|
+
around `0.016`.
|
|
187
|
+
|
|
188
|
+
What a fused score measures is how strongly the legs *agree*, and agreement tracks nothing about
|
|
189
|
+
correctness: two legs returning the same nearest nothing reinforce it to the same ceiling a unanimous
|
|
190
|
+
correct answer gets. Measured on one corpus, the dense leg alone separated cleanly (weakest real
|
|
191
|
+
answer `0.244`, loudest absent-topic answer `0.143`) while those same queries after fusion scored
|
|
192
|
+
`0.032` and `0.033` — inverted.
|
|
193
|
+
|
|
194
|
+
So a relevance floor goes on the legs, never on the fusion:
|
|
195
|
+
|
|
196
|
+
```ts
|
|
197
|
+
const retriever = new HybridRetriever([
|
|
198
|
+
new EmbeddingRetriever(embedder, store, { minScore: 0.2 }), // ← floor here
|
|
199
|
+
new LexicalRetriever(store),
|
|
200
|
+
]);
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
`HybridRetriever` has no `minScore` of its own on purpose: the option would be an invitation to
|
|
204
|
+
apply one to a number that cannot support it.
|
|
205
|
+
|
|
123
206
|
Nothing else changes: no chunks to hand the retriever, no state in the process, no deletes to mirror.
|
|
124
207
|
This is the only option that works when **ingestion and search run in different processes** — a
|
|
125
208
|
worker that ingests and an API pod that searches. The API pod never sees the chunks, so an in-process
|
|
@@ -187,11 +270,66 @@ library upgrade. The interface is open, so it can be added later without changin
|
|
|
187
270
|
|
|
188
271
|
- `chunkText(text, { chunkSize?, overlap? })` — overlapping, boundary-aware chunks.
|
|
189
272
|
- `ingestDocuments(docs, { embedder, store, chunkSize?, overlap? })` — chunk → embed → upsert.
|
|
190
|
-
- `EmbeddingRetriever(embedder, store)` — the core `Retriever` from an embedder +
|
|
273
|
+
- `EmbeddingRetriever(embedder, store, { minScore? })` — the core `Retriever` from an embedder +
|
|
274
|
+
vector store. `minScore` drops passages below a similarity floor, so an unanswerable query can
|
|
275
|
+
return nothing.
|
|
191
276
|
- `LexicalRetriever(store)` — the core `Retriever` from a store's own full-text index (BM25).
|
|
192
277
|
- `KeywordRetriever` — in-process BM25 over chunks you feed it; the single-process lexical half:
|
|
193
278
|
`add(chunks)`, `remove(documentId)`, `clear()`, `size`.
|
|
194
279
|
- `HybridRetriever(retrievers, { k?, fetchTopK?, weights? })` — RRF fusion of the two.
|
|
195
|
-
- `MemoryVectorStore` / `PgVectorStore` / `RedisVectorStore` — `VectorStore` adapters
|
|
280
|
+
- `MemoryVectorStore` / `PgVectorStore` / `RedisVectorStore` — `VectorStore` adapters:
|
|
281
|
+
`upsert(records)`, `search(embedding, opts)`, `remove(documentId)`,
|
|
282
|
+
`updateMetadata(documentId, patch)`, `listDocuments(filter?)`.
|
|
283
|
+
- `applyMetadataPatch(metadata, patch)` — the `MetadataPatch` merge semantics, exported so your own
|
|
284
|
+
`VectorStore` can implement `updateMetadata` with exactly the same ones.
|
|
196
285
|
- `isLexicalVectorStore(store)` — type guard for the optional `LexicalVectorStore` capability.
|
|
197
286
|
- `createRetrievalTool(retriever, { name?, description?, topK? })` — the agentic-retrieval tool.
|
|
287
|
+
|
|
288
|
+
## Collection maintenance
|
|
289
|
+
|
|
290
|
+
`remove` and `listDocuments` can only enumerate documents *with* their metadata and delete them *one
|
|
291
|
+
at a time*, which makes routine maintenance quadratic-ish: dropping a 2 000-document collection that
|
|
292
|
+
way is one `listDocuments()` that parses a metadata blob per chunk, then 2 000 `remove()` calls — and
|
|
293
|
+
on Redis each of those is a `SCAN` over the whole keyspace, queues and app cache included.
|
|
294
|
+
|
|
295
|
+
So `VectorStore` **requires** four more methods, each doing the same work as one filtered query. They
|
|
296
|
+
are not an optional capability and there is nothing to narrow to — every store has them:
|
|
297
|
+
|
|
298
|
+
```ts
|
|
299
|
+
await store.countChunks({ collection: 'handbook' }); // FT.SEARCH LIMIT 0 0 / count(*)
|
|
300
|
+
await store.listDocumentIds({ collection: 'handbook' }); // ids only — no metadata round-trip
|
|
301
|
+
await store.removeWhere({ collection: 'handbook' }); // one query, returns chunks removed
|
|
302
|
+
await store.removeMany(['doc-a', 'doc-b']); // N documents, one pass
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
`removeWhere` is the destructive one, so it has two rules worth knowing before you call it:
|
|
306
|
+
|
|
307
|
+
| filter | result |
|
|
308
|
+
|---|---|
|
|
309
|
+
| `{ collection: 'handbook' }` | deletes that scope, returns the chunk count |
|
|
310
|
+
| `{ 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" |
|
|
311
|
+
| `{}` | throws `UnsafeRemovalError` — an empty filter is far more often a filter built wrong than a deliberate request to wipe the store |
|
|
312
|
+
| `{ notFilterable: 'x' }` (Redis) | throws `UnsafeRemovalError` — the key has no `meta_*` TAG to match on |
|
|
313
|
+
|
|
314
|
+
Deliberate mass deletion is still available; it just has to be spelled out:
|
|
315
|
+
`await store.removeMany(await store.listDocumentIds())`.
|
|
316
|
+
|
|
317
|
+
`VectorStore.remove` deliberately stays a `SCAN` on the Redis adapter. Turning it into an `FT.SEARCH`
|
|
318
|
+
would mean stamping the document id as an indexed field at write time, and chunks written before that
|
|
319
|
+
change carry no such field — the search would find none of them and `remove` would silently stop
|
|
320
|
+
deleting. The methods above need no new field, so they have no migration.
|
|
321
|
+
|
|
322
|
+
### Why these are required and `searchText` is optional
|
|
323
|
+
|
|
324
|
+
A method is **required on `VectorStore`** when every backend can implement it from what it already
|
|
325
|
+
stores. Enumeration and bulk deletion are CRUD over records the store already holds — the same
|
|
326
|
+
footing as `remove` and `listDocuments` — so making them optional would only cost every consumer a
|
|
327
|
+
permanent `if (supported)` and an unreachable "and if it isn't?" branch.
|
|
328
|
+
|
|
329
|
+
A method goes in an **optional capability interface** when some backend genuinely *cannot* provide it
|
|
330
|
+
without infrastructure the consumer has to adopt. `LexicalVectorStore.searchText` is the only one
|
|
331
|
+
today, and it earns it: RediSearch has BM25 natively, Postgres needs a `tsvector` column, a GIN index
|
|
332
|
+
and a migration you run and watch, and `MemoryVectorStore` would have to implement BM25 itself.
|
|
333
|
+
|
|
334
|
+
The test is whether a backend can do it at all — not whether adding it would inconvenience an
|
|
335
|
+
implementer who has already shipped.
|