@dudousxd/nestjs-agent-rag 0.4.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +103 -19
- package/dist/index.cjs +93 -10
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +103 -2
- package/dist/index.d.ts +103 -2
- package/dist/index.js +90 -9
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -54,6 +54,37 @@ interface VectorStore {
|
|
|
54
54
|
*/
|
|
55
55
|
listDocuments(filter?: Record<string, unknown>): Promise<IndexedDocument[]>;
|
|
56
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* An **optional** capability a {@link VectorStore} may also implement: full-text (lexical) search
|
|
59
|
+
* over the chunk text it already stores, using the backing engine's own keyword index — RediSearch's
|
|
60
|
+
* BM25 over the `TEXT` field, say. Its point is the multi-process deployment: when ingestion runs on
|
|
61
|
+
* a worker and search runs on an API pod, the API process never sees the chunks, so the in-process
|
|
62
|
+
* {@link import('./keyword-retriever.js').KeywordRetriever} has nothing to index. A store that
|
|
63
|
+
* implements this needs no second index, no corpus in JS heap, and no refresh window — a chunk is
|
|
64
|
+
* lexically findable the moment it is upserted.
|
|
65
|
+
*
|
|
66
|
+
* Wrap it as a core `Retriever` with {@link import('./lexical-retriever.js').LexicalRetriever}.
|
|
67
|
+
*
|
|
68
|
+
* Implementations MUST apply `options.filter` with exactly the same semantics as
|
|
69
|
+
* {@link VectorStore.search} — including the empty-array deny primitive — because the filter is
|
|
70
|
+
* routinely an access-control boundary, not a hint.
|
|
71
|
+
*/
|
|
72
|
+
interface LexicalVectorStore extends VectorStore {
|
|
73
|
+
/**
|
|
74
|
+
* Rank stored chunks against a natural-language `query` by keyword relevance. `options.filter`
|
|
75
|
+
* narrows exactly as in {@link VectorStore.search}. The returned `score` is the engine's own
|
|
76
|
+
* relevance score (BM25), which shares no scale with `search`'s cosine similarity — fuse the two
|
|
77
|
+
* with {@link import('./hybrid-retriever.js').HybridRetriever}, whose RRF is rank-based and so
|
|
78
|
+
* needs no common scale.
|
|
79
|
+
*/
|
|
80
|
+
searchText(query: string, options: VectorSearchOptions): Promise<Passage[]>;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Does this store carry the optional {@link LexicalVectorStore} capability? Lets a consumer pick the
|
|
84
|
+
* store-backed lexical leg when available and fall back to `KeywordRetriever` (or to dense-only)
|
|
85
|
+
* when it is not, without knowing which adapter it was handed.
|
|
86
|
+
*/
|
|
87
|
+
declare function isLexicalVectorStore(store: VectorStore): store is LexicalVectorStore;
|
|
57
88
|
/** A distinct source document as seen by the index — its id plus a representative chunk's metadata. */
|
|
58
89
|
interface IndexedDocument {
|
|
59
90
|
id: string;
|
|
@@ -93,6 +124,24 @@ interface PgVectorStoreOptions {
|
|
|
93
124
|
* A pgvector-backed {@link VectorStore} — the production reference adapter. Cosine distance via the
|
|
94
125
|
* `<=>` operator over an HNSW index; metadata in a `jsonb` column filtered with `@>`. Call
|
|
95
126
|
* {@link PgVectorStore.ensureSchema} once at boot to create the extension, table, and index.
|
|
127
|
+
*
|
|
128
|
+
* This store does **not** implement the optional
|
|
129
|
+
* {@link import('./vector-store.js').LexicalVectorStore} capability, unlike
|
|
130
|
+
* {@link import('./redis-vector-store.js').RedisVectorStore}. That is a deliberate omission, not an
|
|
131
|
+
* oversight: RediSearch already indexes the chunk text (the schema declares it `TEXT`), so BM25 is
|
|
132
|
+
* free there, whereas the table below indexes nothing lexically — `text` is a plain column with no
|
|
133
|
+
* `tsvector` and no GIN index. Adding one is not a transparent upgrade:
|
|
134
|
+
*
|
|
135
|
+
* - it is new DDL in `ensureSchema`, and `CREATE INDEX` (non-concurrently, as this method must, since
|
|
136
|
+
* `CONCURRENTLY` cannot run in a transaction) takes a write lock on an already-populated chunks
|
|
137
|
+
* table — a boot-time stall proportional to corpus size for every existing deployment;
|
|
138
|
+
* - it forces a text-search-configuration choice (`english` vs `simple` vs …) that must match between
|
|
139
|
+
* the index expression and every query, or Postgres silently ignores the index and sequentially
|
|
140
|
+
* scans the corpus — a failure mode that stays *correct* while quietly getting slower with scale.
|
|
141
|
+
*
|
|
142
|
+
* Both want a migration a consumer runs and observes, not a side effect of upgrading the library. The
|
|
143
|
+
* capability interface is open, so a `PgLexicalVectorStore` (or an option here, gated on an
|
|
144
|
+
* explicitly-created index) can be added later without changing anything else.
|
|
96
145
|
*/
|
|
97
146
|
declare class PgVectorStore implements VectorStore {
|
|
98
147
|
private readonly client;
|
|
@@ -157,19 +206,30 @@ interface RedisVectorStoreOptions {
|
|
|
157
206
|
* front, so only keys listed here can be passed in `search`'s `filter`. Default none.
|
|
158
207
|
*/
|
|
159
208
|
filterableFields?: string[];
|
|
209
|
+
/**
|
|
210
|
+
* RediSearch scorer used by {@link RedisVectorStore.searchText}. Default `BM25` — the standard
|
|
211
|
+
* lexical ranking, and the same family the in-process `KeywordRetriever` implements. Override for
|
|
212
|
+
* a server that names it differently (`BM25STD` on newer builds) or to pick `TFIDF`/`DISMAX`.
|
|
213
|
+
*/
|
|
214
|
+
lexicalScorer?: string;
|
|
160
215
|
}
|
|
161
216
|
/**
|
|
162
217
|
* A RediSearch-backed {@link VectorStore} — HNSW + cosine over Redis, the ecosystem-native option
|
|
163
218
|
* for anyone already running Redis (see `-transport-redis`). Chunks are hashes under `prefix`, the
|
|
164
219
|
* embedding a FLOAT32 vector field; `search` is a KNN query. Call {@link RedisVectorStore.ensureSchema}
|
|
165
220
|
* once at boot to create the index.
|
|
221
|
+
*
|
|
222
|
+
* The chunk text is declared as a RediSearch `TEXT` field, so this store also satisfies
|
|
223
|
+
* {@link LexicalVectorStore}: {@link RedisVectorStore.searchText} is BM25 over that same index —
|
|
224
|
+
* the lexical half of hybrid search with no second index to build, feed, or invalidate.
|
|
166
225
|
*/
|
|
167
|
-
declare class RedisVectorStore implements
|
|
226
|
+
declare class RedisVectorStore implements LexicalVectorStore {
|
|
168
227
|
private readonly client;
|
|
169
228
|
private readonly index;
|
|
170
229
|
private readonly prefix;
|
|
171
230
|
private readonly dimensions;
|
|
172
231
|
private readonly filterableFields;
|
|
232
|
+
private readonly lexicalScorer;
|
|
173
233
|
constructor(client: RedisSearchClient, options?: RedisVectorStoreOptions);
|
|
174
234
|
/**
|
|
175
235
|
* Idempotent, and drift-aware: create the HNSW/cosine index if `FT.INFO` says it doesn't exist —
|
|
@@ -202,6 +262,18 @@ declare class RedisVectorStore implements VectorStore {
|
|
|
202
262
|
remove(documentId: string): Promise<void>;
|
|
203
263
|
listDocuments(filter?: Record<string, unknown>): Promise<IndexedDocument[]>;
|
|
204
264
|
search(embedding: number[], options: VectorSearchOptions): Promise<Passage[]>;
|
|
265
|
+
/**
|
|
266
|
+
* Lexical (BM25) search over the same index `search` uses — the {@link LexicalVectorStore} half of
|
|
267
|
+
* hybrid retrieval, served by RediSearch's own keyword index rather than an in-process copy of the
|
|
268
|
+
* corpus. `options.filter` is ANDed onto the text clause with the identical TAG semantics as
|
|
269
|
+
* `search` (array = OR, empty array = deny), so a lexical hit can never escape a filter a vector
|
|
270
|
+
* hit would have respected.
|
|
271
|
+
*
|
|
272
|
+
* A query with no searchable terms — empty, whitespace, or pure punctuation — returns `[]`. That is
|
|
273
|
+
* deliberate: RediSearch's `*` would match the entire corpus, which is the opposite of what a
|
|
274
|
+
* relevance query with nothing to be relevant to should do.
|
|
275
|
+
*/
|
|
276
|
+
searchText(query: string, options: VectorSearchOptions): Promise<Passage[]>;
|
|
205
277
|
}
|
|
206
278
|
|
|
207
279
|
/**
|
|
@@ -291,6 +363,35 @@ declare class KeywordRetriever implements Retriever {
|
|
|
291
363
|
retrieve(query: string, options?: RetrieveOptions): Promise<Passage[]>;
|
|
292
364
|
}
|
|
293
365
|
|
|
366
|
+
/**
|
|
367
|
+
* The keyword half of hybrid search, served by the store's own full-text index — the mirror of
|
|
368
|
+
* {@link import('./embedding-retriever.js').EmbeddingRetriever}, which serves the dense half. It
|
|
369
|
+
* holds no state: every query goes to the store, so any process that can reach the store can run
|
|
370
|
+
* lexical search, whether or not it did the ingestion.
|
|
371
|
+
*
|
|
372
|
+
* Compose it exactly like the dense leg:
|
|
373
|
+
*
|
|
374
|
+
* ```ts
|
|
375
|
+
* const retriever = new HybridRetriever([
|
|
376
|
+
* new EmbeddingRetriever(embedder, store),
|
|
377
|
+
* new LexicalRetriever(store),
|
|
378
|
+
* ]);
|
|
379
|
+
* ```
|
|
380
|
+
*
|
|
381
|
+
* Both legs read the same chunks from the same index, so their ids line up by construction and
|
|
382
|
+
* `HybridRetriever`'s RRF fuses them without either side needing a score on a common scale.
|
|
383
|
+
*
|
|
384
|
+
* Prefer this over {@link import('./keyword-retriever.js').KeywordRetriever} whenever the store
|
|
385
|
+
* supports it (see {@link import('./vector-store.js').isLexicalVectorStore}) and ingestion and
|
|
386
|
+
* search can run in different processes; `KeywordRetriever` remains the answer for `MemoryVectorStore`
|
|
387
|
+
* and single-process deployments.
|
|
388
|
+
*/
|
|
389
|
+
declare class LexicalRetriever implements Retriever {
|
|
390
|
+
private readonly store;
|
|
391
|
+
constructor(store: LexicalVectorStore);
|
|
392
|
+
retrieve(query: string, options?: RetrieveOptions): Promise<Passage[]>;
|
|
393
|
+
}
|
|
394
|
+
|
|
294
395
|
interface HybridRetrieverOptions {
|
|
295
396
|
/** RRF constant — larger flattens the rank weighting. Default 60 (the value from the original paper). */
|
|
296
397
|
k?: number;
|
|
@@ -369,4 +470,4 @@ interface RetrievalTool {
|
|
|
369
470
|
*/
|
|
370
471
|
declare function createRetrievalTool(retriever: Retriever, options?: RetrievalToolOptions): RetrievalTool;
|
|
371
472
|
|
|
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 };
|
|
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 };
|
package/dist/index.js
CHANGED
|
@@ -40,6 +40,10 @@ function chunkText(text, options = {}) {
|
|
|
40
40
|
__name(chunkText, "chunkText");
|
|
41
41
|
|
|
42
42
|
// src/vector-store.ts
|
|
43
|
+
function isLexicalVectorStore(store) {
|
|
44
|
+
return typeof store.searchText === "function";
|
|
45
|
+
}
|
|
46
|
+
__name(isLexicalVectorStore, "isLexicalVectorStore");
|
|
43
47
|
function documentIdOf(chunkId) {
|
|
44
48
|
return chunkId.replace(/#\d+$/, "");
|
|
45
49
|
}
|
|
@@ -291,12 +295,14 @@ var RedisVectorStore = class {
|
|
|
291
295
|
prefix;
|
|
292
296
|
dimensions;
|
|
293
297
|
filterableFields;
|
|
298
|
+
lexicalScorer;
|
|
294
299
|
constructor(client, options = {}) {
|
|
295
300
|
this.client = client;
|
|
296
301
|
this.index = options.index ?? "agent_rag_idx";
|
|
297
302
|
this.prefix = options.prefix ?? "agent_rag:";
|
|
298
303
|
this.dimensions = options.dimensions ?? 1536;
|
|
299
304
|
this.filterableFields = options.filterableFields ?? [];
|
|
305
|
+
this.lexicalScorer = options.lexicalScorer ?? "BM25";
|
|
300
306
|
}
|
|
301
307
|
/**
|
|
302
308
|
* Idempotent, and drift-aware: create the HNSW/cosine index if `FT.INFO` says it doesn't exist —
|
|
@@ -518,6 +524,47 @@ var RedisVectorStore = class {
|
|
|
518
524
|
]);
|
|
519
525
|
return parseSearchReply(reply, this.prefix);
|
|
520
526
|
}
|
|
527
|
+
/**
|
|
528
|
+
* Lexical (BM25) search over the same index `search` uses — the {@link LexicalVectorStore} half of
|
|
529
|
+
* hybrid retrieval, served by RediSearch's own keyword index rather than an in-process copy of the
|
|
530
|
+
* corpus. `options.filter` is ANDed onto the text clause with the identical TAG semantics as
|
|
531
|
+
* `search` (array = OR, empty array = deny), so a lexical hit can never escape a filter a vector
|
|
532
|
+
* hit would have respected.
|
|
533
|
+
*
|
|
534
|
+
* A query with no searchable terms — empty, whitespace, or pure punctuation — returns `[]`. That is
|
|
535
|
+
* deliberate: RediSearch's `*` would match the entire corpus, which is the opposite of what a
|
|
536
|
+
* relevance query with nothing to be relevant to should do.
|
|
537
|
+
*/
|
|
538
|
+
async searchText(query, options) {
|
|
539
|
+
if (filterMatchesNothing(options.filter)) {
|
|
540
|
+
return [];
|
|
541
|
+
}
|
|
542
|
+
const terms = tokenizeQuery(query);
|
|
543
|
+
if (terms.length === 0) {
|
|
544
|
+
return [];
|
|
545
|
+
}
|
|
546
|
+
const filterQuery = buildFilter(options.filter);
|
|
547
|
+
const textQuery = `@text:(${terms.join("|")})`;
|
|
548
|
+
const reply = await this.client.sendCommand([
|
|
549
|
+
"FT.SEARCH",
|
|
550
|
+
this.index,
|
|
551
|
+
filterQuery === "*" ? textQuery : `(${filterQuery} ${textQuery})`,
|
|
552
|
+
"SCORER",
|
|
553
|
+
this.lexicalScorer,
|
|
554
|
+
"WITHSCORES",
|
|
555
|
+
"RETURN",
|
|
556
|
+
"3",
|
|
557
|
+
"text",
|
|
558
|
+
"source",
|
|
559
|
+
"metadata_json",
|
|
560
|
+
"DIALECT",
|
|
561
|
+
"2",
|
|
562
|
+
"LIMIT",
|
|
563
|
+
"0",
|
|
564
|
+
String(options.topK)
|
|
565
|
+
]);
|
|
566
|
+
return parseSearchReply(reply, this.prefix, "lexical");
|
|
567
|
+
}
|
|
521
568
|
};
|
|
522
569
|
function parseIndexAttributes(reply) {
|
|
523
570
|
const attributes = /* @__PURE__ */ new Map();
|
|
@@ -596,6 +643,10 @@ function filterMatchesNothing(filter) {
|
|
|
596
643
|
return Object.values(filter).some((value) => Array.isArray(value) && value.length === 0);
|
|
597
644
|
}
|
|
598
645
|
__name(filterMatchesNothing, "filterMatchesNothing");
|
|
646
|
+
function tokenizeQuery(query) {
|
|
647
|
+
return query.match(/[\p{L}\p{N}_]+/gu) ?? [];
|
|
648
|
+
}
|
|
649
|
+
__name(tokenizeQuery, "tokenizeQuery");
|
|
599
650
|
function escapeTag(value) {
|
|
600
651
|
return value.replace(/[^a-zA-Z0-9_]/g, (char) => `\\${char}`);
|
|
601
652
|
}
|
|
@@ -632,17 +683,19 @@ function toStr(value) {
|
|
|
632
683
|
return Buffer.isBuffer(value) ? value.toString("utf8") : String(value);
|
|
633
684
|
}
|
|
634
685
|
__name(toStr, "toStr");
|
|
635
|
-
function parseSearchReply(reply, prefix) {
|
|
686
|
+
function parseSearchReply(reply, prefix, scoreSource = "vector") {
|
|
687
|
+
const stride = scoreSource === "lexical" ? 3 : 2;
|
|
636
688
|
if (Array.isArray(reply)) {
|
|
637
689
|
const passages = [];
|
|
638
|
-
for (let index = 1; index + 1 < reply.length; index +=
|
|
639
|
-
const rawFields = reply[index + 1];
|
|
690
|
+
for (let index = 1; index + stride - 1 < reply.length; index += stride) {
|
|
691
|
+
const rawFields = reply[index + stride - 1];
|
|
640
692
|
if (Array.isArray(rawFields)) {
|
|
641
693
|
const attrs = {};
|
|
642
694
|
for (let field = 0; field + 1 < rawFields.length; field += 2) {
|
|
643
695
|
attrs[toStr(rawFields[field])] = toStr(rawFields[field + 1]);
|
|
644
696
|
}
|
|
645
|
-
|
|
697
|
+
const inlineScore = scoreSource === "lexical" ? toStr(reply[index + 1]) : void 0;
|
|
698
|
+
passages.push(toPassage(toStr(reply[index]), attrs, prefix, scoreSource, inlineScore));
|
|
646
699
|
}
|
|
647
700
|
}
|
|
648
701
|
return passages;
|
|
@@ -650,12 +703,19 @@ function parseSearchReply(reply, prefix) {
|
|
|
650
703
|
if (typeof reply === "object" && reply !== null && "results" in reply) {
|
|
651
704
|
const results = reply.results;
|
|
652
705
|
if (Array.isArray(results)) {
|
|
653
|
-
return results.map((result) => toPassage(readId(result), readAttributes(result), prefix));
|
|
706
|
+
return results.map((result) => toPassage(readId(result), readAttributes(result), prefix, scoreSource, scoreSource === "lexical" ? readScore(result) : void 0));
|
|
654
707
|
}
|
|
655
708
|
}
|
|
656
709
|
return [];
|
|
657
710
|
}
|
|
658
711
|
__name(parseSearchReply, "parseSearchReply");
|
|
712
|
+
function readScore(result) {
|
|
713
|
+
if (typeof result === "object" && result !== null && "score" in result) {
|
|
714
|
+
return toStr(result.score);
|
|
715
|
+
}
|
|
716
|
+
return void 0;
|
|
717
|
+
}
|
|
718
|
+
__name(readScore, "readScore");
|
|
659
719
|
function readId(result) {
|
|
660
720
|
if (typeof result === "object" && result !== null && "id" in result) {
|
|
661
721
|
return toStr(result.id);
|
|
@@ -676,13 +736,13 @@ function readAttributes(result) {
|
|
|
676
736
|
return attrs;
|
|
677
737
|
}
|
|
678
738
|
__name(readAttributes, "readAttributes");
|
|
679
|
-
function toPassage(rawId, attrs, prefix) {
|
|
680
|
-
const
|
|
739
|
+
function toPassage(rawId, attrs, prefix, scoreSource = "vector", inlineScore) {
|
|
740
|
+
const score = scoreSource === "lexical" ? Number(inlineScore ?? "0") : 1 - Number(attrs.vector_score ?? "1");
|
|
681
741
|
const source = attrs.source;
|
|
682
742
|
return {
|
|
683
743
|
id: rawId.startsWith(prefix) ? rawId.slice(prefix.length) : rawId,
|
|
684
744
|
text: attrs.text ?? "",
|
|
685
|
-
score:
|
|
745
|
+
score: Number.isFinite(score) ? score : 0,
|
|
686
746
|
...source !== void 0 ? {
|
|
687
747
|
source
|
|
688
748
|
} : {},
|
|
@@ -856,6 +916,25 @@ function tokenize(text) {
|
|
|
856
916
|
}
|
|
857
917
|
__name(tokenize, "tokenize");
|
|
858
918
|
|
|
919
|
+
// src/lexical-retriever.ts
|
|
920
|
+
var LexicalRetriever = class {
|
|
921
|
+
static {
|
|
922
|
+
__name(this, "LexicalRetriever");
|
|
923
|
+
}
|
|
924
|
+
store;
|
|
925
|
+
constructor(store) {
|
|
926
|
+
this.store = store;
|
|
927
|
+
}
|
|
928
|
+
async retrieve(query, options = {}) {
|
|
929
|
+
return this.store.searchText(query, {
|
|
930
|
+
topK: options.topK ?? 5,
|
|
931
|
+
...options.filter !== void 0 ? {
|
|
932
|
+
filter: options.filter
|
|
933
|
+
} : {}
|
|
934
|
+
});
|
|
935
|
+
}
|
|
936
|
+
};
|
|
937
|
+
|
|
859
938
|
// src/hybrid-retriever.ts
|
|
860
939
|
var HybridRetriever = class {
|
|
861
940
|
static {
|
|
@@ -1042,6 +1121,7 @@ export {
|
|
|
1042
1121
|
FilteredRetriever,
|
|
1043
1122
|
HybridRetriever,
|
|
1044
1123
|
KeywordRetriever,
|
|
1124
|
+
LexicalRetriever,
|
|
1045
1125
|
MemoryVectorStore,
|
|
1046
1126
|
PgVectorStore,
|
|
1047
1127
|
RedisVectorSchemaMismatchError,
|
|
@@ -1052,6 +1132,7 @@ export {
|
|
|
1052
1132
|
createRetrievalTool,
|
|
1053
1133
|
documentIdOf,
|
|
1054
1134
|
ingestChunks,
|
|
1055
|
-
ingestDocuments
|
|
1135
|
+
ingestDocuments,
|
|
1136
|
+
isLexicalVectorStore
|
|
1056
1137
|
};
|
|
1057
1138
|
//# sourceMappingURL=index.js.map
|