@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.
Files changed (66) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +1 -1
  3. package/dist/index.cjs +1484 -147
  4. package/dist/index.d.ts +49 -0
  5. package/dist/index.mjs +1 -1
  6. package/dist/testing.cjs +2 -0
  7. package/dist/testing.d.ts +1 -0
  8. package/dist/testing.mjs +5 -0
  9. package/dist/types/adapters/ai-sdk.d.ts +53 -0
  10. package/dist/types/cache/component-keys.d.ts +7 -0
  11. package/dist/types/cache/memory-cache.d.ts +54 -0
  12. package/dist/types/cache/pipeline-cache.d.ts +66 -0
  13. package/dist/types/cache/redis-cache.d.ts +46 -0
  14. package/dist/types/connectors/anthropic-connector.d.ts +7 -2
  15. package/dist/types/connectors/cohere-connector.d.ts +7 -2
  16. package/dist/types/connectors/local-embedder.d.ts +9 -0
  17. package/dist/types/connectors/memory-retriever.d.ts +8 -0
  18. package/dist/types/connectors/ollama-connector.d.ts +7 -2
  19. package/dist/types/connectors/openai-connector.d.ts +16 -1
  20. package/dist/types/core/create-pipeline.d.ts +8 -0
  21. package/dist/types/core/plugin-registry.d.ts +136 -0
  22. package/dist/types/core/stage-contracts.d.ts +36 -0
  23. package/dist/types/core/validate-plugin.d.ts +45 -0
  24. package/dist/types/evaluate/semantic-similarity.d.ts +14 -0
  25. package/dist/types/grounding/check-answer.d.ts +101 -0
  26. package/dist/types/retrieval/file-vector-store.d.ts +12 -0
  27. package/dist/types/testing.d.ts +168 -0
  28. package/package.json +22 -7
  29. package/src/adapters/ai-sdk.js +215 -0
  30. package/src/cache/component-keys.js +38 -0
  31. package/src/cache/memory-cache.js +87 -0
  32. package/src/cache/pipeline-cache.js +260 -0
  33. package/src/cache/redis-cache.js +83 -0
  34. package/src/cli/commands/ai.js +17 -12
  35. package/src/cli/commands/plugin-hub.js +18 -13
  36. package/src/cli/enhanced-cli-commands.js +45 -29
  37. package/src/cli/handlers/query-handler.js +174 -11
  38. package/src/cli/services/instrument.js +83 -0
  39. package/src/cli/services/rag-service.js +51 -12
  40. package/src/cli/wizard/init-wizard.js +2 -17
  41. package/src/config/enhanced-ragrc-schema.js +0 -2
  42. package/src/config/validate-schema.js +4 -1
  43. package/src/connectors/anthropic-connector.js +22 -2
  44. package/src/connectors/cohere-connector.js +20 -2
  45. package/src/connectors/local-embedder.js +20 -0
  46. package/src/connectors/memory-retriever.js +18 -0
  47. package/src/connectors/ollama-connector.js +29 -2
  48. package/src/connectors/openai-connector.js +49 -4
  49. package/src/core/create-pipeline.js +112 -15
  50. package/src/core/performance/parallel-processor.js +62 -1
  51. package/src/core/plugin-registry.js +42 -19
  52. package/src/core/stage-contracts.js +36 -0
  53. package/src/core/validate-plugin.js +85 -0
  54. package/src/debug/execution-tracer.js +14 -4
  55. package/src/dev/dev-server.js +8 -1
  56. package/src/dev/hot-reload.js +4 -3
  57. package/src/enterprise/sso-integration.js +4 -3
  58. package/src/evaluate/semantic-similarity.js +79 -0
  59. package/src/grounding/check-answer.js +173 -0
  60. package/src/index.js +22 -0
  61. package/src/loader/csv-loader.js +3 -2
  62. package/src/loader/html-loader.js +3 -1
  63. package/src/loader/markdown-loader.js +3 -2
  64. package/src/retrieval/file-vector-store.js +30 -1
  65. package/src/security/jwt-validator.js +5 -4
  66. package/src/testing.js +254 -0
@@ -1,6 +1,9 @@
1
1
  'use strict';
2
2
 
3
- const axios = require('axios');
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 = axios.create({
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
- const axios = require('axios');
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 = axios.create({
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] - OpenAI API key (defaults to OPENAI_API_KEY at construction)
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
- apiKey: resolveApiKey(options, API_KEY_ENV),
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({ apiKey: this.config.apiKey, maxRetries: 0 });
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
- if (!this.config.apiKey) {
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
- vector = await embedQuery(embedderI, query, { signal });
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
- let docs = await retrieverI.retrieve({
170
- query,
171
- queryVector: vector,
172
- topK: opts.topK ?? 3,
173
- signal,
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
- const raw = (await llmI.generate?.(query, results, genOptions)) ?? '';
243
- run.throwIfAborted();
244
- const answer = String(raw);
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 { run: runOnce, cleanup() {} };
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.embedder.embed(batch);
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
- const Ajv = require('ajv');
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
- // Initialize Ajv for contract schema validation
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