@devilsdev/rag-pipeline-utils 2.3.1 → 2.4.1

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 (160) hide show
  1. package/README.md +94 -480
  2. package/contracts/loader-contract.json +11 -6
  3. package/contracts/reranker-contract.json +18 -26
  4. package/dist/index.cjs +21190 -47
  5. package/dist/index.d.ts +298 -68
  6. package/dist/index.mjs +9 -76
  7. package/package.json +37 -11
  8. package/src/agentic/agentic-pipeline.js +235 -0
  9. package/src/agentic/iterative-retriever.js +213 -0
  10. package/src/agentic/query-planner.js +208 -0
  11. package/src/agentic/self-critique.js +102 -0
  12. package/src/ai/adaptive-retrieval.js +0 -3
  13. package/src/ai/federated-learning.js +0 -3
  14. package/src/ai/federation.js +1 -6
  15. package/src/ai/index.js +9 -20
  16. package/src/ai/model-training.js +0 -3
  17. package/src/ai/multimodal-processing.js +0 -3
  18. package/src/ai/multimodal.js +1 -6
  19. package/src/ai/orchestrator.js +1 -6
  20. package/src/ai/retrieval-engine.js +1 -6
  21. package/src/chunking/chunking-engine.js +142 -0
  22. package/src/chunking/strategies/fixed-size.js +54 -0
  23. package/src/chunking/strategies/recursive.js +127 -0
  24. package/src/chunking/strategies/semantic.js +128 -0
  25. package/src/chunking/strategies/sentence.js +32 -0
  26. package/src/chunking/strategies/structure-aware.js +138 -0
  27. package/src/citation/citation-tracker.js +161 -0
  28. package/src/citation/hallucination-detector.js +74 -0
  29. package/src/citation/source-mapper.js +95 -0
  30. package/src/cli/commands/ai-ml/adaptive-commands.js +178 -0
  31. package/src/cli/commands/ai-ml/federated-commands.js +207 -0
  32. package/src/cli/commands/ai-ml/index.js +17 -0
  33. package/src/cli/commands/ai-ml/multimodal-commands.js +187 -0
  34. package/src/cli/commands/ai-ml/shared.js +51 -0
  35. package/src/cli/commands/ai-ml/training-commands.js +228 -0
  36. package/src/cli/commands/ai-ml.js +176 -757
  37. package/src/cli/commands/plugin-hub/certification-commands.js +221 -0
  38. package/src/cli/commands/plugin-hub/event-handlers.js +64 -0
  39. package/src/cli/commands/plugin-hub/hub-commands.js +290 -0
  40. package/src/cli/commands/plugin-hub/review-commands.js +78 -0
  41. package/src/cli/commands/plugin-hub.js +106 -782
  42. package/src/cli/diagnostics/config-checker.js +81 -0
  43. package/src/cli/diagnostics/dependency-checker.js +69 -0
  44. package/src/cli/diagnostics/environment-checker.js +45 -0
  45. package/src/cli/diagnostics/performance-checker.js +51 -0
  46. package/src/cli/diagnostics/plugin-checker.js +106 -0
  47. package/src/cli/diagnostics/security-checker.js +61 -0
  48. package/src/cli/doctor-command.js +70 -369
  49. package/src/cli/enhanced-cli-commands.js +162 -706
  50. package/src/cli/handlers/config-handler.js +126 -0
  51. package/src/cli/handlers/diagnostics-handler.js +113 -0
  52. package/src/cli/handlers/ingest-handler.js +42 -0
  53. package/src/cli/handlers/init-handler.js +51 -0
  54. package/src/cli/handlers/query-handler.js +45 -0
  55. package/src/cli/handlers/run-handler.js +181 -0
  56. package/src/cli/interactive-wizard.js +99 -723
  57. package/src/cli/shared/cli-utils.js +89 -0
  58. package/src/cli/wizard/configuration.js +148 -0
  59. package/src/cli/wizard/observability.js +79 -0
  60. package/src/cli/wizard/performance.js +76 -0
  61. package/src/cli/wizard/plugin-selection.js +297 -0
  62. package/src/cli/wizard/preview-save.js +172 -0
  63. package/src/cli/wizard/project-setup.js +66 -0
  64. package/src/config/memory-optimization.js +3 -3
  65. package/src/connectors/anthropic-connector.js +179 -0
  66. package/src/connectors/base-connector.js +94 -0
  67. package/src/connectors/cohere-connector.js +143 -0
  68. package/src/connectors/local-embedder.js +171 -0
  69. package/src/connectors/memory-retriever.js +150 -0
  70. package/src/connectors/ollama-connector.js +205 -0
  71. package/src/connectors/openai-connector.js +173 -0
  72. package/src/core/create-pipeline.js +21 -1
  73. package/src/core/observability/tracing.js +2 -1
  74. package/src/core/pipeline-factory.js +0 -1
  75. package/src/core/plugin-registry.js +14 -14
  76. package/src/cost/cost-calculator.js +146 -0
  77. package/src/cost/cost-tracker.js +260 -0
  78. package/src/cost/token-budget.js +245 -0
  79. package/src/dag/core/execution-scheduler.js +2 -1
  80. package/src/dag/core/topology-validation.js +0 -3
  81. package/src/dag/dag-engine.js +3 -5
  82. package/src/dag/dag-node.js +0 -1
  83. package/src/dag/enhanced-dag-engine.js +171 -99
  84. package/src/dashboard/dashboard-generator.js +642 -0
  85. package/src/dashboard/metrics-aggregator.js +409 -0
  86. package/src/debug/execution-tracer.js +210 -0
  87. package/src/debug/trace-inspector.js +281 -0
  88. package/src/dev/dev-server.js +5 -5
  89. package/src/dev/hot-reload.js +3 -2
  90. package/src/dx/integration-templates.js +0 -3
  91. package/src/ecosystem/plugin-analytics-dashboard.js +2 -2
  92. package/src/ecosystem/plugin-hub.js +2 -2
  93. package/src/enterprise/audit-logging.js +2 -2
  94. package/src/enterprise/sso-integration.js +52 -32
  95. package/src/evaluate/context-metrics.js +127 -0
  96. package/src/evaluate/evaluator.js +4 -2
  97. package/src/evaluate/faithfulness.js +117 -0
  98. package/src/evaluate/groundedness.js +60 -0
  99. package/src/evaluate/pipeline-evaluator.js +156 -0
  100. package/src/evaluate/relevance.js +129 -0
  101. package/src/evaluate/scoring.js +27 -0
  102. package/src/graph/entity-extractor.js +183 -0
  103. package/src/graph/graph-index.js +113 -0
  104. package/src/graph/graph-retriever.js +244 -0
  105. package/src/graph/knowledge-graph.js +256 -0
  106. package/src/guardrails/guardrails-pipeline.js +136 -0
  107. package/src/guardrails/post-generation-guard.js +170 -0
  108. package/src/guardrails/pre-retrieval-guard.js +161 -0
  109. package/src/guardrails/retrieval-guard.js +126 -0
  110. package/src/index.js +255 -9
  111. package/src/loader/html-loader.js +4 -15
  112. package/src/loader/markdown-loader.js +4 -15
  113. package/src/mcp/mcp-server.js +199 -0
  114. package/src/mcp/mcp-tool-builder.js +121 -0
  115. package/src/observability/audit-logger.js +0 -2
  116. package/src/observability/event-logger.js +2 -1
  117. package/src/observability/security-monitor.js +0 -2
  118. package/src/observability/telemetry.js +0 -2
  119. package/src/reranker/cascade-reranker.js +79 -0
  120. package/src/reranker/embedding-reranker.js +124 -0
  121. package/src/reranker/llm-reranker.js +6 -3
  122. package/src/reranker/scoring-reranker.js +123 -0
  123. package/src/retrieval/bm25-search.js +157 -0
  124. package/src/retrieval/hybrid-retriever.js +111 -0
  125. package/src/retrieval/rank-fusion.js +62 -0
  126. package/src/security/jwt-config.js +4 -3
  127. package/src/security/jwt-validator.js +2 -1
  128. package/src/security/plugin-sandbox.js +0 -3
  129. package/src/security/plugin-signature-verifier.js +4 -6
  130. package/src/security/validator.js +0 -2
  131. package/src/streaming/embedding-stream.js +197 -0
  132. package/src/streaming/sse-adapter.js +168 -0
  133. package/src/streaming/stream-router.js +210 -0
  134. package/src/streaming/streaming-embedder.js +231 -0
  135. package/src/streaming/websocket-adapter.js +171 -0
  136. package/src/templates/scaffold.js +147 -0
  137. package/src/templates/template-registry.js +272 -0
  138. package/src/utils/contract-validator.js +2 -1
  139. package/src/utils/input-sanitizer.js +80 -99
  140. package/src/utils/performance-monitor.js +0 -3
  141. package/src/utils/plugin-scaffolder.js +9 -16
  142. package/src/utils/rate-limiter.js +0 -1
  143. package/src/utils/retry-policy.js +0 -3
  144. package/src/utils/safe-expression-evaluator.js +0 -3
  145. package/src/utils/secure-logger-config.js +2 -4
  146. package/src/utils/secure-logging.js +0 -3
  147. package/src/utils/structured-logger.js +0 -3
  148. package/src/ai/index-fixed.js +0 -509
  149. package/src/ai/module-template.js +0 -37
  150. package/src/ci/workflow-analyzer.js +0 -71
  151. package/src/cli/plugin-marketplace-commands.js +0 -869
  152. package/src/dev/README.md +0 -126
  153. package/src/dev/examples/hot-reload-usage.js +0 -355
  154. package/src/mocks/openai-embedder.js +0 -119
  155. package/src/mocks/openai-llm.js +0 -77
  156. package/src/mocks/pdf-loader.js +0 -21
  157. package/src/mocks/pinecone-retriever.js +0 -106
  158. package/src/scripts/script-utilities.js +0 -36
  159. package/src/utils/ci/diagnostic-reporter.js +0 -57
  160. package/src/utils/examples/error-formatter-usage.js +0 -351
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const { SecureLogger } = require('../utils/secure-logger');
4
+ const { logger } = require('../utils/logger');
4
5
 
