@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 +208 -20
- package/dist/index.cjs +634 -26
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +422 -2
- package/dist/index.d.ts +422 -2
- package/dist/index.js +629 -25
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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,97 @@ 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>;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* An **optional** capability a {@link VectorStore} may also implement: full-text (lexical) search
|
|
202
|
+
* over the chunk text it already stores, using the backing engine's own keyword index — RediSearch's
|
|
203
|
+
* BM25 over the `TEXT` field, say. Its point is the multi-process deployment: when ingestion runs on
|
|
204
|
+
* a worker and search runs on an API pod, the API process never sees the chunks, so the in-process
|
|
205
|
+
* {@link import('./keyword-retriever.js').KeywordRetriever} has nothing to index. A store that
|
|
206
|
+
* implements this needs no second index, no corpus in JS heap, and no refresh window — a chunk is
|
|
207
|
+
* lexically findable the moment it is upserted.
|
|
208
|
+
*
|
|
209
|
+
* Wrap it as a core `Retriever` with {@link import('./lexical-retriever.js').LexicalRetriever}.
|
|
210
|
+
*
|
|
211
|
+
* Implementations MUST apply `options.filter` with exactly the same semantics as
|
|
212
|
+
* {@link VectorStore.search} — including the empty-array deny primitive — because the filter is
|
|
213
|
+
* routinely an access-control boundary, not a hint.
|
|
214
|
+
*/
|
|
215
|
+
interface LexicalVectorStore extends VectorStore {
|
|
216
|
+
/**
|
|
217
|
+
* Rank stored chunks against a natural-language `query` by keyword relevance. `options.filter`
|
|
218
|
+
* narrows exactly as in {@link VectorStore.search}. The returned `score` is the engine's own
|
|
219
|
+
* relevance score (BM25), which shares no scale with `search`'s cosine similarity — fuse the two
|
|
220
|
+
* with {@link import('./hybrid-retriever.js').HybridRetriever}, whose RRF is rank-based and so
|
|
221
|
+
* needs no common scale.
|
|
222
|
+
*/
|
|
223
|
+
searchText(query: string, options: VectorSearchOptions): Promise<Passage[]>;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Does this store carry the optional {@link LexicalVectorStore} capability? Lets a consumer pick the
|
|
227
|
+
* store-backed lexical leg when available and fall back to `KeywordRetriever` (or to dense-only)
|
|
228
|
+
* when it is not, without knowing which adapter it was handed.
|
|
229
|
+
*/
|
|
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);
|
|
56
243
|
}
|
|
57
244
|
/** A distinct source document as seen by the index — its id plus a representative chunk's metadata. */
|
|
58
245
|
interface IndexedDocument {
|
|
@@ -65,12 +252,35 @@ declare function documentIdOf(chunkId: string): string;
|
|
|
65
252
|
/**
|
|
66
253
|
* An in-process {@link VectorStore} — cosine similarity over a Map, no infra. The reference adapter
|
|
67
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.
|
|
68
261
|
*/
|
|
69
262
|
declare class MemoryVectorStore implements VectorStore {
|
|
70
263
|
private readonly records;
|
|
71
264
|
upsert(records: VectorRecord[]): Promise<void>;
|
|
72
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>;
|
|
73
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>;
|
|
74
284
|
search(embedding: number[], options: VectorSearchOptions): Promise<Passage[]>;
|
|
75
285
|
}
|
|
76
286
|
|
|
@@ -93,6 +303,28 @@ interface PgVectorStoreOptions {
|
|
|
93
303
|
* A pgvector-backed {@link VectorStore} — the production reference adapter. Cosine distance via the
|
|
94
304
|
* `<=>` operator over an HNSW index; metadata in a `jsonb` column filtered with `@>`. Call
|
|
95
305
|
* {@link PgVectorStore.ensureSchema} once at boot to create the extension, table, and index.
|
|
306
|
+
*
|
|
307
|
+
* This store does **not** implement the optional
|
|
308
|
+
* {@link import('./vector-store.js').LexicalVectorStore} capability, unlike
|
|
309
|
+
* {@link import('./redis-vector-store.js').RedisVectorStore}. That is a deliberate omission, not an
|
|
310
|
+
* oversight: RediSearch already indexes the chunk text (the schema declares it `TEXT`), so BM25 is
|
|
311
|
+
* free there, whereas the table below indexes nothing lexically — `text` is a plain column with no
|
|
312
|
+
* `tsvector` and no GIN index. Adding one is not a transparent upgrade:
|
|
313
|
+
*
|
|
314
|
+
* - it is new DDL in `ensureSchema`, and `CREATE INDEX` (non-concurrently, as this method must, since
|
|
315
|
+
* `CONCURRENTLY` cannot run in a transaction) takes a write lock on an already-populated chunks
|
|
316
|
+
* table — a boot-time stall proportional to corpus size for every existing deployment;
|
|
317
|
+
* - it forces a text-search-configuration choice (`english` vs `simple` vs …) that must match between
|
|
318
|
+
* the index expression and every query, or Postgres silently ignores the index and sequentially
|
|
319
|
+
* scans the corpus — a failure mode that stays *correct* while quietly getting slower with scale.
|
|
320
|
+
*
|
|
321
|
+
* Both want a migration a consumer runs and observes, not a side effect of upgrading the library. The
|
|
322
|
+
* capability interface is open, so a `PgLexicalVectorStore` (or an option here, gated on an
|
|
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.
|
|
96
328
|
*/
|
|
97
329
|
declare class PgVectorStore implements VectorStore {
|
|
98
330
|
private readonly client;
|
|
@@ -103,7 +335,31 @@ declare class PgVectorStore implements VectorStore {
|
|
|
103
335
|
ensureSchema(): Promise<void>;
|
|
104
336
|
upsert(records: VectorRecord[]): Promise<void>;
|
|
105
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>;
|
|
106
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>;
|
|
107
363
|
search(embedding: number[], options: VectorSearchOptions): Promise<Passage[]>;
|
|
108
364
|
}
|
|
109
365
|
|
|
@@ -157,19 +413,30 @@ interface RedisVectorStoreOptions {
|
|
|
157
413
|
* front, so only keys listed here can be passed in `search`'s `filter`. Default none.
|
|
158
414
|
*/
|
|
159
415
|
filterableFields?: string[];
|
|
416
|
+
/**
|
|
417
|
+
* RediSearch scorer used by {@link RedisVectorStore.searchText}. Default `BM25` — the standard
|
|
418
|
+
* lexical ranking, and the same family the in-process `KeywordRetriever` implements. Override for
|
|
419
|
+
* a server that names it differently (`BM25STD` on newer builds) or to pick `TFIDF`/`DISMAX`.
|
|
420
|
+
*/
|
|
421
|
+
lexicalScorer?: string;
|
|
160
422
|
}
|
|
161
423
|
/**
|
|
162
424
|
* A RediSearch-backed {@link VectorStore} — HNSW + cosine over Redis, the ecosystem-native option
|
|
163
425
|
* for anyone already running Redis (see `-transport-redis`). Chunks are hashes under `prefix`, the
|
|
164
426
|
* embedding a FLOAT32 vector field; `search` is a KNN query. Call {@link RedisVectorStore.ensureSchema}
|
|
165
427
|
* once at boot to create the index.
|
|
428
|
+
*
|
|
429
|
+
* The chunk text is declared as a RediSearch `TEXT` field, so this store also satisfies
|
|
430
|
+
* {@link LexicalVectorStore}: {@link RedisVectorStore.searchText} is BM25 over that same index —
|
|
431
|
+
* the lexical half of hybrid search with no second index to build, feed, or invalidate.
|
|
166
432
|
*/
|
|
167
|
-
declare class RedisVectorStore implements
|
|
433
|
+
declare class RedisVectorStore implements LexicalVectorStore {
|
|
168
434
|
private readonly client;
|
|
169
435
|
private readonly index;
|
|
170
436
|
private readonly prefix;
|
|
171
437
|
private readonly dimensions;
|
|
172
438
|
private readonly filterableFields;
|
|
439
|
+
private readonly lexicalScorer;
|
|
173
440
|
constructor(client: RedisSearchClient, options?: RedisVectorStoreOptions);
|
|
174
441
|
/**
|
|
175
442
|
* Idempotent, and drift-aware: create the HNSW/cosine index if `FT.INFO` says it doesn't exist —
|
|
@@ -199,9 +466,133 @@ declare class RedisVectorStore implements VectorStore {
|
|
|
199
466
|
private reconcileSchema;
|
|
200
467
|
private createIndex;
|
|
201
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
|
+
*/
|
|
202
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;
|
|
203
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>;
|
|
204
583
|
search(embedding: number[], options: VectorSearchOptions): Promise<Passage[]>;
|
|
584
|
+
/**
|
|
585
|
+
* Lexical (BM25) search over the same index `search` uses — the {@link LexicalVectorStore} half of
|
|
586
|
+
* hybrid retrieval, served by RediSearch's own keyword index rather than an in-process copy of the
|
|
587
|
+
* corpus. `options.filter` is ANDed onto the text clause with the identical TAG semantics as
|
|
588
|
+
* `search` (array = OR, empty array = deny), so a lexical hit can never escape a filter a vector
|
|
589
|
+
* hit would have respected.
|
|
590
|
+
*
|
|
591
|
+
* A query with no searchable terms — empty, whitespace, or pure punctuation — returns `[]`. That is
|
|
592
|
+
* deliberate: RediSearch's `*` would match the entire corpus, which is the opposite of what a
|
|
593
|
+
* relevance query with nothing to be relevant to should do.
|
|
594
|
+
*/
|
|
595
|
+
searchText(query: string, options: VectorSearchOptions): Promise<Passage[]>;
|
|
205
596
|
}
|
|
206
597
|
|
|
207
598
|
/**
|
|
@@ -291,6 +682,35 @@ declare class KeywordRetriever implements Retriever {
|
|
|
291
682
|
retrieve(query: string, options?: RetrieveOptions): Promise<Passage[]>;
|
|
292
683
|
}
|
|
293
684
|
|
|
685
|
+
/**
|
|
686
|
+
* The keyword half of hybrid search, served by the store's own full-text index — the mirror of
|
|
687
|
+
* {@link import('./embedding-retriever.js').EmbeddingRetriever}, which serves the dense half. It
|
|
688
|
+
* holds no state: every query goes to the store, so any process that can reach the store can run
|
|
689
|
+
* lexical search, whether or not it did the ingestion.
|
|
690
|
+
*
|
|
691
|
+
* Compose it exactly like the dense leg:
|
|
692
|
+
*
|
|
693
|
+
* ```ts
|
|
694
|
+
* const retriever = new HybridRetriever([
|
|
695
|
+
* new EmbeddingRetriever(embedder, store),
|
|
696
|
+
* new LexicalRetriever(store),
|
|
697
|
+
* ]);
|
|
698
|
+
* ```
|
|
699
|
+
*
|
|
700
|
+
* Both legs read the same chunks from the same index, so their ids line up by construction and
|
|
701
|
+
* `HybridRetriever`'s RRF fuses them without either side needing a score on a common scale.
|
|
702
|
+
*
|
|
703
|
+
* Prefer this over {@link import('./keyword-retriever.js').KeywordRetriever} whenever the store
|
|
704
|
+
* supports it (see {@link import('./vector-store.js').isLexicalVectorStore}) and ingestion and
|
|
705
|
+
* search can run in different processes; `KeywordRetriever` remains the answer for `MemoryVectorStore`
|
|
706
|
+
* and single-process deployments.
|
|
707
|
+
*/
|
|
708
|
+
declare class LexicalRetriever implements Retriever {
|
|
709
|
+
private readonly store;
|
|
710
|
+
constructor(store: LexicalVectorStore);
|
|
711
|
+
retrieve(query: string, options?: RetrieveOptions): Promise<Passage[]>;
|
|
712
|
+
}
|
|
713
|
+
|
|
294
714
|
interface HybridRetrieverOptions {
|
|
295
715
|
/** RRF constant — larger flattens the rank weighting. Default 60 (the value from the original paper). */
|
|
296
716
|
k?: number;
|
|
@@ -369,4 +789,4 @@ interface RetrievalTool {
|
|
|
369
789
|
*/
|
|
370
790
|
declare function createRetrievalTool(retriever: Retriever, options?: RetrievalToolOptions): RetrievalTool;
|
|
371
791
|
|
|
372
|
-
export { type ChunkOptions, type ChunkRecord, EmbeddingRetriever, FilteredRetriever, HybridRetriever, type HybridRetrieverOptions, type IndexedDocument, type IngestChunksOptions, type IngestDocument, type IngestOptions, KeywordRetriever, type KeywordRetrieverOptions, 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 };
|
|
792
|
+
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 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 };
|