@devilsdev/rag-pipeline-utils 2.4.5 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (258) hide show
  1. package/.ragrc.schema.json +83 -192
  2. package/CHANGELOG.md +431 -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 +6442 -1355
  9. package/dist/index.d.ts +129 -2
  10. package/dist/index.mjs +1 -1
  11. package/dist/testing.cjs +2 -0
  12. package/dist/testing.d.ts +1 -0
  13. package/dist/testing.mjs +5 -0
  14. package/dist/types/adapters/ai-sdk.d.ts +53 -0
  15. package/dist/types/agentic/agentic-pipeline.d.ts +103 -0
  16. package/dist/types/agentic/iterative-retriever.d.ts +85 -0
  17. package/dist/types/agentic/query-planner.d.ts +145 -0
  18. package/dist/types/agentic/self-critique.d.ts +45 -0
  19. package/dist/types/ai/adaptive-retrieval.d.ts +44 -0
  20. package/dist/types/ai/federation/federated-learning-coordinator.d.ts +62 -0
  21. package/dist/types/ai/multimodal-processing.d.ts +32 -0
  22. package/dist/types/ai/multimodal.d.ts +39 -0
  23. package/dist/types/ai/retrieval-engine.d.ts +37 -0
  24. package/dist/types/ai/training/model-training-orchestrator.d.ts +31 -0
  25. package/dist/types/cache/component-keys.d.ts +7 -0
  26. package/dist/types/cache/memory-cache.d.ts +54 -0
  27. package/dist/types/cache/pipeline-cache.d.ts +66 -0
  28. package/dist/types/cache/redis-cache.d.ts +46 -0
  29. package/dist/types/chunking/chunking-engine.d.ts +70 -0
  30. package/dist/types/chunking/strategies/fixed-size.d.ts +17 -0
  31. package/dist/types/chunking/strategies/recursive.d.ts +20 -0
  32. package/dist/types/chunking/strategies/semantic.d.ts +19 -0
  33. package/dist/types/chunking/strategies/sentence.d.ts +13 -0
  34. package/dist/types/chunking/strategies/structure-aware.d.ts +19 -0
  35. package/dist/types/citation/citation-tracker.d.ts +64 -0
  36. package/dist/types/citation/hallucination-detector.d.ts +40 -0
  37. package/dist/types/citation/source-mapper.d.ts +37 -0
  38. package/dist/types/connectors/anthropic-connector.d.ts +97 -0
  39. package/dist/types/connectors/base-connector.d.ts +87 -0
  40. package/dist/types/connectors/cohere-connector.d.ts +94 -0
  41. package/dist/types/connectors/connector-utils.d.ts +110 -0
  42. package/dist/types/connectors/local-embedder.d.ts +95 -0
  43. package/dist/types/connectors/memory-retriever.d.ts +99 -0
  44. package/dist/types/connectors/ollama-connector.d.ts +115 -0
  45. package/dist/types/connectors/openai-connector.d.ts +129 -0
  46. package/dist/types/connectors/rag-prompt.d.ts +40 -0
  47. package/dist/types/core/create-pipeline.d.ts +8 -0
  48. package/dist/types/core/plugin-registry.d.ts +136 -0
  49. package/dist/types/core/stage-contracts.d.ts +36 -0
  50. package/dist/types/core/validate-plugin.d.ts +45 -0
  51. package/dist/types/cost/cost-calculator.d.ts +152 -0
  52. package/dist/types/cost/cost-tracker.d.ts +126 -0
  53. package/dist/types/cost/token-budget.d.ts +110 -0
  54. package/dist/types/dag/core/error-context.d.ts +239 -0
  55. package/dist/types/dag/core/execution-scheduler.d.ts +191 -0
  56. package/dist/types/dag/core/topology-validation.d.ts +226 -0
  57. package/dist/types/dag/dag-engine.d.ts +156 -0
  58. package/dist/types/dag/dag-node.d.ts +37 -0
  59. package/dist/types/dashboard/dashboard-generator.d.ts +127 -0
  60. package/dist/types/dashboard/metrics-aggregator.d.ts +218 -0
  61. package/dist/types/debug/execution-tracer.d.ts +57 -0
  62. package/dist/types/debug/trace-inspector.d.ts +31 -0
  63. package/dist/types/ecosystem/plugin-certification.d.ts +169 -0
  64. package/dist/types/enterprise/data-governance.d.ts +203 -0
  65. package/dist/types/enterprise/multi-tenancy.d.ts +137 -0
  66. package/dist/types/enterprise/sso-integration.d.ts +420 -0
  67. package/dist/types/evaluate/context-metrics.d.ts +55 -0
  68. package/dist/types/evaluate/faithfulness.d.ts +41 -0
  69. package/dist/types/evaluate/groundedness.d.ts +60 -0
  70. package/dist/types/evaluate/pipeline-evaluator.d.ts +88 -0
  71. package/dist/types/evaluate/relevance.d.ts +21 -0
  72. package/dist/types/evaluate/scoring.d.ts +123 -0
  73. package/dist/types/evaluate/semantic-similarity.d.ts +14 -0
  74. package/dist/types/experimental.d.ts +15 -0
  75. package/dist/types/graph/entity-extractor.d.ts +84 -0
  76. package/dist/types/graph/graph-index.d.ts +49 -0
  77. package/dist/types/graph/graph-retriever.d.ts +81 -0
  78. package/dist/types/graph/knowledge-graph.d.ts +129 -0
  79. package/dist/types/grounding/check-answer.d.ts +101 -0
  80. package/dist/types/guardrails/guardrails-pipeline.d.ts +63 -0
  81. package/dist/types/guardrails/injection-detector.d.ts +47 -0
  82. package/dist/types/guardrails/post-generation-guard.d.ts +61 -0
  83. package/dist/types/guardrails/pre-retrieval-guard.d.ts +64 -0
  84. package/dist/types/guardrails/retrieval-guard.d.ts +57 -0
  85. package/dist/types/mcp/mcp-server.d.ts +85 -0
  86. package/dist/types/mcp/mcp-tool-builder.d.ts +55 -0
  87. package/dist/types/reranker/cascade-reranker.d.ts +40 -0
  88. package/dist/types/reranker/embedding-reranker.d.ts +77 -0
  89. package/dist/types/reranker/scoring-reranker.d.ts +41 -0
  90. package/dist/types/retrieval/bm25-search.d.ts +43 -0
  91. package/dist/types/retrieval/file-vector-store.d.ts +99 -0
  92. package/dist/types/retrieval/hybrid-retriever.d.ts +35 -0
  93. package/dist/types/retrieval/rank-fusion.d.ts +10 -0
  94. package/dist/types/security/jwt-config.d.ts +80 -0
  95. package/dist/types/security/jwt-validator.d.ts +195 -0
  96. package/dist/types/security/plugin-sandbox.d.ts +158 -0
  97. package/dist/types/security/plugin-signature-verifier.d.ts +139 -0
  98. package/dist/types/security/validator.d.ts +5 -0
  99. package/dist/types/streaming/embedding-stream.d.ts +65 -0
  100. package/dist/types/streaming/sse-adapter.d.ts +59 -0
  101. package/dist/types/streaming/stream-router.d.ts +88 -0
  102. package/dist/types/streaming/streaming-embedder.d.ts +99 -0
  103. package/dist/types/streaming/websocket-adapter.d.ts +71 -0
  104. package/dist/types/templates/scaffold.d.ts +55 -0
  105. package/dist/types/templates/template-registry.d.ts +63 -0
  106. package/dist/types/testing.d.ts +168 -0
  107. package/dist/types/utils/embed-query.d.ts +20 -0
  108. package/dist/types/utils/logger.d.ts +50 -0
  109. package/dist/types/utils/rate-limiter.d.ts +66 -0
  110. package/dist/types/utils/retry.d.ts +27 -0
  111. package/dist/types/utils/run-abort.d.ts +34 -0
  112. package/dist/types/utils/secure-logging.d.ts +51 -0
  113. package/dist/types/utils/structured-logger.d.ts +126 -0
  114. package/package.json +72 -33
  115. package/src/adapters/ai-sdk.js +215 -0
  116. package/src/agentic/agentic-pipeline.js +75 -7
  117. package/src/agentic/iterative-retriever.js +47 -10
  118. package/src/agentic/query-planner.js +329 -78
  119. package/src/cache/component-keys.js +38 -0
  120. package/src/cache/memory-cache.js +87 -0
  121. package/src/cache/pipeline-cache.js +260 -0
  122. package/src/cache/redis-cache.js +83 -0
  123. package/src/citation/citation-tracker.js +6 -4
  124. package/src/citation/hallucination-detector.js +2 -2
  125. package/src/citation/source-mapper.js +15 -28
  126. package/src/cli/commands/ai.js +106 -0
  127. package/src/cli/commands/plugin-hub/event-handlers.js +6 -0
  128. package/src/cli/commands/plugin-hub/hub-commands.js +0 -74
  129. package/src/cli/commands/plugin-hub/local-commands.js +143 -0
  130. package/src/cli/commands/plugin-hub.js +114 -49
  131. package/src/cli/diagnostics/config-checker.js +76 -63
  132. package/src/cli/diagnostics/environment-checker.js +62 -31
  133. package/src/cli/diagnostics/plugin-checker.js +36 -89
  134. package/src/cli/doctor-command.js +39 -5
  135. package/src/cli/enhanced-cli-commands.js +104 -233
  136. package/src/cli/handlers/ai-handler.js +364 -0
  137. package/src/cli/handlers/diagnostics-handler.js +78 -31
  138. package/src/cli/handlers/ingest-handler.js +28 -22
  139. package/src/cli/handlers/init-handler.js +148 -25
  140. package/src/cli/handlers/query-handler.js +203 -25
  141. package/src/cli/handlers/run-handler.js +16 -167
  142. package/src/cli/handlers/serve-handler.js +60 -0
  143. package/src/cli/services/bench-stats.js +54 -0
  144. package/src/cli/services/benchmark.js +161 -0
  145. package/src/cli/services/feedback.js +155 -0
  146. package/src/cli/services/instrument.js +83 -0
  147. package/src/cli/services/rag-service.js +361 -0
  148. package/src/cli/shared/cli-utils.js +24 -7
  149. package/src/cli/shared/config-context.js +21 -0
  150. package/src/cli/wizard/config-builder.js +81 -0
  151. package/src/cli/wizard/init-wizard.js +602 -0
  152. package/src/config/component-factory.js +125 -0
  153. package/src/config/enhanced-ragrc-schema.js +0 -2
  154. package/src/config/load-config.js +29 -10
  155. package/src/config/load-plugin-config.js +46 -2
  156. package/src/config/validate-schema.js +4 -1
  157. package/src/connectors/anthropic-connector.js +115 -57
  158. package/src/connectors/base-connector.js +74 -19
  159. package/src/connectors/cohere-connector.js +108 -34
  160. package/src/connectors/connector-utils.js +302 -0
  161. package/src/connectors/local-embedder.js +63 -5
  162. package/src/connectors/memory-retriever.js +125 -14
  163. package/src/connectors/ollama-connector.js +112 -51
  164. package/src/connectors/openai-connector.js +153 -44
  165. package/src/connectors/rag-prompt.js +88 -0
  166. package/src/core/create-pipeline.js +307 -61
  167. package/src/core/performance/parallel-processor.js +62 -1
  168. package/src/core/plugin-registry.js +125 -26
  169. package/src/core/stage-contracts.js +36 -0
  170. package/src/core/validate-plugin.js +85 -0
  171. package/src/cost/cost-calculator.js +205 -32
  172. package/src/debug/execution-tracer.js +14 -4
  173. package/src/dev/dev-server.js +173 -11
  174. package/src/dev/hot-reload.js +44 -4
  175. package/src/ecosystem/plugin-checks.js +181 -0
  176. package/src/ecosystem/plugin-hub.js +50 -125
  177. package/src/ecosystem/plugin-installer.js +298 -0
  178. package/src/ecosystem/plugin-probe.js +127 -0
  179. package/src/ecosystem/plugin-scanner.js +185 -0
  180. package/src/ecosystem/tar-extract.js +114 -0
  181. package/src/enterprise/audit-logging.js +158 -29
  182. package/src/enterprise/sso-integration.js +716 -228
  183. package/src/evaluate/context-metrics.js +28 -16
  184. package/src/evaluate/evaluator.js +3 -3
  185. package/src/evaluate/faithfulness.js +18 -14
  186. package/src/evaluate/groundedness.js +43 -12
  187. package/src/evaluate/pipeline-evaluator.js +35 -8
  188. package/src/evaluate/scoring.js +251 -3
  189. package/src/evaluate/semantic-similarity.js +79 -0
  190. package/src/experimental.js +71 -0
  191. package/src/grounding/check-answer.js +173 -0
  192. package/src/guardrails/guardrails-pipeline.js +69 -7
  193. package/src/guardrails/injection-detector.js +283 -0
  194. package/src/guardrails/post-generation-guard.js +19 -0
  195. package/src/guardrails/pre-retrieval-guard.js +36 -36
  196. package/src/guardrails/retrieval-guard.js +58 -8
  197. package/src/index.js +58 -1
  198. package/src/loader/csv-loader.js +3 -2
  199. package/src/loader/document-reader.js +91 -0
  200. package/src/loader/html-loader.js +3 -1
  201. package/src/loader/markdown-loader.js +3 -2
  202. package/src/reranker/cascade-reranker.js +3 -0
  203. package/src/reranker/embedding-reranker.js +136 -21
  204. package/src/reranker/llm-reranker.js +79 -12
  205. package/src/reranker/scoring-reranker.js +3 -0
  206. package/src/retrieval/bm25-search.js +12 -6
  207. package/src/retrieval/file-vector-store.js +241 -0
  208. package/src/retrieval/hybrid-retriever.js +22 -19
  209. package/src/security/jwt-config.js +13 -2
  210. package/src/security/jwt-validator.js +139 -34
  211. package/src/security/plugin-sandbox.js +18 -1
  212. package/src/security/plugin-signature-verifier.js +245 -70
  213. package/src/testing.js +254 -0
  214. package/src/utils/deprecate.js +69 -0
  215. package/src/utils/embed-query.js +33 -0
  216. package/src/utils/input-sanitizer.js +19 -4
  217. package/src/utils/logger.js +34 -4
  218. package/src/utils/run-abort.js +102 -0
  219. package/src/ai/federated-learning.js +0 -211
  220. package/src/ai/federation.js +0 -71
  221. package/src/ai/index.js +0 -22
  222. package/src/ai/model-trainer.js +0 -217
  223. package/src/ai/model-training.js +0 -202
  224. package/src/ai/multimodal/multi-modal-processor.js +0 -345
  225. package/src/ai/orchestrator.js +0 -65
  226. package/src/ai/retrieval/adaptive-retrieval-engine.js +0 -398
  227. package/src/cli/commands/ai-ml/adaptive-commands.js +0 -178
  228. package/src/cli/commands/ai-ml/federated-commands.js +0 -207
  229. package/src/cli/commands/ai-ml/index.js +0 -17
  230. package/src/cli/commands/ai-ml/multimodal-commands.js +0 -187
  231. package/src/cli/commands/ai-ml/shared.js +0 -51
  232. package/src/cli/commands/ai-ml/training-commands.js +0 -228
  233. package/src/cli/commands/ai-ml.js +0 -386
  234. package/src/cli/commands/docs.js +0 -228
  235. package/src/cli/commands/dx.js +0 -538
  236. package/src/cli/commands/wizard.js +0 -627
  237. package/src/cli/input-sanitization-middleware.js +0 -356
  238. package/src/cli/interactive-wizard.js +0 -373
  239. package/src/cli/templates/development.template.json +0 -64
  240. package/src/cli/templates/minimal.template.json +0 -33
  241. package/src/cli/templates/production.template.json +0 -104
  242. package/src/cli/templates/testing.template.json +0 -45
  243. package/src/cli/wizard/configuration.js +0 -148
  244. package/src/cli/wizard/observability.js +0 -79
  245. package/src/cli/wizard/performance.js +0 -76
  246. package/src/cli/wizard/plugin-selection.js +0 -297
  247. package/src/cli/wizard/preview-save.js +0 -172
  248. package/src/cli/wizard/project-setup.js +0 -66
  249. package/src/core/pipeline-factory.js +0 -59
  250. package/src/dag/enhanced-dag-engine.js +0 -894
  251. package/src/dx/index.js +0 -40
  252. package/src/dx/integration-templates.js +0 -662
  253. package/src/dx/performance-profiler.js +0 -968
  254. package/src/dx/realtime-debugger.js +0 -755
  255. package/src/dx/visual-pipeline-builder.js +0 -578
  256. package/src/ingest.js +0 -59
  257. package/src/pipeline/pipeline.js +0 -18
  258. package/src/query.js +0 -117
