@devilsdev/rag-pipeline-utils 2.5.0 → 2.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/CHANGELOG.md +86 -0
- package/README.md +1 -1
- package/dist/index.cjs +1484 -147
- package/dist/index.d.ts +49 -0
- package/dist/index.mjs +1 -1
- package/dist/testing.cjs +2 -0
- package/dist/testing.d.ts +1 -0
- package/dist/testing.mjs +5 -0
- package/dist/types/adapters/ai-sdk.d.ts +53 -0
- package/dist/types/cache/component-keys.d.ts +7 -0
- package/dist/types/cache/memory-cache.d.ts +54 -0
- package/dist/types/cache/pipeline-cache.d.ts +66 -0
- package/dist/types/cache/redis-cache.d.ts +46 -0
- package/dist/types/connectors/anthropic-connector.d.ts +7 -2
- package/dist/types/connectors/cohere-connector.d.ts +7 -2
- package/dist/types/connectors/local-embedder.d.ts +9 -0
- package/dist/types/connectors/memory-retriever.d.ts +8 -0
- package/dist/types/connectors/ollama-connector.d.ts +7 -2
- package/dist/types/connectors/openai-connector.d.ts +16 -1
- package/dist/types/core/create-pipeline.d.ts +8 -0
- package/dist/types/core/plugin-registry.d.ts +136 -0
- package/dist/types/core/stage-contracts.d.ts +36 -0
- package/dist/types/core/validate-plugin.d.ts +45 -0
- package/dist/types/evaluate/semantic-similarity.d.ts +14 -0
- package/dist/types/grounding/check-answer.d.ts +101 -0
- package/dist/types/retrieval/file-vector-store.d.ts +12 -0
- package/dist/types/testing.d.ts +168 -0
- package/package.json +22 -7
- package/src/adapters/ai-sdk.js +215 -0
- package/src/cache/component-keys.js +38 -0
- package/src/cache/memory-cache.js +87 -0
- package/src/cache/pipeline-cache.js +260 -0
- package/src/cache/redis-cache.js +83 -0
- package/src/cli/commands/ai.js +17 -12
- package/src/cli/commands/plugin-hub.js +18 -13
- package/src/cli/enhanced-cli-commands.js +45 -29
- package/src/cli/handlers/query-handler.js +174 -11
- package/src/cli/services/instrument.js +83 -0
- package/src/cli/services/rag-service.js +51 -12
- package/src/cli/wizard/init-wizard.js +2 -17
- package/src/config/enhanced-ragrc-schema.js +0 -2
- package/src/config/validate-schema.js +4 -1
- package/src/connectors/anthropic-connector.js +22 -2
- package/src/connectors/cohere-connector.js +20 -2
- package/src/connectors/local-embedder.js +20 -0
- package/src/connectors/memory-retriever.js +18 -0
- package/src/connectors/ollama-connector.js +29 -2
- package/src/connectors/openai-connector.js +49 -4
- package/src/core/create-pipeline.js +112 -15
- package/src/core/performance/parallel-processor.js +62 -1
- package/src/core/plugin-registry.js +42 -19
- package/src/core/stage-contracts.js +36 -0
- package/src/core/validate-plugin.js +85 -0
- package/src/debug/execution-tracer.js +14 -4
- package/src/dev/dev-server.js +8 -1
- package/src/dev/hot-reload.js +4 -3
- package/src/enterprise/sso-integration.js +4 -3
- package/src/evaluate/semantic-similarity.js +79 -0
- package/src/grounding/check-answer.js +173 -0
- package/src/index.js +22 -0
- package/src/loader/csv-loader.js +3 -2
- package/src/loader/html-loader.js +3 -1
- package/src/loader/markdown-loader.js +3 -2
- package/src/retrieval/file-vector-store.js +30 -1
- package/src/security/jwt-validator.js +5 -4
- package/src/testing.js +254 -0
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
// axios loads on first use, so importing the package does not pay for it
|
|
4
|
+
// (about 125 ms) unless this connector is constructed.
|
|
5
|
+
const loadAxios = () => require('axios'); // eslint-disable-line global-require
|
|
6
|
+
const { componentCacheKey } = require('../cache/component-keys');
|
|
4
7
|
const { BaseConnector } = require('./base-connector');
|
|
5
8
|
const {
|
|
6
9
|
missingApiKeyError,
|
|
@@ -37,6 +40,21 @@ const DEFAULT_CONFIG = {
|
|
|
37
40
|
* @extends BaseConnector
|
|
38
41
|
*/
|
|
39
42
|
class CohereConnector extends BaseConnector {
|
|
43
|
+
/**
|
|
44
|
+
* Cache identity for createRagPipeline({ cache }).
|
|
45
|
+
* @param {'embedder'} role
|
|
46
|
+
* @returns {string|null}
|
|
47
|
+
*/
|
|
48
|
+
getCacheKey(role) {
|
|
49
|
+
if (role !== 'embedder') return null;
|
|
50
|
+
const c = this.config;
|
|
51
|
+
return componentCacheKey('cohere-embed', {
|
|
52
|
+
baseURL: c.baseURL,
|
|
53
|
+
model: c.embeddingModel,
|
|
54
|
+
queryInputType: c.queryInputType,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
40
58
|
/**
|
|
41
59
|
* @param {Object} [options]
|
|
42
60
|
* @param {string} [options.apiKey] - Cohere API key
|
|
@@ -53,7 +71,7 @@ class CohereConnector extends BaseConnector {
|
|
|
53
71
|
...options,
|
|
54
72
|
apiKey: resolveApiKey(options, API_KEY_ENV),
|
|
55
73
|
};
|
|
56
|
-
this.client =
|
|
74
|
+
this.client = loadAxios().create({
|
|
57
75
|
baseURL: this.config.baseURL,
|
|
58
76
|
headers: {
|
|
59
77
|
Authorization: `Bearer ${this.config.apiKey}`,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
+
const { digest } = require('../cache/component-keys');
|
|
2
3
|
|
|
3
4
|
const { BaseConnector } = require('./base-connector');
|
|
4
5
|
const { tokenize } = require('../evaluate/scoring');
|
|
@@ -42,6 +43,7 @@ class LocalEmbedder extends BaseConnector {
|
|
|
42
43
|
* @param {Array<string|{ content?: string, text?: string }>} documents - Training documents
|
|
43
44
|
*/
|
|
44
45
|
train(documents) {
|
|
46
|
+
this._fingerprint = null; // the cache key changes with the training
|
|
45
47
|
const docs = documents.map((doc) => {
|
|
46
48
|
if (typeof doc === 'string') return doc;
|
|
47
49
|
return doc.content || doc.text || String(doc);
|
|
@@ -171,11 +173,29 @@ class LocalEmbedder extends BaseConnector {
|
|
|
171
173
|
* @param {{docCount: number, vocabulary: Array<[string, number]>, idf: Array<[string, number]>}} state
|
|
172
174
|
*/
|
|
173
175
|
setState(state) {
|
|
176
|
+
this._fingerprint = null;
|
|
174
177
|
this.docCount = state.docCount || 0;
|
|
175
178
|
this.vocabulary = new Map(state.vocabulary || []);
|
|
176
179
|
this.idf = new Map(state.idf || []);
|
|
177
180
|
}
|
|
178
181
|
|
|
182
|
+
/**
|
|
183
|
+
* Cache identity for createRagPipeline({ cache }): a fingerprint of the
|
|
184
|
+
* trained vocabulary and IDF weights, so retraining invalidates cached
|
|
185
|
+
* query vectors (also across processes sharing a Redis cache).
|
|
186
|
+
* @param {'embedder'} role
|
|
187
|
+
* @returns {string|null}
|
|
188
|
+
*/
|
|
189
|
+
getCacheKey(role) {
|
|
190
|
+
if (role !== 'embedder') return null;
|
|
191
|
+
if (!this._fingerprint) {
|
|
192
|
+
this._fingerprint = digest(
|
|
193
|
+
JSON.stringify([this.docCount, [...this.vocabulary], [...this.idf]]),
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
return `local-embedder|${this._fingerprint}`;
|
|
197
|
+
}
|
|
198
|
+
|
|
179
199
|
/** @returns {number} Vector length produced by embed() once trained */
|
|
180
200
|
get dimensions() {
|
|
181
201
|
return this.vocabulary.size;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
+
const { randomBytes } = require('crypto');
|
|
2
3
|
|
|
3
4
|
const { BaseConnector } = require('./base-connector');
|
|
4
5
|
const { throwIfAborted } = require('./connector-utils');
|
|
@@ -40,6 +41,21 @@ class MemoryRetriever extends BaseConnector {
|
|
|
40
41
|
/** @type {number|null} Vector dimension, fixed by the first stored vector */
|
|
41
42
|
this.dimensions = null;
|
|
42
43
|
this.connected = true;
|
|
44
|
+
// Cache identity: unique per instance, bumped on every change, so a
|
|
45
|
+
// shared cache never returns another store's or an older state's results.
|
|
46
|
+
this._cacheId = randomBytes(8).toString('hex');
|
|
47
|
+
this._revision = 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Cache identity for createRagPipeline({ cache }).
|
|
52
|
+
* @param {'retriever'} role
|
|
53
|
+
* @returns {string|null}
|
|
54
|
+
*/
|
|
55
|
+
getCacheKey(role) {
|
|
56
|
+
return role === 'retriever'
|
|
57
|
+
? `memory|${this._cacheId}|${this._revision}`
|
|
58
|
+
: null;
|
|
43
59
|
}
|
|
44
60
|
|
|
45
61
|
/**
|
|
@@ -111,6 +127,7 @@ class MemoryRetriever extends BaseConnector {
|
|
|
111
127
|
this.emit('evicted', { count: evicted, total: this.documents.size });
|
|
112
128
|
}
|
|
113
129
|
|
|
130
|
+
this._revision += 1;
|
|
114
131
|
this.emit('stored', { count: vectors.length, total: this.documents.size });
|
|
115
132
|
}
|
|
116
133
|
|
|
@@ -209,6 +226,7 @@ class MemoryRetriever extends BaseConnector {
|
|
|
209
226
|
async clear() {
|
|
210
227
|
this.documents.clear();
|
|
211
228
|
this.dimensions = null;
|
|
229
|
+
this._revision += 1;
|
|
212
230
|
}
|
|
213
231
|
|
|
214
232
|
/**
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
// axios loads on first use, so importing the package does not pay for it
|
|
4
|
+
// (about 125 ms) unless this connector is constructed.
|
|
5
|
+
const loadAxios = () => require('axios'); // eslint-disable-line global-require
|
|
4
6
|
const { BaseConnector } = require('./base-connector');
|
|
5
7
|
const {
|
|
6
8
|
raceAbort,
|
|
@@ -8,6 +10,7 @@ const {
|
|
|
8
10
|
throwIfAborted,
|
|
9
11
|
} = require('./connector-utils');
|
|
10
12
|
const { buildRagPrompt } = require('./rag-prompt');
|
|
13
|
+
const { componentCacheKey } = require('../cache/component-keys');
|
|
11
14
|
|
|
12
15
|
/**
|
|
13
16
|
* Default configuration for the Ollama connector.
|
|
@@ -30,6 +33,30 @@ const DEFAULT_CONFIG = {
|
|
|
30
33
|
* @extends BaseConnector
|
|
31
34
|
*/
|
|
32
35
|
class OllamaConnector extends BaseConnector {
|
|
36
|
+
/**
|
|
37
|
+
* Cache identity for createRagPipeline({ cache }).
|
|
38
|
+
* @param {'embedder'|'llm'} role
|
|
39
|
+
* @returns {string|null}
|
|
40
|
+
*/
|
|
41
|
+
getCacheKey(role) {
|
|
42
|
+
const c = this.config;
|
|
43
|
+
if (role === 'embedder') {
|
|
44
|
+
return componentCacheKey('ollama-embed', {
|
|
45
|
+
baseURL: c.baseURL,
|
|
46
|
+
model: c.embeddingModel,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
if (role === 'llm') {
|
|
50
|
+
return componentCacheKey('ollama', {
|
|
51
|
+
baseURL: c.baseURL,
|
|
52
|
+
model: c.model,
|
|
53
|
+
temperature: c.temperature,
|
|
54
|
+
systemPrompt: c.systemPrompt,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
33
60
|
/**
|
|
34
61
|
* @param {Object} options
|
|
35
62
|
* @param {string} [options.baseURL='http://localhost:11434'] - Ollama server URL
|
|
@@ -44,7 +71,7 @@ class OllamaConnector extends BaseConnector {
|
|
|
44
71
|
constructor(options = {}) {
|
|
45
72
|
super({ name: 'ollama', ...options });
|
|
46
73
|
this.config = { ...DEFAULT_CONFIG, ...options };
|
|
47
|
-
this.client =
|
|
74
|
+
this.client = loadAxios().create({
|
|
48
75
|
baseURL: this.config.baseURL,
|
|
49
76
|
timeout: this.config.timeout,
|
|
50
77
|
});
|
|
@@ -8,6 +8,7 @@ const {
|
|
|
8
8
|
throwIfAborted,
|
|
9
9
|
} = require('./connector-utils');
|
|
10
10
|
const { buildRagPrompt } = require('./rag-prompt');
|
|
11
|
+
const { componentCacheKey } = require('../cache/component-keys');
|
|
11
12
|
|
|
12
13
|
const API_KEY_ENV = 'OPENAI_API_KEY';
|
|
13
14
|
|
|
@@ -36,23 +37,61 @@ const DEFAULT_CONFIG = {
|
|
|
36
37
|
* @extends BaseConnector
|
|
37
38
|
*/
|
|
38
39
|
class OpenAIConnector extends BaseConnector {
|
|
40
|
+
/**
|
|
41
|
+
* Cache identity for createRagPipeline({ cache }): the settings that
|
|
42
|
+
* change this connector's output in the given role.
|
|
43
|
+
* @param {'embedder'|'llm'} role
|
|
44
|
+
* @returns {string|null}
|
|
45
|
+
*/
|
|
46
|
+
getCacheKey(role) {
|
|
47
|
+
const c = this.config;
|
|
48
|
+
if (role === 'embedder') {
|
|
49
|
+
return componentCacheKey('openai-embed', {
|
|
50
|
+
baseURL: c.baseURL,
|
|
51
|
+
model: c.embeddingModel,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
if (role === 'llm') {
|
|
55
|
+
return componentCacheKey('openai', {
|
|
56
|
+
baseURL: c.baseURL,
|
|
57
|
+
model: c.model,
|
|
58
|
+
temperature: c.temperature,
|
|
59
|
+
maxTokens: c.maxTokens,
|
|
60
|
+
systemPrompt: c.systemPrompt,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
39
66
|
/**
|
|
40
67
|
* @param {Object} [options]
|
|
41
68
|
* @param {string} [options.model='gpt-4o-mini'] - Chat model to use
|
|
42
69
|
* @param {string} [options.embeddingModel='text-embedding-3-small'] - Embedding model
|
|
43
70
|
* @param {number} [options.temperature=0.7] - Sampling temperature
|
|
44
71
|
* @param {number} [options.maxTokens=4096] - Max tokens in completion
|
|
45
|
-
* @param {string} [options.apiKey] -
|
|
72
|
+
* @param {string} [options.apiKey] - API key. Defaults to OPENAI_API_KEY at
|
|
73
|
+
* construction, except when `baseURL` is set (see below)
|
|
74
|
+
* @param {string} [options.baseURL] - Base URL of an OpenAI-compatible API,
|
|
75
|
+
* e.g. Groq, Together, the Hugging Face router, vLLM, LM Studio or
|
|
76
|
+
* Ollama's `/v1` endpoint. With a custom base URL the key is never read
|
|
77
|
+
* from OPENAI_API_KEY (that would send your OpenAI key to another
|
|
78
|
+
* provider): pass `apiKey` explicitly, or omit it for servers that need
|
|
79
|
+
* none
|
|
46
80
|
* @param {number} [options.maxRetries=3] - Retries for transient failures (the SDK's own retries are disabled)
|
|
47
81
|
* @param {number} [options.retryDelay=1000] - Base backoff delay in ms
|
|
48
82
|
* @param {string} [options.systemPrompt] - System prompt used when RAG context is supplied
|
|
49
83
|
*/
|
|
50
84
|
constructor(options = {}) {
|
|
51
85
|
super({ name: 'openai', ...options });
|
|
86
|
+
const baseURL = options.baseURL ? String(options.baseURL) : undefined;
|
|
52
87
|
this.config = {
|
|
53
88
|
...DEFAULT_CONFIG,
|
|
54
89
|
...options,
|
|
55
|
-
|
|
90
|
+
baseURL,
|
|
91
|
+
// A custom endpoint gets only an explicit key, never OPENAI_API_KEY.
|
|
92
|
+
apiKey: baseURL
|
|
93
|
+
? options.apiKey || ''
|
|
94
|
+
: resolveApiKey(options, API_KEY_ENV),
|
|
56
95
|
};
|
|
57
96
|
this._client = null; // lazy-loaded
|
|
58
97
|
}
|
|
@@ -67,7 +106,12 @@ class OpenAIConnector extends BaseConnector {
|
|
|
67
106
|
if (!this._client) {
|
|
68
107
|
const mod = require('openai');
|
|
69
108
|
const OpenAI = mod.OpenAI || mod.default || mod;
|
|
70
|
-
this._client = new OpenAI({
|
|
109
|
+
this._client = new OpenAI({
|
|
110
|
+
// Local OpenAI-compatible servers accept any key; the SDK needs one.
|
|
111
|
+
apiKey: this.config.apiKey || (this.config.baseURL ? 'not-needed' : ''),
|
|
112
|
+
...(this.config.baseURL && { baseURL: this.config.baseURL }),
|
|
113
|
+
maxRetries: 0,
|
|
114
|
+
});
|
|
71
115
|
}
|
|
72
116
|
return this._client;
|
|
73
117
|
}
|
|
@@ -77,7 +121,8 @@ class OpenAIConnector extends BaseConnector {
|
|
|
77
121
|
* @private
|
|
78
122
|
*/
|
|
79
123
|
_requireApiKey() {
|
|
80
|
-
|
|
124
|
+
// Only the OpenAI API itself requires a key; a custom baseURL may not.
|
|
125
|
+
if (!this.config.apiKey && !this.config.baseURL) {
|
|
81
126
|
throw missingApiKeyError('OpenAI', API_KEY_ENV);
|
|
82
127
|
}
|
|
83
128
|
}
|
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
// `pluginRegistry.register()` are resolvable by name here.
|
|
4
4
|
const defaultRegistry = require('./plugin-registry.js');
|
|
5
5
|
const { embedQuery, canEmbed } = require('../utils/embed-query');
|
|
6
|
+
const {
|
|
7
|
+
normalizeCacheOption,
|
|
8
|
+
PipelineCache,
|
|
9
|
+
} = require('../cache/pipeline-cache');
|
|
6
10
|
const { createRunAbort } = require('../utils/run-abort');
|
|
7
11
|
|
|
8
12
|
/** Metrics that only need the query, the retrieved docs and a reference. */
|
|
@@ -102,6 +106,26 @@ async function* guardStream(source, { signal, finalize, onEnd }) {
|
|
|
102
106
|
}
|
|
103
107
|
}
|
|
104
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Pass a stream through and report its text when it completed: it reached a
|
|
111
|
+
* done chunk or its end, and the run was not aborted. A stream the consumer
|
|
112
|
+
* stops reading early is not reported, so a partial answer is never cached.
|
|
113
|
+
*/
|
|
114
|
+
async function* reportWhenComplete(source, signal, onComplete) {
|
|
115
|
+
let text = '';
|
|
116
|
+
let completed = false;
|
|
117
|
+
try {
|
|
118
|
+
for await (const chunk of source) {
|
|
119
|
+
text += typeof chunk === 'string' ? chunk : String(chunk?.token ?? '');
|
|
120
|
+
if (chunk && chunk.done) completed = true;
|
|
121
|
+
yield chunk;
|
|
122
|
+
}
|
|
123
|
+
completed = true;
|
|
124
|
+
} finally {
|
|
125
|
+
if (completed && !signal.aborted) await onComplete(text);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
105
129
|
/**
|
|
106
130
|
* Create a RAG pipeline: (embed) -> retrieve -> (filter) -> (rerank) ->
|
|
107
131
|
* generate -> (citations, evaluation).
|
|
@@ -116,6 +140,12 @@ async function* guardStream(source, { signal, finalize, onEnd }) {
|
|
|
116
140
|
* `referenceAnswer` (ground truth for context recall)
|
|
117
141
|
* - `filterResults(docs)`: hook applied after retrieval
|
|
118
142
|
*
|
|
143
|
+
* `cache` (factory option): `true`, a CacheBackend, or
|
|
144
|
+
* `{ embeddings, retrieval, generation, ttlSeconds }` - see
|
|
145
|
+
* src/cache/pipeline-cache.js. Results then carry `cache: { embeddings,
|
|
146
|
+
* retrieval, generation }` ('hit', 'miss' or 'skip'), and the pipeline has
|
|
147
|
+
* `cacheStats()`.
|
|
148
|
+
*
|
|
119
149
|
* Every component call receives the run's AbortSignal: `retriever.retrieve({
|
|
120
150
|
* ..., signal })`, `embedder.embedQuery(text, { signal })` /
|
|
121
151
|
* `embedder.embed(text, { signal })`, `reranker.rerank(query, docs, { topK,
|
|
@@ -133,6 +163,7 @@ module.exports.createRagPipeline = function createRagPipeline({
|
|
|
133
163
|
retriever,
|
|
134
164
|
llm,
|
|
135
165
|
reranker,
|
|
166
|
+
cache,
|
|
136
167
|
} = {}) {
|
|
137
168
|
const reg = registry || defaultRegistry;
|
|
138
169
|
const loaderI = loader ? resolve(reg, 'loader', loader) : null;
|
|
@@ -147,6 +178,16 @@ module.exports.createRagPipeline = function createRagPipeline({
|
|
|
147
178
|
const llmI = llm ? resolve(reg, 'llm', llm) : null;
|
|
148
179
|
const rerankerI = reranker ? resolve(reg, 'reranker', reranker) : null;
|
|
149
180
|
|
|
181
|
+
const cacheLayers = normalizeCacheOption(cache);
|
|
182
|
+
const pc = cacheLayers
|
|
183
|
+
? new PipelineCache(cacheLayers, {
|
|
184
|
+
embedder: embedderI,
|
|
185
|
+
// The default stand-in retriever returns nothing; never cache it.
|
|
186
|
+
retriever: retriever ? retrieverI : null,
|
|
187
|
+
llm: llmI,
|
|
188
|
+
})
|
|
189
|
+
: null;
|
|
190
|
+
|
|
150
191
|
async function runOnce({ query, queryVector, options = {} } = {}) {
|
|
151
192
|
const opts = options || {};
|
|
152
193
|
const { timeout, stream } = opts;
|
|
@@ -154,6 +195,11 @@ module.exports.createRagPipeline = function createRagPipeline({
|
|
|
154
195
|
const { signal } = run;
|
|
155
196
|
let streaming = false;
|
|
156
197
|
|
|
198
|
+
// Per-run cache outcome per layer: 'hit', 'miss' or 'skip'.
|
|
199
|
+
const cacheStatus = {};
|
|
200
|
+
const withCache = (result) =>
|
|
201
|
+
pc ? { ...result, cache: { ...cacheStatus } } : result;
|
|
202
|
+
|
|
157
203
|
const doWork = (async () => {
|
|
158
204
|
run.throwIfAborted();
|
|
159
205
|
if (loaderI?.load) {
|
|
@@ -162,16 +208,34 @@ module.exports.createRagPipeline = function createRagPipeline({
|
|
|
162
208
|
}
|
|
163
209
|
let vector = queryVector;
|
|
164
210
|
if (!vector && query && canEmbed(embedderI)) {
|
|
165
|
-
|
|
211
|
+
const key = pc && pc.embeddingKey(query);
|
|
212
|
+
vector = pc && (await pc.get('embeddings', key, cacheStatus));
|
|
213
|
+
if (!vector) {
|
|
214
|
+
vector = await embedQuery(embedderI, query, { signal });
|
|
215
|
+
run.throwIfAborted();
|
|
216
|
+
if (pc) await pc.set('embeddings', key, vector);
|
|
217
|
+
}
|
|
166
218
|
run.throwIfAborted();
|
|
167
219
|
}
|
|
168
220
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
221
|
+
// Raw retriever output is cached before filterResults (guardrail ACLs)
|
|
222
|
+
// and the reranker, so both still run on every request.
|
|
223
|
+
const topK = opts.topK ?? 3;
|
|
224
|
+
const retrievalKey =
|
|
225
|
+
pc && pc.retrievalKey({ query, queryVector: vector, topK });
|
|
226
|
+
let docs = pc && (await pc.get('retrieval', retrievalKey, cacheStatus));
|
|
227
|
+
if (!docs) {
|
|
228
|
+
docs = await retrieverI.retrieve({
|
|
229
|
+
query,
|
|
230
|
+
queryVector: vector,
|
|
231
|
+
topK,
|
|
232
|
+
signal,
|
|
233
|
+
});
|
|
234
|
+
run.throwIfAborted();
|
|
235
|
+
if (pc && Array.isArray(docs)) {
|
|
236
|
+
await pc.set('retrieval', retrievalKey, docs);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
175
239
|
run.throwIfAborted();
|
|
176
240
|
// Hook for filtering (e.g. ACL guardrails) before anything downstream,
|
|
177
241
|
// including the LLM, sees the retrieved documents.
|
|
@@ -190,6 +254,15 @@ module.exports.createRagPipeline = function createRagPipeline({
|
|
|
190
254
|
|
|
191
255
|
const genOptions = { ...opts, signal };
|
|
192
256
|
const wantsQuality = Boolean(opts.citations || opts.evaluate);
|
|
257
|
+
const generationKey =
|
|
258
|
+
pc && llmI
|
|
259
|
+
? pc.generationKey({ query, docs: results, options: opts })
|
|
260
|
+
: null;
|
|
261
|
+
const cachedAnswer =
|
|
262
|
+
pc && llmI
|
|
263
|
+
? await pc.get('generation', generationKey, cacheStatus)
|
|
264
|
+
: undefined;
|
|
265
|
+
run.throwIfAborted();
|
|
193
266
|
|
|
194
267
|
if (stream) {
|
|
195
268
|
let source;
|
|
@@ -207,6 +280,11 @@ module.exports.createRagPipeline = function createRagPipeline({
|
|
|
207
280
|
});
|
|
208
281
|
source = (async function* noAnswer() {})();
|
|
209
282
|
finalize = () => ({ results, ...quality });
|
|
283
|
+
} else if (typeof cachedAnswer === 'string') {
|
|
284
|
+
source = (async function* cached() {
|
|
285
|
+
yield { token: cachedAnswer, done: false };
|
|
286
|
+
yield { token: '', done: true };
|
|
287
|
+
})();
|
|
210
288
|
} else if (llmI.generateStream) {
|
|
211
289
|
source = await llmI.generateStream(query, results, genOptions);
|
|
212
290
|
} else if (llmI.generate) {
|
|
@@ -221,6 +299,11 @@ module.exports.createRagPipeline = function createRagPipeline({
|
|
|
221
299
|
if (source) {
|
|
222
300
|
run.throwIfAborted();
|
|
223
301
|
streaming = true;
|
|
302
|
+
if (pc && llmI && generationKey && typeof cachedAnswer !== 'string') {
|
|
303
|
+
source = reportWhenComplete(source, signal, (text) =>
|
|
304
|
+
pc.set('generation', generationKey, text),
|
|
305
|
+
);
|
|
306
|
+
}
|
|
224
307
|
return guardStream(source, {
|
|
225
308
|
signal,
|
|
226
309
|
finalize,
|
|
@@ -230,26 +313,33 @@ module.exports.createRagPipeline = function createRagPipeline({
|
|
|
230
313
|
}
|
|
231
314
|
|
|
232
315
|
if (!llmI) {
|
|
233
|
-
return {
|
|
316
|
+
return withCache({
|
|
234
317
|
success: true,
|
|
235
318
|
query,
|
|
236
319
|
results,
|
|
237
320
|
answer: null,
|
|
238
321
|
...computeQuality({ query, answer: null, results, options: opts }),
|
|
239
|
-
};
|
|
322
|
+
});
|
|
240
323
|
}
|
|
241
324
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
325
|
+
let answer;
|
|
326
|
+
if (typeof cachedAnswer === 'string') {
|
|
327
|
+
answer = cachedAnswer;
|
|
328
|
+
} else {
|
|
329
|
+
const raw = (await llmI.generate?.(query, results, genOptions)) ?? '';
|
|
330
|
+
run.throwIfAborted();
|
|
331
|
+
answer = String(raw);
|
|
332
|
+
if (pc) await pc.set('generation', generationKey, answer);
|
|
333
|
+
run.throwIfAborted();
|
|
334
|
+
}
|
|
245
335
|
|
|
246
|
-
return {
|
|
336
|
+
return withCache({
|
|
247
337
|
success: true,
|
|
248
338
|
query,
|
|
249
339
|
results,
|
|
250
340
|
answer,
|
|
251
341
|
...computeQuality({ query, answer, results, options: opts }),
|
|
252
|
-
};
|
|
342
|
+
});
|
|
253
343
|
})();
|
|
254
344
|
|
|
255
345
|
try {
|
|
@@ -264,5 +354,12 @@ module.exports.createRagPipeline = function createRagPipeline({
|
|
|
264
354
|
}
|
|
265
355
|
}
|
|
266
356
|
|
|
267
|
-
return {
|
|
357
|
+
return {
|
|
358
|
+
run: runOnce,
|
|
359
|
+
cleanup() {},
|
|
360
|
+
/** Cache hits, misses and skips per layer; null when caching is off. */
|
|
361
|
+
cacheStats() {
|
|
362
|
+
return pc ? pc.stats() : null;
|
|
363
|
+
},
|
|
364
|
+
};
|
|
268
365
|
};
|
|
@@ -36,16 +36,77 @@ class Semaphore {
|
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/** Built-in embedders whose embed() takes one string, not an array. */
|
|
40
|
+
function isSingleTextEmbedder(embedder) {
|
|
41
|
+
/* eslint-disable global-require */
|
|
42
|
+
const singleText = [
|
|
43
|
+
require('../../connectors/local-embedder').LocalEmbedder,
|
|
44
|
+
require('../../connectors/openai-connector').OpenAIConnector,
|
|
45
|
+
require('../../connectors/ollama-connector').OllamaConnector,
|
|
46
|
+
require('../../connectors/cohere-connector').CohereConnector,
|
|
47
|
+
];
|
|
48
|
+
/* eslint-enable global-require */
|
|
49
|
+
return singleText.some((Class) => Class && embedder instanceof Class);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const EMBED_MODES = ['auto', 'batch', 'single'];
|
|
53
|
+
|
|
39
54
|
/**
|
|
40
55
|
* Parallel processor for embedding operations with configurable concurrency
|
|
41
56
|
*/
|
|
42
57
|
class ParallelEmbedder {
|
|
58
|
+
/**
|
|
59
|
+
* @param {object} embedder - Embedder with embed() and/or embedBatch()
|
|
60
|
+
* @param {object} [_options]
|
|
61
|
+
* @param {number} [_options.batchSize=10] - Texts per batch
|
|
62
|
+
* @param {number} [_options.maxConcurrency=3] - Batches in flight at once
|
|
63
|
+
* @param {number} [_options.retryAttempts=2] - Retries per failed batch
|
|
64
|
+
* @param {number} [_options.retryDelay=1000] - Base retry delay in ms
|
|
65
|
+
* @param {'auto'|'batch'|'single'} [_options.mode='auto'] - How a batch is
|
|
66
|
+
* embedded: 'batch' calls embed(texts) and expects one vector per text,
|
|
67
|
+
* 'single' calls embed(text) for each text. 'auto' uses embedBatch(texts)
|
|
68
|
+
* when the embedder has it, 'single' for the built-in connectors (which
|
|
69
|
+
* embed one string at a time) and 'batch' otherwise.
|
|
70
|
+
*/
|
|
43
71
|
constructor(embedder, _options = {}) {
|
|
44
72
|
this.embedder = embedder;
|
|
45
73
|
this.batchSize = _options.batchSize || 10;
|
|
46
74
|
this.maxConcurrency = _options.maxConcurrency || 3;
|
|
47
75
|
this.retryAttempts = _options.retryAttempts || 2;
|
|
48
76
|
this.retryDelay = _options.retryDelay || 1000;
|
|
77
|
+
const mode = _options.mode || 'auto';
|
|
78
|
+
if (!EMBED_MODES.includes(mode)) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
`Invalid mode "${mode}". Expected one of: ${EMBED_MODES.join(', ')}`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
this.mode = mode;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Embed one batch according to the mode.
|
|
88
|
+
* @param {string[]} batch
|
|
89
|
+
* @returns {Promise<number[][]>}
|
|
90
|
+
* @private
|
|
91
|
+
*/
|
|
92
|
+
async _embed(batch) {
|
|
93
|
+
const embedder = this.embedder;
|
|
94
|
+
if (this.mode === 'auto' && typeof embedder.embedBatch === 'function') {
|
|
95
|
+
return embedder.embedBatch(batch);
|
|
96
|
+
}
|
|
97
|
+
const single =
|
|
98
|
+
this.mode === 'single' ||
|
|
99
|
+
(this.mode === 'auto' && isSingleTextEmbedder(embedder));
|
|
100
|
+
if (!single) {
|
|
101
|
+
return embedder.embed(batch);
|
|
102
|
+
}
|
|
103
|
+
// One text at a time within a batch, so maxConcurrency still bounds the
|
|
104
|
+
// number of requests in flight.
|
|
105
|
+
const vectors = [];
|
|
106
|
+
for (const text of batch) {
|
|
107
|
+
vectors.push(await embedder.embed(text));
|
|
108
|
+
}
|
|
109
|
+
return vectors;
|
|
49
110
|
}
|
|
50
111
|
|
|
51
112
|
/**
|
|
@@ -133,7 +194,7 @@ class ParallelEmbedder {
|
|
|
133
194
|
|
|
134
195
|
for (let attempt = 0; attempt <= this.retryAttempts; attempt++) {
|
|
135
196
|
try {
|
|
136
|
-
const result = await this.
|
|
197
|
+
const result = await this._embed(batch);
|
|
137
198
|
|
|
138
199
|
// Validate result
|
|
139
200
|
if (!Array.isArray(result) || result.length !== batch.length) {
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const path = require('path');
|
|
5
|
-
|
|
5
|
+
// ajv loads with the contracts; see _ensureContracts().
|
|
6
|
+
const loadAjv = () => require('ajv'); // eslint-disable-line global-require
|
|
6
7
|
const {
|
|
7
8
|
createPluginVerifier,
|
|
8
9
|
} = require('../security/plugin-signature-verifier.js');
|
|
@@ -42,7 +43,43 @@ class PluginRegistry {
|
|
|
42
43
|
? options.validateContractSchema
|
|
43
44
|
: true; // Enable schema validation by default
|
|
44
45
|
|
|
45
|
-
//
|
|
46
|
+
// Environment-aware signature verification defaults
|
|
47
|
+
const isDevelopment = process.env.NODE_ENV === 'development';
|
|
48
|
+
const isProduction = process.env.NODE_ENV === 'production';
|
|
49
|
+
|
|
50
|
+
this._signatureVerifier = createPluginVerifier({
|
|
51
|
+
enabled:
|
|
52
|
+
options.verifySignatures !== undefined
|
|
53
|
+
? options.verifySignatures
|
|
54
|
+
: isProduction, // Enabled by default in production, disabled in development
|
|
55
|
+
failClosed:
|
|
56
|
+
options.failClosed !== undefined ? options.failClosed : isProduction, // Fail closed in production, fail open in development
|
|
57
|
+
trustedKeysPath: options.trustedKeysPath,
|
|
58
|
+
requireContentDigest: options.requireContentDigest === true,
|
|
59
|
+
});
|
|
60
|
+
// Opt-in: when verification is enabled, refuse plugins registered
|
|
61
|
+
// without a signed manifest (default false for backward compatibility).
|
|
62
|
+
this._requireSignedPlugins = options.requireSignedPlugins === true;
|
|
63
|
+
|
|
64
|
+
// Contracts (and ajv, which validates them) load in the constructor, or
|
|
65
|
+
// on first use with `lazyContracts: true`. The shared registry exported
|
|
66
|
+
// by this module is lazy, so importing the package does not pay for
|
|
67
|
+
// them unless a plugin is registered or validated.
|
|
68
|
+
this._contractsLoaded = false;
|
|
69
|
+
if (!options.lazyContracts) {
|
|
70
|
+
this._ensureContracts();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Load the contract schema and the stage contracts once.
|
|
76
|
+
* @private
|
|
77
|
+
*/
|
|
78
|
+
_ensureContracts() {
|
|
79
|
+
if (this._contractsLoaded) return;
|
|
80
|
+
this._contractsLoaded = true;
|
|
81
|
+
|
|
82
|
+
const Ajv = loadAjv();
|
|
46
83
|
this._ajv = new Ajv({
|
|
47
84
|
allErrors: true,
|
|
48
85
|
verbose: true,
|
|
@@ -70,23 +107,6 @@ class PluginRegistry {
|
|
|
70
107
|
}
|
|
71
108
|
}
|
|
72
109
|
|
|
73
|
-
// Environment-aware signature verification defaults
|
|
74
|
-
const isDevelopment = process.env.NODE_ENV === 'development';
|
|
75
|
-
const isProduction = process.env.NODE_ENV === 'production';
|
|
76
|
-
|
|
77
|
-
this._signatureVerifier = createPluginVerifier({
|
|
78
|
-
enabled:
|
|
79
|
-
options.verifySignatures !== undefined
|
|
80
|
-
? options.verifySignatures
|
|
81
|
-
: isProduction, // Enabled by default in production, disabled in development
|
|
82
|
-
failClosed:
|
|
83
|
-
options.failClosed !== undefined ? options.failClosed : isProduction, // Fail closed in production, fail open in development
|
|
84
|
-
trustedKeysPath: options.trustedKeysPath,
|
|
85
|
-
requireContentDigest: options.requireContentDigest === true,
|
|
86
|
-
});
|
|
87
|
-
// Opt-in: when verification is enabled, refuse plugins registered
|
|
88
|
-
// without a signed manifest (default false for backward compatibility).
|
|
89
|
-
this._requireSignedPlugins = options.requireSignedPlugins === true;
|
|
90
110
|
this._loadContracts();
|
|
91
111
|
}
|
|
92
112
|
|
|
@@ -205,6 +225,7 @@ class PluginRegistry {
|
|
|
205
225
|
* @since 2.2.4
|
|
206
226
|
*/
|
|
207
227
|
checkContractExists(type) {
|
|
228
|
+
this._ensureContracts();
|
|
208
229
|
return this._contracts.has(type);
|
|
209
230
|
}
|
|
210
231
|
|
|
@@ -357,6 +378,7 @@ class PluginRegistry {
|
|
|
357
378
|
}
|
|
358
379
|
|
|
359
380
|
_validatePluginContract(category, impl) {
|
|
381
|
+
this._ensureContracts();
|
|
360
382
|
const contract = this._contracts.get(category);
|
|
361
383
|
if (!contract) {
|
|
362
384
|
// Use new warning method when registering a plugin without a contract
|
|
@@ -609,6 +631,7 @@ class PluginRegistry {
|
|
|
609
631
|
const registry = new PluginRegistry({
|
|
610
632
|
verifySignatures: process.env.NODE_ENV === 'production',
|
|
611
633
|
failClosed: process.env.NODE_ENV === 'production',
|
|
634
|
+
lazyContracts: true,
|
|
612
635
|
});
|
|
613
636
|
|
|
614
637
|
// CJS+ESM interop pattern
|