@devilsdev/rag-pipeline-utils 2.4.4 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (225) hide show
  1. package/.ragrc.schema.json +83 -192
  2. package/CHANGELOG.md +345 -0
  3. package/README.md +418 -117
  4. package/SECURITY.md +5 -5
  5. package/dist/experimental.cjs +2 -0
  6. package/dist/experimental.d.ts +1 -0
  7. package/dist/experimental.mjs +5 -0
  8. package/dist/index.cjs +4992 -1242
  9. package/dist/index.d.ts +80 -2
  10. package/dist/index.mjs +1 -1
  11. package/dist/types/agentic/agentic-pipeline.d.ts +103 -0
  12. package/dist/types/agentic/iterative-retriever.d.ts +85 -0
  13. package/dist/types/agentic/query-planner.d.ts +145 -0
  14. package/dist/types/agentic/self-critique.d.ts +45 -0
  15. package/dist/types/ai/adaptive-retrieval.d.ts +44 -0
  16. package/dist/types/ai/federation/federated-learning-coordinator.d.ts +62 -0
  17. package/dist/types/ai/multimodal-processing.d.ts +32 -0
  18. package/dist/types/ai/multimodal.d.ts +39 -0
  19. package/dist/types/ai/retrieval-engine.d.ts +37 -0
  20. package/dist/types/ai/training/model-training-orchestrator.d.ts +31 -0
  21. package/dist/types/chunking/chunking-engine.d.ts +70 -0
  22. package/dist/types/chunking/strategies/fixed-size.d.ts +17 -0
  23. package/dist/types/chunking/strategies/recursive.d.ts +20 -0
  24. package/dist/types/chunking/strategies/semantic.d.ts +19 -0
  25. package/dist/types/chunking/strategies/sentence.d.ts +13 -0
  26. package/dist/types/chunking/strategies/structure-aware.d.ts +19 -0
  27. package/dist/types/citation/citation-tracker.d.ts +64 -0
  28. package/dist/types/citation/hallucination-detector.d.ts +40 -0
  29. package/dist/types/citation/source-mapper.d.ts +37 -0
  30. package/dist/types/connectors/anthropic-connector.d.ts +92 -0
  31. package/dist/types/connectors/base-connector.d.ts +87 -0
  32. package/dist/types/connectors/cohere-connector.d.ts +89 -0
  33. package/dist/types/connectors/connector-utils.d.ts +110 -0
  34. package/dist/types/connectors/local-embedder.d.ts +86 -0
  35. package/dist/types/connectors/memory-retriever.d.ts +91 -0
  36. package/dist/types/connectors/ollama-connector.d.ts +110 -0
  37. package/dist/types/connectors/openai-connector.d.ts +114 -0
  38. package/dist/types/connectors/rag-prompt.d.ts +40 -0
  39. package/dist/types/cost/cost-calculator.d.ts +152 -0
  40. package/dist/types/cost/cost-tracker.d.ts +126 -0
  41. package/dist/types/cost/token-budget.d.ts +110 -0
  42. package/dist/types/dag/core/error-context.d.ts +239 -0
  43. package/dist/types/dag/core/execution-scheduler.d.ts +191 -0
  44. package/dist/types/dag/core/topology-validation.d.ts +226 -0
  45. package/dist/types/dag/dag-engine.d.ts +156 -0
  46. package/dist/types/dag/dag-node.d.ts +37 -0
  47. package/dist/types/dashboard/dashboard-generator.d.ts +127 -0
  48. package/dist/types/dashboard/metrics-aggregator.d.ts +218 -0
  49. package/dist/types/debug/execution-tracer.d.ts +57 -0
  50. package/dist/types/debug/trace-inspector.d.ts +31 -0
  51. package/dist/types/ecosystem/plugin-certification.d.ts +169 -0
  52. package/dist/types/enterprise/data-governance.d.ts +203 -0
  53. package/dist/types/enterprise/multi-tenancy.d.ts +137 -0
  54. package/dist/types/enterprise/sso-integration.d.ts +420 -0
  55. package/dist/types/evaluate/context-metrics.d.ts +55 -0
  56. package/dist/types/evaluate/faithfulness.d.ts +41 -0
  57. package/dist/types/evaluate/groundedness.d.ts +60 -0
  58. package/dist/types/evaluate/pipeline-evaluator.d.ts +88 -0
  59. package/dist/types/evaluate/relevance.d.ts +21 -0
  60. package/dist/types/evaluate/scoring.d.ts +123 -0
  61. package/dist/types/experimental.d.ts +15 -0
  62. package/dist/types/graph/entity-extractor.d.ts +84 -0
  63. package/dist/types/graph/graph-index.d.ts +49 -0
  64. package/dist/types/graph/graph-retriever.d.ts +81 -0
  65. package/dist/types/graph/knowledge-graph.d.ts +129 -0
  66. package/dist/types/guardrails/guardrails-pipeline.d.ts +63 -0
  67. package/dist/types/guardrails/injection-detector.d.ts +47 -0
  68. package/dist/types/guardrails/post-generation-guard.d.ts +61 -0
  69. package/dist/types/guardrails/pre-retrieval-guard.d.ts +64 -0
  70. package/dist/types/guardrails/retrieval-guard.d.ts +57 -0
  71. package/dist/types/mcp/mcp-server.d.ts +85 -0
  72. package/dist/types/mcp/mcp-tool-builder.d.ts +55 -0
  73. package/dist/types/reranker/cascade-reranker.d.ts +40 -0
  74. package/dist/types/reranker/embedding-reranker.d.ts +77 -0
  75. package/dist/types/reranker/scoring-reranker.d.ts +41 -0
  76. package/dist/types/retrieval/bm25-search.d.ts +43 -0
  77. package/dist/types/retrieval/file-vector-store.d.ts +87 -0
  78. package/dist/types/retrieval/hybrid-retriever.d.ts +35 -0
  79. package/dist/types/retrieval/rank-fusion.d.ts +10 -0
  80. package/dist/types/security/jwt-config.d.ts +80 -0
  81. package/dist/types/security/jwt-validator.d.ts +195 -0
  82. package/dist/types/security/plugin-sandbox.d.ts +158 -0
  83. package/dist/types/security/plugin-signature-verifier.d.ts +139 -0
  84. package/dist/types/security/validator.d.ts +5 -0
  85. package/dist/types/streaming/embedding-stream.d.ts +65 -0
  86. package/dist/types/streaming/sse-adapter.d.ts +59 -0
  87. package/dist/types/streaming/stream-router.d.ts +88 -0
  88. package/dist/types/streaming/streaming-embedder.d.ts +99 -0
  89. package/dist/types/streaming/websocket-adapter.d.ts +71 -0
  90. package/dist/types/templates/scaffold.d.ts +55 -0
  91. package/dist/types/templates/template-registry.d.ts +63 -0
  92. package/dist/types/utils/embed-query.d.ts +20 -0
  93. package/dist/types/utils/logger.d.ts +50 -0
  94. package/dist/types/utils/rate-limiter.d.ts +66 -0
  95. package/dist/types/utils/retry.d.ts +27 -0
  96. package/dist/types/utils/run-abort.d.ts +34 -0
  97. package/dist/types/utils/secure-logging.d.ts +51 -0
  98. package/dist/types/utils/structured-logger.d.ts +126 -0
  99. package/package.json +53 -29
  100. package/src/agentic/agentic-pipeline.js +75 -7
  101. package/src/agentic/iterative-retriever.js +47 -10
  102. package/src/agentic/query-planner.js +329 -78
  103. package/src/citation/citation-tracker.js +6 -4
  104. package/src/citation/hallucination-detector.js +2 -2
  105. package/src/citation/source-mapper.js +15 -28
  106. package/src/cli/commands/ai.js +101 -0
  107. package/src/cli/commands/plugin-hub/event-handlers.js +6 -0
  108. package/src/cli/commands/plugin-hub/hub-commands.js +0 -74
  109. package/src/cli/commands/plugin-hub/local-commands.js +143 -0
  110. package/src/cli/commands/plugin-hub.js +96 -36
  111. package/src/cli/diagnostics/config-checker.js +76 -63
  112. package/src/cli/diagnostics/environment-checker.js +62 -31
  113. package/src/cli/diagnostics/plugin-checker.js +36 -89
  114. package/src/cli/doctor-command.js +39 -5
  115. package/src/cli/enhanced-cli-commands.js +72 -217
  116. package/src/cli/handlers/ai-handler.js +364 -0
  117. package/src/cli/handlers/diagnostics-handler.js +78 -31
  118. package/src/cli/handlers/ingest-handler.js +28 -22
  119. package/src/cli/handlers/init-handler.js +148 -25
  120. package/src/cli/handlers/query-handler.js +41 -26
  121. package/src/cli/handlers/run-handler.js +16 -167
  122. package/src/cli/handlers/serve-handler.js +60 -0
  123. package/src/cli/services/bench-stats.js +54 -0
  124. package/src/cli/services/benchmark.js +161 -0
  125. package/src/cli/services/feedback.js +155 -0
  126. package/src/cli/services/rag-service.js +322 -0
  127. package/src/cli/shared/cli-utils.js +24 -7
  128. package/src/cli/shared/config-context.js +21 -0
  129. package/src/cli/wizard/config-builder.js +81 -0
  130. package/src/cli/wizard/init-wizard.js +617 -0
  131. package/src/config/component-factory.js +125 -0
  132. package/src/config/load-config.js +29 -10
  133. package/src/config/load-plugin-config.js +46 -2
  134. package/src/connectors/anthropic-connector.js +93 -55
  135. package/src/connectors/base-connector.js +74 -19
  136. package/src/connectors/cohere-connector.js +88 -32
  137. package/src/connectors/connector-utils.js +302 -0
  138. package/src/connectors/local-embedder.js +43 -5
  139. package/src/connectors/memory-retriever.js +108 -15
  140. package/src/connectors/ollama-connector.js +83 -49
  141. package/src/connectors/openai-connector.js +108 -44
  142. package/src/connectors/rag-prompt.js +88 -0
  143. package/src/core/create-pipeline.js +209 -60
  144. package/src/core/plugin-registry.js +87 -11
  145. package/src/cost/cost-calculator.js +205 -32
  146. package/src/dev/dev-server.js +166 -11
  147. package/src/dev/hot-reload.js +40 -1
  148. package/src/ecosystem/plugin-checks.js +181 -0
  149. package/src/ecosystem/plugin-hub.js +50 -125
  150. package/src/ecosystem/plugin-installer.js +298 -0
  151. package/src/ecosystem/plugin-probe.js +127 -0
  152. package/src/ecosystem/plugin-scanner.js +185 -0
  153. package/src/ecosystem/tar-extract.js +114 -0
  154. package/src/enterprise/audit-logging.js +158 -29
  155. package/src/enterprise/sso-integration.js +715 -228
  156. package/src/evaluate/context-metrics.js +28 -16
  157. package/src/evaluate/evaluator.js +3 -3
  158. package/src/evaluate/faithfulness.js +18 -14
  159. package/src/evaluate/groundedness.js +43 -12
  160. package/src/evaluate/pipeline-evaluator.js +35 -8
  161. package/src/evaluate/scoring.js +251 -3
  162. package/src/experimental.js +71 -0
  163. package/src/guardrails/guardrails-pipeline.js +69 -7
  164. package/src/guardrails/injection-detector.js +283 -0
  165. package/src/guardrails/post-generation-guard.js +19 -0
  166. package/src/guardrails/pre-retrieval-guard.js +36 -36
  167. package/src/guardrails/retrieval-guard.js +58 -8
  168. package/src/index.js +36 -1
  169. package/src/loader/document-reader.js +91 -0
  170. package/src/reranker/cascade-reranker.js +3 -0
  171. package/src/reranker/embedding-reranker.js +136 -21
  172. package/src/reranker/llm-reranker.js +79 -12
  173. package/src/reranker/scoring-reranker.js +3 -0
  174. package/src/retrieval/bm25-search.js +12 -6
  175. package/src/retrieval/file-vector-store.js +212 -0
  176. package/src/retrieval/hybrid-retriever.js +22 -19
  177. package/src/security/jwt-config.js +13 -2
  178. package/src/security/jwt-validator.js +134 -30
  179. package/src/security/plugin-sandbox.js +18 -1
  180. package/src/security/plugin-signature-verifier.js +245 -70
  181. package/src/utils/deprecate.js +69 -0
  182. package/src/utils/embed-query.js +33 -0
  183. package/src/utils/input-sanitizer.js +19 -4
  184. package/src/utils/logger.js +34 -4
  185. package/src/utils/run-abort.js +102 -0
  186. package/src/ai/federated-learning.js +0 -211
  187. package/src/ai/federation.js +0 -71
  188. package/src/ai/index.js +0 -22
  189. package/src/ai/model-trainer.js +0 -217
  190. package/src/ai/model-training.js +0 -202
  191. package/src/ai/multimodal/multi-modal-processor.js +0 -345
  192. package/src/ai/orchestrator.js +0 -65
  193. package/src/ai/retrieval/adaptive-retrieval-engine.js +0 -398
  194. package/src/cli/commands/ai-ml/adaptive-commands.js +0 -178
  195. package/src/cli/commands/ai-ml/federated-commands.js +0 -207
  196. package/src/cli/commands/ai-ml/index.js +0 -17
  197. package/src/cli/commands/ai-ml/multimodal-commands.js +0 -187
  198. package/src/cli/commands/ai-ml/shared.js +0 -51
  199. package/src/cli/commands/ai-ml/training-commands.js +0 -228
  200. package/src/cli/commands/ai-ml.js +0 -386
  201. package/src/cli/commands/docs.js +0 -228
  202. package/src/cli/commands/dx.js +0 -538
  203. package/src/cli/commands/wizard.js +0 -627
  204. package/src/cli/input-sanitization-middleware.js +0 -356
  205. package/src/cli/interactive-wizard.js +0 -373
  206. package/src/cli/templates/development.template.json +0 -64
  207. package/src/cli/templates/minimal.template.json +0 -33
  208. package/src/cli/templates/production.template.json +0 -104
  209. package/src/cli/templates/testing.template.json +0 -45
  210. package/src/cli/wizard/configuration.js +0 -148
  211. package/src/cli/wizard/observability.js +0 -79
  212. package/src/cli/wizard/performance.js +0 -76
  213. package/src/cli/wizard/plugin-selection.js +0 -297
  214. package/src/cli/wizard/preview-save.js +0 -172
  215. package/src/cli/wizard/project-setup.js +0 -66
  216. package/src/core/pipeline-factory.js +0 -59
  217. package/src/dag/enhanced-dag-engine.js +0 -894
  218. package/src/dx/index.js +0 -40
  219. package/src/dx/integration-templates.js +0 -662
  220. package/src/dx/performance-profiler.js +0 -968
  221. package/src/dx/realtime-debugger.js +0 -755
  222. package/src/dx/visual-pipeline-builder.js +0 -578
  223. package/src/ingest.js +0 -59
  224. package/src/pipeline/pipeline.js +0 -18
  225. package/src/query.js +0 -117