@@ -1,10 +1,6 @@
1
1
  'use strict';
2
2
 
3
- const {
4
- tokenize,
5
- splitSentences,
6
- computeJaccardSimilarity,
7
- } = require('./scoring');
3
+ const { tokenize, splitSentences, computeSupportScore } = require('./scoring');
8
4
 
9
5
  /**
10
6
  * Compute context precision: the fraction of retrieved documents that are relevant to the query.
@@ -12,7 +8,7 @@ const {
12
8
  * @param {string} query - The user query
13
9
  * @param {Array<{content: string}>} retrievedDocs - Array of retrieved documents
14
10
  * @param {object} [options={}] - Configuration options
15
- * @param {number} [options.threshold=0.2] - Minimum Jaccard similarity for a doc to be considered relevant
11
+ * @param {number} [options.threshold=0.2] - Minimum share of query content terms a doc must contain to be considered relevant
16
12
  * @returns {{ score: number, relevantDocs: number, totalDocs: number, perDocScores: Array<{ index: number, score: number, isRelevant: boolean }> }}
17
13
  */
18
14
  function computeContextPrecision(query, retrievedDocs, options = {}) {
@@ -39,7 +35,8 @@ function computeContextPrecision(query, retrievedDocs, options = {}) {
39
35
  }
40
36
 
41
37
  const docTokens = tokenize(docContent);
42
- const score = computeJaccardSimilarity(queryTokens, docTokens);
38
+ // Share of the query's content terms that the document contains.
39
+ const score = computeSupportScore(queryTokens, docTokens);
43
40
  perDocScores.push({ index: i, score, isRelevant: score >= threshold });
44
41
  }
