@devilsdev/rag-pipeline-utils 2.4.5 → 2.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.
Files changed (225) hide show
  1. package/.ragrc.schema.json +83 -192
  2. package/CHANGELOG.md +345 -0
  3. package/README.md +418 -117
  4. package/SECURITY.md +5 -5
  5. package/dist/experimental.cjs +2 -0
  6. package/dist/experimental.d.ts +1 -0
  7. package/dist/experimental.mjs +5 -0
  8. package/dist/index.cjs +4992 -1242
  9. package/dist/index.d.ts +80 -2
  10. package/dist/index.mjs +1 -1
  11. package/dist/types/agentic/agentic-pipeline.d.ts +103 -0
  12. package/dist/types/agentic/iterative-retriever.d.ts +85 -0
  13. package/dist/types/agentic/query-planner.d.ts +145 -0
  14. package/dist/types/agentic/self-critique.d.ts +45 -0
  15. package/dist/types/ai/adaptive-retrieval.d.ts +44 -0
  16. package/dist/types/ai/federation/federated-learning-coordinator.d.ts +62 -0
  17. package/dist/types/ai/multimodal-processing.d.ts +32 -0
  18. package/dist/types/ai/multimodal.d.ts +39 -0
  19. package/dist/types/ai/retrieval-engine.d.ts +37 -0
  20. package/dist/types/ai/training/model-training-orchestrator.d.ts +31 -0
  21. package/dist/types/chunking/chunking-engine.d.ts +70 -0
  22. package/dist/types/chunking/strategies/fixed-size.d.ts +17 -0
  23. package/dist/types/chunking/strategies/recursive.d.ts +20 -0
  24. package/dist/types/chunking/strategies/semantic.d.ts +19 -0
  25. package/dist/types/chunking/strategies/sentence.d.ts +13 -0
  26. package/dist/types/chunking/strategies/structure-aware.d.ts +19 -0
  27. package/dist/types/citation/citation-tracker.d.ts +64 -0
  28. package/dist/types/citation/hallucination-detector.d.ts +40 -0
  29. package/dist/types/citation/source-mapper.d.ts +37 -0
  30. package/dist/types/connectors/anthropic-connector.d.ts +92 -0
  31. package/dist/types/connectors/base-connector.d.ts +87 -0
  32. package/dist/types/connectors/cohere-connector.d.ts +89 -0
  33. package/dist/types/connectors/connector-utils.d.ts +110 -0
  34. package/dist/types/connectors/local-embedder.d.ts +86 -0
  35. package/dist/types/connectors/memory-retriever.d.ts +91 -0
  36. package/dist/types/connectors/ollama-connector.d.ts +110 -0
  37. package/dist/types/connectors/openai-connector.d.ts +114 -0
  38. package/dist/types/connectors/rag-prompt.d.ts +40 -0
  39. package/dist/types/cost/cost-calculator.d.ts +152 -0
  40. package/dist/types/cost/cost-tracker.d.ts +126 -0
  41. package/dist/types/cost/token-budget.d.ts +110 -0
  42. package/dist/types/dag/core/error-context.d.ts +239 -0
  43. package/dist/types/dag/core/execution-scheduler.d.ts +191 -0
  44. package/dist/types/dag/core/topology-validation.d.ts +226 -0
  45. package/dist/types/dag/dag-engine.d.ts +156 -0
  46. package/dist/types/dag/dag-node.d.ts +37 -0
  47. package/dist/types/dashboard/dashboard-generator.d.ts +127 -0
  48. package/dist/types/dashboard/metrics-aggregator.d.ts +218 -0
  49. package/dist/types/debug/execution-tracer.d.ts +57 -0
  50. package/dist/types/debug/trace-inspector.d.ts +31 -0
  51. package/dist/types/ecosystem/plugin-certification.d.ts +169 -0
  52. package/dist/types/enterprise/data-governance.d.ts +203 -0
  53. package/dist/types/enterprise/multi-tenancy.d.ts +137 -0
  54. package/dist/types/enterprise/sso-integration.d.ts +420 -0
  55. package/dist/types/evaluate/context-metrics.d.ts +55 -0
  56. package/dist/types/evaluate/faithfulness.d.ts +41 -0
  57. package/dist/types/evaluate/groundedness.d.ts +60 -0
  58. package/dist/types/evaluate/pipeline-evaluator.d.ts +88 -0
  59. package/dist/types/evaluate/relevance.d.ts +21 -0
  60. package/dist/types/evaluate/scoring.d.ts +123 -0
  61. package/dist/types/experimental.d.ts +15 -0
  62. package/dist/types/graph/entity-extractor.d.ts +84 -0
  63. package/dist/types/graph/graph-index.d.ts +49 -0
  64. package/dist/types/graph/graph-retriever.d.ts +81 -0
  65. package/dist/types/graph/knowledge-graph.d.ts +129 -0
  66. package/dist/types/guardrails/guardrails-pipeline.d.ts +63 -0
  67. package/dist/types/guardrails/injection-detector.d.ts +47 -0
  68. package/dist/types/guardrails/post-generation-guard.d.ts +61 -0
  69. package/dist/types/guardrails/pre-retrieval-guard.d.ts +64 -0
  70. package/dist/types/guardrails/retrieval-guard.d.ts +57 -0
  71. package/dist/types/mcp/mcp-server.d.ts +85 -0
  72. package/dist/types/mcp/mcp-tool-builder.d.ts +55 -0
  73. package/dist/types/reranker/cascade-reranker.d.ts +40 -0
  74. package/dist/types/reranker/embedding-reranker.d.ts +77 -0
  75. package/dist/types/reranker/scoring-reranker.d.ts +41 -0
  76. package/dist/types/retrieval/bm25-search.d.ts +43 -0
  77. package/dist/types/retrieval/file-vector-store.d.ts +87 -0
  78. package/dist/types/retrieval/hybrid-retriever.d.ts +35 -0
  79. package/dist/types/retrieval/rank-fusion.d.ts +10 -0
  80. package/dist/types/security/jwt-config.d.ts +80 -0
  81. package/dist/types/security/jwt-validator.d.ts +195 -0
  82. package/dist/types/security/plugin-sandbox.d.ts +158 -0
  83. package/dist/types/security/plugin-signature-verifier.d.ts +139 -0
  84. package/dist/types/security/validator.d.ts +5 -0
  85. package/dist/types/streaming/embedding-stream.d.ts +65 -0
  86. package/dist/types/streaming/sse-adapter.d.ts +59 -0
  87. package/dist/types/streaming/stream-router.d.ts +88 -0
  88. package/dist/types/streaming/streaming-embedder.d.ts +99 -0
  89. package/dist/types/streaming/websocket-adapter.d.ts +71 -0
  90. package/dist/types/templates/scaffold.d.ts +55 -0
  91. package/dist/types/templates/template-registry.d.ts +63 -0
  92. package/dist/types/utils/embed-query.d.ts +20 -0
  93. package/dist/types/utils/logger.d.ts +50 -0
  94. package/dist/types/utils/rate-limiter.d.ts +66 -0
  95. package/dist/types/utils/retry.d.ts +27 -0
  96. package/dist/types/utils/run-abort.d.ts +34 -0
  97. package/dist/types/utils/secure-logging.d.ts +51 -0
  98. package/dist/types/utils/structured-logger.d.ts +126 -0
  99. package/package.json +53 -29
  100. package/src/agentic/agentic-pipeline.js +75 -7
  101. package/src/agentic/iterative-retriever.js +47 -10
  102. package/src/agentic/query-planner.js +329 -78
  103. package/src/citation/citation-tracker.js +6 -4
  104. package/src/citation/hallucination-detector.js +2 -2
  105. package/src/citation/source-mapper.js +15 -28
  106. package/src/cli/commands/ai.js +101 -0
  107. package/src/cli/commands/plugin-hub/event-handlers.js +6 -0
  108. package/src/cli/commands/plugin-hub/hub-commands.js +0 -74
  109. package/src/cli/commands/plugin-hub/local-commands.js +143 -0
  110. package/src/cli/commands/plugin-hub.js +96 -36
  111. package/src/cli/diagnostics/config-checker.js +76 -63
  112. package/src/cli/diagnostics/environment-checker.js +62 -31
  113. package/src/cli/diagnostics/plugin-checker.js +36 -89
  114. package/src/cli/doctor-command.js +39 -5
  115. package/src/cli/enhanced-cli-commands.js +72 -217
  116. package/src/cli/handlers/ai-handler.js +364 -0
  117. package/src/cli/handlers/diagnostics-handler.js +78 -31
  118. package/src/cli/handlers/ingest-handler.js +28 -22
  119. package/src/cli/handlers/init-handler.js +148 -25
  120. package/src/cli/handlers/query-handler.js +41 -26
  121. package/src/cli/handlers/run-handler.js +16 -167
  122. package/src/cli/handlers/serve-handler.js +60 -0
  123. package/src/cli/services/bench-stats.js +54 -0
  124. package/src/cli/services/benchmark.js +161 -0
  125. package/src/cli/services/feedback.js +155 -0
  126. package/src/cli/services/rag-service.js +322 -0
  127. package/src/cli/shared/cli-utils.js +24 -7
  128. package/src/cli/shared/config-context.js +21 -0
  129. package/src/cli/wizard/config-builder.js +81 -0
  130. package/src/cli/wizard/init-wizard.js +617 -0
  131. package/src/config/component-factory.js +125 -0
  132. package/src/config/load-config.js +29 -10
  133. package/src/config/load-plugin-config.js +46 -2
  134. package/src/connectors/anthropic-connector.js +93 -55
  135. package/src/connectors/base-connector.js +74 -19
  136. package/src/connectors/cohere-connector.js +88 -32
  137. package/src/connectors/connector-utils.js +302 -0
  138. package/src/connectors/local-embedder.js +43 -5
  139. package/src/connectors/memory-retriever.js +108 -15
  140. package/src/connectors/ollama-connector.js +83 -49
  141. package/src/connectors/openai-connector.js +108 -44
  142. package/src/connectors/rag-prompt.js +88 -0
  143. package/src/core/create-pipeline.js +209 -60
  144. package/src/core/plugin-registry.js +87 -11
  145. package/src/cost/cost-calculator.js +205 -32
  146. package/src/dev/dev-server.js +166 -11
  147. package/src/dev/hot-reload.js +40 -1
  148. package/src/ecosystem/plugin-checks.js +181 -0
  149. package/src/ecosystem/plugin-hub.js +50 -125
  150. package/src/ecosystem/plugin-installer.js +298 -0
  151. package/src/ecosystem/plugin-probe.js +127 -0
  152. package/src/ecosystem/plugin-scanner.js +185 -0
  153. package/src/ecosystem/tar-extract.js +114 -0
  154. package/src/enterprise/audit-logging.js +158 -29
  155. package/src/enterprise/sso-integration.js +715 -228
  156. package/src/evaluate/context-metrics.js +28 -16
  157. package/src/evaluate/evaluator.js +3 -3
  158. package/src/evaluate/faithfulness.js +18 -14
  159. package/src/evaluate/groundedness.js +43 -12
  160. package/src/evaluate/pipeline-evaluator.js +35 -8
  161. package/src/evaluate/scoring.js +251 -3
  162. package/src/experimental.js +71 -0
  163. package/src/guardrails/guardrails-pipeline.js +69 -7
  164. package/src/guardrails/injection-detector.js +283 -0
  165. package/src/guardrails/post-generation-guard.js +19 -0
  166. package/src/guardrails/pre-retrieval-guard.js +36 -36
  167. package/src/guardrails/retrieval-guard.js +58 -8
  168. package/src/index.js +36 -1
  169. package/src/loader/document-reader.js +91 -0
  170. package/src/reranker/cascade-reranker.js +3 -0
  171. package/src/reranker/embedding-reranker.js +136 -21
  172. package/src/reranker/llm-reranker.js +79 -12
  173. package/src/reranker/scoring-reranker.js +3 -0
  174. package/src/retrieval/bm25-search.js +12 -6
  175. package/src/retrieval/file-vector-store.js +212 -0
  176. package/src/retrieval/hybrid-retriever.js +22 -19
  177. package/src/security/jwt-config.js +13 -2
  178. package/src/security/jwt-validator.js +134 -30
  179. package/src/security/plugin-sandbox.js +18 -1
  180. package/src/security/plugin-signature-verifier.js +245 -70
  181. package/src/utils/deprecate.js +69 -0
  182. package/src/utils/embed-query.js +33 -0
  183. package/src/utils/input-sanitizer.js +19 -4
  184. package/src/utils/logger.js +34 -4
  185. package/src/utils/run-abort.js +102 -0
  186. package/src/ai/federated-learning.js +0 -211
  187. package/src/ai/federation.js +0 -71
  188. package/src/ai/index.js +0 -22
  189. package/src/ai/model-trainer.js +0 -217
  190. package/src/ai/model-training.js +0 -202
  191. package/src/ai/multimodal/multi-modal-processor.js +0 -345
  192. package/src/ai/orchestrator.js +0 -65
  193. package/src/ai/retrieval/adaptive-retrieval-engine.js +0 -398
  194. package/src/cli/commands/ai-ml/adaptive-commands.js +0 -178
  195. package/src/cli/commands/ai-ml/federated-commands.js +0 -207
  196. package/src/cli/commands/ai-ml/index.js +0 -17
  197. package/src/cli/commands/ai-ml/multimodal-commands.js +0 -187
  198. package/src/cli/commands/ai-ml/shared.js +0 -51
  199. package/src/cli/commands/ai-ml/training-commands.js +0 -228
  200. package/src/cli/commands/ai-ml.js +0 -386
  201. package/src/cli/commands/docs.js +0 -228
  202. package/src/cli/commands/dx.js +0 -538
  203. package/src/cli/commands/wizard.js +0 -627
  204. package/src/cli/input-sanitization-middleware.js +0 -356
  205. package/src/cli/interactive-wizard.js +0 -373
  206. package/src/cli/templates/development.template.json +0 -64
  207. package/src/cli/templates/minimal.template.json +0 -33
  208. package/src/cli/templates/production.template.json +0 -104
  209. package/src/cli/templates/testing.template.json +0 -45
  210. package/src/cli/wizard/configuration.js +0 -148
  211. package/src/cli/wizard/observability.js +0 -79
  212. package/src/cli/wizard/performance.js +0 -76
  213. package/src/cli/wizard/plugin-selection.js +0 -297
  214. package/src/cli/wizard/preview-save.js +0 -172
  215. package/src/cli/wizard/project-setup.js +0 -66
  216. package/src/core/pipeline-factory.js +0 -59
  217. package/src/dag/enhanced-dag-engine.js +0 -894
  218. package/src/dx/index.js +0 -40
  219. package/src/dx/integration-templates.js +0 -662
  220. package/src/dx/performance-profiler.js +0 -968
  221. package/src/dx/realtime-debugger.js +0 -755
  222. package/src/dx/visual-pipeline-builder.js +0 -578
  223. package/src/ingest.js +0 -59
  224. package/src/pipeline/pipeline.js +0 -18
  225. package/src/query.js +0 -117