@@ -14,12 +14,9 @@ const DEFAULT_CONFIG = {
14
14
  /** Question words that indicate query complexity */
15
15
  const QUESTION_WORDS = ['who', 'what', 'when', 'where', 'why', 'how', 'which'];
16
16
 
17
- /** Conjunctions used for splitting compound queries */
17
+ /** Conjunctions used for scoring compound queries */
18
18
  const CONJUNCTIONS = ['and', 'or', 'also', 'as well as'];
19
19
 
20
- /** Splitter patterns for decomposing compound queries */
21
- const SPLIT_PATTERNS = [/ and /i, / or /i, /; ?/];
22
-
23
20
  /** Keywords that indicate a comparative strategy */
24
21
  const COMPARATIVE_KEYWORDS = [
25
22
  'compare',
@@ -27,19 +24,111 @@ const COMPARATIVE_KEYWORDS = [
27
24
  'difference',
28
25
  'versus',
29
26
  'vs',
27
+ 'compared',
28
+ 'comparing',
29
+ 'comparison',
30
+ 'contrasting',
31
+ 'differ',
32
+ ];
33
+
34
+ /**
35
+ * Words that start a new question or instruction. A conjunction ("and",
36
+ * "or", "as well as") splits a query only when one of these follows it, so
37
+ * "What is RAG and how does BM25 work" splits while "salt and pepper" and
38
+ * "Should I use A or B" do not.
39
+ */
40
+ const CLAUSE_STARTERS = [
41
+ // question words
42
+ 'who',
43
+ 'whom',
44
+ 'whose',
45
+ 'what',
46
+ 'when',
47
+ 'where',
48
+ 'why',
49
+ 'how',
50
+ 'which',
51
+ // auxiliaries opening a yes/no question
52
+ 'is',
53
+ 'are',
54
+ 'was',
55
+ 'were',
56
+ 'do',
57
+ 'does',
58
+ 'did',
59
+ 'can',
60
+ 'could',
61
+ 'should',
62
+ 'would',
63
+ 'will',
64
+ 'has',
65
+ 'have',
66
+ // imperatives opening an instruction
67
+ 'explain',
68
+ 'describe',
69
+ 'list',
70
+ 'compare',
71
+ 'contrast',
72
+ 'summarize',
73
+ 'summarise',
74
+ 'tell',
75
+ 'show',
76
+ 'give',
77
+ 'define',
78
+ 'find',
30
79
  ];
31
80
 
81
+ const STARTER_ALT = CLAUSE_STARTERS.join('|');
82
+
83
+ /** ", and how ...", " and what ...", " or is ...", " as well as why ..." */
84
+ const CONJUNCTION_BOUNDARY = new RegExp(
85
+ `\\s*,?\\s+(?:and|or|as well as|and also|but)\\s+(?=(?:${STARTER_ALT})\\b)`,
86
+ 'i',
87
+ );
88
+
32
89
  /**
33
- * Heuristic query decomposition planner -- no LLM needed.
34
- * Splits compound queries into sub-queries with assigned strategies
35
- * based on detected question types.
90
+ * Explicit boundaries: "?" / "!" followed by more text, "." followed by a
91
+ * capitalised word (so "e.g. salt" stays whole), and ";".
92
+ */
93
+ const PUNCTUATION_BOUNDARY = /(?<=[?!])\s+(?=\S)|(?<=\.)\s+(?=[A-Z])|\s*;\s*/;
94
+ /** Abbreviations whose period does not end a sentence. */
95
+ const ABBREVIATION_END =
96
+ /\b(?:e\.g|i\.e|etc|vs|cf|approx|mr|mrs|ms|dr|st|no)\.$/i;
97
+
98
+ /** "compare|contrast X and|with|to|vs Y [for|in terms of|... aspect]" */
99
+ const COMPARE_RE =
100
+ /\b(?:compare|contrast)\s+(.+?)\s+(?:and|with|to|against|vs\.?|versus)\s+(.+?)(?:\s+(?:for|in terms of|regarding|when|on)\s+(.+?))?\s*[?.!]?$/i;
101
+ /** "difference(s) between X and Y [for|in|... aspect]" */
102
+ const DIFFERENCE_RE =
103
+ /\bdifferences?\s+between\s+(.+?)\s+and\s+(.+?)(?:\s+(?:for|in terms of|regarding|when|in|on)\s+(.+?))?\s*[?.!]?$/i;
104
+ /** "X vs Y [for|... aspect]" */
105
+ const VERSUS_RE =
106
+ /^(.*?)\s+(?:vs\.?|versus)\s+(.+?)(?:\s+(?:for|in terms of|regarding|when|on)\s+(.+?))?\s*[?.!]?$/i;
107
+
108
+ /** Upper bound on one LLM-proposed sub-query, in characters. */
109
+ const MAX_LLM_SUBQUERY_LENGTH = 500;
110
+
111
+ /**
112
+ * Query decomposition planner. The default heuristic needs no LLM:
113
+ *
114
+ * - splits only at clause/question boundaries: "?" / "." followed by more
115
+ * text, ";", and "and" / "or" / "as well as" (optionally after a comma)
116
+ * when a question word, auxiliary verb or imperative follows;
117
+ * - keeps noun-phrase conjunctions intact ("salt and pepper", "A or B");
118
+ * - expands comparisons ("X vs Y", "compare X and Y", "difference between X
119
+ * and Y") into one sub-query per entity plus the comparison itself.
120
+ *
121
+ * `plan(query, { llm })` can instead ask an LLM for a JSON array of
122
+ * sub-queries (strictly validated, heuristic fallback).
36
123
  *
37
124
  * @extends EventEmitter
38
125
  */
39
126
  class QueryPlanner extends EventEmitter {
40
127
  /**
41
128
  * @param {object} [options] - Override default configuration
42
- * @param {number} [options.minComplexityToSplit=2] - Minimum complexity score before splitting
129
+ * @param {number} [options.minComplexityToSplit=2] - Minimum complexity
130
+ * score before splitting at conjunctions; punctuation boundaries and
131
+ * comparisons are always split
43
132
  * @param {number} [options.maxSubQueries=5] - Maximum number of sub-queries to produce
44
133
  */
45
134
  constructor(options = {}) {
@@ -56,6 +145,8 @@ class QueryPlanner extends EventEmitter {
56
145
  * +1 if length > 100 characters
57
146
  * +1 per semicolon
58
147
  * +1 per comma-separated clause (commas - 1 if > 0 commas)
148
+ * +1 per conjunction followed by a question word, auxiliary or
149
+ * imperative ("... and how ...", "... and explain ...")
59
150
  *
60
151
  * @param {string} query
61
152
  * @returns {number}
@@ -66,12 +157,10 @@ class QueryPlanner extends EventEmitter {
66
157
  const words = lower.split(/\s+/);
67
158
  let score = 0;
68
159
 
69
- // +1 per question word
70
160
  for (const w of QUESTION_WORDS) {
71
161
  if (words.includes(w)) score += 1;
72
162
  }
73
163
 
74
- // +1 per conjunction
75
164
  for (const c of CONJUNCTIONS) {
76
165
  // "as well as" is multi-word -- use indexOf on the full string
77
166
  if (c.includes(' ')) {
@@ -81,17 +170,17 @@ class QueryPlanner extends EventEmitter {
81
170
  }
82
171
  }
83
172
 
84
- // +1 if length > 100 chars
85
173
  if (query.length > 100) score += 1;
86
174
 
87
- // +1 per semicolon
88
175
  const semicolons = (query.match(/;/g) || []).length;
89
176
  score += semicolons;
90
177
 
91
- // +1 per comma-separated clause (number of commas)
92
178
  const commas = (query.match(/,/g) || []).length;
93
179
  if (commas > 0) score += commas;
94
180
 
181
+ // +1 per conjunction that opens a new question or instruction
182
+ score += query.split(CONJUNCTION_BOUNDARY).length - 1;
183
+
95
184
  return score;
96
185
  }
97
186
 
@@ -104,14 +193,12 @@ class QueryPlanner extends EventEmitter {
104
193
  */
105
194
  _detectStrategy(text) {
106
195
  const lower = text.toLowerCase();
107
- const words = lower.split(/\s+/);
196
+ const words = lower.split(/[^a-z]+/);
108
197
 
109
- // Check comparative keywords first (more specific)
110
198
  for (const kw of COMPARATIVE_KEYWORDS) {
111
- if (words.includes(kw) || lower.includes(kw)) return 'comparative';
199
+ if (words.includes(kw) || words.includes(`${kw}s`)) return 'comparative';
112
200
  }
113
201
 
114
- // Check question words
115
202
  for (const w of words) {
116
203
  if (['who', 'what', 'where', 'when'].includes(w)) return 'factual';
117
204
  if (['why', 'how'].includes(w)) return 'analytical';
@@ -121,85 +208,249 @@ class QueryPlanner extends EventEmitter {
121
208
  }
122
209
 
123
210
  /**
124
- * Plan query decomposition.
211
+ * Split a clause describing a comparison into its entities.
125
212
  *
126
- * @param {string} query - The user query to decompose
127
- * @returns {{
128
- * subQueries: Array<{subQuery: string, strategy: string, priority: number}>,
129
- * complexity: number,
130
- * originalQuery: string
131
- * }}
132
- * @throws {Error} If query is not a non-empty string
133
- * @fires QueryPlanner#planned
213
+ * @param {string} clause
214
+ * @returns {{ entities: string[], aspect: string|null }|null}
215
+ * @private
134
216
  */
135
- plan(query) {
136
- // 1. Validate
137
- if (!query || typeof query !== 'string' || !query.trim()) {
138
- throw new Error('QueryPlanner.plan(): query must be a non-empty string');
217
+ _parseComparison(clause) {
218
+ const text = clause.trim();
219
+ let match = text.match(COMPARE_RE) || text.match(DIFFERENCE_RE);
220
+ let left;
221
+ if (match) {
222
+ left = match[1];
223
+ } else {
224
+ match = text.match(VERSUS_RE);
225
+ if (!match) return null;
226
+ // "Which is faster, Postgres vs MySQL" -> "Postgres"
227
+ left = match[1].split(/[,:]/).pop();
228
+ left = left.replace(
229
+ /^\s*(?:(?:what|which|who)\s+(?:is|are)\s+\w+(?:\s+\w+)?\s*[:,-]?\s*)/i,
230
+ '',
231
+ );
139
232
  }
233
+ const clean = (s) =>
234
+ String(s || '')
235
+ .trim()
236
+ .replace(/^(?:the|a|an)\s+/i, '')
237
+ .replace(/[?.!,;:]+$/, '')
238
+ .trim();
239
+ const entities = [...left.split(/\s*,\s*(?:and\s+)?|\s+and\s+/i), match[2]]
240
+ .map(clean)
241
+ .filter(Boolean);
242
+ if (entities.length < 2) return null;
243
+ const aspect = match[3] ? clean(match[3]) : null;
244
+ return { entities, aspect };
245
+ }
140
246
 
141
- const trimmed = query.trim();
142
-
143
- // 2. Compute complexity
144
- const complexity = this._computeComplexity(trimmed);
247
+ /**
248
+ * Heuristic decomposition into sub-query strings.
249
+ *
250
+ * @param {string} query - Trimmed query
251
+ * @param {number} complexity
252
+ * @returns {Array<{subQuery: string, strategy: string}>}
253
+ * @private
254
+ */
255
+ _decompose(query, complexity) {
256
+ const tidy = (s) =>
257
+ s
258
+ .trim()
259
+ .replace(/^[,;]+\s*|[,;]+$/g, '')
260
+ .trim();
145
261
 
146
- // 3. If complexity below threshold, return single sub-query
147
- if (complexity < this.config.minComplexityToSplit) {
148
- const result = {
149
- subQueries: [
150
- {
151
- subQuery: trimmed,
152
- strategy: this._detectStrategy(trimmed),
153
- priority: 0,
154
- },
155
- ],
156
- complexity,
157
- originalQuery: trimmed,
158
- };
159
- this.emit('planned', result);
160
- return result;
161
- }
162
-
163
- // 4. Split into sub-queries
164
- let parts = [trimmed];
165
- for (const pattern of SPLIT_PATTERNS) {
166
- const newParts = [];
167
- for (const part of parts) {
168
- const split = part
169
- .split(pattern)
170
- .map((s) => s.trim())
171
- .filter(Boolean);
172
- newParts.push(...split);
262
+ let clauses = [];
263
+ for (const piece of query.split(PUNCTUATION_BOUNDARY)) {
264
+ const prev = clauses[clauses.length - 1];
265
+ // "e.g. Google" / "Dr. Smith": an abbreviation, not a sentence end.
266
+ if (prev && ABBREVIATION_END.test(prev)) {
267
+ clauses[clauses.length - 1] = `${prev} ${piece}`;
268
+ } else {
269
+ clauses.push(piece);
173
270
  }
174
- parts = newParts;
271
+ }
272
+ clauses = clauses.map(tidy).filter(Boolean);
273
+ if (complexity >= this.config.minComplexityToSplit) {
274
+ clauses = clauses.flatMap((c) =>
275
+ c.split(CONJUNCTION_BOUNDARY).map(tidy).filter(Boolean),
276
+ );
175
277
  }
176
278
 
177
- // Build sub-queries with strategy and priority
178
- let subQueries = parts.map((part, i) => ({
179
- subQuery: part,
180
- strategy: this._detectStrategy(part),
181
- priority: i,
182
- }));
279
+ const out = [];
280
+ for (const clause of clauses) {
281
+ const comparison = this._parseComparison(clause);
282
+ if (comparison) {
283
+ for (const entity of comparison.entities) {
284
+ out.push({
285
+ subQuery: comparison.aspect
286
+ ? `${entity} ${comparison.aspect}`
287
+ : entity,
288
+ strategy: 'factual',
289
+ });
290
+ }
291
+ out.push({ subQuery: clause, strategy: 'comparative' });
292
+ } else {
293
+ out.push({ subQuery: clause, strategy: this._detectStrategy(clause) });
294
+ }
295
+ }
296
+ return out;
297
+ }
183
298
 
184
- // 5. Deduplicate by subQuery text (case-insensitive)
299
+ /**
300
+ * Turn sub-query texts into the plan result (dedupe, limit, priorities).
301
+ *
302
+ * @param {Array<{subQuery: string, strategy: string}>} items
303
+ * @param {number} complexity
304
+ * @param {string} originalQuery
305
+ * @param {'heuristic'|'llm'} source
306
+ * @returns {object}
307
+ * @private
308
+ */
309
+ _finalize(items, complexity, originalQuery, source) {
185
310
  const seen = new Set();
186
- subQueries = subQueries.filter((sq) => {
311
+ const unique = items.filter((sq) => {
187
312
  const key = sq.subQuery.toLowerCase();
188
313
  if (seen.has(key)) return false;
189
314
  seen.add(key);
190
315
  return true;
191
316
  });
317
+ const subQueries = unique
318
+ .slice(0, this.config.maxSubQueries)
319
+ .map((sq, i) => ({
320
+ subQuery: sq.subQuery,
321
+ strategy: sq.strategy,
322
+ priority: i,
323
+ }));
324
+ return { subQueries, complexity, originalQuery, source };
325
+ }
192
326
 
193
- // Limit to maxSubQueries
194
- subQueries = subQueries.slice(0, this.config.maxSubQueries);
327
+ /**
328
+ * Heuristic plan for a trimmed query.
329
+ * @private
330
+ */
331
+ _heuristicPlan(trimmed) {
332
+ const complexity = this._computeComplexity(trimmed);
333
+ const items = this._decompose(trimmed, complexity);
334
+ if (items.length === 0) {
335
+ items.push({
336
+ subQuery: trimmed,
337
+ strategy: this._detectStrategy(trimmed),
338
+ });
339
+ }
340
+ return this._finalize(items, complexity, trimmed, 'heuristic');
341
+ }
342
+
343
+ /**
344
+ * Strictly validate an LLM's sub-query proposal: a JSON array (optionally
345
+ * in a ```json fence) of 1..maxSubQueries non-empty, single-line strings of
346
+ * at most 500 characters. Anything else is rejected.
347
+ *
348
+ * @param {*} raw - Value returned by llm.generate()
349
+ * @returns {string[]|null}
350
+ * @private
351
+ */
352
+ _parseLlmSubQueries(raw) {
353
+ let text = raw;
354
+ if (text && typeof text === 'object') {
355
+ text = text.text ?? text.content ?? text.answer;
356
+ }
357
+ if (typeof text !== 'string') return null;
358
+ text = text.trim();
359
+ const fenced = text.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
360
+ if (fenced) text = fenced[1].trim();
361
+ if (!text.startsWith('[') || !text.endsWith(']')) return null;
362
+
363
+ let parsed;
364
+ try {
365
+ parsed = JSON.parse(text);
366
+ } catch (_err) {
367
+ return null;
368
+ }
369
+ if (!Array.isArray(parsed)) return null;
370
+ if (parsed.length === 0 || parsed.length > this.config.maxSubQueries) {
371
+ return null;
372
+ }
373
+ const out = [];
374
+ for (const item of parsed) {
375
+ if (typeof item !== 'string') return null;
376
+ const s = item.trim();
377
+ if (!s || s.length > MAX_LLM_SUBQUERY_LENGTH || /[\r\n]/.test(s)) {
378
+ return null;
379
+ }
380
+ out.push(s);
381
+ }
382
+ return out;
383
+ }
384
+
385
+ /**
386
+ * Ask the LLM for sub-queries; fall back to the heuristic plan.
387
+ * @private
388
+ */
389
+ async _planWithLlm(trimmed, { llm, signal }) {
390
+ const heuristic = this._heuristicPlan(trimmed);
391
+ let result = heuristic;
392
+ try {
393
+ const prompt =
394
+ 'Split the user question into independent search queries for a ' +
395
+ 'retrieval system. Keep phrases such as "salt and pepper" intact; ' +
396
+ 'for a comparison, give one query per compared item plus the ' +
397
+ `comparison. Reply with ONLY a JSON array of 1 to ${this.config.maxSubQueries} ` +
398
+ `strings.\n\nQuestion: ${trimmed}`;
399
+ const raw = await llm.generate(prompt, [], signal ? { signal } : {});
400
+ const proposed = this._parseLlmSubQueries(raw);
401
+ if (proposed) {
402
+ result = this._finalize(
403
+ proposed.map((s) => ({
404
+ subQuery: s,
405
+ strategy: this._detectStrategy(s),
406
+ })),
407
+ heuristic.complexity,
408
+ trimmed,
409
+ 'llm',
410
+ );
411
+ }
412
+ } catch (err) {
413
+ // A cancelled run must not continue with the heuristic plan.
414
+ if (signal && signal.aborted) throw err;
415
+ }
416
+ this.emit('planned', result);
417
+ return result;
418
+ }
419
+
420
+ /**
421
+ * Plan query decomposition.
422
+ *
423
+ * Without `options.llm` this is synchronous and returns the plan. With
424
+ * `options.llm` (an object with `generate()`), it returns a Promise of the
425
+ * plan: the LLM is asked for a JSON array of sub-queries, which is strictly
426
+ * validated (see _parseLlmSubQueries); on any failure the heuristic plan is
427
+ * used. `result.source` says which one was used.
428
+ *
429
+ * @param {string} query - The user query to decompose
430
+ * @param {object} [options]
431
+ * @param {{ generate: Function }} [options.llm] - Optional LLM planner
432
+ * @param {AbortSignal} [options.signal] - Passed to llm.generate()
433
+ * @returns {{
434
+ * subQueries: Array<{subQuery: string, strategy: string, priority: number}>,
435
+ * complexity: number,
436
+ * originalQuery: string,
437
+ * source: 'heuristic'|'llm'
438
+ * }|Promise<object>}
439
+ * @throws {Error} If query is not a non-empty string
440
+ * @fires QueryPlanner#planned
441
+ */
442
+ plan(query, options = {}) {
443
+ if (!query || typeof query !== 'string' || !query.trim()) {
444
+ throw new Error('QueryPlanner.plan(): query must be a non-empty string');
445
+ }
446
+ const trimmed = query.trim();
195
447
 
196
- // 6. Emit 'planned' event
197
- const result = {
198
- subQueries,
199
- complexity,
200
- originalQuery: trimmed,
201
- };
448
+ const llm = options && options.llm;
449
+ if (llm && typeof llm.generate === 'function') {
450
+ return this._planWithLlm(trimmed, options);
451
+ }
202
452
 
453
+ const result = this._heuristicPlan(trimmed);
203
454
  this.emit('planned', result);
204
455
  return result;
205
456
  }
@@ -10,7 +10,7 @@ const { detectHallucinations } = require('./hallucination-detector');
10
10
  * @type {object}
11
11
  */
12
12
  const DEFAULT_CONFIG = {
13
- similarityThreshold: 0.3,
13
+ similarityThreshold: 0.5,
14
14
  maxCitationsPerSentence: 3,
15
15
  useIDFWeighting: true,
16
16
  };
@@ -24,7 +24,7 @@ const DEFAULT_CONFIG = {
24
24
  class CitationTracker extends EventEmitter {
25
25
  /**
26
26
  * @param {object} [options] - Override default configuration
27
- * @param {number} [options.similarityThreshold=0.3] - Minimum similarity for a citation match
27
+ * @param {number} [options.similarityThreshold=0.5] - Minimum similarity for a citation match
28
28
  * @param {number} [options.maxCitationsPerSentence=3] - Max citations per sentence
29
29
  * @param {boolean} [options.useIDFWeighting=true] - Whether to apply IDF weighting
30
30
  */
@@ -76,9 +76,11 @@ class CitationTracker extends EventEmitter {
76
76
  'CitationTracker.track(): answer must be a non-empty string',
77
77
  );
78
78
  }
79
- if (!Array.isArray(retrievedDocs) || retrievedDocs.length === 0) {
79
+ // An empty array is valid (retrieval found nothing): every sentence is
80
+ // then reported as unsupported rather than failing the whole run.
81
+ if (!Array.isArray(retrievedDocs)) {
80
82
  throw new Error(
81
- 'CitationTracker.track(): retrievedDocs must be a non-empty array',
83
+ 'CitationTracker.track(): retrievedDocs must be an array',
82
84
  );
83
85
  }
84
86
 
@@ -10,7 +10,7 @@
10
10
  *
11
11
  * @param {Array<{sentence: string, sources: Array<{docId: string|undefined, docIndex: number, score: number}>}>} citationResults
12
12
  * @param {object} [options]
13
- * @param {number} [options.threshold=0.3] - Score threshold for grounded classification
13
+ * @param {number} [options.threshold=0.5] - Score threshold for grounded classification
14
14
  * @returns {{
15
15
  * hallucinationRate: number,
16
16
  * sentences: Array<{text: string, classification: string, bestScore: number, bestSource: object|null}>,
@@ -18,7 +18,7 @@
18
18
  * }}
19
19
  */
20
20
  function detectHallucinations(citationResults, options = {}) {
21
- const { threshold = 0.3 } = options;
21
+ const { threshold = 0.5 } = options;
22
22
 
23
23
  if (!Array.isArray(citationResults) || citationResults.length === 0) {
24
24
  return {
@@ -1,10 +1,12 @@
1
1
  'use strict';
2
2
 
3
- const { tokenize, computeJaccardSimilarity } = require('../evaluate/scoring');
3
+ const { tokenize, computeSupportScore } = require('../evaluate/scoring');
4
4
 
5
5
  /**
6
6
  * Build IDF (Inverse Document Frequency) weights from a collection of documents.
7
- * IDF = log(totalDocs / docsContainingToken)
7
+ * IDF = log(1 + totalDocs / docsContainingToken). The +1 keeps weights
8
+ * positive, so tokens found in every document (and every token when only one
9
+ * document was retrieved) still count instead of weighing zero.
8
10
  * @param {Array<{content?: string, text?: string}>} docs - Array of document objects
9
11
  * @returns {Map<string, number>} Map of token to IDF weight
10
12
  */
@@ -24,24 +26,25 @@ function buildIDFWeights(docs) {
24
26
 
25
27
  const idfWeights = new Map();
26
28
  for (const [token, df] of docFrequency) {
27
- idfWeights.set(token, Math.log(totalDocs / df));
29
+ idfWeights.set(token, Math.log(1 + totalDocs / df));
28
30
  }
29
31
 
30
32
  return idfWeights;
31
33
  }
32
34
 
33
35
  /**
34
- * Map a sentence to its most relevant source documents using token similarity.
36
+ * Map a sentence to its most relevant source documents by how much of the
37
+ * sentence each document supports (see computeSupportScore).
35
38
  * @param {string} sentence - The sentence to map
36
39
  * @param {Array<{content?: string, text?: string, id?: string}>} docs - Source documents
37
40
  * @param {object} [options] - Configuration options
38
41
  * @param {number} [options.maxCitations=3] - Maximum number of citations to return
39
- * @param {number} [options.threshold=0.3] - Minimum similarity threshold
42
+ * @param {number} [options.threshold=0.5] - Minimum similarity threshold
40
43
  * @param {Map<string, number>|null} [options.idfWeights=null] - Optional IDF weights for weighted similarity
41
44
  * @returns {Array<{docIndex: number, docId: string|undefined, score: number, matchedTokens: string[]}>}
42
45
  */
43
46
  function mapSentenceToSources(sentence, docs, options = {}) {
44
- const { maxCitations = 3, threshold = 0.3, idfWeights = null } = options;
47
+ const { maxCitations = 3, threshold = 0.5, idfWeights = null } = options;
45
48
 
46
49
  const sentenceTokens = tokenize(sentence);
47
50
  if (!sentenceTokens.length) return [];
@@ -54,29 +57,13 @@ function mapSentenceToSources(sentence, docs, options = {}) {
54
57
  const docTokens = tokenize(docText);
55
58
  if (!docTokens.length) continue;
56
59
 
57
- let score;
58
- const sentenceSet = new Set(sentenceTokens);
60
+ // Share of the sentence's content tokens found in this document.
59
61
  const docSet = new Set(docTokens);
60
- const matched = [...sentenceSet].filter((t) => docSet.has(t));
61
-
62
- if (idfWeights) {
63
- // IDF-weighted Jaccard: sum IDF weights of intersection / sum IDF weights of union
64
- const union = new Set([...sentenceSet, ...docSet]);
65
- let intersectionWeight = 0;
66
- let unionWeight = 0;
67
-
68
- for (const token of union) {
69
- const weight = idfWeights.get(token) || 1;
70
- if (sentenceSet.has(token) && docSet.has(token)) {
71
- intersectionWeight += weight;
72
- }
73
- unionWeight += weight;
74
- }
75
-
76
- score = unionWeight > 0 ? intersectionWeight / unionWeight : 0;
77
- } else {
78
- score = computeJaccardSimilarity(sentenceTokens, docTokens);
79
- }
62
+ const score = computeSupportScore(sentenceTokens, docSet, idfWeights, {
63
+ claimText: sentence,
64
+ docText,
65
+ });
66
+ const matched = [...new Set(sentenceTokens)].filter((t) => docSet.has(t));
80
67
 
81
68
  if (score >= threshold) {
82
69
  results.push({