45
42
 
@@ -51,22 +48,34 @@ function computeContextPrecision(query, retrievedDocs, options = {}) {
51
48
  }
52
49
 
53
50
  /**
54
- * Compute context recall: the fraction of answer claims that are supported by retrieved documents.
51
+ * Compute context recall: the fraction of the claims in a REFERENCE
52
+ * (ground-truth) answer that the retrieved documents support. It measures
53
+ * whether retrieval found everything needed to answer correctly; pass the
54
+ * expected answer, not the generated one (scoring the generated answer
55
+ * against the documents is faithfulness).
55
56
  *
56
- * @param {string} answer - The generated answer text
57
+ * A claim is one sentence of the reference answer. It is supported when some
58
+ * document scores at least `threshold` with computeSupportScore, including
59
+ * the numeric consistency check.
60
+ *
61
+ * @param {string} referenceAnswer - The reference (ground-truth) answer text
57
62
  * @param {Array<{content: string}>} retrievedDocs - Array of retrieved documents
58
63
  * @param {object} [options={}] - Configuration options
59
- * @param {number} [options.threshold=0.3] - Minimum Jaccard similarity for a claim to be considered supported
64
+ * @param {number} [options.threshold=0.5] - Minimum share of a claim's content terms a doc must contain to support it
60
65
  * @returns {{ score: number, supportedClaims: number, totalClaims: number, details: Array<{ claim: string, score: number, isSupported: boolean, bestDocIndex: number }> }}
61
66
  */
