@devilsdev/rag-pipeline-utils 2.6.1 → 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.
- package/BENCHMARKS.md +7 -1
- package/CHANGELOG.md +135 -0
- package/README.md +37 -24
- package/dist/index.cjs +4164 -1825
- package/dist/index.d.ts +265 -256
- package/dist/index.mjs +1 -1
- package/dist/types/cache/memory-cache.d.ts +4 -1
- package/dist/types/cache/redis-cache.d.ts +4 -1
- package/dist/types/cli/services/instrument.d.ts +14 -0
- package/dist/types/cli/services/rag-service.d.ts +140 -0
- package/dist/types/cli/shared/config-context.d.ts +9 -0
- package/dist/types/config/component-factory.d.ts +57 -0
- package/dist/types/config/enhanced-ragrc-schema.d.ts +648 -0
- package/dist/types/config/load-config.d.ts +18 -0
- package/dist/types/config/normalize-config.d.ts +9 -0
- package/dist/types/core/observability/event-logger.d.ts +211 -0
- package/dist/types/core/observability/metrics.d.ts +585 -0
- package/dist/types/core/performance/parallel-processor.d.ts +92 -0
- package/dist/types/core/plugin-registry.d.ts +5 -0
- package/dist/types/dag/core/execution-scheduler.d.ts +3 -1
- package/dist/types/dag/dag-engine.d.ts +15 -2
- package/dist/types/dev/dev-server.d.ts +136 -0
- package/dist/types/dev/hot-reload.d.ts +226 -0
- package/dist/types/ecosystem/plugin-analytics-dashboard.d.ts +141 -0
- package/dist/types/ecosystem/plugin-hub.d.ts +161 -0
- package/dist/types/ecosystem/plugin-installer.d.ts +40 -0
- package/dist/types/ecosystem/plugin-probe.d.ts +21 -0
- package/dist/types/ecosystem/plugin-scanner.d.ts +30 -0
- package/dist/types/ecosystem/tar-extract.d.ts +24 -0
- package/dist/types/enterprise/audit-logging.d.ts +217 -0
- package/dist/types/evaluate/compare.d.ts +140 -0
- package/dist/types/evaluate/dataset.d.ts +43 -0
- package/dist/types/evaluate/evaluator.d.ts +14 -0
- package/dist/types/evaluate/report.d.ts +56 -0
- package/dist/types/evaluate/synthetic.d.ts +64 -0
- package/dist/types/loader/csv-loader.d.ts +10 -0
- package/dist/types/loader/document-reader.d.ts +18 -0
- package/dist/types/loader/html-loader.d.ts +21 -0
- package/dist/types/loader/markdown-loader.d.ts +21 -0
- package/dist/types/retrieval/bm25-search.d.ts +4 -0
- package/dist/types/retrieval/file-vector-store.d.ts +4 -1
- package/dist/types/retrieval/hybrid-retriever.d.ts +4 -0
- package/dist/types/retrieval/pgvector-retriever.d.ts +123 -0
- package/dist/types/retrieval/qdrant-retriever.d.ts +106 -0
- package/dist/types/security/validator.d.ts +11 -1
- package/dist/types/testing.d.ts +2 -0
- package/dist/types/utils/batch-processor.d.ts +205 -0
- package/dist/types/utils/connection-pool.d.ts +158 -0
- package/dist/types/utils/error-formatter.d.ts +312 -0
- package/dist/types/utils/logger.d.ts +29 -4
- package/dist/types/utils/rate-limiter.d.ts +13 -8
- package/dist/types/utils/retry.d.ts +10 -0
- package/package.json +13 -3
- package/src/cache/memory-cache.js +4 -1
- package/src/cache/redis-cache.js +4 -1
- package/src/cli/enhanced-cli-commands.js +79 -0
- package/src/cli/handlers/ai-handler.js +2 -2
- package/src/cli/handlers/eval-handler.js +201 -0
- package/src/cli/handlers/generate-handler.js +103 -0
- package/src/cli/services/benchmark.js +2 -2
- package/src/cli/services/rag-service.js +59 -10
- package/src/cli/shared/cli-utils.js +40 -1
- package/src/config/component-factory.js +32 -0
- package/src/connectors/local-embedder.js +20 -13
- package/src/core/observability/event-logger.js +135 -1
- package/src/core/observability/metrics.js +223 -5
- package/src/core/performance/parallel-processor.js +2 -0
- package/src/core/plugin-registry.js +5 -1
- package/src/dag/core/execution-scheduler.js +2 -0
- package/src/dag/dag-engine.js +13 -0
- package/src/dev/dev-server.js +6 -0
- package/src/ecosystem/plugin-hub.js +4 -0
- package/src/enterprise/audit-logging.js +5 -0
- package/src/evaluate/compare.js +395 -0
- package/src/evaluate/dataset.js +207 -0
- package/src/evaluate/report.js +350 -0
- package/src/evaluate/synthetic.js +246 -0
- package/src/index.js +17 -0
- package/src/retrieval/bm25-search.js +4 -0
- package/src/retrieval/file-vector-store.js +4 -1
- package/src/retrieval/hybrid-retriever.js +4 -0
- package/src/retrieval/pgvector-retriever.js +420 -0
- package/src/retrieval/qdrant-retriever.js +361 -0
- package/src/security/validator.js +8 -0
- package/src/testing.js +2 -0
- package/src/utils/batch-processor.js +7 -3
- package/src/utils/error-formatter.js +48 -20
- package/src/utils/logger.js +31 -1
- package/src/utils/rate-limiter.js +9 -4
- 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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
|
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
|
-
|
|
2
|
-
const { digest } = require(
|
|
1
|
+
"use strict";
|
|
2
|
+
const { digest } = require("../cache/component-keys");
|
|
3
3
|
|
|
4
|
-
const { BaseConnector } = require(
|
|
5
|
-
const { tokenize } = require(
|
|
6
|
-
const { throwIfAborted } = require(
|
|
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:
|
|
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 ===
|
|
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
|
-
//
|
|
74
|
-
|
|
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(
|
|
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(
|
|
108
|
-
message:
|
|
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 !==
|
|
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]]),
|