vectra-js 0.9.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/LICENSE +21 -0
- package/README.md +625 -0
- package/bin/vectra.js +76 -0
- package/documentation.md +288 -0
- package/index.js +11 -0
- package/package.json +53 -0
- package/src/backends/anthropic.js +37 -0
- package/src/backends/chroma_store.js +110 -0
- package/src/backends/gemini.js +68 -0
- package/src/backends/huggingface.js +52 -0
- package/src/backends/milvus_store.js +61 -0
- package/src/backends/ollama.js +63 -0
- package/src/backends/openai.js +46 -0
- package/src/backends/openrouter.js +51 -0
- package/src/backends/prisma_store.js +160 -0
- package/src/backends/qdrant_store.js +68 -0
- package/src/callbacks.js +31 -0
- package/src/config.js +123 -0
- package/src/core.js +591 -0
- package/src/evaluation/index.js +15 -0
- package/src/interfaces.js +21 -0
- package/src/memory.js +96 -0
- package/src/processor.js +155 -0
- package/src/reranker.js +26 -0
- package/src/ui/index.html +665 -0
- package/src/ui/script.js +785 -0
- package/src/ui/style.css +281 -0
- package/src/webconfig_server.js +175 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
class HuggingFaceBackend {
|
|
2
|
+
constructor(config) {
|
|
3
|
+
this.config = config;
|
|
4
|
+
this.apiKey = config.apiKey || process.env.HUGGINGFACE_API_KEY;
|
|
5
|
+
if (!this.apiKey) throw new Error('HuggingFace API Key missing. Set HUGGINGFACE_API_KEY.');
|
|
6
|
+
this.baseUrl = 'https://api-inference.huggingface.co/models';
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
async _post(model, payload) {
|
|
10
|
+
const res = await fetch(`${this.baseUrl}/${encodeURIComponent(model)}`, {
|
|
11
|
+
method: 'POST',
|
|
12
|
+
headers: { 'Authorization': `Bearer ${this.apiKey}`, 'Content-Type': 'application/json' },
|
|
13
|
+
body: JSON.stringify(payload)
|
|
14
|
+
});
|
|
15
|
+
if (!res.ok) {
|
|
16
|
+
const t = await res.text();
|
|
17
|
+
throw new Error(`HF error ${res.status}: ${t}`);
|
|
18
|
+
}
|
|
19
|
+
return await res.json();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async embedDocuments(texts) {
|
|
23
|
+
const out = [];
|
|
24
|
+
for (const text of texts) {
|
|
25
|
+
const r = await this._post(this.config.modelName, { inputs: text, options: { wait_for_model: true } });
|
|
26
|
+
const vec = Array.isArray(r) ? r.flat(2) : (r.embedding || r);
|
|
27
|
+
out.push(vec);
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async embedQuery(text) {
|
|
33
|
+
const r = await this._post(this.config.modelName, { inputs: text, options: { wait_for_model: true } });
|
|
34
|
+
const vec = Array.isArray(r) ? r.flat(2) : (r.embedding || r);
|
|
35
|
+
return vec;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async generate(prompt, sys) {
|
|
39
|
+
const inputs = sys ? `${sys}\n${prompt}` : prompt;
|
|
40
|
+
const r = await this._post(this.config.modelName, { inputs, parameters: { temperature: this.config.temperature }, options: { wait_for_model: true } });
|
|
41
|
+
if (Array.isArray(r) && r[0]?.generated_text) return r[0].generated_text;
|
|
42
|
+
if (typeof r === 'string') return r;
|
|
43
|
+
if (r?.generated_text) return r.generated_text;
|
|
44
|
+
return String(r);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async *generateStream(prompt, sys) {
|
|
48
|
+
const text = await this.generate(prompt, sys);
|
|
49
|
+
yield { delta: text, finish_reason: null, usage: null };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
module.exports = { HuggingFaceBackend };
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
const { VectorStore } = require('../interfaces');
|
|
2
|
+
|
|
3
|
+
class MilvusVectorStore extends VectorStore {
|
|
4
|
+
constructor(config) { super(); this.config = config; this.client = config.clientInstance; this.collection = config.tableName || 'rag_collection'; }
|
|
5
|
+
async addDocuments(documents) {
|
|
6
|
+
const data = documents.map((doc) => ({ vector: doc.embedding, content: doc.content, metadata: JSON.stringify(doc.metadata) }));
|
|
7
|
+
await this.client.insert({ collection_name: this.collection, fields_data: data });
|
|
8
|
+
}
|
|
9
|
+
async upsertDocuments(documents) {
|
|
10
|
+
return this.addDocuments(documents);
|
|
11
|
+
}
|
|
12
|
+
async similaritySearch(vector, limit = 5, filter = null) {
|
|
13
|
+
const lim = Math.max(1, Number(limit) || 5);
|
|
14
|
+
let res;
|
|
15
|
+
if (filter) {
|
|
16
|
+
try {
|
|
17
|
+
res = await this.client.search({ collection_name: this.collection, data: [vector], limit: lim, filter });
|
|
18
|
+
} catch (_) {
|
|
19
|
+
try {
|
|
20
|
+
res = await this.client.search({ collection_name: this.collection, data: [vector], limit: lim, expr: filter });
|
|
21
|
+
} catch (_) {
|
|
22
|
+
res = await this.client.search({ collection_name: this.collection, data: [vector], limit: lim });
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
} else {
|
|
26
|
+
res = await this.client.search({ collection_name: this.collection, data: [vector], limit: lim });
|
|
27
|
+
}
|
|
28
|
+
const hits = res.results ? res.results : res;
|
|
29
|
+
return hits.map(h => ({ content: h.content || '', metadata: h.metadata ? JSON.parse(h.metadata) : {}, score: h.distance }));
|
|
30
|
+
}
|
|
31
|
+
async hybridSearch(text, vector, limit = 5, filter = null) { return this.similaritySearch(vector, limit, filter); }
|
|
32
|
+
|
|
33
|
+
async listDocuments({ filter = null, limit = 100, offset = 0 } = {}) {
|
|
34
|
+
if (typeof this.client.query !== 'function') throw new Error('listDocuments is not supported for this Milvus client');
|
|
35
|
+
const lim = Math.max(1, Math.min(1000, Number(limit) || 100));
|
|
36
|
+
const off = Math.max(0, Number(offset) || 0);
|
|
37
|
+
const res = await this.client.query({
|
|
38
|
+
collection_name: this.collection,
|
|
39
|
+
expr: filter || '',
|
|
40
|
+
output_fields: ['content', 'metadata'],
|
|
41
|
+
limit: lim,
|
|
42
|
+
offset: off,
|
|
43
|
+
});
|
|
44
|
+
const rows = Array.isArray(res) ? res : (res?.data || res?.results || []);
|
|
45
|
+
return rows.map((r) => ({ id: r.id, content: r.content || '', metadata: r.metadata ? JSON.parse(r.metadata) : {} }));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async deleteDocuments({ ids = null, filter = null } = {}) {
|
|
49
|
+
if (typeof this.client.delete !== 'function') throw new Error('deleteDocuments is not supported for this Milvus client');
|
|
50
|
+
if (Array.isArray(ids) && ids.length > 0) {
|
|
51
|
+
await this.client.delete({ collection_name: this.collection, ids });
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (filter) {
|
|
55
|
+
await this.client.delete({ collection_name: this.collection, expr: filter });
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
throw new Error('deleteDocuments requires ids or filter');
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
module.exports = { MilvusVectorStore };
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
class OllamaBackend {
|
|
2
|
+
constructor(config) {
|
|
3
|
+
this.config = config;
|
|
4
|
+
this.baseUrl = config.baseUrl || 'http://localhost:11434';
|
|
5
|
+
}
|
|
6
|
+
async embedDocuments(texts) {
|
|
7
|
+
const out = [];
|
|
8
|
+
for (const t of texts) {
|
|
9
|
+
const res = await fetch(`${this.baseUrl}/api/embeddings`, {
|
|
10
|
+
method: 'POST',
|
|
11
|
+
headers: { 'Content-Type': 'application/json' },
|
|
12
|
+
body: JSON.stringify({ model: this.config.modelName, prompt: t })
|
|
13
|
+
});
|
|
14
|
+
const json = await res.json();
|
|
15
|
+
out.push(json.embedding || json.data || []);
|
|
16
|
+
}
|
|
17
|
+
return out;
|
|
18
|
+
}
|
|
19
|
+
async embedQuery(text) {
|
|
20
|
+
const res = await fetch(`${this.baseUrl}/api/embeddings`, {
|
|
21
|
+
method: 'POST',
|
|
22
|
+
headers: { 'Content-Type': 'application/json' },
|
|
23
|
+
body: JSON.stringify({ model: this.config.modelName, prompt: text })
|
|
24
|
+
});
|
|
25
|
+
const json = await res.json();
|
|
26
|
+
return json.embedding || json.data || [];
|
|
27
|
+
}
|
|
28
|
+
async generate(prompt, sys) {
|
|
29
|
+
const res = await fetch(`${this.baseUrl}/api/generate`, {
|
|
30
|
+
method: 'POST',
|
|
31
|
+
headers: { 'Content-Type': 'application/json' },
|
|
32
|
+
body: JSON.stringify({ model: this.config.modelName, prompt: sys ? `${sys}\n\n${prompt}` : prompt, stream: false })
|
|
33
|
+
});
|
|
34
|
+
const json = await res.json();
|
|
35
|
+
return json.response || '';
|
|
36
|
+
}
|
|
37
|
+
async *generateStream(prompt, sys) {
|
|
38
|
+
const res = await fetch(`${this.baseUrl}/api/generate`, {
|
|
39
|
+
method: 'POST',
|
|
40
|
+
headers: { 'Content-Type': 'application/json' },
|
|
41
|
+
body: JSON.stringify({ model: this.config.modelName, prompt: sys ? `${sys}\n\n${prompt}` : prompt, stream: true })
|
|
42
|
+
});
|
|
43
|
+
const reader = res.body.getReader();
|
|
44
|
+
const decoder = new TextDecoder();
|
|
45
|
+
let done = false;
|
|
46
|
+
while (!done) {
|
|
47
|
+
const chunk = await reader.read();
|
|
48
|
+
done = chunk.done;
|
|
49
|
+
if (chunk.value) {
|
|
50
|
+
const text = decoder.decode(chunk.value);
|
|
51
|
+
const lines = text.split('\n').filter(l => l.trim().length > 0);
|
|
52
|
+
for (const line of lines) {
|
|
53
|
+
try {
|
|
54
|
+
const obj = JSON.parse(line);
|
|
55
|
+
const d = obj.response || '';
|
|
56
|
+
if (d) yield { delta: d, finish_reason: obj.done ? 'stop' : null, usage: null };
|
|
57
|
+
} catch {}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
module.exports = { OllamaBackend };
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
const OpenAI = require('openai');
|
|
2
|
+
|
|
3
|
+
class OpenAIBackend {
|
|
4
|
+
constructor(config) {
|
|
5
|
+
this.config = config;
|
|
6
|
+
this.client = new OpenAI({ apiKey: config.apiKey || process.env.OPENAI_API_KEY });
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
async embedDocuments(texts) {
|
|
10
|
+
const res = await this.client.embeddings.create({ model: this.config.modelName, input: texts, dimensions: this.config.dimensions });
|
|
11
|
+
return res.data.map(d => d.embedding);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async embedQuery(text) {
|
|
15
|
+
const res = await this.client.embeddings.create({ model: this.config.modelName, input: text, dimensions: this.config.dimensions });
|
|
16
|
+
return res.data[0].embedding;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async generate(prompt, sys) {
|
|
20
|
+
const msgs = [];
|
|
21
|
+
if (sys) msgs.push({ role: 'system', content: sys });
|
|
22
|
+
msgs.push({ role: 'user', content: prompt });
|
|
23
|
+
const res = await this.client.chat.completions.create({ model: this.config.modelName, messages: msgs, temperature: this.config.temperature, max_tokens: this.config.maxTokens });
|
|
24
|
+
return res.choices[0].message.content || "";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async *generateStream(prompt, sys) {
|
|
28
|
+
const msgs = [];
|
|
29
|
+
if (sys) msgs.push({ role: 'system', content: sys });
|
|
30
|
+
msgs.push({ role: 'user', content: prompt });
|
|
31
|
+
|
|
32
|
+
const stream = await this.client.chat.completions.create({
|
|
33
|
+
model: this.config.modelName,
|
|
34
|
+
messages: msgs,
|
|
35
|
+
temperature: this.config.temperature,
|
|
36
|
+
max_tokens: this.config.maxTokens,
|
|
37
|
+
stream: true,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
for await (const chunk of stream) {
|
|
41
|
+
const content = chunk.choices[0]?.delta?.content || '';
|
|
42
|
+
if (content) yield { delta: content, finish_reason: null, usage: null };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
module.exports = { OpenAIBackend };
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
const OpenAI = require('openai');
|
|
2
|
+
|
|
3
|
+
class OpenRouterBackend {
|
|
4
|
+
constructor(config) {
|
|
5
|
+
this.config = config;
|
|
6
|
+
const apiKey = config.apiKey || process.env.OPENROUTER_API_KEY;
|
|
7
|
+
if (!apiKey) throw new Error('OpenRouter API Key missing. Set OPENROUTER_API_KEY.');
|
|
8
|
+
this.client = new OpenAI({
|
|
9
|
+
apiKey,
|
|
10
|
+
baseURL: config.baseUrl || 'https://openrouter.ai/api/v1',
|
|
11
|
+
defaultHeaders: config.defaultHeaders || {
|
|
12
|
+
'HTTP-Referer': process.env.OPENROUTER_REFERER || '',
|
|
13
|
+
'X-Title': process.env.OPENROUTER_TITLE || ''
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async embedDocuments(texts) { throw new Error('OpenRouter does not support embeddings via this SDK.'); }
|
|
19
|
+
async embedQuery(text) { throw new Error('OpenRouter does not support embeddings via this SDK.'); }
|
|
20
|
+
|
|
21
|
+
async generate(prompt, sys) {
|
|
22
|
+
const msgs = [];
|
|
23
|
+
if (sys) msgs.push({ role: 'system', content: sys });
|
|
24
|
+
msgs.push({ role: 'user', content: prompt });
|
|
25
|
+
const res = await this.client.chat.completions.create({
|
|
26
|
+
model: this.config.modelName,
|
|
27
|
+
messages: msgs,
|
|
28
|
+
temperature: this.config.temperature,
|
|
29
|
+
max_tokens: this.config.maxTokens
|
|
30
|
+
});
|
|
31
|
+
return res.choices[0]?.message?.content || '';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async *generateStream(prompt, sys) {
|
|
35
|
+
const msgs = [];
|
|
36
|
+
if (sys) msgs.push({ role: 'system', content: sys });
|
|
37
|
+
msgs.push({ role: 'user', content: prompt });
|
|
38
|
+
const stream = await this.client.chat.completions.create({
|
|
39
|
+
model: this.config.modelName,
|
|
40
|
+
messages: msgs,
|
|
41
|
+
temperature: this.config.temperature,
|
|
42
|
+
max_tokens: this.config.maxTokens,
|
|
43
|
+
stream: true
|
|
44
|
+
});
|
|
45
|
+
for await (const chunk of stream) {
|
|
46
|
+
const content = chunk.choices?.[0]?.delta?.content || '';
|
|
47
|
+
if (content) yield { delta: content, finish_reason: null, usage: null };
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
module.exports = { OpenRouterBackend };
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
const { v4: uuidv4 } = require('uuid');
|
|
2
|
+
const { VectorStore } = require('../interfaces');
|
|
3
|
+
|
|
4
|
+
const isSafeIdentifier = (value) => typeof value === 'string' && /^[A-Za-z_][A-Za-z0-9_]*$/.test(value);
|
|
5
|
+
const assertSafeIdentifier = (value, label) => {
|
|
6
|
+
if (!isSafeIdentifier(value)) throw new Error(`Unsafe SQL identifier for ${label}`);
|
|
7
|
+
};
|
|
8
|
+
const quoteIdentifier = (value, label) => {
|
|
9
|
+
assertSafeIdentifier(value, label);
|
|
10
|
+
return `"${value}"`;
|
|
11
|
+
};
|
|
12
|
+
const quoteTableName = (value, label) => {
|
|
13
|
+
if (typeof value !== 'string' || value.trim().length === 0) throw new Error(`Unsafe SQL identifier for ${label}`);
|
|
14
|
+
const parts = value.split('.').map(p => p.trim()).filter(Boolean);
|
|
15
|
+
if (parts.length === 0 || parts.length > 2) throw new Error(`Unsafe SQL identifier for ${label}`);
|
|
16
|
+
parts.forEach((p, i) => assertSafeIdentifier(p, i === 0 && parts.length === 2 ? `${label} schema` : `${label} table`));
|
|
17
|
+
return parts.map(p => `"${p}"`).join('.');
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
class PrismaVectorStore extends VectorStore {
|
|
21
|
+
constructor(config) {
|
|
22
|
+
super();
|
|
23
|
+
this.config = config;
|
|
24
|
+
const tableName = config.tableName || 'Document';
|
|
25
|
+
const columnMap = config.columnMap || {};
|
|
26
|
+
this._table = quoteTableName(tableName, 'tableName');
|
|
27
|
+
this._tableBase = tableName.split('.').pop();
|
|
28
|
+
this._cContent = quoteIdentifier(columnMap.content || 'content', 'columnMap.content');
|
|
29
|
+
this._cMeta = quoteIdentifier(columnMap.metadata || 'metadata', 'columnMap.metadata');
|
|
30
|
+
this._cVec = quoteIdentifier(columnMap.vector || 'vector', 'columnMap.vector');
|
|
31
|
+
}
|
|
32
|
+
normalizeVector(v) {
|
|
33
|
+
const m = Math.sqrt(v.reduce((s, x) => s + x * x, 0));
|
|
34
|
+
return m === 0 ? v : v.map(x => x / m);
|
|
35
|
+
}
|
|
36
|
+
async addDocuments(docs) {
|
|
37
|
+
const { clientInstance } = this.config;
|
|
38
|
+
const q = `INSERT INTO ${this._table} ("id", ${this._cContent}, ${this._cMeta}, ${this._cVec}, "createdAt") VALUES ($1, $2, $3, $4::vector, NOW())`;
|
|
39
|
+
for (const doc of docs) {
|
|
40
|
+
const id = doc.id || uuidv4();
|
|
41
|
+
const vec = JSON.stringify(this.normalizeVector(doc.embedding));
|
|
42
|
+
try {
|
|
43
|
+
await clientInstance.$executeRawUnsafe(q, id, doc.content, doc.metadata, vec);
|
|
44
|
+
} catch (e) {
|
|
45
|
+
const msg = e?.message || String(e);
|
|
46
|
+
if (msg.includes('vector') && msg.includes('dimension')) {
|
|
47
|
+
throw new Error('DimensionMismatchError: Embedding dimension does not match pgvector column. Configure embedding.dimensions to match the column or migrate the column dimension.');
|
|
48
|
+
}
|
|
49
|
+
throw e;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async upsertDocuments(docs) {
|
|
55
|
+
const { clientInstance } = this.config;
|
|
56
|
+
const q = `INSERT INTO ${this._table} ("id", ${this._cContent}, ${this._cMeta}, ${this._cVec}, "createdAt") VALUES ($1, $2, $3, $4::vector, NOW()) ON CONFLICT ("id") DO UPDATE SET ${this._cContent} = EXCLUDED.${this._cContent}, ${this._cMeta} = EXCLUDED.${this._cMeta}, ${this._cVec} = EXCLUDED.${this._cVec}`;
|
|
57
|
+
for (const doc of docs) {
|
|
58
|
+
const id = doc.id || uuidv4();
|
|
59
|
+
const vec = JSON.stringify(this.normalizeVector(doc.embedding));
|
|
60
|
+
await clientInstance.$executeRawUnsafe(q, id, doc.content, doc.metadata, vec);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async similaritySearch(vector, limit = 5, filter = null) {
|
|
65
|
+
const { clientInstance } = this.config;
|
|
66
|
+
|
|
67
|
+
const vec = JSON.stringify(this.normalizeVector(vector));
|
|
68
|
+
let where = ""; const params = [vec];
|
|
69
|
+
if (filter) { where = `WHERE ${this._cMeta} @> $2::jsonb`; params.push(JSON.stringify(filter)); }
|
|
70
|
+
const q = `SELECT ${this._cContent} as content, ${this._cMeta} as metadata, 1 - (${this._cVec} <=> $1::vector) as score FROM ${this._table} ${where} ORDER BY score DESC LIMIT ${Math.max(1, Number(limit) || 5)}`;
|
|
71
|
+
const res = await clientInstance.$queryRawUnsafe(q, ...params);
|
|
72
|
+
return res.map(r => ({ content: r.content, metadata: r.metadata, score: r.score }));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async hybridSearch(text, vector, limit = 5, filter = null) {
|
|
76
|
+
const semantic = await this.similaritySearch(vector, limit * 2, filter);
|
|
77
|
+
const { clientInstance } = this.config;
|
|
78
|
+
const params = [text];
|
|
79
|
+
const where = filter ? ` AND ${this._cMeta} @> $2::jsonb` : '';
|
|
80
|
+
if (filter) params.push(JSON.stringify(filter));
|
|
81
|
+
const q = `SELECT ${this._cContent} as content, ${this._cMeta} as metadata FROM ${this._table} WHERE to_tsvector('simple', ${this._cContent}) @@ plainto_tsquery($1)${where} LIMIT ${Math.max(1, Number(limit) || 5) * 2}`;
|
|
82
|
+
let lexical = [];
|
|
83
|
+
try {
|
|
84
|
+
const res = await clientInstance.$queryRawUnsafe(q, ...params);
|
|
85
|
+
lexical = res.map(r => ({ content: r.content, metadata: r.metadata, score: 1.0 }));
|
|
86
|
+
} catch (_) {
|
|
87
|
+
lexical = [];
|
|
88
|
+
}
|
|
89
|
+
const combined = {};
|
|
90
|
+
const add = (list, weight = 1) => {
|
|
91
|
+
list.forEach((doc, idx) => {
|
|
92
|
+
const key = doc.content;
|
|
93
|
+
const score = 1 / (60 + idx + 1) * weight;
|
|
94
|
+
if (!combined[key]) combined[key] = { ...doc, score: 0 };
|
|
95
|
+
combined[key].score += score;
|
|
96
|
+
});
|
|
97
|
+
};
|
|
98
|
+
add(semantic, 1);
|
|
99
|
+
add(lexical, 1);
|
|
100
|
+
return Object.values(combined).sort((a, b) => b.score - a.score).slice(0, limit);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async ensureIndexes() {
|
|
104
|
+
const { clientInstance } = this.config;
|
|
105
|
+
const base = this._tableBase;
|
|
106
|
+
assertSafeIdentifier(base, 'tableName table');
|
|
107
|
+
const idxVec = `"${base}_embedding_ivfflat"`;
|
|
108
|
+
const idxFts = `"${base}_content_fts_gin"`;
|
|
109
|
+
try {
|
|
110
|
+
await clientInstance.$executeRawUnsafe('CREATE EXTENSION IF NOT EXISTS vector');
|
|
111
|
+
await clientInstance.$executeRawUnsafe(`CREATE INDEX IF NOT EXISTS ${idxVec} ON ${this._table} USING ivfflat (${this._cVec} vector_cosine_ops) WITH (lists = 100);`);
|
|
112
|
+
await clientInstance.$executeRawUnsafe(`CREATE INDEX IF NOT EXISTS ${idxFts} ON ${this._table} USING GIN (to_tsvector('english', ${this._cContent}));`);
|
|
113
|
+
} catch (e) {
|
|
114
|
+
// Best-effort; indexes are optional
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async fileExists(sha256, size, lastModified) {
|
|
119
|
+
const { clientInstance } = this.config;
|
|
120
|
+
const payload = JSON.stringify({ fileSHA256: sha256, fileSize: size, lastModified });
|
|
121
|
+
const q = `SELECT 1 FROM ${this._table} WHERE ${this._cMeta} @> $1::jsonb LIMIT 1`;
|
|
122
|
+
try {
|
|
123
|
+
const res = await clientInstance.$queryRawUnsafe(q, payload);
|
|
124
|
+
return Array.isArray(res) && res.length > 0;
|
|
125
|
+
} catch (_) {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async listDocuments({ filter = null, limit = 100, offset = 0 } = {}) {
|
|
131
|
+
const { clientInstance } = this.config;
|
|
132
|
+
const params = [];
|
|
133
|
+
let where = '';
|
|
134
|
+
if (filter) {
|
|
135
|
+
where = `WHERE ${this._cMeta} @> $1::jsonb`;
|
|
136
|
+
params.push(JSON.stringify(filter));
|
|
137
|
+
}
|
|
138
|
+
const lim = Math.max(1, Math.min(1000, Number(limit) || 100));
|
|
139
|
+
const off = Math.max(0, Number(offset) || 0);
|
|
140
|
+
const q = `SELECT "id" as id, ${this._cContent} as content, ${this._cMeta} as metadata, "createdAt" as createdAt FROM ${this._table} ${where} ORDER BY "createdAt" DESC LIMIT ${lim} OFFSET ${off}`;
|
|
141
|
+
const res = await clientInstance.$queryRawUnsafe(q, ...params);
|
|
142
|
+
return res.map(r => ({ id: r.id, content: r.content, metadata: r.metadata, createdAt: r.createdAt }));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async deleteDocuments({ ids = null, filter = null } = {}) {
|
|
146
|
+
const { clientInstance } = this.config;
|
|
147
|
+
if (Array.isArray(ids) && ids.length > 0) {
|
|
148
|
+
const q = `DELETE FROM ${this._table} WHERE "id" = ANY($1::uuid[])`;
|
|
149
|
+
await clientInstance.$executeRawUnsafe(q, ids);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (filter) {
|
|
153
|
+
const q = `DELETE FROM ${this._table} WHERE ${this._cMeta} @> $1::jsonb`;
|
|
154
|
+
await clientInstance.$executeRawUnsafe(q, JSON.stringify(filter));
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
throw new Error('deleteDocuments requires ids or filter');
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
module.exports = { PrismaVectorStore };
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
const { VectorStore } = require('../interfaces');
|
|
2
|
+
|
|
3
|
+
class QdrantVectorStore extends VectorStore {
|
|
4
|
+
constructor(config) { super(); this.config = config; this.client = config.clientInstance; this.collection = config.tableName || 'rag_collection'; }
|
|
5
|
+
|
|
6
|
+
normalizeFilter(filter) {
|
|
7
|
+
if (!filter) return null;
|
|
8
|
+
if (typeof filter !== 'object') return filter;
|
|
9
|
+
if (filter.must || filter.should || filter.must_not) return filter;
|
|
10
|
+
const must = [];
|
|
11
|
+
Object.entries(filter).forEach(([k, v]) => {
|
|
12
|
+
if (v === undefined) return;
|
|
13
|
+
if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') {
|
|
14
|
+
must.push({ key: `metadata.${k}`, match: { value: v } });
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
return must.length ? { must } : null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async addDocuments(documents) {
|
|
21
|
+
const points = documents.map((doc, i) => ({ id: doc.id || `${Date.now()}-${i}`, vector: doc.embedding, payload: { content: doc.content, metadata: doc.metadata } }));
|
|
22
|
+
await this.client.upsert(this.collection, { points });
|
|
23
|
+
}
|
|
24
|
+
async upsertDocuments(documents) {
|
|
25
|
+
return this.addDocuments(documents);
|
|
26
|
+
}
|
|
27
|
+
async similaritySearch(vector, limit = 5, filter = null) {
|
|
28
|
+
const qFilter = this.normalizeFilter(filter);
|
|
29
|
+
const res = await this.client.search(this.collection, { vector, limit, filter: qFilter });
|
|
30
|
+
return res.map(r => ({ content: r.payload.content, metadata: r.payload.metadata, score: r.score }));
|
|
31
|
+
}
|
|
32
|
+
async hybridSearch(text, vector, limit = 5, filter = null) { return this.similaritySearch(vector, limit, filter); }
|
|
33
|
+
async listDocuments({ filter = null, limit = 100, offset = 0 } = {}) {
|
|
34
|
+
if (typeof this.client.scroll !== 'function') throw new Error('listDocuments is not supported for this Qdrant client');
|
|
35
|
+
const qFilter = this.normalizeFilter(filter);
|
|
36
|
+
const lim = Math.max(1, Math.min(1000, Number(limit) || 100));
|
|
37
|
+
const off = Math.max(0, Number(offset) || 0);
|
|
38
|
+
let skipped = 0;
|
|
39
|
+
const out = [];
|
|
40
|
+
let nextOffset = undefined;
|
|
41
|
+
while (out.length < lim) {
|
|
42
|
+
const res = await this.client.scroll(this.collection, { limit: Math.min(256, lim), filter: qFilter, offset: nextOffset });
|
|
43
|
+
const points = res?.points || res?.result?.points || [];
|
|
44
|
+
nextOffset = res?.next_page_offset || res?.result?.next_page_offset || res?.next_page_offset;
|
|
45
|
+
if (!points.length) break;
|
|
46
|
+
for (const p of points) {
|
|
47
|
+
if (skipped < off) { skipped++; continue; }
|
|
48
|
+
out.push({ id: p.id, content: p.payload?.content, metadata: p.payload?.metadata });
|
|
49
|
+
if (out.length >= lim) break;
|
|
50
|
+
}
|
|
51
|
+
if (!nextOffset) break;
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
async deleteDocuments({ ids = null, filter = null } = {}) {
|
|
56
|
+
if (typeof this.client.delete !== 'function') throw new Error('deleteDocuments is not supported for this Qdrant client');
|
|
57
|
+
if (Array.isArray(ids) && ids.length > 0) {
|
|
58
|
+
await this.client.delete(this.collection, { points: ids });
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (filter) {
|
|
62
|
+
await this.client.delete(this.collection, { filter: this.normalizeFilter(filter) });
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
throw new Error('deleteDocuments requires ids or filter');
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
module.exports = { QdrantVectorStore };
|
package/src/callbacks.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
class LoggingCallbackHandler {
|
|
2
|
+
onIngestStart(filePath) { console.info(`[RAG] Starting ingestion: ${filePath}`); }
|
|
3
|
+
onIngestEnd(file, count, durationMs) { console.info(`[RAG] Finished ingestion. Chunks: ${count} (${durationMs} ms)`); }
|
|
4
|
+
onIngestSummary(summary) { console.info(`[RAG] Ingest summary: processed=${summary.processed}, ok=${summary.succeeded}, failed=${summary.failed}`); }
|
|
5
|
+
onChunkingStart(strategy) { console.debug(`[RAG] Chunking strategy: ${strategy}`); }
|
|
6
|
+
onEmbeddingStart(count) { console.debug(`[RAG] Embedding ${count} chunks...`); }
|
|
7
|
+
onRetrievalStart(query) { console.info(`[RAG] Querying: "${query}"`); }
|
|
8
|
+
onRetrievalEnd(count, durationMs) { console.info(`[RAG] Retrieved ${count} docs (${durationMs} ms).`); }
|
|
9
|
+
onRerankingStart(count) { console.debug(`[RAG] Reranking ${count} docs...`); }
|
|
10
|
+
onRerankingEnd(count) { console.debug(`[RAG] Reranking finished. Keeping top ${count}.`); }
|
|
11
|
+
onGenerationStart() { console.debug(`[RAG] Generating answer...`); }
|
|
12
|
+
onGenerationEnd(answer, durationMs) { console.info(`[RAG] Answer generated (${durationMs} ms).`); }
|
|
13
|
+
onError(err) { console.error(`[RAG] Error: ${err.message || err}`); }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
class StructuredLoggingCallbackHandler {
|
|
17
|
+
constructor() { this.enabled = true; }
|
|
18
|
+
onIngestStart(filePath) { console.log(JSON.stringify({ event: 'ingest_start', filePath })); }
|
|
19
|
+
onIngestEnd(filePath, chunkCount, durationMs) { console.log(JSON.stringify({ event: 'ingest_end', filePath, chunkCount, durationMs })); }
|
|
20
|
+
onIngestSummary(summary) { console.log(JSON.stringify({ event: 'ingest_summary', ...summary })); }
|
|
21
|
+
onChunkingStart(strategy) { console.log(JSON.stringify({ event: 'chunking_start', strategy })); }
|
|
22
|
+
onEmbeddingStart(count) { console.log(JSON.stringify({ event: 'embedding_start', count })); }
|
|
23
|
+
onRetrievalStart(query) { console.log(JSON.stringify({ event: 'retrieval_start', query })); }
|
|
24
|
+
onRetrievalEnd(count, durationMs) { console.log(JSON.stringify({ event: 'retrieval_end', count, durationMs })); }
|
|
25
|
+
onRerankingStart(count) { console.log(JSON.stringify({ event: 'reranking_start', count })); }
|
|
26
|
+
onRerankingEnd(count) { console.log(JSON.stringify({ event: 'reranking_end', count })); }
|
|
27
|
+
onGenerationStart(promptPreview) { console.log(JSON.stringify({ event: 'generation_start', promptPreview: String(promptPreview).slice(0, 120) })); }
|
|
28
|
+
onGenerationEnd(answerPreview, durationMs) { console.log(JSON.stringify({ event: 'generation_end', answerPreview: String(answerPreview).slice(0, 120), durationMs })); }
|
|
29
|
+
onError(err) { console.log(JSON.stringify({ event: 'error', message: err?.message || String(err) })); }
|
|
30
|
+
}
|
|
31
|
+
module.exports = { LoggingCallbackHandler, StructuredLoggingCallbackHandler };
|
package/src/config.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
const { z } = require('zod');
|
|
2
|
+
|
|
3
|
+
const ProviderType = {
|
|
4
|
+
OPENAI: 'openai',
|
|
5
|
+
ANTHROPIC: 'anthropic',
|
|
6
|
+
GEMINI: 'gemini',
|
|
7
|
+
OPENROUTER: 'openrouter',
|
|
8
|
+
HUGGINGFACE: 'huggingface',
|
|
9
|
+
OLLAMA: 'ollama',
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const ChunkingStrategy = {
|
|
13
|
+
RECURSIVE: 'recursive',
|
|
14
|
+
AGENTIC: 'agentic',
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const RetrievalStrategy = {
|
|
18
|
+
NAIVE: 'naive',
|
|
19
|
+
HYDE: 'hyde',
|
|
20
|
+
MULTI_QUERY: 'multi_query',
|
|
21
|
+
HYBRID: 'hybrid',
|
|
22
|
+
MMR: 'mmr'
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const EmbeddingConfigSchema = z.object({
|
|
26
|
+
provider: z.nativeEnum(ProviderType),
|
|
27
|
+
apiKey: z.string().optional(),
|
|
28
|
+
modelName: z.string().default('text-embedding-3-small'),
|
|
29
|
+
dimensions: z.number().optional(),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const LLMConfigSchema = z.object({
|
|
33
|
+
provider: z.nativeEnum(ProviderType),
|
|
34
|
+
apiKey: z.string().optional(),
|
|
35
|
+
modelName: z.string(),
|
|
36
|
+
temperature: z.number().default(0),
|
|
37
|
+
maxTokens: z.number().default(1024),
|
|
38
|
+
baseUrl: z.string().optional(),
|
|
39
|
+
defaultHeaders: z.record(z.string()).optional(),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const ChunkingConfigSchema = z.object({
|
|
43
|
+
strategy: z.nativeEnum(ChunkingStrategy).default(ChunkingStrategy.RECURSIVE),
|
|
44
|
+
chunkSize: z.number().default(1000),
|
|
45
|
+
chunkOverlap: z.number().default(200),
|
|
46
|
+
separators: z.array(z.string()).default(['\n\n', '\n', ' ', '']),
|
|
47
|
+
agenticLlm: LLMConfigSchema.optional(),
|
|
48
|
+
}).refine((data) => {
|
|
49
|
+
if (data.strategy === ChunkingStrategy.AGENTIC && !data.agenticLlm) return false;
|
|
50
|
+
return true;
|
|
51
|
+
}, { message: "agenticLlm required for AGENTIC strategy", path: ["agenticLlm"] });
|
|
52
|
+
|
|
53
|
+
const RerankingConfigSchema = z.object({
|
|
54
|
+
enabled: z.boolean().default(false),
|
|
55
|
+
provider: z.literal('llm').default('llm'),
|
|
56
|
+
llmConfig: LLMConfigSchema.optional(),
|
|
57
|
+
topN: z.number().default(5),
|
|
58
|
+
windowSize: z.number().default(20)
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const RetrievalConfigSchema = z.object({
|
|
62
|
+
strategy: z.nativeEnum(RetrievalStrategy).default(RetrievalStrategy.NAIVE),
|
|
63
|
+
llmConfig: LLMConfigSchema.optional(),
|
|
64
|
+
hybridAlpha: z.number().default(0.5),
|
|
65
|
+
mmrLambda: z.number().default(0.5),
|
|
66
|
+
mmrFetchK: z.number().default(20)
|
|
67
|
+
}).refine((data) => {
|
|
68
|
+
if ((data.strategy === RetrievalStrategy.HYDE || data.strategy === RetrievalStrategy.MULTI_QUERY) && !data.llmConfig) return false;
|
|
69
|
+
return true;
|
|
70
|
+
}, { message: "llmConfig required for advanced retrieval", path: ["llmConfig"] });
|
|
71
|
+
|
|
72
|
+
const DatabaseConfigSchema = z.object({
|
|
73
|
+
type: z.string(), // 'prisma', 'chroma', etc.
|
|
74
|
+
tableName: z.string().optional(),
|
|
75
|
+
columnMap: z.object({
|
|
76
|
+
content: z.string(),
|
|
77
|
+
vector: z.string(),
|
|
78
|
+
metadata: z.string(),
|
|
79
|
+
}).default({ content: 'content', vector: 'vector', metadata: 'metadata' }),
|
|
80
|
+
clientInstance: z.any(), // The actual client object
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const RAGConfigSchema = z.object({
|
|
84
|
+
embedding: EmbeddingConfigSchema,
|
|
85
|
+
llm: LLMConfigSchema,
|
|
86
|
+
database: DatabaseConfigSchema,
|
|
87
|
+
chunking: ChunkingConfigSchema.default({}),
|
|
88
|
+
retrieval: RetrievalConfigSchema.default({}),
|
|
89
|
+
reranking: RerankingConfigSchema.default({}),
|
|
90
|
+
metadata: z.object({ enrichment: z.boolean().default(false) }).optional(),
|
|
91
|
+
ingestion: z.object({ rateLimitEnabled: z.boolean().default(false), concurrencyLimit: z.number().default(5) }).optional(),
|
|
92
|
+
memory: z.object({
|
|
93
|
+
enabled: z.boolean().default(false),
|
|
94
|
+
type: z.enum(['in-memory','redis','postgres']).default('in-memory'),
|
|
95
|
+
maxMessages: z.number().default(20),
|
|
96
|
+
redis: z.object({
|
|
97
|
+
clientInstance: z.any().optional(),
|
|
98
|
+
keyPrefix: z.string().default('vectra:chat:')
|
|
99
|
+
}).optional(),
|
|
100
|
+
postgres: z.object({
|
|
101
|
+
clientInstance: z.any().optional(),
|
|
102
|
+
tableName: z.string().default('ChatMessage'),
|
|
103
|
+
columnMap: z.object({
|
|
104
|
+
sessionId: z.string().default('sessionId'),
|
|
105
|
+
role: z.string().default('role'),
|
|
106
|
+
content: z.string().default('content'),
|
|
107
|
+
createdAt: z.string().default('createdAt')
|
|
108
|
+
}).default({ sessionId: 'sessionId', role: 'role', content: 'content', createdAt: 'createdAt' })
|
|
109
|
+
}).optional()
|
|
110
|
+
}).optional(),
|
|
111
|
+
queryPlanning: z.object({ tokenBudget: z.number().default(2048), preferSummariesBelow: z.number().default(1024), includeCitations: z.boolean().default(true) }).optional(),
|
|
112
|
+
grounding: z.object({ enabled: z.boolean().default(false), strict: z.boolean().default(false), maxSnippets: z.number().default(3) }).optional(),
|
|
113
|
+
generation: z.object({ structuredOutput: z.enum(['none','citations']).default('none'), outputFormat: z.enum(['text','json']).default('text') }).optional(),
|
|
114
|
+
prompts: z.object({ query: z.string().optional(), reranking: z.string().optional() }).optional(),
|
|
115
|
+
tracing: z.object({ enable: z.boolean().default(false) }).optional(),
|
|
116
|
+
callbacks: z.array(z.custom((val) => true)).optional(),
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
module.exports = {
|
|
120
|
+
ProviderType, ChunkingStrategy, RetrievalStrategy,
|
|
121
|
+
EmbeddingConfigSchema, LLMConfigSchema, ChunkingConfigSchema,
|
|
122
|
+
RetrievalConfigSchema, RerankingConfigSchema, DatabaseConfigSchema, RAGConfigSchema
|
|
123
|
+
};
|