@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
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const path = require('path');
|
|
5
|
+
const { randomBytes } = require('crypto');
|
|
6
|
+
const { digest } = require('../cache/component-keys');
|
|
5
7
|
|
|
6
8
|
const FORMAT_VERSION = 1;
|
|
7
9
|
|
|
@@ -25,6 +27,25 @@ class FileVectorStore {
|
|
|
25
27
|
}
|
|
26
28
|
this.path = path.resolve(options.path);
|
|
27
29
|
this._index = null;
|
|
30
|
+
this._loadedDigest = null;
|
|
31
|
+
this._cacheId = randomBytes(8).toString('hex');
|
|
32
|
+
this._revision = 0;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Cache identity for createRagPipeline({ cache }): a digest of the index
|
|
37
|
+
* as loaded, so processes reading the same unchanged index share cached
|
|
38
|
+
* results; after this instance changes the index the key also carries the
|
|
39
|
+
* instance and a revision.
|
|
40
|
+
* @param {'retriever'} role
|
|
41
|
+
* @returns {string|null}
|
|
42
|
+
*/
|
|
43
|
+
getCacheKey(role) {
|
|
44
|
+
if (role !== 'retriever') return null;
|
|
45
|
+
this.load();
|
|
46
|
+
return this._revision === 0
|
|
47
|
+
? `file|${this._loadedDigest}`
|
|
48
|
+
: `file|${this._loadedDigest}|${this._cacheId}|${this._revision}`;
|
|
28
49
|
}
|
|
29
50
|
|
|
30
51
|
_emptyIndex() {
|
|
@@ -39,15 +60,18 @@ class FileVectorStore {
|
|
|
39
60
|
if (this._index) return this._index;
|
|
40
61
|
if (!fs.existsSync(this.path)) {
|
|
41
62
|
this._index = this._emptyIndex();
|
|
63
|
+
this._loadedDigest = 'empty';
|
|
42
64
|
return this._index;
|
|
43
65
|
}
|
|
44
|
-
const
|
|
66
|
+
const raw = fs.readFileSync(this.path, 'utf8');
|
|
67
|
+
const parsed = JSON.parse(raw);
|
|
45
68
|
if (parsed.version !== FORMAT_VERSION || !parsed.documents) {
|
|
46
69
|
throw new Error(
|
|
47
70
|
`Unsupported index format in ${this.path} (version ${parsed.version})`,
|
|
48
71
|
);
|
|
49
72
|
}
|
|
50
73
|
this._index = parsed;
|
|
74
|
+
this._loadedDigest = digest(raw);
|
|
51
75
|
return this._index;
|
|
52
76
|
}
|
|
53
77
|
|
|
@@ -68,6 +92,7 @@ class FileVectorStore {
|
|
|
68
92
|
/** @param {{name: string, dimensions: number, state?: object}} info */
|
|
69
93
|
setEmbedderInfo(info) {
|
|
70
94
|
this.load().embedder = info;
|
|
95
|
+
this._revision += 1;
|
|
71
96
|
}
|
|
72
97
|
|
|
73
98
|
/**
|
|
@@ -78,6 +103,7 @@ class FileVectorStore {
|
|
|
78
103
|
*/
|
|
79
104
|
async store(docs) {
|
|
80
105
|
const { documents } = this.load();
|
|
106
|
+
this._revision += 1;
|
|
81
107
|
for (const doc of docs) {
|
|
82
108
|
if (!doc.id || !Array.isArray(doc.vector)) {
|
|
83
109
|
throw new Error(
|
|
@@ -99,6 +125,7 @@ class FileVectorStore {
|
|
|
99
125
|
*/
|
|
100
126
|
removeBySource(source) {
|
|
101
127
|
const { documents } = this.load();
|
|
128
|
+
this._revision += 1;
|
|
102
129
|
let removed = 0;
|
|
103
130
|
for (const [id, doc] of Object.entries(documents)) {
|
|
104
131
|
if (doc.metadata?.source === source) {
|
|
@@ -111,7 +138,9 @@ class FileVectorStore {
|
|
|
111
138
|
|
|
112
139
|
/** Remove every document and the recorded embedder info. */
|
|
113
140
|
clear() {
|
|
141
|
+
this.load();
|
|
114
142
|
this._index = this._emptyIndex();
|
|
143
|
+
this._revision += 1;
|
|
115
144
|
}
|
|
116
145
|
|
|
117
146
|
/** @returns {Array<{id: string, content: string, metadata: object}>} */
|
|
@@ -30,7 +30,8 @@
|
|
|
30
30
|
* @since 2.3.0
|
|
31
31
|
*/
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
// jsonwebtoken loads on first use (about 90 ms otherwise, on every import).
|
|
34
|
+
const loadJwt = () => require('jsonwebtoken'); // eslint-disable-line global-require
|
|
34
35
|
const crypto = require('crypto');
|
|
35
36
|
const { EventEmitter } = require('events');
|
|
36
37
|
const { logger } = require('../utils/logger');
|
|
@@ -315,7 +316,7 @@ class JWTValidator extends EventEmitter {
|
|
|
315
316
|
}
|
|
316
317
|
|
|
317
318
|
// Sign token with explicit algorithm
|
|
318
|
-
const token =
|
|
319
|
+
const token = loadJwt().sign(completePayload, signingKey, {
|
|
319
320
|
algorithm: this.config.algorithm,
|
|
320
321
|
// Do not allow algorithm to be overridden
|
|
321
322
|
header: {
|
|
@@ -433,7 +434,7 @@ class JWTValidator extends EventEmitter {
|
|
|
433
434
|
verifyOptions.maxAge = options.maxAge || `${this.config.maxTokenAge}s`;
|
|
434
435
|
}
|
|
435
436
|
|
|
436
|
-
const payload =
|
|
437
|
+
const payload = loadJwt().verify(token, verificationKey, verifyOptions);
|
|
437
438
|
|
|
438
439
|
// Additional claim validation
|
|
439
440
|
this._validateRequiredClaims(payload);
|
|
@@ -734,7 +735,7 @@ class JWTValidator extends EventEmitter {
|
|
|
734
735
|
*/
|
|
735
736
|
decode(token) {
|
|
736
737
|
try {
|
|
737
|
-
return
|
|
738
|
+
return loadJwt().decode(token, { complete: false });
|
|
738
739
|
} catch (error) {
|
|
739
740
|
throw new Error(`Failed to decode token: ${error.message}`);
|
|
740
741
|
}
|
package/src/testing.js
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @devilsdev/rag-pipeline-utils/testing
|
|
5
|
+
*
|
|
6
|
+
* Deterministic, offline stand-ins for testing code that uses this library:
|
|
7
|
+
* no network, no API keys, the same output on every run.
|
|
8
|
+
*
|
|
9
|
+
* - HashEmbedder: bag-of-words vectors from hashed words.
|
|
10
|
+
* - EchoLLM: answers with the top retrieved document (or an answer you set)
|
|
11
|
+
* and records every call.
|
|
12
|
+
* - fixtures: a few short policy documents.
|
|
13
|
+
* - createTestPipeline(): a ready, indexed pipeline built from the above.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const { createRagPipeline } = require('./core/create-pipeline');
|
|
17
|
+
const { MemoryRetriever } = require('./connectors/memory-retriever');
|
|
18
|
+
|
|
19
|
+
function throwIfAborted(signal) {
|
|
20
|
+
if (signal && signal.aborted) {
|
|
21
|
+
throw signal.reason || new Error('Aborted');
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Deterministic embedder: hashed bag of words, L2-normalised. Texts that
|
|
27
|
+
* share words get similar vectors, so retrieval ranks sensibly in tests.
|
|
28
|
+
*/
|
|
29
|
+
class HashEmbedder {
|
|
30
|
+
/**
|
|
31
|
+
* @param {Object} [options]
|
|
32
|
+
* @param {number} [options.dimensions=256] - Vector length
|
|
33
|
+
*/
|
|
34
|
+
constructor({ dimensions = 256 } = {}) {
|
|
35
|
+
if (!Number.isInteger(dimensions) || dimensions < 1) {
|
|
36
|
+
throw new Error('dimensions must be a positive integer');
|
|
37
|
+
}
|
|
38
|
+
this.dimensions = dimensions;
|
|
39
|
+
this.metadata = {
|
|
40
|
+
name: 'hash-embedder',
|
|
41
|
+
version: '1.0.0',
|
|
42
|
+
type: 'embedder',
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Cache identity for createRagPipeline({ cache }).
|
|
48
|
+
* @param {string} role
|
|
49
|
+
* @returns {string|null}
|
|
50
|
+
*/
|
|
51
|
+
getCacheKey(role) {
|
|
52
|
+
return role === 'embedder' ? `hash-embedder|${this.dimensions}` : null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** @private */
|
|
56
|
+
_vector(text) {
|
|
57
|
+
const vector = new Array(this.dimensions).fill(0);
|
|
58
|
+
for (const word of String(text)
|
|
59
|
+
.toLowerCase()
|
|
60
|
+
.match(/[\p{L}\p{N}]+/gu) || []) {
|
|
61
|
+
let hash = 2166136261;
|
|
62
|
+
for (const ch of word) {
|
|
63
|
+
hash ^= ch.codePointAt(0);
|
|
64
|
+
hash = Math.imul(hash, 16777619) >>> 0;
|
|
65
|
+
}
|
|
66
|
+
// FNV-1a's low bits are weak (e.g. "above" and "vacation" agree in
|
|
67
|
+
// their low 10 bits), so mix them (MurmurHash3 fmix32) before taking
|
|
68
|
+
// the bucket.
|
|
69
|
+
hash ^= hash >>> 16;
|
|
70
|
+
hash = Math.imul(hash, 0x85ebca6b);
|
|
71
|
+
hash ^= hash >>> 13;
|
|
72
|
+
hash = Math.imul(hash, 0xc2b2ae35);
|
|
73
|
+
hash ^= hash >>> 16;
|
|
74
|
+
hash >>>= 0;
|
|
75
|
+
// Signed feature hashing: the top bit picks the sign, so colliding
|
|
76
|
+
// words cancel out on average instead of adding up.
|
|
77
|
+
vector[hash % this.dimensions] += hash & 0x80000000 ? -1 : 1;
|
|
78
|
+
}
|
|
79
|
+
const norm = Math.hypot(...vector);
|
|
80
|
+
return norm > 0 ? vector.map((v) => v / norm) : vector;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* @param {string} text
|
|
85
|
+
* @param {{ signal?: AbortSignal }} [options]
|
|
86
|
+
* @returns {Promise<number[]>}
|
|
87
|
+
*/
|
|
88
|
+
async embed(text, { signal } = {}) {
|
|
89
|
+
throwIfAborted(signal);
|
|
90
|
+
return this._vector(text);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Same as embed().
|
|
95
|
+
* @param {string} text
|
|
96
|
+
* @param {{ signal?: AbortSignal }} [options]
|
|
97
|
+
* @returns {Promise<number[]>}
|
|
98
|
+
*/
|
|
99
|
+
async embedQuery(text, options) {
|
|
100
|
+
return this.embed(text, options);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* @param {string[]} texts
|
|
105
|
+
* @param {{ signal?: AbortSignal }} [options]
|
|
106
|
+
* @returns {Promise<number[][]>}
|
|
107
|
+
*/
|
|
108
|
+
async embedBatch(texts, { signal } = {}) {
|
|
109
|
+
throwIfAborted(signal);
|
|
110
|
+
return texts.map((t) => this._vector(t));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* LLM stand-in. Answers with `answer` (a string, or a function of the query
|
|
116
|
+
* and documents), or else with the top document's content. Every call is
|
|
117
|
+
* recorded in `calls`, so tests can assert on what the pipeline sent.
|
|
118
|
+
*/
|
|
119
|
+
class EchoLLM {
|
|
120
|
+
/**
|
|
121
|
+
* @param {Object} [options]
|
|
122
|
+
* @param {string|function(string, Array<Object>): string} [options.answer]
|
|
123
|
+
* @param {string} [options.fallback='No information found.'] - Answer when
|
|
124
|
+
* there are no documents and no `answer`
|
|
125
|
+
*/
|
|
126
|
+
constructor({ answer, fallback = 'No information found.' } = {}) {
|
|
127
|
+
this.answer = answer;
|
|
128
|
+
this.fallback = fallback;
|
|
129
|
+
/** @type {Array<{query: string, docs: Array<Object>, options: Object}>} */
|
|
130
|
+
this.calls = [];
|
|
131
|
+
this.metadata = { name: 'echo-llm', version: '1.0.0', type: 'llm' };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Cache identity for createRagPipeline({ cache }); none when `answer` is a
|
|
136
|
+
* function, since its output cannot be identified.
|
|
137
|
+
* @param {string} role
|
|
138
|
+
* @returns {string|null}
|
|
139
|
+
*/
|
|
140
|
+
getCacheKey(role) {
|
|
141
|
+
if (role !== 'llm' || typeof this.answer === 'function') return null;
|
|
142
|
+
return `echo-llm|${JSON.stringify([this.answer ?? null, this.fallback])}`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** @private */
|
|
146
|
+
_answer(query, docs) {
|
|
147
|
+
if (typeof this.answer === 'function')
|
|
148
|
+
return String(this.answer(query, docs));
|
|
149
|
+
if (typeof this.answer === 'string') return this.answer;
|
|
150
|
+
const top = docs && docs[0];
|
|
151
|
+
if (!top) return this.fallback;
|
|
152
|
+
return typeof top === 'string'
|
|
153
|
+
? top
|
|
154
|
+
: String(top.content ?? top.text ?? '');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* @param {string} query
|
|
159
|
+
* @param {Array<Object>} [docs]
|
|
160
|
+
* @param {{ signal?: AbortSignal }} [options]
|
|
161
|
+
* @returns {Promise<string>}
|
|
162
|
+
*/
|
|
163
|
+
async generate(query, docs = [], options = {}) {
|
|
164
|
+
throwIfAborted(options.signal);
|
|
165
|
+
this.calls.push({ query, docs, options });
|
|
166
|
+
return this._answer(query, docs);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Streams the same answer word by word, then a done chunk.
|
|
171
|
+
* @param {string} query
|
|
172
|
+
* @param {Array<Object>} [docs]
|
|
173
|
+
* @param {{ signal?: AbortSignal }} [options]
|
|
174
|
+
* @yields {{ token: string, done: boolean }}
|
|
175
|
+
*/
|
|
176
|
+
async *generateStream(query, docs = [], options = {}) {
|
|
177
|
+
throwIfAborted(options.signal);
|
|
178
|
+
this.calls.push({ query, docs, options, stream: true });
|
|
179
|
+
for (const token of this._answer(query, docs).split(/(?<= )/)) {
|
|
180
|
+
throwIfAborted(options.signal);
|
|
181
|
+
yield { token, done: false };
|
|
182
|
+
}
|
|
183
|
+
yield { token: '', done: true };
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Short documents for tests and examples. */
|
|
188
|
+
const fixtures = Object.freeze({
|
|
189
|
+
documents: Object.freeze([
|
|
190
|
+
Object.freeze({
|
|
191
|
+
id: 'vacation',
|
|
192
|
+
content:
|
|
193
|
+
'Full-time employees receive twenty days of paid vacation per year. Unused days carry over to March.',
|
|
194
|
+
metadata: Object.freeze({ source: 'vacation.md' }),
|
|
195
|
+
}),
|
|
196
|
+
Object.freeze({
|
|
197
|
+
id: 'remote-work',
|
|
198
|
+
content:
|
|
199
|
+
'Employees may work remotely up to three days per week with manager approval.',
|
|
200
|
+
metadata: Object.freeze({ source: 'remote-work.md' }),
|
|
201
|
+
}),
|
|
202
|
+
Object.freeze({
|
|
203
|
+
id: 'expenses',
|
|
204
|
+
content:
|
|
205
|
+
'Submit receipts within thirty days. Travel above 500 EUR needs pre-approval from finance.',
|
|
206
|
+
metadata: Object.freeze({ source: 'expenses.md' }),
|
|
207
|
+
}),
|
|
208
|
+
]),
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* A pipeline with HashEmbedder, a MemoryRetriever already holding
|
|
213
|
+
* `documents`, and EchoLLM (or the llm you pass).
|
|
214
|
+
*
|
|
215
|
+
* @example
|
|
216
|
+
* const { createTestPipeline } = require('@devilsdev/rag-pipeline-utils/testing');
|
|
217
|
+
* const { pipeline, llm } = await createTestPipeline();
|
|
218
|
+
* const result = await pipeline.run({ query: 'How many vacation days?' });
|
|
219
|
+
* // result.results[0].id === 'vacation'; llm.calls.length === 1
|
|
220
|
+
*
|
|
221
|
+
* @param {Object} [options]
|
|
222
|
+
* @param {Array<{id: string, content: string, metadata?: Object}>} [options.documents=fixtures.documents]
|
|
223
|
+
* @param {Object} [options.embedder] - Defaults to a new HashEmbedder
|
|
224
|
+
* @param {Object|null} [options.llm] - Defaults to a new EchoLLM; null for retrieval only
|
|
225
|
+
* @param {Object} [options.pipeline] - Extra createRagPipeline options (e.g. reranker)
|
|
226
|
+
* @returns {Promise<{ pipeline: Object, embedder: Object, retriever: MemoryRetriever, llm: Object|null }>}
|
|
227
|
+
*/
|
|
228
|
+
async function createTestPipeline({
|
|
229
|
+
documents = fixtures.documents,
|
|
230
|
+
embedder = new HashEmbedder(),
|
|
231
|
+
llm = new EchoLLM(),
|
|
232
|
+
pipeline: pipelineOptions = {},
|
|
233
|
+
} = {}) {
|
|
234
|
+
const retriever = new MemoryRetriever();
|
|
235
|
+
const vectors = await Promise.all(
|
|
236
|
+
documents.map((d) => embedder.embed(d.content)),
|
|
237
|
+
);
|
|
238
|
+
await retriever.store(
|
|
239
|
+
documents.map((d, i) => ({
|
|
240
|
+
...d,
|
|
241
|
+
metadata: { ...(d.metadata || {}) },
|
|
242
|
+
vector: vectors[i],
|
|
243
|
+
})),
|
|
244
|
+
);
|
|
245
|
+
const pipeline = createRagPipeline({
|
|
246
|
+
...pipelineOptions,
|
|
247
|
+
embedder,
|
|
248
|
+
retriever,
|
|
249
|
+
...(llm && { llm }),
|
|
250
|
+
});
|
|
251
|
+
return { pipeline, embedder, retriever, llm: llm || null };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
module.exports = { HashEmbedder, EchoLLM, fixtures, createTestPipeline };
|