62
- function computeContextRecall(answer, retrievedDocs, options = {}) {
63
- const { threshold = 0.3 } = options;
64
-
65
- if (!answer || typeof answer !== 'string' || answer.trim().length === 0) {
67
+ function computeContextRecall(referenceAnswer, retrievedDocs, options = {}) {
68
+ const { threshold = 0.5 } = options;
69
+
70
+ if (
71
+ !referenceAnswer ||
72
+ typeof referenceAnswer !== 'string' ||
73
+ referenceAnswer.trim().length === 0
74
+ ) {
66
75
  return { score: 0, supportedClaims: 0, totalClaims: 0, details: [] };
67
76
  }
68
77
 
69
- const claims = splitSentences(answer);
78
+ const claims = splitSentences(referenceAnswer);
70
79
 
71
80
  if (claims.length === 0) {
72
81
  return { score: 0, supportedClaims: 0, totalClaims: 0, details: [] };
@@ -101,7 +110,10 @@ function computeContextRecall(answer, retrievedDocs, options = {}) {
101
110
  }
102
111
 
103
112
  const docTokens = tokenize(docContent);
104
- const similarity = computeJaccardSimilarity(claimTokens, docTokens);
113
+ const similarity = computeSupportScore(claimTokens, docTokens, null, {
114
+ claimText: claim,
115
+ docText: docContent,
116
+ });
105
117
 
106
118
  if (similarity > bestScore) {
107
119
  bestScore = similarity;
@@ -22,15 +22,15 @@ async function evaluateRagDataset(_datasetPath, _config) {
22
22
  const results = [];
23
23
  for (const { prompt, expected } of cases) {
24
24
  try {
25
- logger.info({ prompt }, 'Evaluating case');
25
+ logger.info('Evaluating case', { prompt });
26
26
  const result = await pipeline.run({ query: prompt });
27
- const actual = result.results || '';
27
+ const actual = typeof result.answer === 'string' ? result.answer : '';
28
28
  const success =
29
29
  result.success && normalizeText(actual) === normalizeText(expected);
30
30
  const scores = scoreAnswer(actual, expected);
31
31
  results.push({ prompt, expected, actual, success, scores });
32
32
  } catch (err) {
33
- logger.error({ prompt, error: err.message }, 'Evaluation failed');
33
+ logger.error('Evaluation failed', { prompt, error: err.message });
34
34
  results.push({
35
35
  prompt,
36
36
  expected,
@@ -1,10 +1,6 @@
1
1
  'use strict';
2
2
 
3
- const {
4
- splitSentences,
5
- tokenize,
6
- computeJaccardSimilarity,
7
- } = require('./scoring');
3
+ const { splitSentences, tokenize, computeSupportScore } = require('./scoring');
8
4
  const { mapSentenceToSources } = require('../citation/source-mapper');
9
5
 
10
6
  /**
@@ -14,11 +10,11 @@ const { mapSentenceToSources } = require('../citation/source-mapper');
14
10
  * @param {string} answer - The generated answer text
15
11
  * @param {Array<{content: string}>} retrievedDocs - Array of retrieved documents with content
16
12
  * @param {object} [options={}] - Configuration options
17
- * @param {number} [options.threshold=0.3] - Minimum similarity score to consider a sentence faithful
13
+ * @param {number} [options.threshold=0.5] - Minimum similarity score to consider a sentence faithful
18
14
  * @returns {{ score: number, faithfulSentences: number, totalSentences: number, details: Array<{ sentence: string, score: number, isFaithful: boolean, bestSource: number }> }}
19
15
  */
20
16
  function computeFaithfulness(answer, retrievedDocs, options = {}) {
21
- const { threshold = 0.3 } = options;
17
+ const { threshold = 0.5 } = options;
22
18
 
23
19
  if (!answer || typeof answer !== 'string' || answer.trim().length === 0) {
24
20
  return { score: 0, faithfulSentences: 0, totalSentences: 0, details: [] };
@@ -60,7 +56,10 @@ function computeFaithfulness(answer, retrievedDocs, options = {}) {
60
56
  }
61
57
 
62
58
  const docTokens = tokenize(docContent);
63
- const similarity = computeJaccardSimilarity(sentenceTokens, docTokens);
59
+ const similarity = computeSupportScore(sentenceTokens, docTokens, null, {
60
+ claimText: sentence,
61
+ docText: docContent,
62
+ });
64
63
 
65
64
  if (similarity > bestScore) {
66
65
  bestScore = similarity;
@@ -102,13 +101,18 @@ function computeFaithfulnessFromCitations(citationResult) {
102
101
  ? citationResult.groundednessScore
103
102
  : 0;
104
103
 
104
+ // CitationTracker only lists sources at or above its similarity threshold,
105
+ // so a sentence is faithful exactly when it has at least one source.
105
106
  const details = Array.isArray(citationResult.citations)
106
- ? citationResult.citations.map((c) => ({
107
- sentence: c.sentence || c.text || '',
108
- score: c.score || c.similarity || 0,
109
- isFaithful: (c.score || c.similarity || 0) > 0,
110
- bestSource: c.sourceIndex != null ? c.sourceIndex : -1,
111
- }))
107
+ ? citationResult.citations.map((c) => {
108
+ const best = Array.isArray(c.sources) ? c.sources[0] : undefined;
109
+ return {
110
+ sentence: c.sentence || c.text || '',
111
+ score: best ? best.score : 0,
112
+ isFaithful: Boolean(best),
113
+ bestSource: best ? best.docIndex : -1,
114
+ };
115
+ })
112
116
  : [];
113
117
 
114
118
  return { score, details };
@@ -16,20 +16,40 @@ const DEFAULT_WEIGHTS = {
16
16
  contextRecall: 0.3,
17
17
  };
18
18
 
19
+ /** Reason reported when context recall cannot be computed. */
20
+ const NO_REFERENCE_REASON =
21
+ 'No reference answer provided: context recall measures how much of a ' +
22
+ 'reference (ground-truth) answer the retrieved documents support.';
23
+
19
24
  /**
20
- * Compute a composite groundedness score combining faithfulness, context precision, and context recall.
21
- * Uses a weighted average of the three component metrics.
25
+ * Compute a composite groundedness score from faithfulness, context precision
26
+ * and (when a reference answer is given) context recall.
27
+ *
28
+ * Formula: a weighted mean over the components that are available,
29
+ *
30
+ * score = sum(w_i * m_i) / sum(w_i)
31
+ *
32
+ * With a reference answer and the default weights this is
33
+ * 0.4 * faithfulness + 0.3 * contextPrecision + 0.3 * contextRecall.
34
+ * Without one, context recall is left out and the remaining weights are
35
+ * renormalised:
36
+ * (0.4 * faithfulness + 0.3 * contextPrecision) / 0.7.
37
+ * If every available weight is 0 the score is 0.
22
38
  *
23
39
  * @param {string} query - The user query
24
40
  * @param {string} answer - The generated answer text
25
41
  * @param {Array<{content: string}>} retrievedDocs - Array of retrieved documents
26
42
  * @param {object} [options={}] - Configuration options
27
43
  * @param {{ faithfulness: number, contextPrecision: number, contextRecall: number }} [options.weights] - Custom weights for each component
28
- * @param {number} [options.threshold=0.3] - Similarity threshold passed to component metrics
44
+ * @param {number} [options.threshold=0.5] - Similarity threshold passed to component metrics
45
+ * @param {string} [options.referenceAnswer] - Reference (ground-truth) answer used for context recall
29
46
  * @returns {{ score: number, components: { faithfulness: object, contextPrecision: object, contextRecall: object }, weights: { faithfulness: number, contextPrecision: number, contextRecall: number } }}
47
+ * `components.contextRecall` is `{ score: null, skipped: true, reason }`
48
+ * when no reference answer was given.
30
49
  */
31
50
  function computeGroundedness(query, answer, retrievedDocs, options = {}) {
32
- const { weights = DEFAULT_WEIGHTS, threshold = 0.3 } = options;
51
+ const { threshold = 0.5, referenceAnswer } = options;
52
+ const weights = { ...DEFAULT_WEIGHTS, ...(options.weights || {}) };
33
53
 
34
54
  const faithfulness = computeFaithfulness(answer, retrievedDocs, {
35
55
  threshold,
@@ -37,14 +57,25 @@ function computeGroundedness(query, answer, retrievedDocs, options = {}) {
37
57
  const contextPrecision = computeContextPrecision(query, retrievedDocs, {
38
58
  threshold: threshold * 0.67,
39
59
  });
40
- const contextRecall = computeContextRecall(answer, retrievedDocs, {
41
- threshold,
42
- });
60
+ const hasReference =
61
+ typeof referenceAnswer === 'string' && referenceAnswer.trim().length > 0;
62
+ const contextRecall = hasReference
63
+ ? computeContextRecall(referenceAnswer, retrievedDocs, { threshold })
64
+ : { score: null, skipped: true, reason: NO_REFERENCE_REASON };
65
+
66
+ const parts = [
67
+ [weights.faithfulness, faithfulness.score],
68
+ [weights.contextPrecision, contextPrecision.score],
69
+ ];
70
+ if (hasReference) parts.push([weights.contextRecall, contextRecall.score]);
43
71
 
44
- const score =
45
- weights.faithfulness * faithfulness.score +
46
- weights.contextPrecision * contextPrecision.score +
47
- weights.contextRecall * contextRecall.score;
72
+ let weighted = 0;
73
+ let totalWeight = 0;
74
+ for (const [w, m] of parts) {
75
+ weighted += w * m;
76
+ totalWeight += w;
77
+ }
78
+ const score = totalWeight > 0 ? weighted / totalWeight : 0;
48
79
 
49
80
  return {
50
81
  score,
@@ -57,4 +88,4 @@ function computeGroundedness(query, answer, retrievedDocs, options = {}) {
57
88
  };
58
89
  }
59
90
 
60
- module.exports = { computeGroundedness, DEFAULT_WEIGHTS };
91
+ module.exports = { computeGroundedness, DEFAULT_WEIGHTS, NO_REFERENCE_REASON };
@@ -10,7 +10,7 @@ const {
10
10
  computeContextPrecision,
11
11
  computeContextRecall,
12
12
  } = require('./context-metrics');
13
- const { computeGroundedness } = require('./groundedness');
13
+ const { computeGroundedness, NO_REFERENCE_REASON } = require('./groundedness');
14
14
  const { scoreAnswer } = require('./scoring');
15
15
 
16
16
  /**
@@ -25,7 +25,7 @@ const DEFAULT_CONFIG = {
25
25
  'contextRecall',
26
26
  'groundedness',
27
27
  ],
28
- threshold: 0.3,
28
+ threshold: 0.5,
29
29
  includeBLEU: false,
30
30
  includeROUGE: false,
31
31
  };
@@ -64,8 +64,14 @@ class PipelineEvaluator extends EventEmitter {
64
64
  * @param {Array<{content: string}>} pipelineResult.results - Retrieved documents
65
65
  * @param {object} [options={}] - Additional evaluation options
66
66
  * @param {object} [options.citationResult] - Pre-computed citation result for faithfulness shortcut
67
- * @param {string} [options.referenceAnswer] - Reference answer for BLEU/ROUGE scoring
68
- * @returns {{ scores: Object<string, number>, details: Object<string, object>, metadata: { metricsComputed: string[], threshold: number, timestamp: string } }}
67
+ * @param {string} [options.referenceAnswer] - Reference (ground-truth)
68
+ * answer. Required for context recall (how much of it the retrieved
69
+ * documents support) and used for BLEU/ROUGE. Without it, contextRecall is
70
+ * left out of `scores`, `details.contextRecall` is
71
+ * `{ score: null, skipped: true, reason }`, it is listed in
72
+ * `metadata.metricsSkipped`, and groundedness renormalises over
73
+ * faithfulness and context precision.
74
+ * @returns {{ scores: Object<string, number>, details: Object<string, object>, metadata: { metricsComputed: string[], metricsSkipped: Array<{metric: string, reason: string}>, threshold: number, timestamp: string } }}
69
75
  */
70
76
  evaluate(pipelineResult, options = {}) {
71
77
  const { citationResult, referenceAnswer } = options;
@@ -76,6 +82,7 @@ class PipelineEvaluator extends EventEmitter {
76
82
  const scores = {};
77
83
  const details = {};
78
84
  const metricsComputed = [];
85
+ const metricsSkipped = [];
79
86
  const { metrics, threshold } = this.config;
80
87
 
81
88
  if (metrics.includes('faithfulness')) {
@@ -106,16 +113,35 @@ class PipelineEvaluator extends EventEmitter {
106
113
  metricsComputed.push('contextPrecision');
107
114
  }
108
115
 
116
+ const hasReference =
117
+ typeof referenceAnswer === 'string' && referenceAnswer.trim().length > 0;
118
+
109
119
  if (metrics.includes('contextRecall')) {
110
- const recallResult = computeContextRecall(answer, results, { threshold });
111
- scores.contextRecall = recallResult.score;
112
- details.contextRecall = recallResult;
113
- metricsComputed.push('contextRecall');
120
+ if (hasReference) {
121
+ const recallResult = computeContextRecall(referenceAnswer, results, {
122
+ threshold,
123
+ });
124
+ scores.contextRecall = recallResult.score;
125
+ details.contextRecall = recallResult;
126
+ metricsComputed.push('contextRecall');
127
+ } else {
128
+ // No ground truth: report why instead of a made-up score.
129
+ details.contextRecall = {
130
+ score: null,
131
+ skipped: true,
132
+ reason: NO_REFERENCE_REASON,
133
+ };
134
+ metricsSkipped.push({
135
+ metric: 'contextRecall',
136
+ reason: NO_REFERENCE_REASON,
137
+ });
138
+ }
114
139
  }
115
140
 
116
141
  if (metrics.includes('groundedness')) {
117
142
  const groundednessResult = computeGroundedness(query, answer, results, {
118
143
  threshold,
144
+ referenceAnswer: hasReference ? referenceAnswer : undefined,
119
145
  });
120
146
  scores.groundedness = groundednessResult.score;
121
147
  details.groundedness = groundednessResult;
@@ -142,6 +168,7 @@ class PipelineEvaluator extends EventEmitter {
142
168
  details,
143
169
  metadata: {
144
170
  metricsComputed,
171
+ metricsSkipped,
145
172
  threshold,
146
173
  timestamp: new Date().toISOString(),
147
174
  },
@@ -11,10 +11,14 @@
11
11
  * @returns {string[]}
12
12
  */
13
13
  function tokenize(text) {
14
- return text
14
+ // Unicode-aware: keep letters/digits in any script, drop apostrophes so
15
+ // "don't" stays one token, and treat every other character (including
16
+ // newlines and hyphens) as a separator.
17
+ return String(text)
15
18
  .toLowerCase()
16
- .replace(/[^a-z0-9 ]/g, '')
17
- .split(/\s+/)
19
+ .replace(/['’]/g, '')
20
+ .replace(/[^\p{L}\p{N}]+/gu, ' ')
21
+ .split(' ')
18
22
  .filter(Boolean);
19
23
  }
20
24
 
@@ -108,7 +112,251 @@ function computeJaccardSimilarity(tokensA, tokensB) {
108
112
  return intersection.size / union.size;
109
113
  }
110
114
 
115
+ /**
116
+ * Common English function words. Ignored when measuring how much of a
117
+ * sentence a document supports, so "the" and "is" cannot ground a claim.
118
+ */
119
+ const STOPWORDS = new Set(
120
+ (
121
+ 'a an and are as at be been but by can could did do does for from had has ' +
122
+ 'have he her his how i if in into is it its may me might must my no not ' +
123
+ 'of on or our she should so than that the their them then there these ' +
124
+ 'they this those to was we were what when where which while who why will ' +
125
+ 'with would you your'
126
+ ).split(' '),
127
+ );
128
+
129
+ /**
130
+ * Content tokens of a sentence: tokens minus stopwords. Falls back to all
131
+ * tokens when nothing is left (e.g. "Yes."), so short sentences are still
132
+ * scored rather than skipped.
133
+ *
134
+ * @param {string[]} tokens
135
+ * @returns {string[]}
136
+ */
137
+ function contentTokens(tokens) {
138
+ const content = tokens.filter((t) => !STOPWORDS.has(t));
139
+ return content.length > 0 ? content : tokens;
140
+ }
141
+
142
+ /** Values of English number words. "one" is left out on purpose: it is far
143
+ * more often a pronoun ("no one", "one of") than a quantity. */
144
+ const NUMBER_WORDS = {
145
+ zero: 0,
146
+ two: 2,
147
+ three: 3,
148
+ four: 4,
149
+ five: 5,
150
+ six: 6,
151
+ seven: 7,
152
+ eight: 8,
153
+ nine: 9,
154
+ ten: 10,
155
+ eleven: 11,
156
+ twelve: 12,
157
+ thirteen: 13,
158
+ fourteen: 14,
159
+ fifteen: 15,
160
+ sixteen: 16,
161
+ seventeen: 17,
162
+ eighteen: 18,
163
+ nineteen: 19,
164
+ twenty: 20,
165
+ thirty: 30,
166
+ forty: 40,
167
+ fifty: 50,
168
+ sixty: 60,
169
+ seventy: 70,
170
+ eighty: 80,
171
+ ninety: 90,
172
+ };
173
+ /** Multipliers; "one" counts as 1 only inside a run ("one hundred"). */
174
+ const NUMBER_SCALES = {
175
+ hundred: 100,
176
+ thousand: 1000,
177
+ million: 1e6,
178
+ billion: 1e9,
179
+ trillion: 1e12,
180
+ };
181
+
182
+ /**
183
+ * Digit numbers, optionally with thousands separators or a decimal part (a
184
+ * trailing "%" is ignored). The lookbehind skips digits glued to letters (BM25).
185
+ */
186
+ const DIGIT_NUMBER_RE = /(?<![\p{L}\p{N}])\d+(?:[.,]\d+)*/gu;
187
+
188
+ /**
189
+ * Turn one run of number words into its value: "twenty five" -> 25,
190
+ * "two hundred" -> 200, "three thousand four hundred" -> 3400.
191
+ *
192
+ * @param {string[]} words
193
+ * @returns {number}
194
+ */
195
+ function parseNumberWords(words) {
196
+ let total = 0;
197
+ let current = 0;
198
+ for (const w of words) {
199
+ if (w === 'one') current += 1;
200
+ else if (w in NUMBER_WORDS) current += NUMBER_WORDS[w];
201
+ else if (w === 'hundred') current = (current || 1) * 100;
202
+ else {
203
+ total += (current || 1) * NUMBER_SCALES[w];
204
+ current = 0;
205
+ }
206
+ }
207
+ return total + current;
208
+ }
209
+
210
+ /**
211
+ * Extract the numbers stated in a text, as normalised value strings, so
212
+ * "20", "20%", "twenty" and "Twenty" all yield "20", "1,000" yields "1000"
213
+ * and "2.5" yields "2.5".
214
+ *
215
+ * @param {string} text
216
+ * @returns {{ values: string[], parts: string[] }} values: one entry per
217
+ * number as written; parts: values plus each number word on its own
218
+ * (used on the document side so "twenty-five" also supports "20" and "5"
219
+ * is not required)
220
+ */
221
+ function extractNumbers(text) {
222
+ const values = [];
223
+ const parts = [];
224
+ if (!text || typeof text !== 'string') return { values, parts };
225
+
226
+ for (const match of text.matchAll(DIGIT_NUMBER_RE)) {
227
+ const raw = match[0];
228
+ const pieces = /^\d{1,3}(,\d{3})+(\.\d+)?$/.test(raw)
229
+ ? [raw.replace(/,/g, '')]
230
+ : raw.split(',');
231
+ for (const piece of pieces) {
232
+ const n = Number(piece);
233
+ if (Number.isFinite(n)) values.push(String(n));
234
+ }
235
+ }
236
+
237
+ const words = text.toLowerCase().split(/[^\p{L}]+/u);
238
+ let run = [];
239
+ const flush = () => {
240
+ // "and" only joins a run ("two hundred and five"); never ends one.
241
+ while (run.length && run[run.length - 1] === 'and') run.pop();
242
+ // A lone "one" is not treated as a number (see NUMBER_WORDS).
243
+ if (run.length && !(run.length === 1 && run[0] === 'one')) {
244
+ const numberWords = run.filter((w) => w !== 'and');
245
+ values.push(String(parseNumberWords(numberWords)));
246
+ for (const w of numberWords) {
247
+ if (w in NUMBER_WORDS) parts.push(String(NUMBER_WORDS[w]));
248
+ }
249
+ }
250
+ run = [];
251
+ };
252
+ for (const w of words) {
253
+ const last = run[run.length - 1];
254
+ const isNumberWord =
255
+ w in NUMBER_WORDS ||
256
+ w in NUMBER_SCALES ||
257
+ w === 'one' ||
258
+ (w === 'and' && last in NUMBER_SCALES);
259
+ if (isNumberWord) {
260
+ run.push(w);
261
+ } else {
262
+ flush();
263
+ }
264
+ }
265
+ flush();
266
+
267
+ return { values, parts: [...values, ...parts] };
268
+ }
269
+
270
+ /**
271
+ * Numeric consistency between a claim and a document: every number the claim
272
+ * states (digits, decimals, percentages or number words) must also appear in
273
+ * the document. Lexical overlap alone would ground "thirty days" against a
274
+ * source that says "twenty days".
275
+ *
276
+ * @param {string} claimText
277
+ * @param {string} docText
278
+ * @returns {{ consistent: boolean, claimNumbers: string[], missing: string[] }}
279
+ */
280
+ function checkNumericConsistency(claimText, docText) {
281
+ const claimNumbers = [...new Set(extractNumbers(claimText).values)];
282
+ if (claimNumbers.length === 0) {
283
+ return { consistent: true, claimNumbers, missing: [] };
284
+ }
285
+ const docNumbers = new Set(extractNumbers(docText).parts);
286
+ const missing = claimNumbers.filter((n) => !docNumbers.has(n));
287
+ return { consistent: missing.length === 0, claimNumbers, missing };
288
+ }
289
+
290
+ /**
291
+ * Multiplier applied to the support score when a claim states numbers the
292
+ * document does not contain. Together with the share of matching numbers it
293
+ * keeps the score below 0.4, i.e. under the default 0.5 support threshold.
294
+ */
295
+ const NUMERIC_MISMATCH_FACTOR = 0.4;
296
+
297
+ /**
298
+ * How much of a claim is supported by a document: the (optionally
299
+ * IDF-weighted) fraction of the claim's unique content tokens that appear in
300
+ * the document. Unlike Jaccard against the whole document, this does not
301
+ * shrink as the document gets longer, so a sentence copied verbatim from a
302
+ * long chunk scores 1.
303
+ *
304
+ * Numeric consistency: when `options.claimText` and `options.docText` are
305
+ * given and the claim states a number the document does not contain (see
306
+ * checkNumericConsistency), the score becomes
307
+ * lexicalScore * NUMERIC_MISMATCH_FACTOR (0.4) * matchedNumbers / claimNumbers
308
+ * which is always below 0.4, so the claim fails the default 0.5 threshold.
309
+ *
310
+ * @param {string[]} claimTokens - Tokens of the sentence/claim/query
311
+ * @param {string[]|Set<string>} docTokens - Tokens of the document
312
+ * @param {Map<string, number>|null} [idfWeights] - Optional token weights
313
+ * @param {{ claimText?: string, docText?: string }} [options] - Raw texts
314
+ * enabling the numeric consistency check
315
+ * @returns {number} Support score between 0 and 1
316
+ */
317
+ function computeSupportScore(
318
+ claimTokens,
319
+ docTokens,
320
+ idfWeights = null,
321
+ options = {},
322
+ ) {
323
+ const claim = [...new Set(contentTokens(claimTokens))];
324
+ if (claim.length === 0) return 0;
325
+ const docSet = docTokens instanceof Set ? docTokens : new Set(docTokens);
326
+
327
+ let matched = 0;
328
+ let total = 0;
329
+ for (const token of claim) {
330
+ const weight = idfWeights ? (idfWeights.get(token) ?? 1) : 1;
331
+ total += weight;
332
+ if (docSet.has(token)) matched += weight;
333
+ }
334
+ let score = total > 0 ? matched / total : 0;
335
+
336
+ const { claimText, docText } = options || {};
337
+ if (
338
+ score > 0 &&
339
+ typeof claimText === 'string' &&
340
+ typeof docText === 'string'
341
+ ) {
342
+ const numeric = checkNumericConsistency(claimText, docText);
343
+ if (!numeric.consistent) {
344
+ const share =
345
+ (numeric.claimNumbers.length - numeric.missing.length) /
346
+ numeric.claimNumbers.length;
347
+ score *= NUMERIC_MISMATCH_FACTOR * share;
348
+ }
349
+ }
350
+ return score;
351
+ }
352
+
111
353
  module.exports = {
354
+ STOPWORDS,
355
+ contentTokens,
356
+ computeSupportScore,
357
+ checkNumericConsistency,
358
+ extractNumbers,
359
+ NUMERIC_MISMATCH_FACTOR,
112
360
  scoreAnswer,
113
361
  tokenize,
114
362
  computeBLEU,