5
6
  const EventTypes = {
6
7
  PLUGIN_START: 'plugin.start',
@@ -88,7 +89,7 @@ class PipelineEventLogger {
88
89
  try {
89
90
  fn(evt);
90
91
  } catch (e) {
91
- console.warn('Event listener error:', e);
92
+ logger.warn('Event listener error:', e);
92
93
  }
93
94
  }
94
95
  }
@@ -826,5 +826,3 @@ module.exports = {
826
826
  getSecurityMonitor,
827
827
  initializeSecurityMonitoring,
828
828
  };
829
-
830
- module.exports.default = module.exports;
@@ -682,5 +682,3 @@ module.exports = {
682
682
  initializeTelemetry,
683
683
  traceFunction,
684
684
  };
685
-
686
- module.exports.default = module.exports;
@@ -0,0 +1,79 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Version: 1.0.0
5
+ * Path: /src/reranker/cascade-reranker.js
6
+ * Description: Chains multiple rerankers in sequence for multi-stage reranking
7
+ * Author: Ali Kahwaji
8
+ */
9
+
10
+ const { EventEmitter } = require('events');
11
+
12
+ /** @type {object} */
13
+ const DEFAULT_CONFIG = {};
14
+
15
+ /**
16
+ * Chains multiple rerankers in sequence, passing the output of each
17
+ * stage as input to the next. Implements the reranker contract:
18
+ * rerank(query, documents, options).
19
+ *
20
+ * @extends EventEmitter
21
+ */
22
+ class CascadeReranker extends EventEmitter {
23
+ /**
24
+ * @param {object[]} [rerankers=[]] - Array of objects with .rerank() method
25
+ * @param {object} [options]
26
+ */
27
+ constructor(rerankers = [], options = {}) {
28
+ super();
29
+ this.rerankers = rerankers;
30
+ this.config = { ...DEFAULT_CONFIG, ...options };
31
+ }
32
+
33
+ /**
34
+ * Run documents through each reranker in sequence.
35
+ *
36
+ * @param {string} query - The search query
37
+ * @param {Array<object|string>} documents - Documents to rerank
38
+ * @param {object} [options]
39
+ * @param {number} [options.topK] - Maximum number of results to return
40
+ * @returns {Promise<object[]>} Reranked documents from the final stage
41
+ * @throws {Error} If no rerankers are configured
42
+ */
43
+ async rerank(query, documents, options = {}) {
44
+ if (this.rerankers.length === 0) {
45
+ throw new Error('CascadeReranker requires at least one reranker');
46
+ }
47
+
48
+ if (!Array.isArray(documents) || documents.length === 0) {
49
+ return [];
50
+ }
51
+
52
+ let currentDocs = documents;
53
+
54
+ for (let i = 0; i < this.rerankers.length; i++) {
55
+ const reranker = this.rerankers[i];
56
+ currentDocs = await reranker.rerank(query, currentDocs, options);
57
+
58
+ this.emit('stage', {
59
+ rerankerIndex: i,
60
+ resultCount: currentDocs.length,
61
+ });
62
+ }
63
+
64
+ this.emit('reranked', { count: currentDocs.length });
65
+
66
+ return currentDocs;
67
+ }
68
+
69
+ /**
70
+ * Add a reranker to the end of the cascade.
71
+ *
72
+ * @param {object} reranker - Must have a .rerank() method
73
+ */
74
+ addReranker(reranker) {
75
+ this.rerankers.push(reranker);
76
+ }
77
+ }
78
+
79
+ module.exports = { CascadeReranker, DEFAULT_CONFIG };
@@ -0,0 +1,124 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Version: 1.0.0
5
+ * Path: /src/reranker/embedding-reranker.js
6
+ * Description: Cosine similarity reranker using any embedder
7
+ * Author: Ali Kahwaji
8
+ */
9
+
10
+ const { EventEmitter } = require('events');
11
+
12
+ /** @type {object} */
13
+ const DEFAULT_CONFIG = {};
14
+
15
+ /**
16
+ * Reranks documents by computing cosine similarity between query and
17
+ * document embeddings produced by an external embedder.
18
+ * Implements the reranker contract: rerank(query, documents, options).
19
+ *
20
+ * @extends EventEmitter
21
+ */
22
+ class EmbeddingReranker extends EventEmitter {
23
+ /**
24
+ * @param {object} embedder - Must expose .embedQuery(text) or .embed(text)
25
+ * @param {object} [options]
26
+ */
27
+ constructor(embedder, options = {}) {
28
+ super();
29
+ if (!embedder) {
30
+ throw new Error('EmbeddingReranker requires an embedder instance');
31
+ }
32
+ this.embedder = embedder;
33
+ this.config = { ...DEFAULT_CONFIG, ...options };
34
+ }
35
+
36
+ /**
37
+ * Rerank documents by cosine similarity to the query embedding.
38
+ *
39
+ * @param {string} query - The search query
40
+ * @param {Array<object|string>} documents - Documents to rerank
41
+ * @param {object} [options]
42
+ * @param {number} [options.topK] - Maximum number of results to return
43
+ * @returns {Promise<object[]>} Reranked documents with .relevanceScore
44
+ */
45
+ async rerank(query, documents, options = {}) {
46
+ if (!Array.isArray(documents) || documents.length === 0) {
47
+ return [];
48
+ }
49
+
50
+ const { topK = documents.length } = options;
51
+
52
+ // Get query embedding
53
+ const queryEmbedding = this.embedder.embedQuery
54
+ ? await this.embedder.embedQuery(query)
55
+ : await this.embedder.embed(query);
56
+
57
+ // Get document embeddings and compute similarities
58
+ const scored = await Promise.all(
59
+ documents.map(async (doc, originalIndex) => {
60
+ 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
+ );
71
+
72
+ // Sort by similarity descending, then by original index for stability
73
+ scored.sort((a, b) => {
74
+ if (b.similarity !== a.similarity) return b.similarity - a.similarity;
75
+ return a.originalIndex - b.originalIndex;
76
+ });
77
+
78
+ const results = scored.slice(0, topK).map(({ doc, similarity }) => {
79
+ const result =
80
+ typeof doc === 'object' ? { ...doc } : { text: String(doc) };
81
+ result.relevanceScore = similarity;
82
+ return result;
83
+ });
84
+
85
+ this.emit('reranked', { count: results.length, topK });
86
+
87
+ return results;
88
+ }
89
+
90
+ /**
91
+ * Compute cosine similarity between two vectors.
92
+ *
93
+ * @param {number[]} a - First vector
94
+ * @param {number[]} b - Second vector
95
+ * @returns {number} Cosine similarity in [-1, 1], or 0 for zero vectors
96
+ */
97
+ _cosineSimilarity(a, b) {
98
+ if (!a || !b || a.length !== b.length || a.length === 0) {
99
+ return 0;
100
+ }
101
+
102
+ let dot = 0;
103
+ let magA = 0;
104
+ let magB = 0;
105
+
106
+ for (let i = 0; i < a.length; i++) {
107
+ dot += a[i] * b[i];
108
+ magA += a[i] * a[i];
109
+ magB += b[i] * b[i];
110
+ }
111
+
112
+ magA = Math.sqrt(magA);
113
+ magB = Math.sqrt(magB);
114
+
115
+ // Handle zero vectors
116
+ if (magA === 0 || magB === 0) {
117
+ return 0;
118
+ }
119
+
120
+ return dot / (magA * magB);
121
+ }
122
+ }
123
+
124
+ module.exports = { EmbeddingReranker, DEFAULT_CONFIG };
@@ -30,12 +30,16 @@ class LLMReranker {
30
30
  }
31
31
 
32
32
  // Security validation: ensure all elements are valid numbers
33
- if (!parsed.every(item => typeof item === 'number' && Number.isInteger(item))) {
33
+ if (
34
+ !parsed.every(
35
+ (item) => typeof item === 'number' && Number.isInteger(item),
36
+ )
37
+ ) {
34
38
  throw new Error('LLM response contains non-integer values');
35
39
  }
36
40
 
37
41
  // Security validation: ensure all indices are within valid bounds
38
- const validIndices = parsed.filter(i => i >= 0 && i < docs.length);
42
+ const validIndices = parsed.filter((i) => i >= 0 && i < docs.length);
39
43
 
40
44
  // Security validation: remove duplicates to prevent manipulation
41
45
  rankingOrder = [...new Set(validIndices)];
@@ -108,4 +112,3 @@ const defaultInstance = new LLMReranker();
108
112
  // CJS+ESM interop pattern
109
113
  module.exports = defaultInstance;
110
114
  module.exports.LLMReranker = LLMReranker;
111
- module.exports.default = module.exports;
@@ -0,0 +1,123 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Version: 1.0.0
5
+ * Path: /src/reranker/scoring-reranker.js
6
+ * Description: BM25-based reranker with IDF-weighted scoring
7
+ * Author: Ali Kahwaji
8
+ */
9
+
10
+ const { EventEmitter } = require('events');
11
+ const { tokenize } = require('../evaluate/scoring');
12
+
13
+ /** @type {{ k1: number, b: number }} */
14
+ const DEFAULT_CONFIG = { k1: 1.2, b: 0.75 };
15
+
16
+ /**
17
+ * Reranks documents using BM25-like scoring with IDF weighting.
18
+ * Implements the reranker contract: rerank(query, documents, options).
19
+ *
20
+ * @extends EventEmitter
21
+ */
22
+ class ScoringReranker extends EventEmitter {
23
+ /**
24
+ * @param {object} [options]
25
+ * @param {number} [options.k1=1.2] - Term frequency saturation parameter
26
+ * @param {number} [options.b=0.75] - Length normalisation parameter
27
+ */
28
+ constructor(options = {}) {
29
+ super();
30
+ this.config = { ...DEFAULT_CONFIG, ...options };
31
+ }
32
+
33
+ /**
34
+ * Rerank documents against a query using BM25 scoring.
35
+ *
36
+ * @param {string} query - The search query
37
+ * @param {Array<object|string>} documents - Documents to rerank
38
+ * @param {object} [options]
39
+ * @param {number} [options.topK] - Maximum number of results to return
40
+ * @returns {Promise<object[]>} Reranked documents with .relevanceScore
41
+ */
42
+ async rerank(query, documents, options = {}) {
43
+ if (!Array.isArray(documents) || documents.length === 0) {
44
+ return [];
45
+ }
46
+
47
+ const { topK = documents.length } = options;
48
+ const { k1, b } = this.config;
49
+
50
+ const queryTokens = tokenize(query);
51
+ if (queryTokens.length === 0) {
52
+ return documents.slice(0, topK);
53
+ }
54
+
55
+ // Tokenize all documents and compute average document length
56
+ const docData = documents.map((doc) => {
57
+ const text = doc.content || doc.text || String(doc);
58
+ const tokens = tokenize(text);
59
+ return { doc, tokens };
60
+ });
61
+
62
+ const avgDocLen =
63
+ docData.reduce((sum, d) => sum + d.tokens.length, 0) / docData.length;
64
+
65
+ // Build document-frequency counts for IDF
66
+ const df = {};
67
+ for (const { tokens } of docData) {
68
+ const unique = new Set(tokens);
69
+ for (const token of unique) {
70
+ df[token] = (df[token] || 0) + 1;
71
+ }
72
+ }
73
+
74
+ const N = documents.length;
75
+
76
+ // Score each document
77
+ const scored = docData.map(({ doc, tokens }, originalIndex) => {
78
+ const docLen = tokens.length;
79
+
80
+ // Build term-frequency map for this document
81
+ const tf = {};
82
+ for (const t of tokens) {
83
+ tf[t] = (tf[t] || 0) + 1;
84
+ }
85
+
86
+ let score = 0;
87
+ for (const qToken of queryTokens) {
88
+ const termFreq = tf[qToken] || 0;
89
+ if (termFreq === 0) continue;
90
+
91
+ // IDF: log((N - df + 0.5) / (df + 0.5) + 1)
92
+ const docFreq = df[qToken] || 0;
93
+ const idf = Math.log((N - docFreq + 0.5) / (docFreq + 0.5) + 1);
94
+
95
+ // BM25 term score
96
+ const numerator = termFreq * (k1 + 1);
97
+ const denominator = termFreq + k1 * (1 - b + b * (docLen / avgDocLen));
98
+ score += idf * (numerator / denominator);
99
+ }
100
+
101
+ return { doc, score, originalIndex };
102
+ });
103
+
104
+ // Sort by score descending, then by original index for stability
105
+ scored.sort((a, bb) => {
106
+ if (bb.score !== a.score) return bb.score - a.score;
107
+ return a.originalIndex - bb.originalIndex;
108
+ });
109
+
110
+ const results = scored.slice(0, topK).map(({ doc, score }) => {
111
+ const result =
112
+ typeof doc === 'object' ? { ...doc } : { text: String(doc) };
113
+ result.relevanceScore = score;
114
+ return result;
115
+ });
116
+
117
+ this.emit('reranked', { count: results.length, topK });
118
+
119
+ return results;
120
+ }
121
+ }
122
+
123
+ module.exports = { ScoringReranker, DEFAULT_CONFIG };
@@ -0,0 +1,157 @@
1
+ 'use strict';
2
+
3
+ const { EventEmitter } = require('events');
4
+ const { tokenize } = require('../evaluate/scoring');
5
+
6
+ const DEFAULT_CONFIG = { k1: 1.2, b: 0.75 };
7
+
8
+ class BM25Search extends EventEmitter {
9
+ constructor(options = {}) {
10
+ super();
11
+ this.config = { ...DEFAULT_CONFIG, ...options };
12
+ this.invertedIndex = new Map(); // token -> [{docIndex, tf}]
13
+ this.documents = [];
14
+ this.avgDocLength = 0;
15
+ this.docCount = 0;
16
+ }
17
+
18
+ /**
19
+ * Index documents for BM25 search
20
+ * @param {Array} documents - [{id, content|text, metadata?}]
21
+ */
22
+ index(documents) {
23
+ if (!Array.isArray(documents) || documents.length === 0) {
24
+ return;
25
+ }
26
+
27
+ this.documents = documents;
28
+ this.docCount = documents.length;
29
+ this.invertedIndex.clear();
30
+
31
+ let totalLength = 0;
32
+
33
+ for (let docIndex = 0; docIndex < documents.length; docIndex++) {
34
+ const doc = documents[docIndex];
35
+ const text = doc.content || doc.text || '';
36
+ const tokens = tokenize(text);
37
+
38
+ totalLength += tokens.length;
39
+
40
+ // Count term frequencies for this document
41
+ const tfMap = new Map();
42
+ for (const token of tokens) {
43
+ tfMap.set(token, (tfMap.get(token) || 0) + 1);
44
+ }
45
+
46
+ // Add to inverted index
47
+ for (const [token, tf] of tfMap) {
48
+ if (!this.invertedIndex.has(token)) {
49
+ this.invertedIndex.set(token, []);
50
+ }
51
+ this.invertedIndex.get(token).push({ docIndex, tf });
52
+ }
53
+ }
54
+
55
+ this.avgDocLength = this.docCount > 0 ? totalLength / this.docCount : 0;
56
+
57
+ this.emit('indexed', {
58
+ docCount: this.docCount,
59
+ uniqueTerms: this.invertedIndex.size,
60
+ });
61
+ }
62
+
63
+ /**
64
+ * Search using BM25 scoring
65
+ * @param {string} query
66
+ * @param {object} options - {k: 10}
67
+ * @returns {Array} [{id, score, content, metadata}] sorted by BM25 score
68
+ */
69
+ search(query, options = {}) {
70
+ const { k = 10 } = options;
71
+
72
+ if (!query || this.docCount === 0) {
73
+ return [];
74
+ }
75
+
76
+ const queryTokens = tokenize(query);
77
+ if (queryTokens.length === 0) {
78
+ return [];
79
+ }
80
+
81
+ const { k1, b } = this.config;
82
+ const N = this.docCount;
83
+
84
+ /** @type {Map<number, number>} docIndex -> score */
85
+ const scores = new Map();
86
+
87
+ for (const token of queryTokens) {
88
+ const postings = this.invertedIndex.get(token);
89
+ if (!postings) {
90
+ continue;
91
+ }
92
+
93
+ const df = postings.length;
94
+ const idf = Math.log((N - df + 0.5) / (df + 0.5) + 1);
95
+
96
+ 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;
100
+
101
+ const numerator = tf * (k1 + 1);
102
+ const denominator =
103
+ tf + k1 * (1 - b + b * (docLen / this.avgDocLength));
104
+ const termScore = idf * (numerator / denominator);
105
+
106
+ scores.set(docIndex, (scores.get(docIndex) || 0) + termScore);
107
+ }
108
+ }
109
+
110
+ // Build result array and sort
111
+ const results = [];
112
+ for (const [docIndex, score] of scores) {
113
+ const doc = this.documents[docIndex];
114
+ results.push({
115
+ id: doc.id,
116
+ score,
117
+ content: doc.content || doc.text || '',
118
+ metadata: doc.metadata || {},
119
+ });
120
+ }
121
+
122
+ results.sort((a, b) => b.score - a.score);
123
+ const topResults = results.slice(0, k);
124
+
125
+ this.emit('searched', {
126
+ query,
127
+ resultCount: topResults.length,
128
+ totalMatches: results.length,
129
+ });
130
+
131
+ return topResults;
132
+ }
133
+
134
+ /**
135
+ * Clear the index
136
+ */
137
+ clear() {
138
+ this.invertedIndex.clear();
139
+ this.documents = [];
140
+ this.avgDocLength = 0;
141
+ this.docCount = 0;
142
+ }
143
+
144
+ /**
145
+ * Get index stats
146
+ * @returns {{docCount: number, uniqueTerms: number, avgDocLength: number}}
147
+ */
148
+ getStats() {
149
+ return {
150
+ docCount: this.docCount,
151
+ uniqueTerms: this.invertedIndex.size,
152
+ avgDocLength: this.avgDocLength,
153
+ };
154
+ }
155
+ }
156
+
157
+ module.exports = { BM25Search, DEFAULT_CONFIG };
@@ -0,0 +1,111 @@
1
+ 'use strict';
2
+
3
+ const { EventEmitter } = require('events');
4
+ const { reciprocalRankFusion } = require('./rank-fusion');
5
+
6
+ const DEFAULT_CONFIG = {
7
+ topK: 10,
8
+ fusionK: 60,
9
+ };
10
+
11
+ class HybridRetriever extends EventEmitter {
12
+ /**
13
+ * @param {Array<{retriever: object, weight: number, name: string}>} retrievers
14
+ * @param {object} options
15
+ */
16
+ constructor(retrievers = [], options = {}) {
17
+ super();
18
+ this.retrievers = retrievers.map((r, i) => ({
19
+ retriever: r.retriever,
20
+ weight: r.weight != null ? r.weight : 1.0,
21
+ name: r.name || `retriever-${i}`,
22
+ }));
23
+ this.config = { ...DEFAULT_CONFIG, ...options };
24
+ }
25
+
26
+ /**
27
+ * Run all retrievers in parallel, fuse results
28
+ * @param {object} query - {query: string, queryVector?: number[], topK?: number}
29
+ * @returns {Promise<Array>} Fused results
30
+ */
31
+ async retrieve(query) {
32
+ if (this.retrievers.length === 0) {
33
+ return [];
34
+ }
35
+
36
+ const topK = (query && query.topK) || this.config.topK;
37
+
38
+ // Run all retrievers in parallel
39
+ const promises = this.retrievers.map(({ retriever, name }) => {
40
+ const fn =
41
+ typeof retriever.retrieve === 'function'
42
+ ? retriever.retrieve.bind(retriever)
43
+ : typeof retriever.search === 'function'
44
+ ? retriever.search.bind(retriever)
45
+ : null;
46
+
47
+ if (!fn) {
48
+ return Promise.reject(
49
+ new Error(`Retriever "${name}" has no retrieve() or search() method`),
50
+ );
51
+ }
52
+
53
+ // Pass query string or the full query object depending on method
54
+ const arg = typeof query === 'string' ? query : query.query || query;
55
+ return fn(arg);
56
+ });
57
+
58
+ const settled = await Promise.allSettled(promises);
59
+
60
+ const resultSets = [];
61
+ const weights = [];
62
+ let successCount = 0;
63
+
64
+ for (let i = 0; i < settled.length; i++) {
65
+ const outcome = settled[i];
66
+ if (outcome.status === 'fulfilled') {
67
+ const results = Array.isArray(outcome.value) ? outcome.value : [];
68
+ resultSets.push(results);
69
+ weights.push(this.retrievers[i].weight);
70
+ successCount++;
71
+ } else {
72
+ // Graceful degradation: log warning and continue
73
+ this.emit('retrieverError', {
74
+ name: this.retrievers[i].name,
75
+ error: outcome.reason,
76
+ });
77
+ }
78
+ }
79
+
80
+ const fused = reciprocalRankFusion(resultSets, {
81
+ k: this.config.fusionK,
82
+ weights,
83
+ });
84
+
85
+ const topResults = fused.slice(0, topK);
86
+
87
+ this.emit('retrieved', {
88
+ totalRetrievers: this.retrievers.length,
89
+ successfulRetrievers: successCount,
90
+ resultCount: topResults.length,
91
+ });
92
+
93
+ return topResults;
94
+ }
95
+
96
+ /**
97
+ * Add a retriever
98
+ * @param {object} retriever - Object with .retrieve() or .search() method
99
+ * @param {number} weight - Weight for rank fusion
100
+ * @param {string} name - Name for this retriever
101
+ */
102
+ addRetriever(retriever, weight = 1.0, name = '') {
103
+ this.retrievers.push({
104
+ retriever,
105
+ weight,
106
+ name: name || `retriever-${this.retrievers.length}`,
107
+ });
108
+ }
109
+ }
110
+
111
+ module.exports = { HybridRetriever, DEFAULT_CONFIG };