package/src/index.js CHANGED
@@ -49,6 +49,8 @@ const {
49
49
  scoreAnswer,
50
50
  computeBLEU,
51
51
  computeROUGE,
52
+ checkNumericConsistency,
53
+ extractNumbers,
52
54
  } = require('./evaluate/scoring');
53
55
  const { computeFaithfulness } = require('./evaluate/faithfulness');
54
56
  const { computeAnswerRelevance } = require('./evaluate/relevance');
@@ -74,9 +76,11 @@ const { detectHallucinations } = require('./citation/hallucination-detector');
74
76
  const { BM25Search } = require('./retrieval/bm25-search');
75
77
  const { reciprocalRankFusion } = require('./retrieval/rank-fusion');
76
78
  const { HybridRetriever } = require('./retrieval/hybrid-retriever');
79
+ const { FileVectorStore } = require('./retrieval/file-vector-store');
77
80
 
78
81
  // Guardrails
79
82
  const { PreRetrievalGuard } = require('./guardrails/pre-retrieval-guard');
83
+ const { detectInjection } = require('./guardrails/injection-detector');
80
84
  const { RetrievalGuard } = require('./guardrails/retrieval-guard');
81
85
  const { PostGenerationGuard } = require('./guardrails/post-generation-guard');
82
86
  const { GuardrailsPipeline } = require('./guardrails/guardrails-pipeline');
