@devilsdev/rag-pipeline-utils 2.5.0 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +86 -0
- package/README.md +1 -1
- package/dist/index.cjs +1484 -147
- package/dist/index.d.ts +49 -0
- package/dist/index.mjs +1 -1
- package/dist/testing.cjs +2 -0
- package/dist/testing.d.ts +1 -0
- package/dist/testing.mjs +5 -0
- package/dist/types/adapters/ai-sdk.d.ts +53 -0
- package/dist/types/cache/component-keys.d.ts +7 -0
- package/dist/types/cache/memory-cache.d.ts +54 -0
- package/dist/types/cache/pipeline-cache.d.ts +66 -0
- package/dist/types/cache/redis-cache.d.ts +46 -0
- package/dist/types/connectors/anthropic-connector.d.ts +7 -2
- package/dist/types/connectors/cohere-connector.d.ts +7 -2
- package/dist/types/connectors/local-embedder.d.ts +9 -0
- package/dist/types/connectors/memory-retriever.d.ts +8 -0
- package/dist/types/connectors/ollama-connector.d.ts +7 -2
- package/dist/types/connectors/openai-connector.d.ts +16 -1
- package/dist/types/core/create-pipeline.d.ts +8 -0
- package/dist/types/core/plugin-registry.d.ts +136 -0
- package/dist/types/core/stage-contracts.d.ts +36 -0
- package/dist/types/core/validate-plugin.d.ts +45 -0
- package/dist/types/evaluate/semantic-similarity.d.ts +14 -0
- package/dist/types/grounding/check-answer.d.ts +101 -0
- package/dist/types/retrieval/file-vector-store.d.ts +12 -0
- package/dist/types/testing.d.ts +168 -0
- package/package.json +22 -7
- package/src/adapters/ai-sdk.js +215 -0
- package/src/cache/component-keys.js +38 -0
- package/src/cache/memory-cache.js +87 -0
- package/src/cache/pipeline-cache.js +260 -0
- package/src/cache/redis-cache.js +83 -0
- package/src/cli/commands/ai.js +17 -12
- package/src/cli/commands/plugin-hub.js +18 -13
- package/src/cli/enhanced-cli-commands.js +45 -29
- package/src/cli/handlers/query-handler.js +174 -11
- package/src/cli/services/instrument.js +83 -0
- package/src/cli/services/rag-service.js +51 -12
- package/src/cli/wizard/init-wizard.js +2 -17
- package/src/config/enhanced-ragrc-schema.js +0 -2
- package/src/config/validate-schema.js +4 -1
- package/src/connectors/anthropic-connector.js +22 -2
- package/src/connectors/cohere-connector.js +20 -2
- package/src/connectors/local-embedder.js +20 -0
- package/src/connectors/memory-retriever.js +18 -0
- package/src/connectors/ollama-connector.js +29 -2
- package/src/connectors/openai-connector.js +49 -4
- package/src/core/create-pipeline.js +112 -15
- package/src/core/performance/parallel-processor.js +62 -1
- package/src/core/plugin-registry.js +42 -19
- package/src/core/stage-contracts.js +36 -0
- package/src/core/validate-plugin.js +85 -0
- package/src/debug/execution-tracer.js +14 -4
- package/src/dev/dev-server.js +8 -1
- package/src/dev/hot-reload.js +4 -3
- package/src/enterprise/sso-integration.js +4 -3
- package/src/evaluate/semantic-similarity.js +79 -0
- package/src/grounding/check-answer.js +173 -0
- package/src/index.js +22 -0
- package/src/loader/csv-loader.js +3 -2
- package/src/loader/html-loader.js +3 -1
- package/src/loader/markdown-loader.js +3 -2
- package/src/retrieval/file-vector-store.js +30 -1
- package/src/security/jwt-validator.js +5 -4
- package/src/testing.js +254 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What each pipeline stage must provide, as createRagPipeline and the CLI
|
|
5
|
+
* use it. A plugin needs at least one of `anyOf`; a missing `recommended`
|
|
6
|
+
* method only matters for the command named in its note.
|
|
7
|
+
*
|
|
8
|
+
* Shared by validatePlugin() and the `rag-pipeline init` wizard. The
|
|
9
|
+
* stricter rules pluginRegistry.register() applies (metadata, and the
|
|
10
|
+
* methods in contracts/*.json) are checked by validatePlugin(..., {
|
|
11
|
+
* registry: true }).
|
|
12
|
+
*/
|
|
13
|
+
const STAGE_CONTRACTS = Object.freeze({
|
|
14
|
+
loader: {
|
|
15
|
+
anyOf: ['load'],
|
|
16
|
+
recommended: {},
|
|
17
|
+
},
|
|
18
|
+
embedder: {
|
|
19
|
+
anyOf: ['embed', 'embedQuery'],
|
|
20
|
+
recommended: { embed: '`ingest` calls embed() to embed documents' },
|
|
21
|
+
},
|
|
22
|
+
retriever: {
|
|
23
|
+
anyOf: ['retrieve'],
|
|
24
|
+
recommended: { store: '`ingest` calls store() to save vectors' },
|
|
25
|
+
},
|
|
26
|
+
reranker: {
|
|
27
|
+
anyOf: ['rerank'],
|
|
28
|
+
recommended: {},
|
|
29
|
+
},
|
|
30
|
+
llm: {
|
|
31
|
+
anyOf: ['generate'],
|
|
32
|
+
recommended: {},
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
module.exports = { STAGE_CONTRACTS };
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { STAGE_CONTRACTS } = require('./stage-contracts');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @typedef {Object} PluginValidationResult
|
|
7
|
+
* @property {boolean} valid - True when there are no errors
|
|
8
|
+
* @property {string[]} errors - Problems that stop the plugin from working
|
|
9
|
+
* @property {string[]} warnings - Methods some commands need, e.g. `store`
|
|
10
|
+
* for `rag-pipeline ingest`
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Checks that an object can be used as a pipeline stage, without throwing,
|
|
15
|
+
* so plugin authors can assert on it in their own tests.
|
|
16
|
+
*
|
|
17
|
+
* By default it checks what createRagPipeline() calls. With
|
|
18
|
+
* `{ registry: true }` it also applies the rules pluginRegistry.register()
|
|
19
|
+
* enforces: `metadata` with a string `name` and `version` and a `type`
|
|
20
|
+
* equal to the stage, plus the methods in the stage's contract.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* const { validatePlugin } = require('@devilsdev/rag-pipeline-utils');
|
|
24
|
+
* const result = validatePlugin('llm', myLlm, { registry: true });
|
|
25
|
+
* expect(result.errors).toEqual([]);
|
|
26
|
+
*
|
|
27
|
+
* @param {'loader'|'embedder'|'retriever'|'reranker'|'llm'} stage
|
|
28
|
+
* @param {object} plugin - Plugin instance or object
|
|
29
|
+
* @param {Object} [options]
|
|
30
|
+
* @param {boolean} [options.registry=false] - Also check registry rules
|
|
31
|
+
* @returns {PluginValidationResult}
|
|
32
|
+
*/
|
|
33
|
+
function validatePlugin(stage, plugin, options = {}) {
|
|
34
|
+
const contract = STAGE_CONTRACTS[stage];
|
|
35
|
+
if (!contract) {
|
|
36
|
+
return {
|
|
37
|
+
valid: false,
|
|
38
|
+
errors: [
|
|
39
|
+
`Unknown stage "${stage}". Expected one of: ${Object.keys(STAGE_CONTRACTS).join(', ')}`,
|
|
40
|
+
],
|
|
41
|
+
warnings: [],
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (
|
|
46
|
+
plugin === null ||
|
|
47
|
+
(typeof plugin !== 'object' && typeof plugin !== 'function')
|
|
48
|
+
) {
|
|
49
|
+
return {
|
|
50
|
+
valid: false,
|
|
51
|
+
errors: [`A ${stage} plugin must be an object or class instance`],
|
|
52
|
+
warnings: [],
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const has = (method) => typeof plugin[method] === 'function';
|
|
57
|
+
const errors = [];
|
|
58
|
+
const warnings = [];
|
|
59
|
+
|
|
60
|
+
if (!contract.anyOf.some(has)) {
|
|
61
|
+
errors.push(
|
|
62
|
+
`A ${stage} needs ${contract.anyOf.map((m) => `${m}()`).join(' or ')}`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
for (const [method, why] of Object.entries(contract.recommended)) {
|
|
66
|
+
if (!has(method)) warnings.push(`No ${method}() method: ${why}`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (options.registry) {
|
|
70
|
+
// Reuse the registry's own check so the two cannot drift. It stops at
|
|
71
|
+
// the first problem, so this reports one registry error at a time.
|
|
72
|
+
// eslint-disable-next-line global-require
|
|
73
|
+
const { PluginRegistry } = require('./plugin-registry');
|
|
74
|
+
try {
|
|
75
|
+
new PluginRegistry()._validatePluginContract(stage, plugin);
|
|
76
|
+
} catch (error) {
|
|
77
|
+
const message = `pluginRegistry.register(): ${error.message}`;
|
|
78
|
+
if (!errors.includes(message)) errors.push(message);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return { valid: errors.length === 0, errors, warnings };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
module.exports = { validatePlugin };
|
|
@@ -28,10 +28,17 @@ function generateTraceId() {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
/**
|
|
31
|
-
*
|
|
32
|
-
*
|
|
31
|
+
* Snapshot a value for a trace: a JSON copy, or a truncated string when its
|
|
32
|
+
* JSON exceeds maxSize characters.
|
|
33
|
+
*
|
|
34
|
+
* Always a copy, never the live object: the traced run attaches the trace to
|
|
35
|
+
* its result (`result.trace`), so storing the result itself made the trace
|
|
36
|
+
* circular and exportTraces('json') threw. A copy also keeps later changes to
|
|
37
|
+
* the result out of the recorded trace.
|
|
38
|
+
*
|
|
39
|
+
* @param {*} value - Value to snapshot
|
|
33
40
|
* @param {number} maxSize - Maximum serialized size in characters
|
|
34
|
-
* @returns {*}
|
|
41
|
+
* @returns {*} JSON copy of the value, or a truncated string
|
|
35
42
|
*/
|
|
36
43
|
function truncatePayload(value, maxSize) {
|
|
37
44
|
if (value === undefined || value === null) {
|
|
@@ -43,8 +50,11 @@ function truncatePayload(value, maxSize) {
|
|
|
43
50
|
} catch {
|
|
44
51
|
return '[unserializable]';
|
|
45
52
|
}
|
|
53
|
+
if (serialized === undefined) {
|
|
54
|
+
return undefined; // functions and symbols have no JSON form
|
|
55
|
+
}
|
|
46
56
|
if (serialized.length <= maxSize) {
|
|
47
|
-
return
|
|
57
|
+
return JSON.parse(serialized);
|
|
48
58
|
}
|
|
49
59
|
return serialized.slice(0, maxSize) + '...[truncated]';
|
|
50
60
|
}
|
package/src/dev/dev-server.js
CHANGED
|
@@ -356,7 +356,14 @@ ${this.options.watchPaths.map((p) => `║ - ${p.padEnd(56)}║`).join('\n')}
|
|
|
356
356
|
const { config, configDir } = loadCliConfig({
|
|
357
357
|
config: this.options.configPath,
|
|
358
358
|
});
|
|
359
|
-
|
|
359
|
+
// The HTTP response keeps its shape: answer, results, citations.
|
|
360
|
+
const { timings, durationMs, ...result } = await query({
|
|
361
|
+
config,
|
|
362
|
+
configDir,
|
|
363
|
+
question,
|
|
364
|
+
topK,
|
|
365
|
+
});
|
|
366
|
+
return result;
|
|
360
367
|
}
|
|
361
368
|
|
|
362
369
|
/** @private */
|
package/src/dev/hot-reload.js
CHANGED
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
const { EventEmitter } = require('events');
|
|
21
|
-
|
|
21
|
+
// chokidar loads when watching starts, not on import.
|
|
22
|
+
const loadChokidar = () => require('chokidar'); // eslint-disable-line global-require
|
|
22
23
|
const path = require('path');
|
|
23
24
|
const fs = require('fs');
|
|
24
25
|
const {
|
|
@@ -170,7 +171,7 @@ class HotReloadManager extends EventEmitter {
|
|
|
170
171
|
* @returns {Promise<void>}
|
|
171
172
|
*/
|
|
172
173
|
async _watchPlugins() {
|
|
173
|
-
const watcher =
|
|
174
|
+
const watcher = loadChokidar().watch(this.options.watchPaths, {
|
|
174
175
|
ignored: toIgnoreMatcher(this.options.ignorePatterns),
|
|
175
176
|
persistent: true,
|
|
176
177
|
ignoreInitial: true,
|
|
@@ -214,7 +215,7 @@ class HotReloadManager extends EventEmitter {
|
|
|
214
215
|
return;
|
|
215
216
|
}
|
|
216
217
|
|
|
217
|
-
const watcher =
|
|
218
|
+
const watcher = loadChokidar().watch(this.options.configPath, {
|
|
218
219
|
persistent: true,
|
|
219
220
|
ignoreInitial: true,
|
|
220
221
|
awaitWriteFinish: {
|
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
|
|
18
18
|
const crypto = require('crypto');
|
|
19
19
|
const { EventEmitter } = require('events');
|
|
20
|
-
|
|
20
|
+
// jsonwebtoken loads on first use (about 90 ms otherwise, on every import).
|
|
21
|
+
const loadJwt = () => require('jsonwebtoken'); // eslint-disable-line global-require
|
|
21
22
|
const RateLimiter = require('../utils/rate-limiter');
|
|
22
23
|
const { logger } = require('../utils/logger');
|
|
23
24
|
const { JWTValidator } = require('../security/jwt-validator');
|
|
@@ -1386,7 +1387,7 @@ class OIDCProvider {
|
|
|
1386
1387
|
if (!this._config.clientId) {
|
|
1387
1388
|
throw new Error('clientId must be configured');
|
|
1388
1389
|
}
|
|
1389
|
-
const decoded =
|
|
1390
|
+
const decoded = loadJwt().decode(idToken, { complete: true });
|
|
1390
1391
|
if (!decoded || !decoded.header || !decoded.payload) {
|
|
1391
1392
|
throw new Error('malformed id_token');
|
|
1392
1393
|
}
|
|
@@ -1410,7 +1411,7 @@ class OIDCProvider {
|
|
|
1410
1411
|
throw new Error('id_token has no sub claim');
|
|
1411
1412
|
}
|
|
1412
1413
|
|
|
1413
|
-
const claims =
|
|
1414
|
+
const claims = loadJwt().verify(idToken, key, {
|
|
1414
1415
|
algorithms: [header.alg],
|
|
1415
1416
|
issuer: this._config.issuer,
|
|
1416
1417
|
audience: this._config.clientId,
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Semantic similarity between an answer and a reference answer, from the
|
|
5
|
+
* cosine similarity of their embeddings. Unlike BLEU and ROUGE it does not
|
|
6
|
+
* penalise a correct answer that is worded differently.
|
|
7
|
+
*
|
|
8
|
+
* Embeddings capture topic and meaning, not facts: "twenty days" and
|
|
9
|
+
* "thirty days" score as very similar. Use it next to the grounding checks
|
|
10
|
+
* (computeFaithfulness, checkAnswer), not instead of them.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
function cosine(a, b) {
|
|
14
|
+
if (
|
|
15
|
+
!Array.isArray(a) ||
|
|
16
|
+
!Array.isArray(b) ||
|
|
17
|
+
a.length !== b.length ||
|
|
18
|
+
a.length === 0
|
|
19
|
+
) {
|
|
20
|
+
throw new Error(
|
|
21
|
+
`Embeddings must be non-empty arrays of the same length (got ${a && a.length} and ${b && b.length})`,
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
let dot = 0;
|
|
25
|
+
let na = 0;
|
|
26
|
+
let nb = 0;
|
|
27
|
+
for (let i = 0; i < a.length; i += 1) {
|
|
28
|
+
dot += a[i] * b[i];
|
|
29
|
+
na += a[i] * a[i];
|
|
30
|
+
nb += b[i] * b[i];
|
|
31
|
+
}
|
|
32
|
+
if (na === 0 || nb === 0) return 0;
|
|
33
|
+
return dot / (Math.sqrt(na) * Math.sqrt(nb));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function embedText(embedder, text, signal) {
|
|
37
|
+
const options = signal ? { signal } : {};
|
|
38
|
+
if (typeof embedder.embedQuery === 'function') {
|
|
39
|
+
return embedder.embedQuery(text, options);
|
|
40
|
+
}
|
|
41
|
+
return embedder.embed(text, options);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {string} answer - The generated answer
|
|
46
|
+
* @param {string} reference - The reference (ground-truth) answer
|
|
47
|
+
* @param {Object} options
|
|
48
|
+
* @param {Object} options.embedder - Any embedder with embed() or embedQuery()
|
|
49
|
+
* (a built-in connector, LocalEmbedder fitted to your corpus, an AI SDK
|
|
50
|
+
* adapter, HashEmbedder from /testing)
|
|
51
|
+
* @param {AbortSignal} [options.signal]
|
|
52
|
+
* @returns {Promise<number>} Cosine similarity clamped to 0..1
|
|
53
|
+
*/
|
|
54
|
+
async function computeSemanticSimilarity(
|
|
55
|
+
answer,
|
|
56
|
+
reference,
|
|
57
|
+
{ embedder, signal } = {},
|
|
58
|
+
) {
|
|
59
|
+
if (typeof answer !== 'string' || typeof reference !== 'string') {
|
|
60
|
+
throw new TypeError('answer and reference must be strings');
|
|
61
|
+
}
|
|
62
|
+
if (
|
|
63
|
+
!embedder ||
|
|
64
|
+
(typeof embedder.embed !== 'function' &&
|
|
65
|
+
typeof embedder.embedQuery !== 'function')
|
|
66
|
+
) {
|
|
67
|
+
throw new TypeError(
|
|
68
|
+
'computeSemanticSimilarity needs an embedder with embed() or embedQuery()',
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
if (answer.trim() === '' || reference.trim() === '') return 0;
|
|
72
|
+
const [a, b] = await Promise.all([
|
|
73
|
+
embedText(embedder, answer, signal),
|
|
74
|
+
embedText(embedder, reference, signal),
|
|
75
|
+
]);
|
|
76
|
+
return Math.min(1, Math.max(0, cosine(a, b)));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
module.exports = { computeSemanticSimilarity };
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Check an answer that any framework produced (LangChain, LlamaIndex, Mastra,
|
|
5
|
+
* the AI SDK, your own code) against the sources it was given: citations
|
|
6
|
+
* per sentence, groundedness, unsupported sentences and evaluation scores,
|
|
7
|
+
* without using createRagPipeline().
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const { CitationTracker } = require('../citation/citation-tracker');
|
|
11
|
+
const { PipelineEvaluator } = require('../evaluate/pipeline-evaluator');
|
|
12
|
+
const {
|
|
13
|
+
computeSemanticSimilarity,
|
|
14
|
+
} = require('../evaluate/semantic-similarity');
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Text of one source in the shapes common frameworks use.
|
|
18
|
+
* @private
|
|
19
|
+
*/
|
|
20
|
+
function sourceText(source) {
|
|
21
|
+
if (source == null) return '';
|
|
22
|
+
if (typeof source === 'string') return source;
|
|
23
|
+
if (typeof source.content === 'string') return source.content; // this library
|
|
24
|
+
if (typeof source.pageContent === 'string') return source.pageContent; // LangChain Document
|
|
25
|
+
if (typeof source.text === 'string') return source.text; // LlamaIndex node, plain
|
|
26
|
+
if (source.node) return sourceText(source.node); // LlamaIndex NodeWithScore
|
|
27
|
+
if (typeof source.getContent === 'function')
|
|
28
|
+
return String(source.getContent());
|
|
29
|
+
return '';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* An id for a source: its own id when it has one, otherwise its position.
|
|
34
|
+
* @private
|
|
35
|
+
*/
|
|
36
|
+
function sourceId(source, index) {
|
|
37
|
+
const candidates = [
|
|
38
|
+
source && source.id,
|
|
39
|
+
source && source.id_,
|
|
40
|
+
source && source.node && (source.node.id_ || source.node.id),
|
|
41
|
+
source && source.metadata && (source.metadata.id || source.metadata.source),
|
|
42
|
+
];
|
|
43
|
+
const id = candidates.find(
|
|
44
|
+
(c) => typeof c === 'string' || typeof c === 'number',
|
|
45
|
+
);
|
|
46
|
+
return id !== undefined ? String(id) : String(index + 1);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Normalize sources to `{ id, content, metadata }`.
|
|
51
|
+
*
|
|
52
|
+
* Accepts strings, `{ id, content }` (this library), LangChain Documents
|
|
53
|
+
* (`{ pageContent, metadata }`), LlamaIndex nodes (`{ text }`) and
|
|
54
|
+
* `NodeWithScore` (`{ node, score }`).
|
|
55
|
+
*
|
|
56
|
+
* @param {Array<string|Object>} sources
|
|
57
|
+
* @returns {Array<{id: string, content: string, metadata: Object}>}
|
|
58
|
+
*/
|
|
59
|
+
function normalizeSources(sources) {
|
|
60
|
+
if (!Array.isArray(sources)) {
|
|
61
|
+
throw new TypeError('sources must be an array');
|
|
62
|
+
}
|
|
63
|
+
return sources
|
|
64
|
+
.map((source, index) => ({
|
|
65
|
+
id: sourceId(source, index),
|
|
66
|
+
content: sourceText(source),
|
|
67
|
+
metadata:
|
|
68
|
+
(source &&
|
|
69
|
+
(source.metadata || (source.node && source.node.metadata))) ||
|
|
70
|
+
{},
|
|
71
|
+
}))
|
|
72
|
+
.filter((s) => s.content.trim() !== '');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @typedef {Object} AnswerCheck
|
|
77
|
+
* @property {boolean} grounded - Every sentence is supported by a source
|
|
78
|
+
* @property {number} groundedness - Share of supported sentences, 0..1
|
|
79
|
+
* @property {string[]} unsupported - Sentences no source supports (this
|
|
80
|
+
* includes a sentence whose numbers do not match its source)
|
|
81
|
+
* @property {Array<{sentence: string, sources: Array}>} citations - Sources
|
|
82
|
+
* supporting each sentence
|
|
83
|
+
* @property {{scores: Object, details: Object, metadata: Object}|undefined} evaluation
|
|
84
|
+
* - Faithfulness, relevance, context precision (and context recall when a
|
|
85
|
+
* referenceAnswer is given); only when `evaluate` is not false
|
|
86
|
+
* @property {Array<{id: string, content: string, metadata: Object}>} sources
|
|
87
|
+
* - The sources as they were checked
|
|
88
|
+
*/
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Check an answer against the sources it was generated from.
|
|
92
|
+
*
|
|
93
|
+
* @example
|
|
94
|
+
* // A LangChain chain's output
|
|
95
|
+
* const check = await checkAnswer({
|
|
96
|
+
* question: input,
|
|
97
|
+
* answer: result.answer,
|
|
98
|
+
* sources: result.context, // LangChain Documents
|
|
99
|
+
* });
|
|
100
|
+
* if (!check.grounded) console.warn(check.unsupported);
|
|
101
|
+
*
|
|
102
|
+
* @param {Object} params
|
|
103
|
+
* @param {string} params.answer - The generated answer
|
|
104
|
+
* @param {Array<string|Object>} params.sources - Documents the answer was
|
|
105
|
+
* generated from (see normalizeSources for accepted shapes)
|
|
106
|
+
* @param {string} [params.question] - The question; needed for relevance
|
|
107
|
+
* @param {string} [params.referenceAnswer] - Ground truth, for context recall
|
|
108
|
+
* (and semantic similarity when an embedder is given)
|
|
109
|
+
* @param {Object} [params.embedder] - With a referenceAnswer, adds
|
|
110
|
+
* `evaluation.scores.semanticSimilarity` (computeSemanticSimilarity)
|
|
111
|
+
* @param {boolean} [params.evaluate=true] - Also compute evaluation scores
|
|
112
|
+
* @returns {Promise<AnswerCheck>}
|
|
113
|
+
*/
|
|
114
|
+
async function checkAnswer({
|
|
115
|
+
answer,
|
|
116
|
+
sources,
|
|
117
|
+
question,
|
|
118
|
+
referenceAnswer,
|
|
119
|
+
embedder,
|
|
120
|
+
evaluate = true,
|
|
121
|
+
} = {}) {
|
|
122
|
+
if (typeof answer !== 'string') {
|
|
123
|
+
throw new TypeError('answer must be a string');
|
|
124
|
+
}
|
|
125
|
+
const docs = normalizeSources(sources);
|
|
126
|
+
|
|
127
|
+
// Models sometimes return nothing; that is an ungrounded answer, not an
|
|
128
|
+
// error. (CitationTracker rejects empty text.)
|
|
129
|
+
if (answer.trim() === '') {
|
|
130
|
+
return {
|
|
131
|
+
grounded: false,
|
|
132
|
+
groundedness: 0,
|
|
133
|
+
unsupported: [],
|
|
134
|
+
citations: [],
|
|
135
|
+
evaluation: undefined,
|
|
136
|
+
sources: docs,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const tracked = new CitationTracker().track(answer, docs);
|
|
141
|
+
const sentences =
|
|
142
|
+
(tracked.hallucinationReport && tracked.hallucinationReport.sentences) ||
|
|
143
|
+
[];
|
|
144
|
+
const unsupported = sentences
|
|
145
|
+
.filter((s) => s.classification !== 'grounded')
|
|
146
|
+
.map((s) => s.text);
|
|
147
|
+
|
|
148
|
+
let evaluation;
|
|
149
|
+
if (evaluate) {
|
|
150
|
+
evaluation = await new PipelineEvaluator().evaluate(
|
|
151
|
+
{ query: question || '', answer, results: docs },
|
|
152
|
+
referenceAnswer ? { referenceAnswer } : {},
|
|
153
|
+
);
|
|
154
|
+
if (referenceAnswer && embedder) {
|
|
155
|
+
evaluation.scores.semanticSimilarity = await computeSemanticSimilarity(
|
|
156
|
+
answer,
|
|
157
|
+
referenceAnswer,
|
|
158
|
+
{ embedder },
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
grounded: sentences.length > 0 && unsupported.length === 0,
|
|
165
|
+
groundedness: tracked.groundednessScore,
|
|
166
|
+
unsupported,
|
|
167
|
+
citations: tracked.citations,
|
|
168
|
+
evaluation,
|
|
169
|
+
sources: docs,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
module.exports = { checkAnswer, normalizeSources };
|
package/src/index.js
CHANGED
|
@@ -13,6 +13,7 @@ const { normalizeConfig } = require('./config/normalize-config');
|
|
|
13
13
|
|
|
14
14
|
// Plugin system
|
|
15
15
|
const pluginRegistry = require('./core/plugin-registry');
|
|
16
|
+
const { validatePlugin } = require('./core/validate-plugin');
|
|
16
17
|
|
|
17
18
|
// Utilities
|
|
18
19
|
const logger = require('./utils/logger');
|
|
@@ -59,6 +60,7 @@ const {
|
|
|
59
60
|
computeContextRecall,
|
|
60
61
|
} = require('./evaluate/context-metrics');
|
|
61
62
|
const { computeGroundedness } = require('./evaluate/groundedness');
|
|
63
|
+
const { computeSemanticSimilarity } = require('./evaluate/semantic-similarity');
|
|
62
64
|
const { PipelineEvaluator } = require('./evaluate/pipeline-evaluator');
|
|
63
65
|
|
|
64
66
|
// Chunking
|
|
@@ -66,6 +68,10 @@ const { ChunkingEngine } = require('./chunking/chunking-engine');
|
|
|
66
68
|
|
|
67
69
|
// Citation & Grounding
|
|
68
70
|
const { CitationTracker } = require('./citation/citation-tracker');
|
|
71
|
+
const { checkAnswer, normalizeSources } = require('./grounding/check-answer');
|
|
72
|
+
const { createAiSdkAdapter } = require('./adapters/ai-sdk');
|
|
73
|
+
const { MemoryCache } = require('./cache/memory-cache');
|
|
74
|
+
const { RedisCache } = require('./cache/redis-cache');
|
|
69
75
|
const {
|
|
70
76
|
mapSentenceToSources,
|
|
71
77
|
buildIDFWeights,
|
|
@@ -188,6 +194,7 @@ module.exports = {
|
|
|
188
194
|
|
|
189
195
|
// Plugin system
|
|
190
196
|
pluginRegistry,
|
|
197
|
+
validatePlugin,
|
|
191
198
|
|
|
192
199
|
// Utilities
|
|
193
200
|
logger,
|
|
@@ -225,6 +232,7 @@ module.exports = {
|
|
|
225
232
|
checkNumericConsistency,
|
|
226
233
|
extractNumbers,
|
|
227
234
|
computeFaithfulness,
|
|
235
|
+
computeSemanticSimilarity,
|
|
228
236
|
computeAnswerRelevance,
|
|
229
237
|
computeContextPrecision,
|
|
230
238
|
computeContextRecall,
|
|
@@ -236,6 +244,11 @@ module.exports = {
|
|
|
236
244
|
|
|
237
245
|
// Citation & Grounding
|
|
238
246
|
CitationTracker,
|
|
247
|
+
checkAnswer,
|
|
248
|
+
normalizeSources,
|
|
249
|
+
createAiSdkAdapter,
|
|
250
|
+
MemoryCache,
|
|
251
|
+
RedisCache,
|
|
239
252
|
mapSentenceToSources,
|
|
240
253
|
buildIDFWeights,
|
|
241
254
|
detectHallucinations,
|
|
@@ -358,6 +371,15 @@ for (const name of Object.keys(experimental)) {
|
|
|
358
371
|
});
|
|
359
372
|
}
|
|
360
373
|
|
|
374
|
+
// The ./testing build entry reads its module from here, so it shares the
|
|
375
|
+
// main bundle's classes (MemoryRetriever, createRagPipeline). A getter, so
|
|
376
|
+
// importing the package does not load the testing helpers.
|
|
377
|
+
Object.defineProperty(
|
|
378
|
+
module.exports,
|
|
379
|
+
Symbol.for('@devilsdev/rag-pipeline-utils/testing'),
|
|
380
|
+
{ get: () => require('./testing'), enumerable: false }, // eslint-disable-line global-require
|
|
381
|
+
);
|
|
382
|
+
|
|
361
383
|
// The ./experimental build entry reads the unwrapped classes from here, so
|
|
362
384
|
// both entries share one copy of each class (non-enumerable: not a named export).
|
|
363
385
|
Object.defineProperty(
|
package/src/loader/csv-loader.js
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
const fs = require('fs/promises');
|
|
6
6
|
const path = require('path');
|
|
7
|
-
|
|
7
|
+
// csv-parse loads when a CSV file is read, not on import.
|
|
8
|
+
const loadCsvParse = () => require('csv-parse/sync'); // eslint-disable-line global-require
|
|
8
9
|
|
|
9
10
|
class CSVLoader {
|
|
10
11
|
/**
|
|
@@ -20,7 +21,7 @@ class CSVLoader {
|
|
|
20
21
|
const src = raw.replace(/^\uFEFF/, ''); // Remove BOM
|
|
21
22
|
const normalizedSrc = src.replace(/\r\n|\r/g, '\n'); // Normalize line endings
|
|
22
23
|
|
|
23
|
-
const records = parse(normalizedSrc, { columns: true });
|
|
24
|
+
const records = loadCsvParse().parse(normalizedSrc, { columns: true });
|
|
24
25
|
|
|
25
26
|
return [
|
|
26
27
|
{
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
const fs = require('fs/promises');
|
|
6
6
|
const path = require('path');
|
|
7
|
-
|
|
7
|
+
// jsdom (about 100 packages) loads when an HTML file is read, not on import.
|
|
8
|
+
const loadJsdom = () => require('jsdom'); // eslint-disable-line global-require
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* HTMLLoader reads HTML files and extracts visible text.
|
|
@@ -19,6 +20,7 @@ class HTMLLoader {
|
|
|
19
20
|
async load(filePath) {
|
|
20
21
|
const absPath = path.resolve(filePath);
|
|
21
22
|
const raw = await fs.readFile(absPath, 'utf-8');
|
|
23
|
+
const { JSDOM } = loadJsdom();
|
|
22
24
|
const dom = new JSDOM(raw);
|
|
23
25
|
let text = dom.window.document.body.textContent || '';
|
|
24
26
|
|
|
@@ -10,7 +10,8 @@ const fs = require('fs/promises');
|
|
|
10
10
|
// eslint-disable-line global-require
|
|
11
11
|
const path = require('path');
|
|
12
12
|
// eslint-disable-line global-require
|
|
13
|
-
|
|
13
|
+
// marked loads when a Markdown file is read, not on import.
|
|
14
|
+
const loadMarked = () => require('marked'); // eslint-disable-line global-require
|
|
14
15
|
// eslint-disable-line global-require
|
|
15
16
|
|
|
16
17
|
/**
|
|
@@ -26,7 +27,7 @@ class MarkdownLoader {
|
|
|
26
27
|
async load(_filePath) {
|
|
27
28
|
const absPath = path.resolve(_filePath);
|
|
28
29
|
const raw = await fs.readFile(absPath, 'utf-8');
|
|
29
|
-
const html = marked(raw);
|
|
30
|
+
const html = loadMarked().marked(raw);
|
|
30
31
|
const text = html.replace(/<[^>]+>/g, ''); // strip tags
|
|
31
32
|
|
|
32
33
|
return [
|