@devilsdev/rag-pipeline-utils 2.6.2 → 2.7.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 (90) hide show
  1. package/BENCHMARKS.md +7 -1
  2. package/CHANGELOG.md +121 -0
  3. package/README.md +35 -24
  4. package/dist/index.cjs +4164 -1825
  5. package/dist/index.d.ts +265 -256
  6. package/dist/index.mjs +1 -1
  7. package/dist/types/cache/memory-cache.d.ts +4 -1
  8. package/dist/types/cache/redis-cache.d.ts +4 -1
  9. package/dist/types/cli/services/instrument.d.ts +14 -0
  10. package/dist/types/cli/services/rag-service.d.ts +140 -0
  11. package/dist/types/cli/shared/config-context.d.ts +9 -0
  12. package/dist/types/config/component-factory.d.ts +57 -0
  13. package/dist/types/config/enhanced-ragrc-schema.d.ts +648 -0
  14. package/dist/types/config/load-config.d.ts +18 -0
  15. package/dist/types/config/normalize-config.d.ts +9 -0
  16. package/dist/types/core/observability/event-logger.d.ts +211 -0
  17. package/dist/types/core/observability/metrics.d.ts +585 -0
  18. package/dist/types/core/performance/parallel-processor.d.ts +92 -0
  19. package/dist/types/core/plugin-registry.d.ts +5 -0
  20. package/dist/types/dag/core/execution-scheduler.d.ts +3 -1
  21. package/dist/types/dag/dag-engine.d.ts +15 -2
  22. package/dist/types/dev/dev-server.d.ts +136 -0
  23. package/dist/types/dev/hot-reload.d.ts +226 -0
  24. package/dist/types/ecosystem/plugin-analytics-dashboard.d.ts +141 -0
  25. package/dist/types/ecosystem/plugin-hub.d.ts +161 -0
  26. package/dist/types/ecosystem/plugin-installer.d.ts +40 -0
  27. package/dist/types/ecosystem/plugin-probe.d.ts +21 -0
  28. package/dist/types/ecosystem/plugin-scanner.d.ts +30 -0
  29. package/dist/types/ecosystem/tar-extract.d.ts +24 -0
  30. package/dist/types/enterprise/audit-logging.d.ts +217 -0
  31. package/dist/types/evaluate/compare.d.ts +140 -0
  32. package/dist/types/evaluate/dataset.d.ts +43 -0
  33. package/dist/types/evaluate/evaluator.d.ts +14 -0
  34. package/dist/types/evaluate/report.d.ts +56 -0
  35. package/dist/types/evaluate/synthetic.d.ts +64 -0
  36. package/dist/types/loader/csv-loader.d.ts +10 -0
  37. package/dist/types/loader/document-reader.d.ts +18 -0
  38. package/dist/types/loader/html-loader.d.ts +21 -0
  39. package/dist/types/loader/markdown-loader.d.ts +21 -0
  40. package/dist/types/retrieval/bm25-search.d.ts +4 -0
  41. package/dist/types/retrieval/file-vector-store.d.ts +4 -1
  42. package/dist/types/retrieval/hybrid-retriever.d.ts +4 -0
  43. package/dist/types/retrieval/pgvector-retriever.d.ts +123 -0
  44. package/dist/types/retrieval/qdrant-retriever.d.ts +106 -0
  45. package/dist/types/security/validator.d.ts +11 -1
  46. package/dist/types/testing.d.ts +2 -0
  47. package/dist/types/utils/batch-processor.d.ts +205 -0
  48. package/dist/types/utils/connection-pool.d.ts +158 -0
  49. package/dist/types/utils/error-formatter.d.ts +312 -0
  50. package/dist/types/utils/logger.d.ts +29 -4
  51. package/dist/types/utils/rate-limiter.d.ts +13 -8
  52. package/dist/types/utils/retry.d.ts +10 -0
  53. package/package.json +12 -2
  54. package/src/cache/memory-cache.js +4 -1
  55. package/src/cache/redis-cache.js +4 -1
  56. package/src/cli/enhanced-cli-commands.js +79 -0
  57. package/src/cli/handlers/ai-handler.js +2 -2
  58. package/src/cli/handlers/eval-handler.js +201 -0
  59. package/src/cli/handlers/generate-handler.js +103 -0
  60. package/src/cli/services/benchmark.js +2 -2
  61. package/src/cli/services/rag-service.js +59 -10
  62. package/src/cli/shared/cli-utils.js +40 -1
  63. package/src/config/component-factory.js +32 -0
  64. package/src/connectors/local-embedder.js +20 -13
  65. package/src/core/observability/event-logger.js +135 -1
  66. package/src/core/observability/metrics.js +223 -5
  67. package/src/core/performance/parallel-processor.js +2 -0
  68. package/src/core/plugin-registry.js +5 -1
  69. package/src/dag/core/execution-scheduler.js +2 -0
  70. package/src/dag/dag-engine.js +13 -0
  71. package/src/dev/dev-server.js +6 -0
  72. package/src/ecosystem/plugin-hub.js +4 -0
  73. package/src/enterprise/audit-logging.js +5 -0
  74. package/src/evaluate/compare.js +395 -0
  75. package/src/evaluate/dataset.js +207 -0
  76. package/src/evaluate/report.js +350 -0
  77. package/src/evaluate/synthetic.js +246 -0
  78. package/src/index.js +17 -0
  79. package/src/retrieval/bm25-search.js +4 -0
  80. package/src/retrieval/file-vector-store.js +4 -1
  81. package/src/retrieval/hybrid-retriever.js +4 -0
  82. package/src/retrieval/pgvector-retriever.js +420 -0
  83. package/src/retrieval/qdrant-retriever.js +361 -0
  84. package/src/security/validator.js +8 -0
  85. package/src/testing.js +2 -0
  86. package/src/utils/batch-processor.js +7 -3
  87. package/src/utils/error-formatter.js +48 -20
  88. package/src/utils/logger.js +31 -1
  89. package/src/utils/rate-limiter.js +9 -4
  90. package/src/utils/retry.js +10 -1
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Eval command handler
3
+ * Runs an evaluation dataset through the configured pipeline, prints the
4
+ * scores and exits 1 when the run fails: a case errors, a metric is below
5
+ * its --min, or the run regressed from the --baseline report.
6
+ *
7
+ * Options: --baseline <report.json>, --output <report.json>, --report
8
+ * <file.html|file.md>, --min <metric=value> (repeatable), --tolerance,
9
+ * --case-tolerance, --concurrency, --timeout, --top-k, --json.
10
+ */
11
+
12
+ const fs = require("fs");
13
+ const path = require("path");
14
+ const { loadCliConfig } = require("../shared/config-context.js");
15
+ const { createPipeline } = require("../services/rag-service.js");
16
+ const { validateDataset } = require("../../evaluate/dataset.js");
17
+ const {
18
+ runEvaluation,
19
+ compareConfigurations,
20
+ EVALUATION_METRICS,
21
+ } = require("../../evaluate/compare.js");
22
+ const {
23
+ evaluationVerdict,
24
+ renderEvaluationReport,
25
+ } = require("../../evaluate/report.js");
26
+
27
+ /** Reads a dataset from .json (array or { cases }) or .jsonl (a case per line). */
28
+ function readDataset(file) {
29
+ const text = fs.readFileSync(file, "utf8");
30
+ if (/\.jsonl$/i.test(file)) {
31
+ return text
32
+ .split(/\r?\n/)
33
+ .map((line, i) => ({ line: line.trim(), n: i + 1 }))
34
+ .filter(({ line }) => line.length > 0)
35
+ .map(({ line, n }) => {
36
+ try {
37
+ return JSON.parse(line);
38
+ } catch (err) {
39
+ throw new Error(`${file}:${n}: not valid JSON (${err.message})`);
40
+ }
41
+ });
42
+ }
43
+ try {
44
+ return JSON.parse(text);
45
+ } catch (err) {
46
+ throw new Error(`${file}: not valid JSON (${err.message})`);
47
+ }
48
+ }
49
+
50
+ function printText({ report, comparison, verdict, quiet }) {
51
+ const { summary } = report;
52
+ if (!quiet) {
53
+ console.log(
54
+ `\n${summary.cases} case${summary.cases === 1 ? "" : "s"}, ${summary.failed} failed`,
55
+ );
56
+ const names = EVALUATION_METRICS.filter((m) => summary.metrics[m]);
57
+ if (names.length > 0) {
58
+ console.log("");
59
+ for (const name of names) {
60
+ const mean = summary.metrics[name].mean.toFixed(3);
61
+ const cmp = comparison && comparison.metrics[name];
62
+ const change = cmp
63
+ ? ` (baseline ${cmp.baseline.toFixed(3)}, ${cmp.delta >= 0 ? "+" : ""}${cmp.delta.toFixed(3)}${cmp.regressed ? ", regressed" : ""})`
64
+ : "";
65
+ console.log(` ${name.padEnd(17)} ${mean}${change}`);
66
+ }
67
+ }
68
+ const failed = report.cases.filter((c) => !c.success);
69
+ for (const c of failed.slice(0, 10)) {
70
+ console.log(`\n ✗ ${c.id}: ${c.error}`);
71
+ }
72
+ if (comparison) {
73
+ const worse = comparison.regressions.filter((r) => r.metric);
74
+ if (worse.length > 0) {
75
+ console.log("\nCases that got worse:");
76
+ for (const r of worse.slice(0, 10)) {
77
+ console.log(
78
+ ` ${r.id}: ${r.metric} ${r.baseline.toFixed(3)} -> ${r.candidate.toFixed(3)}`,
79
+ );
80
+ }
81
+ if (worse.length > 10)
82
+ console.log(` ...and ${worse.length - 10} more`);
83
+ }
84
+ for (const w of comparison.warnings) console.log(` ! ${w}`);
85
+ }
86
+ }
87
+ if (verdict.passed) {
88
+ console.log("\n✅ Evaluation passed");
89
+ } else {
90
+ console.log("\n❌ Evaluation failed");
91
+ for (const reason of verdict.reasons) console.log(` - ${reason}`);
92
+ }
93
+ }
94
+
95
+ async function handleEval(globalOptions, datasetPath, options = {}) {
96
+ try {
97
+ const datasetFile = path.resolve(datasetPath);
98
+ const dataset = readDataset(datasetFile);
99
+ const checked = validateDataset(dataset);
100
+ for (const w of checked.warnings) console.warn(`! ${w.message}`);
101
+ if (!checked.valid) {
102
+ console.error(`❌ Invalid dataset ${datasetPath}:`);
103
+ for (const e of checked.errors) console.error(` - ${e.message}`);
104
+ process.exitCode = 1;
105
+ return;
106
+ }
107
+
108
+ let baseline;
109
+ if (options.baseline) {
110
+ baseline = JSON.parse(
111
+ fs.readFileSync(path.resolve(options.baseline), "utf8"),
112
+ );
113
+ }
114
+
115
+ if (globalOptions.dryRun) {
116
+ console.log(
117
+ `🧪 Dry run: would evaluate ${checked.cases.length} cases from ${datasetPath}${baseline ? ` against ${options.baseline}` : ""}`,
118
+ );
119
+ return;
120
+ }
121
+
122
+ const { config, configDir } = loadCliConfig(globalOptions);
123
+ const { pipeline } = createPipeline({ config, configDir });
124
+ const quiet = Boolean(globalOptions.quiet) || Boolean(options.json);
125
+ let done = 0;
126
+ const report = await runEvaluation(checked.cases, pipeline, {
127
+ concurrency: options.concurrency || 1,
128
+ ...(options.timeout && { timeout: options.timeout }),
129
+ runOptions: { topK: options.topK || 3 },
130
+ onCase: () => {
131
+ done += 1;
132
+ if (!quiet && process.stderr.isTTY) {
133
+ process.stderr.write(`\rEvaluating ${done}/${checked.cases.length}`);
134
+ }
135
+ },
136
+ });
137
+ if (!quiet && process.stderr.isTTY) process.stderr.write("\n");
138
+
139
+ const comparison = baseline
140
+ ? await compareConfigurations(null, {
141
+ baseline,
142
+ candidate: report,
143
+ ...(options.tolerance !== undefined && {
144
+ tolerance: options.tolerance,
145
+ }),
146
+ ...(options.caseTolerance !== undefined && {
147
+ caseTolerance: options.caseTolerance,
148
+ }),
149
+ })
150
+ : undefined;
151
+ const min = options.min || {};
152
+ const verdict = evaluationVerdict({ report, comparison, min });
153
+
154
+ if (options.output) {
155
+ fs.writeFileSync(
156
+ path.resolve(options.output),
157
+ `${JSON.stringify(report, null, 2)}\n`,
158
+ );
159
+ }
160
+ if (options.report) {
161
+ const file = path.resolve(options.report);
162
+ const format = /\.html?$/i.test(file) ? "html" : "markdown";
163
+ fs.writeFileSync(
164
+ file,
165
+ renderEvaluationReport({ report, comparison, min }, { format }),
166
+ );
167
+ }
168
+
169
+ if (options.json) {
170
+ const { baseline: _b, candidate: _c, ...rest } = comparison || {};
171
+ console.log(
172
+ JSON.stringify(
173
+ {
174
+ passed: verdict.passed,
175
+ reasons: verdict.reasons,
176
+ thresholds: verdict.thresholds,
177
+ summary: report.summary,
178
+ ...(comparison && { comparison: rest }),
179
+ },
180
+ null,
181
+ 2,
182
+ ),
183
+ );
184
+ } else {
185
+ printText({
186
+ report,
187
+ comparison,
188
+ verdict,
189
+ quiet: Boolean(globalOptions.quiet),
190
+ });
191
+ if (options.output) console.log(`Report saved to ${options.output}`);
192
+ if (options.report) console.log(`Summary written to ${options.report}`);
193
+ }
194
+ if (!verdict.passed) process.exitCode = 1;
195
+ } catch (error) {
196
+ console.error(`❌ Evaluation failed: ${error.message}`);
197
+ process.exitCode = 1;
198
+ }
199
+ }
200
+
201
+ module.exports = { handleEval, readDataset };
@@ -0,0 +1,103 @@
1
+ /**
2
+ * generate-dataset command handler
3
+ * Writes an evaluation test set from the ingested index: the configured
4
+ * llm writes question and answer pairs about each chunk, and every case
5
+ * records its source file, so `rag-pipeline eval` can check retrieval too.
6
+ *
7
+ * Options: --output <file> (required), --count, --per-document,
8
+ * --min-faithfulness, --concurrency, --force (overwrite --output).
9
+ */
10
+
11
+ const fs = require("fs");
12
+ const path = require("path");
13
+ const { loadCliConfig } = require("../shared/config-context.js");
14
+ const { loadComponents } = require("../services/rag-service.js");
15
+ const { generateSyntheticDataset } = require("../../evaluate/synthetic.js");
16
+
17
+ async function handleGenerateDataset(globalOptions, options = {}) {
18
+ try {
19
+ const output = path.resolve(options.output);
20
+ if (fs.existsSync(output) && !options.force) {
21
+ console.error(
22
+ `❌ ${options.output} already exists. Pass --force to replace it (reviewed cases would be lost).`,
23
+ );
24
+ process.exitCode = 1;
25
+ return;
26
+ }
27
+
28
+ const { config, configDir } = loadCliConfig(globalOptions);
29
+ const components = loadComponents({ config, configDir });
30
+ if (!components.llm) {
31
+ console.error(
32
+ "❌ generate-dataset needs an llm stage in the config to write the questions.",
33
+ );
34
+ process.exitCode = 1;
35
+ return;
36
+ }
37
+ if (typeof components.retriever.list !== "function") {
38
+ console.error(
39
+ "❌ The retriever can't list its documents; generate-dataset reads them from the CLI's file-store index.",
40
+ );
41
+ process.exitCode = 1;
42
+ return;
43
+ }
44
+ const documents = await components.retriever.list();
45
+ const count = options.count || 20;
46
+
47
+ if (globalOptions.dryRun) {
48
+ console.log(
49
+ `🧪 Dry run: would write up to ${count} cases from ${documents.length} indexed chunks to ${options.output}`,
50
+ );
51
+ return;
52
+ }
53
+
54
+ const quiet = Boolean(globalOptions.quiet);
55
+ let written = 0;
56
+ const dataset = await generateSyntheticDataset(documents, {
57
+ llm: components.llm,
58
+ count,
59
+ perDocument: options.perDocument || 1,
60
+ ...(options.minFaithfulness !== undefined && {
61
+ minFaithfulness: options.minFaithfulness,
62
+ }),
63
+ concurrency: options.concurrency || 1,
64
+ onCase: () => {
65
+ written += 1;
66
+ if (!quiet && process.stderr.isTTY) {
67
+ process.stderr.write(`\rGenerated ${written}/${count}`);
68
+ }
69
+ },
70
+ });
71
+ if (!quiet && process.stderr.isTTY) process.stderr.write("\n");
72
+
73
+ fs.writeFileSync(output, `${JSON.stringify(dataset.cases, null, 2)}\n`);
74
+
75
+ if (!quiet && dataset.skipped.length > 0) {
76
+ const reasons = {};
77
+ for (const s of dataset.skipped) {
78
+ const reason = s.reason.replace(/ \(faithfulness [\d.]+\)$/, "");
79
+ reasons[reason] = (reasons[reason] || 0) + 1;
80
+ }
81
+ console.log("Skipped:");
82
+ for (const [reason, n] of Object.entries(reasons)) {
83
+ console.log(` ${n} × ${reason}`);
84
+ }
85
+ }
86
+ if (dataset.cases.length === 0) {
87
+ console.error(
88
+ `❌ No cases were generated from ${documents.length} chunks; see the reasons above.`,
89
+ );
90
+ process.exitCode = 1;
91
+ return;
92
+ }
93
+ console.log(
94
+ `✅ Wrote ${dataset.cases.length} case${dataset.cases.length === 1 ? "" : "s"} to ${options.output}. ` +
95
+ 'Review them: fix or delete wrong ones and set "reviewed": true.',
96
+ );
97
+ } catch (error) {
98
+ console.error(`❌ Generating the dataset failed: ${error.message}`);
99
+ process.exitCode = 1;
100
+ }
101
+ }
102
+
103
+ module.exports = { handleGenerateDataset };
@@ -87,7 +87,7 @@ async function runBenchmark({
87
87
 
88
88
  const queries = queriesFile
89
89
  ? readQueryFile(queriesFile)
90
- : sampleQueries(retriever.list ? retriever.list() : []);
90
+ : sampleQueries(retriever.list ? await retriever.list() : []);
91
91
  if (queries.length === 0) {
92
92
  throw new Error(
93
93
  "No queries: pass --queries <file> (the index has no text to sample from).",
@@ -148,7 +148,7 @@ async function runBenchmark({
148
148
  environment: {
149
149
  node: process.version,
150
150
  platform: `${process.platform} ${process.arch}`,
151
- chunks: retriever.size ? retriever.size() : null,
151
+ chunks: retriever.size ? await retriever.size() : null,
152
152
  embedder: components.names.embedder,
153
153
  dimensions: retriever.getEmbedderInfo?.()?.dimensions ?? null,
154
154
  retriever: components.names.retriever,
@@ -28,6 +28,30 @@ function embedDocument(embedder, text) {
28
28
  return embedQuery({ embed: (t) => embedder.embed(t) }, text);
29
29
  }
30
30
 
31
+ /**
32
+ * Corpus-fitted embedders (the built-in `local` TF-IDF embedder) keep a
33
+ * vocabulary that must be saved with the index and restored before every
34
+ * query. A store that can't save it (pgvector, Qdrant, custom stores) would
35
+ * embed queries with an empty vocabulary and return wrong results, so stop
36
+ * with a clear message instead.
37
+ */
38
+ function assertEmbedderFits(components) {
39
+ const { embedder, retriever, names } = components;
40
+ const fitted =
41
+ typeof embedder.train === "function" ||
42
+ typeof embedder.setState === "function";
43
+ const persists =
44
+ typeof retriever.getEmbedderInfo === "function" &&
45
+ typeof retriever.setEmbedderInfo === "function";
46
+ if (fitted && !persists) {
47
+ throw new Error(
48
+ `The "${names.embedder}" embedder is fitted to your documents and needs a retriever ` +
49
+ `that saves its state, such as "file-store". With "${names.retriever}", use an ` +
50
+ "embedder like openai, ollama or cohere.",
51
+ );
52
+ }
53
+ }
54
+
31
55
  /**
32
56
  * Restore embedder state saved with the index (e.g. LocalEmbedder's
33
57
  * vocabulary), so queries embed into the same vector space as ingestion.
@@ -65,14 +89,14 @@ async function embedAndStore({
65
89
  }
66
90
 
67
91
  await store.store(vectors);
68
- store.setEmbedderInfo?.({
92
+ await store.setEmbedderInfo?.({
69
93
  name: embedderName,
70
94
  dimensions,
71
95
  ...(typeof embedder.getState === "function" && {
72
96
  state: embedder.getState(),
73
97
  }),
74
98
  });
75
- store.save?.();
99
+ await store.save?.();
76
100
  return vectors;
77
101
  }
78
102
 
@@ -97,10 +121,12 @@ async function ingest({ config, configDir, inputPath, rebuild = false }) {
97
121
  );
98
122
  }
99
123
 
124
+ assertEmbedderFits(components);
100
125
  const embedderName = components.names.embedder;
101
- if (rebuild) store.clear?.();
102
- const previous = store.getEmbedderInfo?.();
103
- if (previous && previous.name !== embedderName && store.size() > 0) {
126
+ // Stores may be remote (pgvector, Qdrant), so await every call.
127
+ if (rebuild) await store.clear?.();
128
+ const previous = await store.getEmbedderInfo?.();
129
+ if (previous && previous.name !== embedderName && (await store.size()) > 0) {
104
130
  throw new Error(
105
131
  `The index was built with embedder "${previous.name}" but the config now uses ` +
106
132
  `"${embedderName}". Re-run with --rebuild to re-index everything.`,
@@ -108,7 +134,7 @@ async function ingest({ config, configDir, inputPath, rebuild = false }) {
108
134
  }
109
135
 
110
136
  const documents = await readDocuments(inputPath, { baseDir: configDir });
111
- for (const { source } of documents) store.removeBySource?.(source);
137
+ for (const { source } of documents) await store.removeBySource?.(source);
112
138
 
113
139
  let entries = documents.flatMap(({ source, chunks }) =>
114
140
  chunks.map((content, i) => ({
@@ -121,7 +147,7 @@ async function ingest({ config, configDir, inputPath, rebuild = false }) {
121
147
  // Corpus-fitted embedders (LocalEmbedder) must be retrained on everything
122
148
  // stored, and every stored chunk re-embedded into the new vector space.
123
149
  if (typeof embedder.train === "function") {
124
- entries = [...(store.list?.() || []), ...entries];
150
+ entries = [...((await store.list?.()) || []), ...entries];
125
151
  embedder.train(entries.map((e) => e.content));
126
152
  } else {
127
153
  restoreEmbedder(embedder, store);
@@ -138,7 +164,7 @@ async function ingest({ config, configDir, inputPath, rebuild = false }) {
138
164
  return {
139
165
  files: documents.length,
140
166
  chunks: documents.reduce((n, d) => n + d.chunks.length, 0),
141
- totalChunks: store.size?.() ?? vectors.length,
167
+ totalChunks: (await store.size?.()) ?? vectors.length,
142
168
  sources: documents.map((d) => d.source),
143
169
  };
144
170
  }
@@ -186,13 +212,13 @@ async function retrain({ config, configDir, inputPath }) {
186
212
  );
187
213
  }
188
214
 
189
- const stored = store.list();
215
+ const stored = await store.list();
190
216
  if (stored.length === 0) {
191
217
  throw new Error(
192
218
  "The index is empty. Run `rag-pipeline ingest <path>` first.",
193
219
  );
194
220
  }
195
- const previous = store.getEmbedderInfo?.();
221
+ const previous = await store.getEmbedderInfo?.();
196
222
  if (previous && previous.name !== embedderName) {
197
223
  throw new Error(
198
224
  `The index was built with embedder "${previous.name}". ` +
@@ -241,6 +267,8 @@ function loadComponents({ config, configDir }) {
241
267
  const components = buildComponents(config, { configDir });
242
268
  const embedder = requireStage(components, "embedder");
243
269
  const retriever = requireStage(components, "retriever");
270
+ assertEmbedderFits(components);
271
+ // A remote store's size() is a Promise; its callers check emptiness.
244
272
  if (retriever.size && retriever.size() === 0) {
245
273
  throw new Error(
246
274
  "The index is empty. Run `rag-pipeline ingest <path>` first.",
@@ -351,10 +379,31 @@ async function query({
351
379
  };
352
380
  }
353
381
 
382
+ /**
383
+ * A createRagPipeline() pipeline over the configured components and index,
384
+ * for running many questions (rag-pipeline eval).
385
+ *
386
+ * @param {object} params
387
+ * @param {object} params.config
388
+ * @param {string} params.configDir
389
+ * @returns {{ pipeline: object, hasLlm: boolean }}
390
+ */
391
+ function createPipeline({ config, configDir }) {
392
+ const c = loadComponents({ config, configDir });
393
+ const pipeline = createRagPipeline({
394
+ embedder: c.embedder,
395
+ retriever: c.retriever,
396
+ llm: c.llm,
397
+ reranker: c.reranker,
398
+ });
399
+ return { pipeline, hasLlm: Boolean(c.llm) };
400
+ }
401
+
354
402
  module.exports = {
355
403
  ingest,
356
404
  retrain,
357
405
  query,
406
+ createPipeline,
358
407
  retrieve,
359
408
  loadComponents,
360
409
  restoreEmbedder,
@@ -2,6 +2,7 @@
2
2
  * Shared CLI utility functions
3
3
  */
4
4
 
5
+ const { InvalidArgumentError } = require("commander");
5
6
  const pkg = require("../../../package.json");
6
7
 
7
8
  /**
@@ -23,16 +24,52 @@ function createProgressBar(percentage, width = 20) {
23
24
  function parsePositiveInteger(value) {
24
25
  const parsed = parseInt(value, 10);
25
26
  if (isNaN(parsed) || parsed <= 0) {
26
- throw new Error(`Invalid value '${value}'. Expected positive integer.`);
27
+ throw new InvalidArgumentError("Expected a positive integer.");
27
28
  }
28
29
  return parsed;
29
30
  }
30
31
 
32
+ /**
33
+ * Collector for repeatable --min metric=value (rag-pipeline eval). The
34
+ * metric list loads only when the option is used.
35
+ */
36
+ function collectMin(value, previous = {}) {
37
+ const match = /^([A-Za-z]+)=(\d*\.?\d+)$/.exec(String(value).trim());
38
+ if (!match) {
39
+ throw new InvalidArgumentError("Use metric=value, e.g. faithfulness=0.8.");
40
+ }
41
+ const [, metric, number] = match;
42
+ // eslint-disable-next-line global-require
43
+ const { EVALUATION_METRICS } = require("../../evaluate/compare.js");
44
+ if (!EVALUATION_METRICS.includes(metric)) {
45
+ throw new InvalidArgumentError(
46
+ `Unknown metric "${metric}"; use one of ${EVALUATION_METRICS.join(", ")}.`,
47
+ );
48
+ }
49
+ const min = Number(number);
50
+ if (min > 1)
51
+ throw new InvalidArgumentError("The value must be between 0 and 1.");
52
+ return { ...previous, [metric]: min };
53
+ }
54
+
55
+ /**
56
+ * Parser for a non-negative number (eval --tolerance, --case-tolerance)
57
+ */
58
+ function parseTolerance(value) {
59
+ const n = Number(value);
60
+ if (value === "" || !Number.isFinite(n) || n < 0) {
61
+ throw new InvalidArgumentError("Expected a number of 0 or more.");
62
+ }
63
+ return n;
64
+ }
65
+
31
66
  // Top-level commands offered by shell completion
32
67
  const COMMANDS = [
33
68
  "init",
34
69
  "ingest",
35
70
  "query",
71
+ "eval",
72
+ "generate-dataset",
36
73
  "run",
37
74
  "serve",
38
75
  "doctor",
@@ -100,6 +137,8 @@ For more information, visit: https://github.com/DevilsDev/rag-pipeline-utils
100
137
  module.exports = {
101
138
  createProgressBar,
102
139
  parsePositiveInteger,
140
+ collectMin,
141
+ parseTolerance,
103
142
  generateCompletionScript,
104
143
  getVersion,
105
144
  getExtendedHelp,
@@ -30,6 +30,38 @@ const BUILT_INS = {
30
30
  }),
31
31
  memory: (cfg) =>
32
32
  new (require("../connectors/memory-retriever").MemoryRetriever)(cfg),
33
+ // The connection string comes from the environment, never the config
34
+ // file: `connectionStringEnv` names the variable (default DATABASE_URL).
35
+ pgvector: (cfg) => {
36
+ const variable = cfg.connectionStringEnv || "DATABASE_URL";
37
+ const connectionString = process.env[variable];
38
+ if (!connectionString) {
39
+ throw new Error(
40
+ `The pgvector retriever reads its connection string from ${variable}, which is not set`,
41
+ );
42
+ }
43
+ const { PgVectorRetriever } = require("../retrieval/pgvector-retriever");
44
+ return new PgVectorRetriever({
45
+ connectionString,
46
+ table: cfg.table,
47
+ dimensions: cfg.dimensions,
48
+ createTable: cfg.createTable,
49
+ index: cfg.index,
50
+ cacheKey: cfg.cacheKey,
51
+ });
52
+ },
53
+ // The API key comes only from QDRANT_API_KEY, never the config file.
54
+ qdrant: (cfg) => {
55
+ const { QdrantRetriever } = require("../retrieval/qdrant-retriever");
56
+ return new QdrantRetriever({
57
+ url: cfg.url || process.env.QDRANT_URL || "http://localhost:6333",
58
+ apiKey: process.env.QDRANT_API_KEY,
59
+ collection: cfg.collection,
60
+ dimensions: cfg.dimensions,
61
+ createCollection: cfg.createCollection,
62
+ cacheKey: cfg.cacheKey,
63
+ });
64
+ },
33
65
  },
34
66
  llm: {
35
67
  openai: (cfg) =>
@@ -1,9 +1,9 @@
1
- 'use strict';
2
- const { digest } = require('../cache/component-keys');
1
+ "use strict";
2
+ const { digest } = require("../cache/component-keys");
3
3
 
4
- const { BaseConnector } = require('./base-connector');
5
- const { tokenize } = require('../evaluate/scoring');
6
- const { throwIfAborted } = require('./connector-utils');
4
+ const { BaseConnector } = require("./base-connector");
5
+ const { tokenize } = require("../evaluate/scoring");
6
+ const { throwIfAborted } = require("./connector-utils");
7
7
 
8
8
  /**
9
9
  * Default configuration for the local TF-IDF embedder.
@@ -27,7 +27,7 @@ class LocalEmbedder extends BaseConnector {
27
27
  * @param {number} [options.minDocFrequency=1] - Minimum document frequency for a token to be included
28
28
  */
29
29
  constructor(options = {}) {
30
- super({ name: 'local-embedder', ...options });
30
+ super({ name: "local-embedder", ...options });
31
31
  this.config = { ...DEFAULT_CONFIG, ...options };
32
32
  /** @type {Map<string, number>} token -> vocabulary index */
33
33
  this.vocabulary = new Map();
@@ -45,7 +45,7 @@ class LocalEmbedder extends BaseConnector {
45
45
  train(documents) {
46
46
  this._fingerprint = null; // the cache key changes with the training
47
47
  const docs = documents.map((doc) => {
48
- if (typeof doc === 'string') return doc;
48
+ if (typeof doc === "string") return doc;
49
49
  return doc.content || doc.text || String(doc);
50
50
  });
51
51
 
@@ -70,8 +70,15 @@ class LocalEmbedder extends BaseConnector {
70
70
  idfEntries.push([token, idfValue]);
71
71
  }
72
72
 
73
- // Sort by IDF descending and take top maxVocabularySize tokens
74
- idfEntries.sort((a, b) => b[1] - a[1]);
73
+ // Keep the maxVocabularySize tokens found in the most documents (ties
74
+ // in token order, so training is deterministic). Keeping the highest
75
+ // IDF instead kept the rarest tokens: on a large corpus, words that
76
+ // appear in one document only, so most queries embedded to all zeros.
77
+ idfEntries.sort(
78
+ (a, b) =>
79
+ docFreq.get(b[0]) - docFreq.get(a[0]) ||
80
+ (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0),
81
+ );
75
82
  const topEntries = idfEntries.slice(0, this.config.maxVocabularySize);
76
83
 
77
84
  // Build vocabulary map and IDF map
@@ -83,7 +90,7 @@ class LocalEmbedder extends BaseConnector {
83
90
  this.idf.set(token, idfValue);
84
91
  }
85
92
 
86
- this.emit('trained', {
93
+ this.emit("trained", {
87
94
  vocabSize: this.vocabulary.size,
88
95
  docCount: this.docCount,
89
96
  });
@@ -104,8 +111,8 @@ class LocalEmbedder extends BaseConnector {
104
111
  const vocabSize = this.vocabulary.size;
105
112
 
106
113
  if (vocabSize === 0) {
107
- this.emit('warning', {
108
- message: 'LocalEmbedder has not been trained yet',
114
+ this.emit("warning", {
115
+ message: "LocalEmbedder has not been trained yet",
109
116
  });
110
117
  return new Array(1).fill(0);
111
118
  }
@@ -187,7 +194,7 @@ class LocalEmbedder extends BaseConnector {
187
194
  * @returns {string|null}
188
195
  */
189
196
  getCacheKey(role) {
190
- if (role !== 'embedder') return null;
197
+ if (role !== "embedder") return null;
191
198
  if (!this._fingerprint) {
192
199
  this._fingerprint = digest(
193
200
  JSON.stringify([this.docCount, [...this.vocabulary], [...this.idf]]),