@@ -88,7 +92,7 @@ const { SelfCritiqueChecker } = require('./agentic/self-critique');
88
92
  const { AgenticPipeline } = require('./agentic/agentic-pipeline');
89
93
 
90
94
  // Cost Management
91
- const { CostCalculator } = require('./cost/cost-calculator');
95
+ const { CostCalculator, PRICING_METADATA } = require('./cost/cost-calculator');
92
96
  const { CostTracker } = require('./cost/cost-tracker');
93
97
  const { TokenBudget } = require('./cost/token-budget');
94
98
 
@@ -218,6 +222,8 @@ module.exports = {
218
222
  scoreAnswer,
219
223
  computeBLEU,
220
224
  computeROUGE,
225
+ checkNumericConsistency,
226
+ extractNumbers,
221
227
  computeFaithfulness,
222
228
  computeAnswerRelevance,
223
229
  computeContextPrecision,
@@ -238,9 +244,11 @@ module.exports = {
238
244
  BM25Search,
239
245
  reciprocalRankFusion,
240
246
  HybridRetriever,
247
+ FileVectorStore,
241
248
 
242
249
  // Guardrails
243
250
  PreRetrievalGuard,
251
+ detectInjection,
244
252
  RetrievalGuard,
245
253
  PostGenerationGuard,
246
254
  GuardrailsPipeline,
@@ -253,6 +261,7 @@ module.exports = {
253
261
 
254
262
  // Cost Management
255
263
  CostCalculator,
264
+ PRICING_METADATA,
256
265
  CostTracker,
257
266
  TokenBudget,
258
267
 
@@ -330,3 +339,29 @@ module.exports = {
330
339
  DevServer,
331
340
  createDevServer,
332
341
  };
342
+
343
+ // Simulated or incomplete modules now live in the ./experimental entry
344
+ // (src/experimental.js). Their main-entry exports keep working but emit a
345
+ // one-time DeprecationWarning when used, and will be removed from the main
346
+ // entry in the next major release (SEMVER.md, "Deprecation process").
347
+ const experimental = require('./experimental');
348
+ const { deprecateClass } = require('./utils/deprecate');
349
+
350
+ for (const name of Object.keys(experimental)) {
351
+ if (!(name in module.exports)) continue;
352
+ module.exports[name] = deprecateClass(experimental[name], {
353
+ name,
354
+ message:
355
+ `${name} is experimental (simulated or incomplete) and is deprecated in ` +
356
+ `the main entry. Import it from "@devilsdev/rag-pipeline-utils/experimental"; ` +
357
+ 'it will be removed from the main entry in the next major release.',
358
+ });
359
+ }
360
+
361
+ // The ./experimental build entry reads the unwrapped classes from here, so
362
+ // both entries share one copy of each class (non-enumerable: not a named export).
363
+ Object.defineProperty(
364
+ module.exports,
365
+ Symbol.for('@devilsdev/rag-pipeline-utils/experimental'),
366
+ { value: experimental, enumerable: false },
367
+ );
@@ -0,0 +1,91 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const fg = require('fast-glob');
6
+ const { MarkdownLoader } = require('./markdown-loader.js');
7
+ const { HTMLLoader } = require('./html-loader.js');
8
+ const { CSVLoader } = require('./csv-loader.js');
9
+
10
+ const textLoader = {
11
+ async load(filePath) {
12
+ const text = await fs.promises.readFile(filePath, 'utf8');
13
+ return [
14
+ {
15
+ chunk: () => {
16
+ const { ChunkingEngine } = require('../chunking/chunking-engine');
17
+ return new ChunkingEngine({
18
+ strategy: 'sentence',
19
+ maxLen: 500,
20
+ }).chunk(text);
21
+ },
22
+ },
23
+ ];
24
+ },
25
+ };
26
+
27
+ const LOADERS_BY_EXTENSION = {
28
+ '.md': new MarkdownLoader(),
29
+ '.markdown': new MarkdownLoader(),
30
+ '.html': new HTMLLoader(),
31
+ '.htm': new HTMLLoader(),
32
+ '.csv': new CSVLoader(),
33
+ '.txt': textLoader,
34
+ };
35
+
36
+ const SUPPORTED_EXTENSIONS = Object.keys(LOADERS_BY_EXTENSION);
37
+
38
+ /**
39
+ * Read a file or every supported file under a directory and return its
40
+ * chunks together with the file they came from. Unlike DirectoryLoader this
41
+ * keeps the source path, which ingestion needs for stable chunk ids,
42
+ * re-ingestion and citations.
43
+ *
44
+ * @param {string} inputPath - File or directory
45
+ * @param {object} [options]
46
+ * @param {string} [options.baseDir=process.cwd()] - Sources are reported relative to this
47
+ * @returns {Promise<Array<{source: string, chunks: string[]}>>}
48
+ */
49
+ async function readDocuments(inputPath, options = {}) {
50
+ const baseDir = path.resolve(options.baseDir || process.cwd());
51
+ const absolute = path.resolve(inputPath);
52
+ if (!fs.existsSync(absolute)) {
53
+ throw new Error(`Path not found: ${inputPath}`);
54
+ }
55
+
56
+ let files;
57
+ if (fs.statSync(absolute).isDirectory()) {
58
+ const pattern = `**/*{${SUPPORTED_EXTENSIONS.join(',')}}`;
59
+ files = (
60
+ await fg(pattern, {
61
+ cwd: absolute,
62
+ absolute: true,
63
+ caseSensitiveMatch: false,
64
+ ignore: ['**/node_modules/**', '**/.git/**'],
65
+ })
66
+ ).sort();
67
+ } else {
68
+ const ext = path.extname(absolute).toLowerCase();
69
+ if (!LOADERS_BY_EXTENSION[ext]) {
70
+ throw new Error(
71
+ `Unsupported file type "${ext}". Supported: ${SUPPORTED_EXTENSIONS.join(', ')}`,
72
+ );
73
+ }
74
+ files = [absolute];
75
+ }
76
+
77
+ const documents = [];
78
+ for (const file of files) {
79
+ const loader = LOADERS_BY_EXTENSION[path.extname(file).toLowerCase()];
80
+ const loaded = await loader.load(file);
81
+ const chunks = loaded
82
+ .flatMap((doc) => doc.chunk())
83
+ .map((chunk) => String(chunk).trim())
84
+ .filter(Boolean);
85
+ const source = path.relative(baseDir, file).split(path.sep).join('/');
86
+ documents.push({ source, chunks });
87
+ }
88
+ return documents;
89
+ }
90
+
91
+ module.exports = { readDocuments, SUPPORTED_EXTENSIONS };
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  const { EventEmitter } = require('events');
11
+ const { throwIfAborted } = require('../connectors/connector-utils');
11
12
 
12
13
  /** @type {object} */
13
14
  const DEFAULT_CONFIG = {};
@@ -37,6 +38,7 @@ class CascadeReranker extends EventEmitter {
37
38
  * @param {Array<object|string>} documents - Documents to rerank
38
39
  * @param {object} [options]
39
40
  * @param {number} [options.topK] - Maximum number of results to return
41
+ * @param {AbortSignal} [options.signal] - Forwarded to every stage; checked between stages
40
42
  * @returns {Promise<object[]>} Reranked documents from the final stage
41
43
  * @throws {Error} If no rerankers are configured
42
44
  */
@@ -52,6 +54,7 @@ class CascadeReranker extends EventEmitter {
52
54
  let currentDocs = documents;
53
55
 
54
56
  for (let i = 0; i < this.rerankers.length; i++) {
57
+ throwIfAborted(options.signal);
55
58
  const reranker = this.rerankers[i];
56
59
  currentDocs = await reranker.rerank(query, currentDocs, options);
57
60
 
@@ -1,28 +1,53 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * Version: 1.0.0
4
+ * Version: 1.1.0
5
5
  * Path: /src/reranker/embedding-reranker.js
6
6
  * Description: Cosine similarity reranker using any embedder
7
7
  * Author: Ali Kahwaji
8
8
  */
9
9
 
10
10
  const { EventEmitter } = require('events');
11
+ const { createHash } = require('crypto');
12
+ const {
13
+ isAbortError,
14
+ throwIfAborted,
15
+ raceAbort,
16
+ } = require('../connectors/connector-utils');
17
+
18
+ /** @type {{ cacheSize: number, concurrency: number }} */
19
+ const DEFAULT_CONFIG = {
20
+ // Maximum number of document embeddings kept in the LRU cache (0 disables).
21
+ cacheSize: 1000,
22
+ // Maximum number of document embedding calls in flight at once.
23
+ concurrency: 4,
24
+ };
11
25
 
12
- /** @type {object} */
13
- const DEFAULT_CONFIG = {};
26
+ /**
27
+ * Hash document text so cache keys stay small regardless of document size.
28
+ * @param {string} text
29
+ * @returns {string}
30
+ */
31
+ function contentHash(text) {
32
+ return createHash('sha256').update(text, 'utf8').digest('hex');
33
+ }
14
34
 
15
35
  /**
16
36
  * Reranks documents by computing cosine similarity between query and
17
37
  * document embeddings produced by an external embedder.
18
38
  * Implements the reranker contract: rerank(query, documents, options).
19
39
  *
40
+ * Document embeddings are cached in a bounded LRU keyed by a SHA-256 of the
41
+ * document text, and embedding calls run with bounded concurrency.
42
+ *
20
43
  * @extends EventEmitter
21
44
  */
22
45
  class EmbeddingReranker extends EventEmitter {
23
46
  /**
24
47
  * @param {object} embedder - Must expose .embedQuery(text) or .embed(text)
25
48
  * @param {object} [options]
49
+ * @param {number} [options.cacheSize=1000] - Max cached document embeddings (0 disables caching)
50
+ * @param {number} [options.concurrency=4] - Max concurrent embedding calls
26
51
  */
27
52
  constructor(embedder, options = {}) {
28
53
  super();
@@ -31,6 +56,79 @@ class EmbeddingReranker extends EventEmitter {
31
56
  }
32
57
  this.embedder = embedder;
33
58
  this.config = { ...DEFAULT_CONFIG, ...options };
59
+ if (
60
+ !(
61
+ Number.isInteger(this.config.concurrency) && this.config.concurrency > 0
62
+ )
63
+ ) {
64
+ throw new Error(
65
+ 'EmbeddingReranker: concurrency must be a positive integer',
66
+ );
67
+ }
68
+ /** @type {Map<string, Promise<number[]>>} LRU: oldest entry first */
69
+ this._cache = new Map();
70
+ this.cacheStats = { hits: 0, misses: 0 };
71
+ }
72
+
73
+ /**
74
+ * Call the embedder, forwarding the signal only when one is given.
75
+ * @private
76
+ */
77
+ _embed(text, signal) {
78
+ const fn = this.embedder.embedQuery ? 'embedQuery' : 'embed';
79
+ return signal
80
+ ? this.embedder[fn](text, { signal })
81
+ : this.embedder[fn](text);
82
+ }
83
+
84
+ /**
85
+ * Embed a document, using the LRU cache. In-flight promises are cached so
86
+ * duplicate documents in one batch are embedded once; failed embeddings
87
+ * are evicted so they can be retried.
88
+ * @private
89
+ */
90
+ _embedDocument(text, signal) {
91
+ const { cacheSize } = this.config;
92
+ if (!cacheSize || cacheSize <= 0) {
93
+ this.cacheStats.misses++;
94
+ return Promise.resolve().then(() => this._embed(text, signal));
95
+ }
96
+
97
+ const key = contentHash(text);
98
+ const cached = this._cache.get(key);
99
+ if (cached) {
100
+ this.cacheStats.hits++;
101
+ // Refresh recency.
102
+ this._cache.delete(key);
103
+ this._cache.set(key, cached);
104
+ // The shared promise may have been started by another request whose
105
+ // signal aborted; that abort must not fail this request.
106
+ return cached.catch((err) => {
107
+ if (isAbortError(err) && !(signal && signal.aborted)) {
108
+ return this._embedDocument(text, signal);
109
+ }
110
+ throw err;
111
+ });
112
+ }
113
+
114
+ this.cacheStats.misses++;
115
+ const promise = Promise.resolve().then(() => this._embed(text, signal));
116
+ this._cache.set(key, promise);
117
+ promise.catch(() => {
118
+ if (this._cache.get(key) === promise) this._cache.delete(key);
119
+ });
120
+ while (this._cache.size > cacheSize) {
121
+ this._cache.delete(this._cache.keys().next().value);
122
+ }
123
+ return promise;
124
+ }
125
+
126
+ /**
127
+ * Clear the document embedding cache.
128
+ */
129
+ clearCache() {
130
+ this._cache.clear();
131
+ this.cacheStats = { hits: 0, misses: 0 };
34
132
  }
35
133
 
36
134
  /**
@@ -40,6 +138,7 @@ class EmbeddingReranker extends EventEmitter {
40
138
  * @param {Array<object|string>} documents - Documents to rerank
41
139
  * @param {object} [options]
42
140
  * @param {number} [options.topK] - Maximum number of results to return
141
+ * @param {AbortSignal} [options.signal] - Cancels pending embedding calls
43
142
  * @returns {Promise<object[]>} Reranked documents with .relevanceScore
44
143
  */
45
144
  async rerank(query, documents, options = {}) {
@@ -47,27 +146,43 @@ class EmbeddingReranker extends EventEmitter {
47
146
  return [];
48
147
  }
49
148
 
50
- const { topK = documents.length } = options;
149
+ const { topK = documents.length, signal } = options;
150
+ throwIfAborted(signal);
51
151
 
52
- // Get query embedding
53
- const queryEmbedding = this.embedder.embedQuery
54
- ? await this.embedder.embedQuery(query)
55
- : await this.embedder.embed(query);
152
+ const queryEmbedding = await raceAbort(
153
+ Promise.resolve().then(() => this._embed(query, signal)),
154
+ signal,
155
+ );
56
156
 
57
- // Get document embeddings and compute similarities
58
- const scored = await Promise.all(
59
- documents.map(async (doc, originalIndex) => {
157
+ // Embed documents with at most `concurrency` calls in flight, keeping
158
+ // results in input order.
159
+ const embeddings = new Array(documents.length);
160
+ let next = 0;
161
+ const worker = async () => {
162
+ while (next < documents.length) {
163
+ throwIfAborted(signal);
164
+ const i = next++;
165
+ const doc = documents[i];
60
166
  const text = doc.content || doc.text || String(doc);
61
-
62
- const docEmbedding = this.embedder.embedQuery
63
- ? await this.embedder.embedQuery(text)
64
- : await this.embedder.embed(text);
65
-
66
- const similarity = this._cosineSimilarity(queryEmbedding, docEmbedding);
67
-
68
- return { doc, similarity, originalIndex };
69
- }),
70
- );
167
+ embeddings[i] = await raceAbort(
168
+ this._embedDocument(text, signal),
169
+ signal,
170
+ );
171
+ }
172
+ };
173
+ const workers = [];
174
+ const poolSize = Math.min(this.config.concurrency, documents.length);
175
+ for (let w = 0; w < poolSize; w++) workers.push(worker());
176
+ await Promise.all(workers);
177
+
178
+ const scored = documents.map((doc, originalIndex) => ({
179
+ doc,
180
+ similarity: this._cosineSimilarity(
181
+ queryEmbedding,
182
+ embeddings[originalIndex],
183
+ ),
184
+ originalIndex,
185
+ }));
71
186
 
72
187
  // Sort by similarity descending, then by original index for stability
73
188
  scored.sort((a, b) => {
@@ -1,28 +1,83 @@
1
1
  'use strict';
2
2
 
3
+ const {
4
+ createAbortError,
5
+ isAbortError,
6
+ raceAbort,
7
+ throwIfAborted,
8
+ } = require('../connectors/connector-utils');
9
+
10
+ // Characters of each document shown to the LLM; keeps the ranking prompt small.
11
+ const DEFAULT_MAX_DOC_CHARS = 500;
12
+
3
13
  class LLMReranker {
4
14
  constructor(options = {}) {
5
15
  this.llm = options.llm;
16
+ this.maxDocChars = options.maxDocChars || DEFAULT_MAX_DOC_CHARS;
17
+ }
18
+
19
+ /**
20
+ * Build the instruction sent to the LLM. Documents are numbered from 0 so
21
+ * the response can be validated as indices into `docs`.
22
+ */
23
+ _buildRankingPrompt(query, docs) {
24
+ const listing = docs
25
+ .map((doc, i) => {
26
+ const text = String(doc.text || doc.content || '').slice(
27
+ 0,
28
+ this.maxDocChars,
29
+ );
30
+ return `[${i}] ${text.replace(/\s+/g, ' ')}`;
31
+ })
32
+ .join('\n');
33
+ return [
34
+ 'Rank the documents below by how relevant they are to the query.',
35
+ 'Respond with only a JSON array of document numbers, most relevant',
36
+ 'first, for example [2, 0, 1]. Do not include any other text.',
37
+ '',
38
+ `Query: ${query}`,
39
+ '',
40
+ 'Documents:',
41
+ listing,
42
+ ].join('\n');
43
+ }
44
+
45
+ /**
46
+ * Parse the first JSON array in the response; models often wrap it in
47
+ * prose or a code fence.
48
+ */
49
+ _parseRanking(response) {
50
+ const text = typeof response === 'string' ? response : String(response);
51
+ const match = text.match(/\[[\s\d,]*\]/);
52
+ return JSON.parse(match ? match[0] : text);
6
53
  }
7
54
 
8
55
  async rerank(query, docs, opts = {}) {
9
56
  if (!Array.isArray(docs)) {
10
57
  return [];
11
58
  }
59
+ const signal = opts.signal;
60
+ throwIfAborted(signal);
12
61
 
13
62
  // If no LLM provided, fall back to simple overlap scoring
14
63
  if (!this.llm) {
15
- return this._fallbackRerank(query, docs);
64
+ return this._limit(this._fallbackRerank(query, docs), opts);
16
65
  }
17
66
 
18
67
  try {
19
- // Use LLM to get reranking order
20
- const response = await this.llm.generate(query, docs, opts);
68
+ // Ask the LLM for a ranking. Documents are inlined in the prompt, so no
69
+ // separate retrieval context is passed.
70
+ const response = await raceAbort(
71
+ Promise.resolve().then(() =>
72
+ this.llm.generate(this._buildRankingPrompt(query, docs), [], opts),
73
+ ),
74
+ signal,
75
+ );
21
76
 
22
77
  // Validate and sanitize LLM output before parsing (security fix)
23
78
  let rankingOrder;
24
79
  try {
25
- const parsed = JSON.parse(response);
80
+ const parsed = this._parseRanking(response);
26
81
 
27
82
  // Security validation: ensure parsed result is an array
28
83
  if (!Array.isArray(parsed)) {
@@ -49,22 +104,34 @@ class LLMReranker {
49
104
  }
50
105
  } catch (parseErr) {
51
106
  // Fallback: use simple overlap scoring if parsing fails
52
- return this._fallbackRerank(query, docs);
107
+ return this._limit(this._fallbackRerank(query, docs), opts);
53
108
  }
54
109
 
55
- // Apply the ranking order
56
- const reranked = [];
57
- for (const index of rankingOrder) {
58
- reranked.push(docs[index]);
59
- }
110
+ // Apply the ranking order; documents the LLM left out keep their
111
+ // original relative order after the ranked ones instead of vanishing.
112
+ const ranked = new Set(rankingOrder);
113
+ const reranked = [
114
+ ...rankingOrder.map((index) => docs[index]),
115
+ ...docs.filter((_, index) => !ranked.has(index)),
116
+ ];
60
117
 
61
- return reranked;
118
+ return this._limit(reranked, opts);
62
119
  } catch (error) {
120
+ // Cancellation is not an LLM failure: propagate it instead of falling back.
121
+ if (isAbortError(error) || (signal && signal.aborted)) {
122
+ throw createAbortError(signal);
123
+ }
63
124
  // Fall back to simple overlap scoring on LLM error
64
- return this._fallbackRerank(query, docs);
125
+ return this._limit(this._fallbackRerank(query, docs), opts);
65
126
  }
66
127
  }
67
128
 
129
+ _limit(docs, opts) {
130
+ return Number.isInteger(opts.topK) && opts.topK > 0
131
+ ? docs.slice(0, opts.topK)
132
+ : docs;
133
+ }
134
+
68
135
  _fallbackRerank(query, docs) {
69
136
  // Simple bag-of-words overlap scoring
70
137
  const queryTokens = query
@@ -9,6 +9,7 @@
9
9
 
10
10
  const { EventEmitter } = require('events');
11
11
  const { tokenize } = require('../evaluate/scoring');
12
+ const { throwIfAborted } = require('../connectors/connector-utils');
12
13
 
13
14
  /** @type {{ k1: number, b: number }} */
14
15
  const DEFAULT_CONFIG = { k1: 1.2, b: 0.75 };
@@ -37,9 +38,11 @@ class ScoringReranker extends EventEmitter {
37
38
  * @param {Array<object|string>} documents - Documents to rerank
38
39
  * @param {object} [options]
39
40
  * @param {number} [options.topK] - Maximum number of results to return
41
+ * @param {AbortSignal} [options.signal] - Rejects with AbortError when already aborted
40
42
  * @returns {Promise<object[]>} Reranked documents with .relevanceScore
41
43
  */
42
44
  async rerank(query, documents, options = {}) {
45
+ throwIfAborted(options.signal);
43
46
  if (!Array.isArray(documents) || documents.length === 0) {
44
47
  return [];
45
48
  }
@@ -1,7 +1,8 @@
1
1
  'use strict';
2
2
 
3
3
  const { EventEmitter } = require('events');
4
- const { tokenize } = require('../evaluate/scoring');
4
+ // Called through the module object so tests can observe tokenization.
5
+ const scoring = require('../evaluate/scoring');
5
6
 
6
7
  const DEFAULT_CONFIG = { k1: 1.2, b: 0.75 };
7
8
 
@@ -11,6 +12,8 @@ class BM25Search extends EventEmitter {
11
12
  this.config = { ...DEFAULT_CONFIG, ...options };
12
13
  this.invertedIndex = new Map(); // token -> [{docIndex, tf}]
13
14
  this.documents = [];
15
+ /** @type {number[]} token count per document, computed at index time */
16
+ this.docLengths = [];
14
17
  this.avgDocLength = 0;
15
18
  this.docCount = 0;
16
19
  }
@@ -27,14 +30,16 @@ class BM25Search extends EventEmitter {
27
30
  this.documents = documents;
28
31
  this.docCount = documents.length;
29
32
  this.invertedIndex.clear();
33
+ this.docLengths = new Array(documents.length);
30
34
 
31
35
  let totalLength = 0;
32
36
 
33
37
  for (let docIndex = 0; docIndex < documents.length; docIndex++) {
34
38
  const doc = documents[docIndex];
35
39
  const text = doc.content || doc.text || '';
36
- const tokens = tokenize(text);
40
+ const tokens = scoring.tokenize(text);
37
41
 
42
+ this.docLengths[docIndex] = tokens.length;
38
43
  totalLength += tokens.length;
39
44
 
40
45
  // Count term frequencies for this document
@@ -73,7 +78,7 @@ class BM25Search extends EventEmitter {
73
78
  return [];
74
79
  }
75
80
 
76
- const queryTokens = tokenize(query);
81
+ const queryTokens = scoring.tokenize(query);
77
82
  if (queryTokens.length === 0) {
78
83
  return [];
79
84
  }
@@ -94,9 +99,9 @@ class BM25Search extends EventEmitter {
94
99
  const idf = Math.log((N - df + 0.5) / (df + 0.5) + 1);
95
100
 
96
101
  for (const { docIndex, tf } of postings) {
97
- const doc = this.documents[docIndex];
98
- const docText = doc.content || doc.text || '';
99
- const docLen = tokenize(docText).length;
102
+ // Document lengths were computed at index time; scoring never
103
+ // re-tokenizes documents.
104
+ const docLen = this.docLengths[docIndex];
100
105
 
101
106
  const numerator = tf * (k1 + 1);
102
107
  const denominator =
@@ -137,6 +142,7 @@ class BM25Search extends EventEmitter {
137
142
  clear() {
138
143
  this.invertedIndex.clear();
139
144
  this.documents = [];
145
+ this.docLengths = [];
140
146
  this.avgDocLength = 0;
141
147
  this.docCount = 0;
142
148
  }