@devilsdev/rag-pipeline-utils 2.3.0 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +293 -361
- package/contracts/loader-contract.json +11 -6
- package/contracts/reranker-contract.json +18 -26
- package/dist/index.cjs +21190 -47
- package/dist/index.d.ts +298 -68
- package/dist/index.mjs +9 -76
- package/package.json +20 -8
- package/src/agentic/agentic-pipeline.js +235 -0
- package/src/agentic/iterative-retriever.js +213 -0
- package/src/agentic/query-planner.js +208 -0
- package/src/agentic/self-critique.js +102 -0
- package/src/ai/adaptive-retrieval.js +0 -3
- package/src/ai/federated-learning.js +0 -3
- package/src/ai/federation.js +1 -6
- package/src/ai/index.js +9 -20
- package/src/ai/model-training.js +0 -3
- package/src/ai/multimodal-processing.js +0 -3
- package/src/ai/multimodal.js +1 -6
- package/src/ai/orchestrator.js +1 -6
- package/src/ai/retrieval-engine.js +1 -6
- package/src/chunking/chunking-engine.js +142 -0
- package/src/chunking/strategies/fixed-size.js +54 -0
- package/src/chunking/strategies/recursive.js +127 -0
- package/src/chunking/strategies/semantic.js +128 -0
- package/src/chunking/strategies/sentence.js +32 -0
- package/src/chunking/strategies/structure-aware.js +138 -0
- package/src/citation/citation-tracker.js +161 -0
- package/src/citation/hallucination-detector.js +74 -0
- package/src/citation/source-mapper.js +95 -0
- package/src/cli/commands/ai-ml/adaptive-commands.js +178 -0
- package/src/cli/commands/ai-ml/federated-commands.js +207 -0
- package/src/cli/commands/ai-ml/index.js +17 -0
- package/src/cli/commands/ai-ml/multimodal-commands.js +187 -0
- package/src/cli/commands/ai-ml/shared.js +51 -0
- package/src/cli/commands/ai-ml/training-commands.js +228 -0
- package/src/cli/commands/ai-ml.js +176 -757
- package/src/cli/commands/plugin-hub/certification-commands.js +221 -0
- package/src/cli/commands/plugin-hub/event-handlers.js +64 -0
- package/src/cli/commands/plugin-hub/hub-commands.js +290 -0
- package/src/cli/commands/plugin-hub/review-commands.js +78 -0
- package/src/cli/commands/plugin-hub.js +106 -782
- package/src/cli/diagnostics/config-checker.js +81 -0
- package/src/cli/diagnostics/dependency-checker.js +69 -0
- package/src/cli/diagnostics/environment-checker.js +45 -0
- package/src/cli/diagnostics/performance-checker.js +51 -0
- package/src/cli/diagnostics/plugin-checker.js +106 -0
- package/src/cli/diagnostics/security-checker.js +61 -0
- package/src/cli/doctor-command.js +70 -369
- package/src/cli/enhanced-cli-commands.js +162 -706
- package/src/cli/handlers/config-handler.js +126 -0
- package/src/cli/handlers/diagnostics-handler.js +113 -0
- package/src/cli/handlers/ingest-handler.js +42 -0
- package/src/cli/handlers/init-handler.js +51 -0
- package/src/cli/handlers/query-handler.js +45 -0
- package/src/cli/handlers/run-handler.js +181 -0
- package/src/cli/interactive-wizard.js +99 -723
- package/src/cli/shared/cli-utils.js +89 -0
- package/src/cli/wizard/configuration.js +148 -0
- package/src/cli/wizard/observability.js +79 -0
- package/src/cli/wizard/performance.js +76 -0
- package/src/cli/wizard/plugin-selection.js +297 -0
- package/src/cli/wizard/preview-save.js +172 -0
- package/src/cli/wizard/project-setup.js +66 -0
- package/src/config/memory-optimization.js +3 -3
- package/src/connectors/anthropic-connector.js +179 -0
- package/src/connectors/base-connector.js +94 -0
- package/src/connectors/cohere-connector.js +143 -0
- package/src/connectors/local-embedder.js +171 -0
- package/src/connectors/memory-retriever.js +150 -0
- package/src/connectors/ollama-connector.js +205 -0
- package/src/connectors/openai-connector.js +173 -0
- package/src/core/create-pipeline.js +21 -1
- package/src/core/observability/tracing.js +2 -1
- package/src/core/pipeline-factory.js +0 -1
- package/src/core/plugin-registry.js +14 -14
- package/src/cost/cost-calculator.js +146 -0
- package/src/cost/cost-tracker.js +260 -0
- package/src/cost/token-budget.js +245 -0
- package/src/dag/core/execution-scheduler.js +2 -1
- package/src/dag/core/topology-validation.js +0 -3
- package/src/dag/dag-engine.js +3 -5
- package/src/dag/dag-node.js +0 -1
- package/src/dag/enhanced-dag-engine.js +171 -99
- package/src/dashboard/dashboard-generator.js +642 -0
- package/src/dashboard/metrics-aggregator.js +409 -0
- package/src/debug/execution-tracer.js +210 -0
- package/src/debug/trace-inspector.js +281 -0
- package/src/dev/dev-server.js +5 -5
- package/src/dev/hot-reload.js +3 -2
- package/src/dx/integration-templates.js +0 -3
- package/src/ecosystem/plugin-analytics-dashboard.js +2 -2
- package/src/ecosystem/plugin-hub.js +2 -2
- package/src/enterprise/audit-logging.js +2 -2
- package/src/enterprise/sso-integration.js +52 -32
- package/src/evaluate/context-metrics.js +127 -0
- package/src/evaluate/evaluator.js +4 -2
- package/src/evaluate/faithfulness.js +117 -0
- package/src/evaluate/groundedness.js +60 -0
- package/src/evaluate/pipeline-evaluator.js +156 -0
- package/src/evaluate/relevance.js +129 -0
- package/src/evaluate/scoring.js +27 -0
- package/src/graph/entity-extractor.js +183 -0
- package/src/graph/graph-index.js +113 -0
- package/src/graph/graph-retriever.js +244 -0
- package/src/graph/knowledge-graph.js +256 -0
- package/src/guardrails/guardrails-pipeline.js +136 -0
- package/src/guardrails/post-generation-guard.js +170 -0
- package/src/guardrails/pre-retrieval-guard.js +161 -0
- package/src/guardrails/retrieval-guard.js +126 -0
- package/src/index.js +255 -9
- package/src/loader/html-loader.js +4 -15
- package/src/loader/markdown-loader.js +4 -15
- package/src/mcp/mcp-server.js +199 -0
- package/src/mcp/mcp-tool-builder.js +121 -0
- package/src/observability/audit-logger.js +0 -2
- package/src/observability/event-logger.js +2 -1
- package/src/observability/security-monitor.js +0 -2
- package/src/observability/telemetry.js +0 -2
- package/src/reranker/cascade-reranker.js +79 -0
- package/src/reranker/embedding-reranker.js +124 -0
- package/src/reranker/llm-reranker.js +6 -3
- package/src/reranker/scoring-reranker.js +123 -0
- package/src/retrieval/bm25-search.js +157 -0
- package/src/retrieval/hybrid-retriever.js +111 -0
- package/src/retrieval/rank-fusion.js +62 -0
- package/src/security/jwt-config.js +4 -3
- package/src/security/jwt-validator.js +2 -1
- package/src/security/plugin-sandbox.js +0 -3
- package/src/security/plugin-signature-verifier.js +4 -6
- package/src/security/validator.js +0 -2
- package/src/streaming/embedding-stream.js +197 -0
- package/src/streaming/sse-adapter.js +168 -0
- package/src/streaming/stream-router.js +210 -0
- package/src/streaming/streaming-embedder.js +231 -0
- package/src/streaming/websocket-adapter.js +171 -0
- package/src/templates/scaffold.js +147 -0
- package/src/templates/template-registry.js +272 -0
- package/src/utils/contract-validator.js +2 -1
- package/src/utils/input-sanitizer.js +80 -99
- package/src/utils/performance-monitor.js +0 -3
- package/src/utils/plugin-scaffolder.js +9 -16
- package/src/utils/rate-limiter.js +0 -1
- package/src/utils/retry-policy.js +0 -3
- package/src/utils/safe-expression-evaluator.js +0 -3
- package/src/utils/secure-logger-config.js +2 -4
- package/src/utils/secure-logging.js +0 -3
- package/src/utils/structured-logger.js +0 -3
- package/src/ai/index-fixed.js +0 -509
- package/src/ai/module-template.js +0 -37
- package/src/ci/workflow-analyzer.js +0 -71
- package/src/cli/plugin-marketplace-commands.js +0 -869
- package/src/dev/README.md +0 -126
- package/src/dev/examples/hot-reload-usage.js +0 -355
- package/src/mocks/openai-embedder.js +0 -119
- package/src/mocks/openai-llm.js +0 -77
- package/src/mocks/pdf-loader.js +0 -21
- package/src/mocks/pinecone-retriever.js +0 -106
- package/src/scripts/script-utilities.js +0 -36
- package/src/utils/ci/diagnostic-reporter.js +0 -57
- 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
|
-
|
|
92
|
+
logger.warn('Event listener error:', e);
|
|
92
93
|
}
|
|
93
94
|
}
|
|
94
95
|
}
|
|
@@ -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 (
|
|
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 };
|