@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/dist/index.d.ts CHANGED
@@ -14,6 +14,45 @@ interface ChunkOptions {
14
14
  */
15
15
  declare function chunkText(text: string, options?: ChunkOptions): string[];
16
16
 
17
+ /**
18
+ * A partial update to a document's metadata, as taken by
19
+ * {@link import('./vector-store.js').VectorStore.updateMetadata}. Deliberately *partial*: the useful
20
+ * shape is "this one dimension was reclassified", not "here is the whole metadata object again" —
21
+ * a caller that had the whole object would have to reconstruct fields it does not own (the ingestion
22
+ * fingerprint, the source, the mime type) just to change one string, and would silently drop them
23
+ * the day the library adds another.
24
+ *
25
+ * The semantics are RFC 7386 (JSON Merge Patch), with one deliberate narrowing:
26
+ *
27
+ * - **Merge, not replace.** Keys present in the patch are written; keys absent from it keep their
28
+ * stored value.
29
+ * - **`null` removes the key.** That is the RFC's sentinel and it is the *only* way to remove one.
30
+ * The consequence — a literal `null` cannot be stored as a value — is intended: metadata is a
31
+ * filter surface, and "the key is absent" and "the key is null" would filter identically anyway.
32
+ * - **`undefined` is ignored** — the key is left exactly as stored, as if the patch had not mentioned
33
+ * it. `undefined` does not survive `JSON.stringify`, and a patch routinely crosses a JSON boundary
34
+ * (an HTTP body, a durable-workflow step payload) on its way here; honouring it would make
35
+ * `{ audience: undefined }` mean *delete* on one side of that boundary and *no-op* on the other.
36
+ * It is also what `{ ...doc, audience: doc.audience }` produces by accident, and an accidental
37
+ * deletion of an access-control dimension is the worst outcome this API can have. Removing a key
38
+ * is therefore always spelled `null`.
39
+ * - **Shallow.** A value replaces wholesale; it is not merged into the stored value. This is the
40
+ * narrowing versus RFC 7386, which recurses into nested objects. It matters most for **arrays**,
41
+ * where wholesale replacement is certainly what a caller means: `{ bases: ['A', 'C'] }` sets the
42
+ * document's bases to exactly A and C — it does not append to, or union with, what was there. An
43
+ * array-valued key is a *set-valued dimension* (the multi-valued TAG a store filters on), and the
44
+ * only sane way to shrink such a set is to state the new one.
45
+ */
46
+ type MetadataPatch = Record<string, unknown>;
47
+ /**
48
+ * Apply a {@link MetadataPatch} to a document's stored metadata, returning a NEW object — the
49
+ * reference implementation of the semantics documented on `MetadataPatch`, so every adapter that
50
+ * merges in JS agrees with every other one. Never mutates its input: `MemoryVectorStore` hands out
51
+ * the very object a caller upserted, and quietly rewriting it under them would be a surprise no
52
+ * other store reproduces.
53
+ */
54
+ declare function applyMetadataPatch(metadata: Record<string, unknown> | undefined, patch: MetadataPatch): Record<string, unknown>;
55
+
17
56
  /** A stored, embedded chunk. `embedding` length must match the store's configured dimensions. */
18
57
  interface VectorRecord {
19
58
  id: string;
@@ -33,6 +72,31 @@ interface VectorSearchOptions {
33
72
  * (in-JS cosine, tests + small scale) and `PgVectorStore` (pgvector); any impl works. Pair one with
34
73
  * an `EmbeddingProvider` via {@link import('./embedding-retriever.js').EmbeddingRetriever} to get a
35
74
  * core `Retriever`.
75
+ *
76
+ * Every method here is **required**, the enumeration and bulk-deletion half included. The rule for
77
+ * where a new method goes, so the next person adding one has it rather than inferring it from
78
+ * precedent:
79
+ *
80
+ * - **Required, on this interface**, when every backend *can* implement it from what it already
81
+ * stores. {@link VectorStore.updateMetadata}, {@link VectorStore.listDocumentIds},
82
+ * {@link VectorStore.removeMany}, {@link VectorStore.removeWhere} and
83
+ * {@link VectorStore.countChunks} are CRUD over the records the store already holds — the same
84
+ * footing as {@link VectorStore.remove} and
85
+ * {@link VectorStore.listDocuments}. Making them optional would buy nothing but a permanent
86
+ * `if (supported)` at every call site plus an unreachable "and if it isn't?" branch.
87
+ * - **Optional, in a capability interface**, when some backend genuinely *cannot* without
88
+ * infrastructure the consumer has to adopt. The only such capability today is
89
+ * {@link LexicalVectorStore.searchText}: RediSearch has BM25 natively, Postgres would need a
90
+ * `tsvector` column plus a GIN index plus a migration the consumer must run, and
91
+ * `MemoryVectorStore` would have to implement BM25 itself.
92
+ *
93
+ * The test is whether a backend can do it at all — not whether adding it would inconvenience an
94
+ * implementer who has already shipped.
95
+ *
96
+ * Implementations MUST apply every filter with exactly the same semantics as
97
+ * {@link VectorStore.search} — including the empty-array deny primitive — because the filter is
98
+ * routinely an access-control boundary, not a hint. On {@link VectorStore.removeWhere} that is not a
99
+ * correctness nicety but the single most destructive thing an implementation could get wrong.
36
100
  */
37
101
  interface VectorStore {
38
102
  upsert(records: VectorRecord[]): Promise<void>;
@@ -45,6 +109,38 @@ interface VectorStore {
45
109
  * without a preceding `remove` leaves the old tail orphaned.
46
110
  */
47
111
  remove(documentId: string): Promise<void>;
112
+ /**
113
+ * Rewrite a document's metadata **without touching its text or its embeddings** — the update path
114
+ * for a dimension that is genuinely mutable (which bases a document is relevant to, who may see it,
115
+ * what it was reclassified as) rather than derived from its content.
116
+ *
117
+ * Without this, a consumer whose documents get re-classified has exactly two options, and both are
118
+ * bad: re-embed the whole document — paying the model bill, and burning the ingestion budget, to
119
+ * change a string — or refuse to stamp the dimension onto chunks at all and resolve it at query
120
+ * time instead, which turns a filter the index could have applied into a join the caller has to.
121
+ *
122
+ * `patch` is applied to **every chunk** of the document (metadata is a document-level property that
123
+ * happens to be stored per chunk), with the merge semantics documented on {@link MetadataPatch}:
124
+ * partial merge, `null` to remove a key, `undefined` ignored, values — including arrays — replaced
125
+ * wholesale.
126
+ *
127
+ * Returns **the number of chunks written**. An unknown `documentId` updates nothing and returns
128
+ * `0`; it does not throw. That is deliberate and matches {@link VectorStore.remove}, which is
129
+ * likewise silent on a document that isn't there: this is a reconciliation-shaped API, called by a
130
+ * loop diffing a source of truth against the index, and such a loop races with ingestion and
131
+ * deletion by construction. Turning "the document was removed between the diff and the write" into
132
+ * an exception would make the ordinary case an error, and the caller would have to catch and
133
+ * swallow it to get back the behaviour it wanted. The count gives the same information without
134
+ * that: `0` means nothing was there. (An empty patch also writes nothing and returns `0` — the
135
+ * return counts chunks *written*, not chunks *matched*.)
136
+ *
137
+ * Implementations MUST keep every representation of the metadata in step. A store that keeps both
138
+ * a filterable projection and a returned blob (RediSearch's `meta_*` TAGs beside its
139
+ * `metadata_json`) and updates only one makes the store lie to itself: a chunk that *filters* as
140
+ * one value but *reports* another, where which half you notice depends on whether you searched or
141
+ * read.
142
+ */
143
+ updateMetadata(documentId: string, patch: MetadataPatch): Promise<number>;
48
144
  /**
49
145
  * List the distinct source documents currently indexed (chunk ids collapsed back to their document
50
146
  * by stripping the trailing `#<n>`), each with a representative chunk's `metadata`, optionally
@@ -53,6 +149,53 @@ interface VectorStore {
53
149
  * `size` changed), or {@link VectorStore.remove remove} (orphaned).
54
150
  */
55
151
  listDocuments(filter?: Record<string, unknown>): Promise<IndexedDocument[]>;
152
+ /**
153
+ * The distinct source document ids currently indexed, optionally narrowed by a metadata `filter` —
154
+ * {@link VectorStore.listDocuments} without the metadata. Use it when you only need the id set (a
155
+ * reconciliation diff against your source of truth, a count, a delete list): implementations are
156
+ * expected to skip fetching and parsing per-chunk metadata entirely, which is most of the cost of
157
+ * `listDocuments` on a large corpus.
158
+ *
159
+ * An empty-array filter value denies, yielding `[]`.
160
+ */
161
+ listDocumentIds(filter?: Record<string, unknown>): Promise<string[]>;
162
+ /**
163
+ * Delete every chunk of every one of `documentIds`, with the same per-document semantics as
164
+ * {@link VectorStore.remove} — but as one bulk operation rather than N independent ones. An empty
165
+ * array is a no-op.
166
+ *
167
+ * The gap this closes is asymptotic, not cosmetic: on the Redis adapter each `remove` is a `SCAN`
168
+ * that walks the whole keyspace, which in a real deployment also holds queues, workflow state and
169
+ * the app cache. Two thousand documents is two thousand full passes; this is one filtered query.
170
+ */
171
+ removeMany(documentIds: string[]): Promise<void>;
172
+ /**
173
+ * Delete every chunk matching a metadata `filter`, and resolve the number of **chunks** removed
174
+ * (not documents). The collection-maintenance primitive: dropping a collection, evicting a tenant,
175
+ * purging a retired audience.
176
+ *
177
+ * Two guard rails, because this is the one method here that destroys data:
178
+ *
179
+ * - **The empty-array deny is honoured, and it means "delete nothing".** `removeWhere({ audience: [] })`
180
+ * removes zero chunks. Treating a deny as "no filter" would delete the entire corpus, which is
181
+ * exactly inverted from what the caller asked for.
182
+ * - **The filter must be non-empty.** `removeWhere({})` throws {@link UnsafeRemovalError} rather
183
+ * than wiping the store, because an empty object is overwhelmingly more likely to be a filter that
184
+ * got built wrong (a dropped key, an `undefined` scope) than a deliberate request to delete
185
+ * everything. Deliberate mass deletion stays available and stays explicit:
186
+ * `removeMany(await store.listDocumentIds())`, or the backend's own drop.
187
+ *
188
+ * What it removes is exactly what a {@link VectorStore.search} carrying the same filter could
189
+ * reach — no more (it cannot escape the filter) and no less.
190
+ */
191
+ removeWhere(filter: Record<string, unknown>): Promise<number>;
192
+ /**
193
+ * How many **chunks** match `filter` (all of them when it is omitted) — a counted query, without
194
+ * transferring the chunks. Sizing a reindex, reporting a collection's footprint, and confirming a
195
+ * {@link VectorStore.removeWhere} did what you expected. An empty-array filter value denies,
196
+ * yielding `0`.
197
+ */
198
+ countChunks(filter?: Record<string, unknown>): Promise<number>;
56
199
  }
57
200
  /**
58
201
  * An **optional** capability a {@link VectorStore} may also implement: full-text (lexical) search
@@ -85,6 +228,19 @@ interface LexicalVectorStore extends VectorStore {
85
228
  * when it is not, without knowing which adapter it was handed.
86
229
  */
87
230
  declare function isLexicalVectorStore(store: VectorStore): store is LexicalVectorStore;
231
+ /**
232
+ * A destructive call was refused because its scope could not be trusted. Thrown by
233
+ * {@link VectorStore.removeWhere} — never for something that merely matched nothing (that
234
+ * is a legitimate result of `0`), only for a filter that would have deleted *more* than the caller
235
+ * plausibly meant.
236
+ */
237
+ declare class UnsafeRemovalError extends Error {
238
+ /** Why the removal was refused. */
239
+ readonly reason: 'empty-filter' | 'unindexed-field';
240
+ constructor(
241
+ /** Why the removal was refused. */
242
+ reason: 'empty-filter' | 'unindexed-field', message: string);
243
+ }
88
244
  /** A distinct source document as seen by the index — its id plus a representative chunk's metadata. */
89
245
  interface IndexedDocument {
90
246
  id: string;
@@ -96,12 +252,35 @@ declare function documentIdOf(chunkId: string): string;
96
252
  /**
97
253
  * An in-process {@link VectorStore} — cosine similarity over a Map, no infra. The reference adapter
98
254
  * for tests and small/embedded corpora; for production scale use `PgVectorStore` (or your own).
255
+ *
256
+ * It implements the enumeration and bulk-deletion half of {@link VectorStore} too. There is no
257
+ * performance argument for it here — every method below is a loop over the same Map — but a store that tests are
258
+ * written against has to behave identically to the one production runs, and the semantics that matter
259
+ * (`removeWhere` refusing an empty filter, an empty-array filter deleting nothing) are behavioural,
260
+ * not incidental to Redis.
99
261
  */
100
262
  declare class MemoryVectorStore implements VectorStore {
101
263
  private readonly records;
102
264
  upsert(records: VectorRecord[]): Promise<void>;
103
265
  remove(documentId: string): Promise<void>;
266
+ /**
267
+ * Merge `patch` into every chunk of `documentId`, leaving `text` and `embedding` untouched. The
268
+ * record is *replaced* rather than mutated: `upsert` stores the caller's own object, and patching
269
+ * it in place would edit an object the caller still holds — a surprise no out-of-process store
270
+ * could reproduce, and therefore one this reference adapter must not teach.
271
+ */
272
+ updateMetadata(documentId: string, patch: MetadataPatch): Promise<number>;
104
273
  listDocuments(filter?: Record<string, unknown>): Promise<IndexedDocument[]>;
274
+ listDocumentIds(filter?: Record<string, unknown>): Promise<string[]>;
275
+ removeMany(documentIds: string[]): Promise<void>;
276
+ /**
277
+ * See {@link VectorStore.removeWhere}. The empty-array short-circuit below is redundant
278
+ * against `matchesFilter` (which already matches nothing for an empty array) and is kept anyway:
279
+ * this is the method where "the filter accidentally means everything" destroys data, so the deny is
280
+ * stated where a reader can see it rather than inferred from another module's truth table.
281
+ */
282
+ removeWhere(filter: Record<string, unknown>): Promise<number>;
283
+ countChunks(filter?: Record<string, unknown>): Promise<number>;
105
284
  search(embedding: number[], options: VectorSearchOptions): Promise<Passage[]>;
106
285
  }
107
286
 
@@ -142,6 +321,10 @@ interface PgVectorStoreOptions {
142
321
  * Both want a migration a consumer runs and observes, not a side effect of upgrading the library. The
143
322
  * capability interface is open, so a `PgLexicalVectorStore` (or an option here, gated on an
144
323
  * explicitly-created index) can be added later without changing anything else.
324
+ *
325
+ * The enumeration and bulk-deletion methods {@link VectorStore} requires need nothing new here,
326
+ * unlike the lexical capability above: enumeration and bulk deletion are `DISTINCT`, `= ANY($1)`, `DELETE … WHERE`
327
+ * and `count(*)` over the table and index that already exist. No DDL, no boot-time lock, no migration.
145
328
  */
146
329
  declare class PgVectorStore implements VectorStore {
147
330
  private readonly client;
@@ -152,7 +335,31 @@ declare class PgVectorStore implements VectorStore {
152
335
  ensureSchema(): Promise<void>;
153
336
  upsert(records: VectorRecord[]): Promise<void>;
154
337
  remove(documentId: string): Promise<void>;
338
+ /**
339
+ * Rewrite a document's metadata without re-embedding it. See {@link VectorStore.updateMetadata}
340
+ * for the semantics; here the whole thing is one statement, because jsonb already *is* the merge:
341
+ * `||` is a shallow key-wise merge (right side wins, arrays replaced wholesale — precisely
342
+ * {@link MetadataPatch}) and `- text[]` drops the keys the patch removed. `COALESCE` covers a chunk
343
+ * ingested with no metadata at all, so a patch can create the object rather than no-op on `NULL`.
344
+ *
345
+ * Unlike RediSearch there is no second representation to keep in step — the `metadata` column is
346
+ * both what `search` filters on and what it returns — so the correctness trap the Redis adapter has
347
+ * to work for simply does not exist here. `RETURNING id` is what supplies the chunk count, since
348
+ * {@link PgClient} is a rows-only surface with no `rowCount`.
349
+ */
350
+ updateMetadata(documentId: string, patch: MetadataPatch): Promise<number>;
155
351
  listDocuments(filter?: Record<string, unknown>): Promise<IndexedDocument[]>;
352
+ /** {@link listDocuments} without the metadata: one `DISTINCT` over the derived document id. */
353
+ listDocumentIds(filter?: Record<string, unknown>): Promise<string[]>;
354
+ /** N documents in one statement — the set-based form of {@link PgVectorStore.remove}. */
355
+ removeMany(documentIds: string[]): Promise<void>;
356
+ /**
357
+ * See {@link VectorStore.removeWhere}: empty filter object throws, empty-array value
358
+ * deletes nothing. The count comes from `RETURNING id` rather than a driver-specific `rowCount`,
359
+ * because {@link PgClient} is deliberately just "run SQL, get rows".
360
+ */
361
+ removeWhere(filter: Record<string, unknown>): Promise<number>;
362
+ countChunks(filter?: Record<string, unknown>): Promise<number>;
156
363
  search(embedding: number[], options: VectorSearchOptions): Promise<Passage[]>;
157
364
  }
158
365
 
@@ -259,8 +466,120 @@ declare class RedisVectorStore implements LexicalVectorStore {
259
466
  private reconcileSchema;
260
467
  private createIndex;
261
468
  upsert(records: VectorRecord[]): Promise<void>;
469
+ /**
470
+ * Single-document deletion, by `SCAN … MATCH`.
471
+ *
472
+ * This deliberately stays a keyspace scan even though the rest of this class no longer needs one.
473
+ * The obvious optimisation is to stamp each chunk with its document id as an implicit filterable
474
+ * TAG at {@link RedisVectorStore.upsert} and turn `remove` into an `FT.SEARCH` — and that is exactly
475
+ * the change that must not ship, because chunks written *before* it carry no such field. `FT.SEARCH`
476
+ * would find zero of them, `remove` would return happily, and a document that has been "deleted"
477
+ * would go on being retrieved. A delete that silently stops deleting is worse than a slow one, and
478
+ * the library cannot tell a fully-migrated corpus from an unmigrated one: both look like "this
479
+ * document has no indexed chunks". Nor is a search-then-fall-back-to-scan hybrid safe — a document
480
+ * re-ingested after the upgrade has *some* stamped chunks, so the search finds hits, the fallback
481
+ * never runs, and the unstamped tail survives. That is the same silent failure with a smaller blast
482
+ * radius, which makes it harder to notice rather than less wrong.
483
+ *
484
+ * The consumer pain this was measured against — deleting a whole collection, one `remove` per
485
+ * document — is answered instead by {@link RedisVectorStore.removeWhere} (one filtered query, no
486
+ * keyspace scan at all) and {@link RedisVectorStore.removeMany} (one scan for N documents rather
487
+ * than N scans). Neither depends on a field that older chunks lack, so neither has a migration.
488
+ */
262
489
  remove(documentId: string): Promise<void>;
490
+ /**
491
+ * Rewrite the metadata of every chunk of `documentId` in place, leaving `text` and `embedding`
492
+ * alone. See {@link VectorStore.updateMetadata} for the merge semantics and the return value.
493
+ *
494
+ * The care this needs is entirely about the fact that a chunk stores its metadata **twice**: as the
495
+ * `metadata_json` blob (what {@link RedisVectorStore.search} reads back onto a `Passage`) and as
496
+ * `meta_<field>` TAG fields (what RediSearch actually filters on, and only for the declared
497
+ * `filterableFields`). Move one without the other and the store lies to itself — a chunk that
498
+ * filters as the old value but reports the new one, or the reverse — so this method rewrites both
499
+ * from the same merged object:
500
+ *
501
+ * - a patched key that is **not** filterable lands in `metadata_json` only, and no stray `meta_*`
502
+ * field is invented for it (it would be dead weight the index does not know about);
503
+ * - a patched key that **is** filterable also rewrites its TAG, using the identical encoding
504
+ * `upsert` uses, so an updated chunk is indistinguishable from a re-ingested one;
505
+ * - a filterable key the patch **removes** has its TAG `HDEL`-ed, not left behind as a tag matching
506
+ * a value the document no longer carries.
507
+ *
508
+ * A plain `HSET` is all the index needs: RediSearch re-indexes a hash the moment it is written, so
509
+ * the very next `FT.SEARCH` sees the new TAG and no longer the old one — no reindex command, and
510
+ * no need to rewrite the (untouched, and expensive) vector to nudge it. `HDEL` on an indexed field
511
+ * likewise drops it from the index. Verified against a real Redis Stack in
512
+ * `redis-vector-store.db.spec.ts`, not assumed.
513
+ *
514
+ * Removals are issued **before** the write, so the only window a concurrent reader can observe is
515
+ * "TAG already gone, `metadata_json` not yet updated" — which fails *closed* on a filter carrying
516
+ * an ACL. The reverse order would briefly leave a document tagged with a capability its metadata no
517
+ * longer claims. Read-modify-write across chunks is not transactional; concurrent writers to the
518
+ * same chunk are last-writer-wins, exactly as two concurrent `upsert`s already are.
519
+ */
520
+ updateMetadata(documentId: string, patch: MetadataPatch): Promise<number>;
521
+ /**
522
+ * Every chunk key that currently EXISTS for `documentId` — the bare `${prefix}${documentId}` plus
523
+ * the `${documentId}#<n>` chunks found by `SCAN`. One definition of "chunk belongs to document",
524
+ * shared by `remove` and `updateMetadata`, so the two can never disagree about which keys a
525
+ * document owns. The glob is escaped so `gone` does not reach into `goner#0`.
526
+ */
527
+ private chunkKeys;
263
528
  listDocuments(filter?: Record<string, unknown>): Promise<IndexedDocument[]>;
529
+ /**
530
+ * The id-only half of {@link RedisVectorStore.listDocuments}, served by `FT.SEARCH … NOCONTENT`:
531
+ * RediSearch returns bare keys, so nothing carries `metadata_json` over the wire and nothing is
532
+ * JSON-parsed. `listDocuments` has to parse one metadata blob per *chunk* — thousands of parses to
533
+ * produce a few hundred ids it then throws the metadata away from — and this does none of it.
534
+ *
535
+ * The collapse to document ids still happens client-side. RediSearch can group server-side
536
+ * (`FT.AGGREGATE … GROUPBY`), but only over an indexed *field*, and a chunk's document id lives in
537
+ * its key, not in a field — deriving it would mean stamping one at write time, which is the
538
+ * migration {@link RedisVectorStore.remove} explains this class refuses. Deduplicating a page of
539
+ * keys in JS is free by comparison.
540
+ */
541
+ listDocumentIds(filter?: Record<string, unknown>): Promise<string[]>;
542
+ /**
543
+ * Bulk deletion by document id — **one** keyspace scan for N documents, where N calls to
544
+ * {@link RedisVectorStore.remove} would be N of them. The scan is `MATCH ${prefix}*`, so Redis still
545
+ * filters to this store's own keys server-side; membership is then decided in JS against a `Set`,
546
+ * which is what lets a single pass serve any number of ids.
547
+ *
548
+ * It deletes by key, exactly as `remove` does, so it is correct on chunks written by any version of
549
+ * this library — there is no field it needs the corpus to already carry.
550
+ */
551
+ removeMany(documentIds: string[]): Promise<void>;
552
+ /**
553
+ * Delete every chunk matching `filter`, via `FT.SEARCH … NOCONTENT` + `DEL` — no keyspace scan at
554
+ * all, so dropping a collection costs one indexed query per page instead of one full pass per
555
+ * document. Resolves the number of chunk keys actually deleted.
556
+ *
557
+ * The two refusals, in this order, before any query is issued:
558
+ *
559
+ * 1. **An empty filter object throws** {@link UnsafeRemovalError}. `buildFilter({})` is `*`, and `*`
560
+ * here would mean "delete the corpus" — far too destructive an outcome for the shape a filter
561
+ * degenerates to when it is built wrong.
562
+ * 2. **An empty-array value deletes nothing** and resolves `0`. This is the package's deny primitive
563
+ * (an actor with no capability tokens), and it is the one that has to be gated *explicitly*: a
564
+ * plausible-looking "normalise the filter first" refactor that drops empty arrays turns a deny
565
+ * into `*` and wipes everything the deny existed to protect.
566
+ *
567
+ * Then one more, which is specific to RediSearch: every filter key must be a declared
568
+ * `filterableFields` entry. `search` lets the engine reject an unknown field, which is fine for a
569
+ * read; for a delete the check is worth making up front and by name.
570
+ *
571
+ * Deletion happens page by page, always re-querying from offset 0 — the rows just deleted are gone
572
+ * from the index, so paging forward would skip their replacements. If a page comes back non-empty
573
+ * but `DEL` reports nothing removed (keys expired between query and delete, or the index is stale),
574
+ * the loop stops rather than spinning on results it can never consume.
575
+ */
576
+ removeWhere(filter: Record<string, unknown>): Promise<number>;
577
+ /**
578
+ * Count matching chunks with `FT.SEARCH … LIMIT 0 0` — RediSearch returns the total and no
579
+ * documents, so the cost is the query, not the corpus. An empty-array filter value denies and
580
+ * yields `0`, same as every other filtered path here.
581
+ */
582
+ countChunks(filter?: Record<string, unknown>): Promise<number>;
264
583
  search(embedding: number[], options: VectorSearchOptions): Promise<Passage[]>;
265
584
  /**
266
585
  * Lexical (BM25) search over the same index `search` uses — the {@link LexicalVectorStore} half of
@@ -276,6 +595,29 @@ declare class RedisVectorStore implements LexicalVectorStore {
276
595
  searchText(query: string, options: VectorSearchOptions): Promise<Passage[]>;
277
596
  }
278
597
 
598
+ interface EmbeddingRetrieverOptions {
599
+ /**
600
+ * Drop passages scoring below this, so a query the corpus cannot answer returns nothing.
601
+ *
602
+ * A vector search always returns its `topK` nearest neighbours, and "nearest" is not "relevant".
603
+ * Ask a corpus of maintenance policy about the dining hall menu and it hands back the five least
604
+ * unrelated paragraphs it has, each with a score, in confident rank order. Nothing in that result
605
+ * says the answer is absent, so without a floor the consumer either feeds a model five irrelevant
606
+ * passages or invents a threshold of its own.
607
+ *
608
+ * **This is the only place in the package where a threshold is meaningful, which is why it lives
609
+ * here and not on `HybridRetriever`.** The score here comes straight from the store's vector
610
+ * search: a calibrated similarity, comparable across queries and monotonic in how well the passage
611
+ * matches. A fused score is neither — see the score section on `HybridRetriever`. Threshold each
612
+ * leg, then fuse; never the other way round.
613
+ *
614
+ * Pick the number by measuring, not by taste: score questions you know the corpus answers against
615
+ * questions you know it does not, and put the floor in the gap between the two populations. It is
616
+ * specific to the embedding model and the distance metric, so it does not survive changing either.
617
+ * Left unset nothing is dropped, which is the previous behaviour exactly.
618
+ */
619
+ minScore?: number;
620
+ }
279
621
  /**
280
622
  * Bridges an {@link EmbeddingProvider} + {@link VectorStore} into the core {@link Retriever} SPI:
281
623
  * embed the query, then vector-search. This is what you wire as the agent's retriever (as a tool
@@ -284,7 +626,8 @@ declare class RedisVectorStore implements LexicalVectorStore {
284
626
  declare class EmbeddingRetriever implements Retriever {
285
627
  private readonly embedder;
286
628
  private readonly store;
287
- constructor(embedder: EmbeddingProvider, store: VectorStore);
629
+ private readonly options;
630
+ constructor(embedder: EmbeddingProvider, store: VectorStore, options?: EmbeddingRetrieverOptions);
288
631
  retrieve(query: string, options?: RetrieveOptions): Promise<Passage[]>;
289
632
  }
290
633
 
@@ -405,6 +748,36 @@ interface HybridRetrieverOptions {
405
748
  * vector search with lexical BM25. RRF scores each passage by `Σ weight / (k + rank)` across the
406
749
  * lists it appears in, so it needs no score normalization between incompatible scales (cosine vs
407
750
  * BM25). Deduplicates by passage id, so aligned chunk ids from the two retrievers reinforce.
751
+ *
752
+ * ## The score on a fused passage is not a similarity. Do not threshold it.
753
+ *
754
+ * Read this before using `passage.score` for anything other than the order it already produced.
755
+ *
756
+ * Not normalizing between scales is what makes RRF robust, and the price is that the number it
757
+ * emits is a function of **rank alone** — it never sees a similarity at all. Two consequences that
758
+ * bite in practice:
759
+ *
760
+ * - **It is not comparable to the score on a passage from {@link EmbeddingRetriever}**, even though
761
+ * it arrives in the same field, of the same type, from the same `Retriever` interface. There is no
762
+ * discriminator; a consumer holding a `Passage[]` cannot tell which kind of number it has. If you
763
+ * surface scores to a user or a model, know which retriever produced them.
764
+ * - **Its range is narrow, fixed, and says nothing about quality.** Every possible score lies in
765
+ * `[minWeight / (k + fetchTopK), Σ weights / (k + 1)]` — with the defaults (`k = 60`,
766
+ * `fetchTopK = 20`, two unweighted legs) that is `[0.0125, 0.0328]`. A perfect match and the
767
+ * least-bad member of a set of terrible matches both come back around `0.016`.
768
+ *
769
+ * The second point is why a relevance floor cannot live here. What the fused score actually measures
770
+ * is how strongly the legs *agree* — and agreement tracks nothing about correctness. When both legs
771
+ * return the same nearest nothing for an unanswerable query, they reinforce it to the ceiling, the
772
+ * same ceiling a unanimous correct answer gets. Measured on one corpus, the dense leg alone
773
+ * separated cleanly (weakest real answer 0.244, loudest absent-topic answer 0.143) while those same
774
+ * queries after fusion scored 0.032 and 0.033 — inverted. On another corpus the inversion may not
775
+ * appear; what does not change is that both numbers live in a band roughly one part in fifty wide,
776
+ * with no room for a threshold to mean anything.
777
+ *
778
+ * So: put the floor on the legs, with {@link EmbeddingRetrieverOptions.minScore}, and fuse what
779
+ * survives. `HybridRetriever` deliberately has no `minScore` of its own; a threshold option here
780
+ * would be an invitation to apply it to a number that cannot support one.
408
781
  */
409
782
  declare class HybridRetriever implements Retriever {
410
783
  private readonly retrievers;
@@ -470,4 +843,4 @@ interface RetrievalTool {
470
843
  */
471
844
  declare function createRetrievalTool(retriever: Retriever, options?: RetrievalToolOptions): RetrievalTool;
472
845
 
473
- export { type ChunkOptions, type ChunkRecord, EmbeddingRetriever, FilteredRetriever, HybridRetriever, type HybridRetrieverOptions, type IndexedDocument, type IngestChunksOptions, type IngestDocument, type IngestOptions, KeywordRetriever, type KeywordRetrieverOptions, LexicalRetriever, type LexicalVectorStore, MemoryVectorStore, type PgClient, PgVectorStore, type PgVectorStoreOptions, type RedisSearchClient, RedisVectorSchemaMismatchError, RedisVectorStore, type RedisVectorStoreOptions, RerankingRetriever, type RerankingRetrieverOptions, type RetrievalTool, type RetrievalToolOptions, type VectorRecord, type VectorSearchOptions, type VectorStore, chunkDocuments, chunkText, createRetrievalTool, documentIdOf, ingestChunks, ingestDocuments, isLexicalVectorStore };
846
+ export { type ChunkOptions, type ChunkRecord, EmbeddingRetriever, type EmbeddingRetrieverOptions, FilteredRetriever, HybridRetriever, type HybridRetrieverOptions, type IndexedDocument, type IngestChunksOptions, type IngestDocument, type IngestOptions, KeywordRetriever, type KeywordRetrieverOptions, LexicalRetriever, type LexicalVectorStore, MemoryVectorStore, type MetadataPatch, type PgClient, PgVectorStore, type PgVectorStoreOptions, type RedisSearchClient, RedisVectorSchemaMismatchError, RedisVectorStore, type RedisVectorStoreOptions, RerankingRetriever, type RerankingRetrieverOptions, type RetrievalTool, type RetrievalToolOptions, UnsafeRemovalError, type VectorRecord, type VectorSearchOptions, type VectorStore, applyMetadataPatch, chunkDocuments, chunkText, createRetrievalTool, documentIdOf, ingestChunks, ingestDocuments, isLexicalVectorStore };